on master help sign in
Fix 54 bugs across compiler, CLI, dev server, formatter and converter

Compiler core (parser/codegen): - Panics: multi-byte truncate/length filters, non-ASCII hex colors, truncated @data JSON, _page 0 underflow, range overflow at i64 limits, malformed chunked HTTP bodies - Layer the reset CSS so generated rules can override it - Bijective short class names (no collisions past ~700 classes) - Only append px to bare numbers (no 10pxpx / 0 autopx / nonepx) - --minify no longer drops significant spaces; preserves <textarea> - CSS var substitution limited to declaration values (not @media) - Partial output emits the same CSS as full pages (shared helper) - @script and @markdown bodies captured verbatim - No intrinsic image size attrs when a CSS dimension is set - Source maps read real output lines; manifest JSON is escaped - Text brackets no longer join lines; triple-quoted @let is a string; quoted @let values skip arithmetic; [page N] suffix stripped; @each @else scoped; named args not reused positionally; keyframe and attribute comma splits respect parens/quotes; @svg only edits the <svg> tag; JSON \u escapes decoded; diagnostic columns include indentation; fewer false unused-variable warnings CLI / serve / fmt / convert: - fmt no longer empties files, reorders attributes, or reindents raw bodies; build -o . no longer truncates assets - Dev server: safe request path resolution, no wildcard CORS, dir mode - Watch/build: correct output paths and flags, sound build cache, shared-CSS extraction now opt-in (--shared-css) - Converter, outline, upgrade, feed/sitemap, JSON output fixes Tests: tests/regressions.rs plus unit tests; snapshot .html files are now tracked (they were gitignored, so snapshot tests could never fail).

commit888794e54c0e00136b0b8c6cf84fd4ead04ecf5f authoriko <mail@iko.soy> dateFri 25 Sep 2026, 16:40 (2 weeks ago) parent1e469f9
115 files changed+2107−1022
.gitignore+1 README.md+33−17 crates/htmlang-core/src/codegen.rs+242−274 crates/htmlang-core/src/codegen_tests.rs+9−3 crates/htmlang-core/src/parser.rs+316−145 crates/htmlang-wasm/src/lib.rs+1−1 src/bin/htmlang_lsp/completion.rs+57−57 src/bin/htmlang_lsp/main.rs+7−7 src/convert.rs+239−86 src/fmt.rs+294−239 src/main.rs+379−157 src/serve.rs+100−31 tests/regressions.rs+286 tests/snapshots.rs+26−5 tests/snapshots/accessibility.html+1 tests/snapshots/alignment.html+1 tests/snapshots/anchor_positioning.html+1 tests/snapshots/animate_shorthand.html+1 tests/snapshots/animations.html+1 tests/snapshots/arithmetic.html+1 tests/snapshots/assert_directive.html+1 tests/snapshots/attributes.html+1 tests/snapshots/basic_elements.html+1 tests/snapshots/breadcrumb.html+1 tests/snapshots/chain_operator.html+1 tests/snapshots/clamp_css.html+1 tests/snapshots/color_scheme.html+1 tests/snapshots/computed_let.html+1 tests/snapshots/conditionals.html+1 tests/snapshots/container_queries.html+1 tests/snapshots/css_property.html+1 tests/snapshots/css_shorthands.html+1 tests/snapshots/css_units.html+1 tests/snapshots/css_vars.html+1 tests/snapshots/dark_mode.html+1 tests/snapshots/data_directive.html+1 tests/snapshots/direction_attr.html+1 tests/snapshots/each_destructuring.html+1 tests/snapshots/each_else.html+1 tests/snapshots/each_index.html+1 tests/snapshots/each_range.html+1 tests/snapshots/each_step.html+1 tests/snapshots/else_if.html+1 tests/snapshots/env_directive.html+1 tests/snapshots/extra_css.html+1 tests/snapshots/fn_defaults.html+1 tests/snapshots/form_elements.html+1 tests/snapshots/fragment.html+1 tests/snapshots/functions.html+1 tests/snapshots/grid_areas.html+1 tests/snapshots/has_pseudo.html+1 tests/snapshots/hidden_attr.html+1 tests/snapshots/if_expr.html+1 tests/snapshots/implicit_el.html+1 tests/snapshots/initial_letter.html+1 tests/snapshots/inline_text.html+1 tests/snapshots/lang_directive.html+1 tests/snapshots/layer_wrapping.html+1 tests/snapshots/list_elements.html+1 tests/snapshots/loops.html+1 tests/snapshots/manifest_directive.html+1 tests/snapshots/markdown_block.html+10 tests/snapshots/match_directive.html+1 tests/snapshots/media_elements.html+1 tests/snapshots/media_extended.html+1 tests/snapshots/meta_head.html+1 tests/snapshots/mixin_spread.html+1 tests/snapshots/named_slots.html+1 tests/snapshots/new_css.html+1 tests/snapshots/new_css_attrs.html+1 tests/snapshots/new_css_properties.html+1 tests/snapshots/new_css_properties_2.html+1 tests/snapshots/new_css_properties_3.html+1 tests/snapshots/new_css_properties_4.html+1 tests/snapshots/new_directives.html+5 tests/snapshots/new_elements.html+1 tests/snapshots/new_elements_2.html+1 tests/snapshots/new_elements_3.html+1 tests/snapshots/new_elements_4.html+1 tests/snapshots/new_elements_5.html+1 tests/snapshots/new_elements_6.html+1 tests/snapshots/new_html_attrs.html+1 tests/snapshots/new_pseudos.html+1 tests/snapshots/no_page.html+1 tests/snapshots/nth_pseudo.html+1 tests/snapshots/og_directive.html+1 tests/snapshots/pagination_each.html+1 tests/snapshots/popover_api.html+1 tests/snapshots/positional.html+1 tests/snapshots/pseudo_elements.html+1 tests/snapshots/pseudo_states.html+1 tests/snapshots/pseudo_states_extended.html+1 tests/snapshots/raw_html.html+1 tests/snapshots/repeat_directive.html+1 tests/snapshots/responsive.html+1 tests/snapshots/responsive_images.html+1 tests/snapshots/scope_css.html+1 tests/snapshots/script_element.html+4 tests/snapshots/scroll_driven_animations.html+1 tests/snapshots/selection_pseudo.html+1 tests/snapshots/semantic_elements.html+1 tests/snapshots/sizing.html+1 tests/snapshots/starting_style.html+1 tests/snapshots/stress_deeply_nested.html+1 tests/snapshots/stress_large.html+1 tests/snapshots/string_interpolation.html+1 tests/snapshots/style_block.html+1 tests/snapshots/subgrid_css.html+1 tests/snapshots/table_elements.html+1 tests/snapshots/translations_i18n.html+1 tests/snapshots/unless_directive.html+1 tests/snapshots/variable_filters.html+1 tests/snapshots/variables_defines.html+1 tests/snapshots/view_transitions.html+1 tests/snapshots/with_directive.html+1
.gitignore+1
@@ -1,2 +1,3 @@
11/target
22*.html
3+!tests/snapshots/*.html
README.md+33−17
@@ -10,7 +10,7 @@ A minimalist layout language inspired by [elm-ui](https://package.elm-lang.org/p
1010@page My Site
1111@let primary #3b82f6
1212
13−@fn card $title
13+@let card $title
1414 @el [padding 20, background white, rounded 8, border 1 #e5e7eb, hover:border 1 $primary, transition all 0.15s ease]
1515 @text [bold] $title
1616 @children
@@ -59,9 +59,9 @@ More commands (run with `--help` for flags):
5959| `serve [path]` | Dev server with live reload (`--https` ready) |
6060| `convert file.html`| Convert HTML back to htmlang |
6161| `new name` | Scaffold a new page from a template |
62−| `components` | List `@fn` / `@component` definitions |
62+| `components` | List function (`@let` with body) definitions |
6363| `deps file.hl` | Print the include / import graph |
64−| `dead-code [dir]` | Report unused `@fn` / `@let` / `@define` |
64+| `dead-code [dir]` | Report unused functions, variables and bundles |
6565| `feed [dir]` | Generate an Atom feed from `@article` pages |
6666| `sitemap [dir]` | Generate `sitemap.xml` |
6767| `lint [path]` | Run warnings-only checks, optional JSON output |
@@ -115,14 +115,23 @@ A VS Code extension with syntax highlighting and LSP integration is available in
115115### Layout attributes
116116
117117```
118−@row [spacing 20] -- gap between children
119−@row [gap-x 10, gap-y 20] -- separate horizontal/vertical gaps
120−@el [padding 20] -- uniform padding (also padding-x, padding-y)
121−@el [width fill] -- take remaining space (also width 200, width shrink)
122−@el [center-x] -- center horizontally (also align-left, align-right)
123−@el [overflow hidden] -- overflow behavior (hidden, scroll, auto)
118+-- gap between children
119+@row [spacing 20]
120+-- separate horizontal/vertical gaps
121+@row [gap-x 10, gap-y 20]
122+-- uniform padding (also padding-x, padding-y)
123+@el [padding 20]
124+-- take remaining space (also width 200, width shrink)
125+@el [width fill]
126+-- center horizontally (also align-left, align-right)
127+@el [center-x]
128+-- overflow behavior (hidden, scroll, auto)
129+@el [overflow hidden]
124130```
125131
132+Comments start with `--` and run to the end of the line; they must be on a
133+line of their own.
134+
126135### Style attributes
127136
128137```
@@ -167,21 +176,27 @@ Use `dark:` for dark mode and `print:` for print styles:
167176@el [background white, dark:background #1a1a2e, print:display none]
168177```
169178
170−### Variables and defines
179+### Variables and attribute bundles
171180
172181```
173−@let primary #3b82f6 -- simple variable, used as $primary
174−@let greeting "Hello $name" -- quoted string interpolation
175−@define card [padding 20, rounded 8] -- attribute bundle, used as [$card]
182+-- simple variable, used as $primary
183+@let primary #3b82f6
184+-- quoted string interpolation
185+@let greeting "Hello $name"
186+-- attribute bundle, used as [$card]
187+@let card [padding 20, rounded 8]
176188```
177189
178190### Functions
179191
192+A `@let` with an indented body defines a function. `@children` is where the
193+caller's children go:
194+
180195```
181−@fn button $label
196+@let button $label
182197 @el [padding 12, background $primary, rounded 8]
183198 @text [color white, bold] $label
184− @children -- slot for caller's children
199+ @children
185200
186201@button [label Click me]
187202```
@@ -189,7 +204,8 @@ Use `dark:` for dark mode and `print:` for print styles:
189204### File includes
190205
191206```
192−@include header.hl -- inline another .hl file
207+-- inline another .hl file
208+@include header.hl
193209```
194210
195211### Multi-line attributes
@@ -220,7 +236,7 @@ Attribute lists can span multiple lines:
220236- `[animate fade-in 0.3s ease]` animation shorthand
221237- `[view-transition-name hero]` View Transitions API
222238- `[has(.active):background blue]` `:has()` pseudo-selector
223−- `@slot header` / `@slot content` named slots in `@fn`
239+- `@slot header` / `@slot content` named slots in functions
224240- `@theme` design tokens as runtime CSS custom properties
225241- VS Code snippets for common patterns
226242
crates/htmlang-core/src/codegen.rs+242−274
@@ -16,25 +16,29 @@ const BREAKPOINTS: &[(&str, &str)] = &[
1616 ("2xl", "1536px"),
1717];
1818
19−/// Generate short CSS class names: a, b, ..., z, a0, a1, ..., z9, aa, ab, ...
19+/// Generate short CSS class names: a..z, then aa..a9, ba..b9, ..., z9, then
20+/// aaa, ... The first character is always a letter; later ones are drawn from
21+/// [a-z0-9]. The mapping is a bijection, so distinct indices never collide.
2022pub(crate) fn short_class_name(idx: usize) -> String {
21− if idx < 26 {
22− return String::from((b'a' + idx as u8) as char);
23+ const REST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
24+ // Find the name length: 26 names of length 1, 26*36 of length 2, ...
25+ let mut n = idx;
26+ let mut len = 1;
27+ let mut count = 26usize;
28+ while n >= count {
29+ n -= count;
30+ len += 1;
31+ count = count.saturating_mul(36);
2332 }
24− let mut n = idx - 26;
25− let mut name = String::new();
26− // First char is always a letter
27− name.push((b'a' + (n % 26) as u8) as char);
28− n /= 26;
29− loop {
30− name.push((b'a' + (n % 36).min(25) as u8) as char);
31− if n < 36 {
32− break;
33− }
33+ let mut tail = Vec::with_capacity(len - 1);
34+ for _ in 1..len {
35+ tail.push(REST[n % 36]);
3436 n /= 36;
3537 }
36− // Reverse so it reads naturally
37− name.chars().rev().collect()
38+ let mut name = String::with_capacity(len);
39+ name.push((b'a' + n as u8) as char);
40+ name.extend(tail.iter().rev().map(|&b| b as char));
41+ name
3842}
3943
4044struct StyleEntry {
@@ -566,9 +570,9 @@ fn minify_html(html: &str) -> String {
566570 if i + 4 < chars.len() && chars[i] == '<' {
567571 let rest: String = chars[i..].iter().take(10).collect();
568572 let rest_lower = rest.to_lowercase();
569− if rest_lower.starts_with("<pre") {
573+ if rest_lower.starts_with("<pre") || rest_lower.starts_with("<textarea") {
570574 in_pre = true;
571− } else if rest_lower.starts_with("</pre") {
575+ } else if rest_lower.starts_with("</pre") || rest_lower.starts_with("</textarea") {
572576 in_pre = false;
573577 } else if rest_lower.starts_with("<script") {
574578 in_script = true;
@@ -610,10 +614,11 @@ fn minify_html(html: &str) -> String {
610614 continue;
611615 }
612616
613− // Collapse whitespace between tags
617+ // Collapse whitespace runs to a single space. Spaces are never
618+ // dropped entirely: next to inline elements they are significant
619+ // ("Built with <span>" must keep its space).
614620 if chars[i].is_whitespace() {
615621 if !prev_was_space {
616− // Only emit a space if we're between content (not between tags)
617622 result.push(' ');
618623 prev_was_space = true;
619624 }
@@ -621,14 +626,6 @@ fn minify_html(html: &str) -> String {
621626 continue;
622627 }
623628
624− // Trim space before closing tags
625− if chars[i] == '<' && prev_was_space {
626− // Remove trailing space before tag
627− if result.ends_with(' ') {
628− result.pop();
629− }
630− }
631−
632629 prev_was_space = false;
633630 result.push(chars[i]);
634631 i += 1;
@@ -771,131 +768,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
771768 // Collect external domains for DNS prefetch
772769 let dns_prefetch_html = collect_dns_prefetch(&body, dev);
773770
774− let mut element_css = String::new();
775−
776− // Collect all CSS custom properties (explicit `@let --name` / `@theme`
777− // tokens, plus any auto-extracted repeats) so they can be emitted in a
778− // single `:root` block below.
779− let mut root_vars: Vec<(String, String)> = Vec::new();
780− for (name, value) in &doc.css_vars {
781− root_vars.push((name.clone(), value.clone()));
782− }
783− for (name, value) in &doc.theme_tokens {
784− let css_name = format!("--{}", name);
785− if !root_vars.iter().any(|(n, _)| *n == css_name) {
786− root_vars.push((css_name, value.clone()));
787− }
788− }
789−
790− let has_custom_css = !doc.custom_css.is_empty();
791− let styles_css = styles.to_css_formatted(dev, !has_custom_css);
792− // Fold literal values declared via `@theme` / `@let --name` back into
793− // `var(--name)` references so the generated CSS actually uses the
794− // custom properties emitted in `:root`. Saves bytes and makes runtime
795− // theming take effect.
796− let styles_css = substitute_css_vars(&styles_css, &root_vars);
797− // Further compress the CSS by auto-extracting any remaining literal
798− // values that appear often enough for `var(--hN)` references to come out
799− // shorter overall. Disabled in dev mode to keep the generated CSS
800− // readable.
801− let styles_css = if dev {
802− styles_css
803− } else {
804− let (new_css, auto_vars) = auto_extract_repeats(&styles_css, &root_vars);
805− root_vars.extend(auto_vars);
806− new_css
807− };
808−
809− // Emit the (possibly extended) :root block first so the cascade picks up
810− // the custom properties before the class rules consume them.
811− if !root_vars.is_empty() {
812− if dev {
813− element_css.push_str(":root {\n");
814− for (name, value) in &root_vars {
815− element_css.push_str(&format!(" {}: {};\n", name, value));
816− }
817− element_css.push_str("}\n");
818− } else {
819− element_css.push_str(":root{");
820− for (name, value) in &root_vars {
821− element_css.push_str(name);
822− element_css.push(':');
823− element_css.push_str(value);
824− element_css.push(';');
825− }
826− element_css.push('}');
827− }
828− }
829−
830− element_css.push_str(&styles_css);
831−
832− // @keyframes
833− for (name, kf_body) in &doc.keyframes {
834− if dev {
835− element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
836− } else {
837− element_css.push_str(&format!("@keyframes {}{{{}}}", name, kf_body));
838− }
839− }
840−
841− // Auto-inject skeleton keyframe if skeleton attribute is used
842− let skeleton_used = element_css.contains("hl-skeleton");
843− if skeleton_used {
844− if dev {
845− element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
846− } else {
847− element_css.push_str("@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}");
848− }
849− }
850−
851− // Also inject carousel webkit scrollbar hiding
852− let carousel_used = element_css.contains("scroll-snap-type:x mandatory");
853− if carousel_used && !element_css.contains("::-webkit-scrollbar") {
854− // The carousel class already has scrollbar-width:none, but webkit needs pseudo-element
855− // We add a global rule for carousel-style elements
856− }
857−
858− // @style blocks (custom CSS)
859− for block in &doc.custom_css {
860− if dev {
861− element_css.push_str(block);
862− element_css.push('\n');
863− } else {
864− // Minify: collapse whitespace
865− let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
866− element_css.push_str(&minified);
867− }
868− }
869−
870− // @scope blocks
871− for block in &doc.scope_blocks {
872− if dev {
873− element_css.push_str(block);
874− element_css.push('\n');
875− } else {
876− let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
877− element_css.push_str(&minified);
878− }
879− }
880−
881− // @starting-style blocks
882− if !doc.starting_style_blocks.is_empty() {
883− if dev {
884− element_css.push_str("@starting-style {\n");
885− for block in &doc.starting_style_blocks {
886− element_css.push_str(block);
887− element_css.push('\n');
888− }
889− element_css.push_str("}\n");
890− } else {
891− element_css.push_str("@starting-style{");
892− for block in &doc.starting_style_blocks {
893− let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
894− element_css.push_str(&minified);
895− }
896− element_css.push('}');
897− }
898− }
771+ let element_css = build_element_css(doc, &styles, dev);
899772
900773 // Build meta tags string
901774 let meta_html = if doc.meta_tags.is_empty() {
@@ -1020,35 +893,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1020893 None => String::new(),
1021894 };
1022895
1023− // @font-face CSS
1024− for (name, url) in &doc.font_faces {
1025− let format_hint = if url.ends_with(".woff2") {
1026− " format('woff2')"
1027− } else if url.ends_with(".woff") {
1028− " format('woff')"
1029− } else if url.ends_with(".ttf") {
1030− " format('truetype')"
1031− } else if url.ends_with(".otf") {
1032− " format('opentype')"
1033− } else {
1034− ""
1035− };
1036− if dev {
1037− element_css.insert_str(0, &format!(
1038− "@font-face {{\n font-family: '{}';\n src: url('{}'){};\n font-display: swap;\n}}\n",
1039− name, url, format_hint
1040− ));
1041− } else {
1042− element_css.insert_str(
1043− 0,
1044− &format!(
1045− "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
1046− name, url, format_hint
1047− ),
1048− );
1049− }
1050− }
1051−
1052896 // JSON-LD blocks
1053897 let json_ld_html: String = doc
1054898 .json_ld_blocks
@@ -1068,20 +912,20 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1068912 // Manifest link
1069913 let manifest_html = if let Some(ref manifest) = doc.manifest {
1070914 let mut json = String::from("{");
1071− json.push_str(&format!("\"name\":\"{}\",", manifest.name));
915+ json.push_str(&format!("\"name\":{},", json_str(&manifest.name)));
1072916 if let Some(ref short) = manifest.short_name {
1073− json.push_str(&format!("\"short_name\":\"{}\",", short));
917+ json.push_str(&format!("\"short_name\":{},", json_str(short)));
1074918 }
1075− json.push_str(&format!("\"start_url\":\"{}\",", manifest.start_url));
1076− json.push_str(&format!("\"display\":\"{}\"", manifest.display));
919+ json.push_str(&format!("\"start_url\":{},", json_str(&manifest.start_url)));
920+ json.push_str(&format!("\"display\":{}", json_str(&manifest.display)));
1077921 if let Some(ref bg) = manifest.background_color {
1078− json.push_str(&format!(",\"background_color\":\"{}\"", bg));
922+ json.push_str(&format!(",\"background_color\":{}", json_str(bg)));
1079923 }
1080924 if let Some(ref tc) = manifest.theme_color {
1081− json.push_str(&format!(",\"theme_color\":\"{}\"", tc));
925+ json.push_str(&format!(",\"theme_color\":{}", json_str(tc)));
1082926 }
1083927 if let Some(ref desc) = manifest.description {
1084− json.push_str(&format!(",\"description\":\"{}\"", desc));
928+ json.push_str(&format!(",\"description\":{}", json_str(desc)));
1085929 }
1086930 if !manifest.icons.is_empty() {
1087931 json.push_str(",\"icons\":[");
@@ -1090,8 +934,9 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1090934 json.push(',');
1091935 }
1092936 json.push_str(&format!(
1093− "{{\"src\":\"{}\",\"sizes\":\"{}\",\"type\":\"image/png\"}}",
1094− src, sizes
937+ "{{\"src\":{},\"sizes\":{},\"type\":\"image/png\"}}",
938+ json_str(src),
939+ json_str(sizes)
1095940 ));
1096941 }
1097942 json.push(']');
@@ -1202,6 +1047,8 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
12021047 ""
12031048 };
12041049
1050+ let reset_css = reset_css(doc, dev, focus_visible_css, skip_link_css);
1051+
12051052 match &doc.page_title {
12061053 Some(title) => {
12071054 if dev {
@@ -1215,11 +1062,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
12151062<title>{title}</title>
12161063{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}\
12171064<style>
1218−*, *::before, *::after {{ box-sizing: border-box; }}
1219−body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
1220−img {{ display: block; }}
1221−a {{ text-decoration: none; color: inherit; }}
1222−{focus_visible_css}{skip_link_css}{element_css}\
1065+{reset_css}{element_css}\
12231066</style>
12241067{noscript_html}\
12251068</head>
@@ -1241,15 +1084,14 @@ a {{ text-decoration: none; color: inherit; }}
12411084 json_ld_html = json_ld_html,
12421085 head_html = head_html,
12431086 og_html = og_html,
1244− focus_visible_css = focus_visible_css,
1245− skip_link_css = skip_link_css,
1087+ reset_css = reset_css,
12461088 noscript_html = noscript_html,
12471089 element_css = element_css,
12481090 body = body,
12491091 )
12501092 } else {
12511093 format!(
1252− "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}<style>*,*::before,*::after{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif}}img{{display:block}}a{{text-decoration:none;color:inherit}}{focus_visible_css}{skip_link_css}{element_css}</style>{noscript_html}</head><body>{body}</body></html>",
1094+ "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}<style>{reset_css}{element_css}</style>{noscript_html}</head><body>{body}</body></html>",
12531095 title = html_escape(title),
12541096 lang_attr = lang_attr,
12551097 theme_color_html = theme_color_html,
@@ -1263,8 +1105,7 @@ a {{ text-decoration: none; color: inherit; }}
12631105 favicon_html = favicon_html,
12641106 json_ld_html = json_ld_html,
12651107 head_html = head_html,
1266− focus_visible_css = focus_visible_css,
1267− skip_link_css = skip_link_css,
1108+ reset_css = reset_css,
12681109 noscript_html = noscript_html,
12691110 element_css = element_css,
12701111 body = body,
@@ -1283,25 +1124,60 @@ a {{ text-decoration: none; color: inherit; }}
12831124 }
12841125}
12851126
1286−/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1287−fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1288− let mut styles = StyleCollector::new();
1289− let mut ctx = GenContext {
1290− dev,
1291− depth: 0,
1292− image_count: 0,
1293− has_interactive: false,
1294− has_defer: false,
1295− };
1296− let mut body = String::new();
1297−
1298− for node in &doc.nodes {
1299− generate_node(node, None, &mut body, &mut styles, &mut ctx);
1127+/// Encode `s` as a JSON string literal (with surrounding quotes).
1128+pub(crate) fn json_str(s: &str) -> String {
1129+ let mut out = String::with_capacity(s.len() + 2);
1130+ out.push('"');
1131+ for c in s.chars() {
1132+ match c {
1133+ '"' => out.push_str("\\\""),
1134+ '\\' => out.push_str("\\\\"),
1135+ '\n' => out.push_str("\\n"),
1136+ '\r' => out.push_str("\\r"),
1137+ '\t' => out.push_str("\\t"),
1138+ c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1139+ c => out.push(c),
1140+ }
13001141 }
1142+ out.push('"');
1143+ out
1144+}
13011145
1302− let has_custom_css = !doc.custom_css.is_empty();
1146+/// Assemble every CSS block the document needs (font faces, custom
1147+/// properties, generated class rules, keyframes, and user CSS). Shared by
1148+/// full-page and partial output so both emit the same styles.
1149+fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
13031150 let mut element_css = String::new();
13041151
1152+ // @font-face rules, in declaration order
1153+ for (name, url) in &doc.font_faces {
1154+ let format_hint = if url.ends_with(".woff2") {
1155+ " format('woff2')"
1156+ } else if url.ends_with(".woff") {
1157+ " format('woff')"
1158+ } else if url.ends_with(".ttf") {
1159+ " format('truetype')"
1160+ } else if url.ends_with(".otf") {
1161+ " format('opentype')"
1162+ } else {
1163+ ""
1164+ };
1165+ if dev {
1166+ element_css.push_str(&format!(
1167+ "@font-face {{\n font-family: '{}';\n src: url('{}'){};\n font-display: swap;\n}}\n",
1168+ name, url, format_hint
1169+ ));
1170+ } else {
1171+ element_css.push_str(&format!(
1172+ "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
1173+ name, url, format_hint
1174+ ));
1175+ }
1176+ }
1177+
1178+ // Collect all CSS custom properties (explicit `@let --name` / `@theme`
1179+ // tokens, plus any auto-extracted repeats) so they can be emitted in a
1180+ // single `:root` block below.
13051181 let mut root_vars: Vec<(String, String)> = Vec::new();
13061182 for (name, value) in &doc.css_vars {
13071183 root_vars.push((name.clone(), value.clone()));
@@ -1313,8 +1189,14 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
13131189 }
13141190 }
13151191
1316− let styles_css = styles.to_css_formatted(dev, !has_custom_css);
1192+ let styles_css = styles.to_css_formatted(dev, uses_layer(doc));
1193+ // Fold literal values declared via `@theme` / `@let --name` back into
1194+ // `var(--name)` references so the generated CSS actually uses the
1195+ // custom properties emitted in `:root`.
13171196 let styles_css = substitute_css_vars(&styles_css, &root_vars);
1197+ // Further compress the CSS by auto-extracting any remaining literal
1198+ // values that appear often enough for `var(--hN)` references to come out
1199+ // shorter overall. Disabled in dev mode to keep the CSS readable.
13181200 let styles_css = if dev {
13191201 styles_css
13201202 } else {
@@ -1323,6 +1205,8 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
13231205 new_css
13241206 };
13251207
1208+ // Emit the :root block first so the cascade picks up the custom
1209+ // properties before the class rules consume them.
13261210 if !root_vars.is_empty() {
13271211 if dev {
13281212 element_css.push_str(":root {\n");
@@ -1344,6 +1228,7 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
13441228
13451229 element_css.push_str(&styles_css);
13461230
1231+ // @keyframes
13471232 for (name, kf_body) in &doc.keyframes {
13481233 if dev {
13491234 element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
@@ -1352,15 +1237,84 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
13521237 }
13531238 }
13541239
1355− for block in &doc.custom_css {
1240+ // Auto-inject skeleton keyframe if skeleton attribute is used
1241+ if element_css.contains("hl-skeleton") {
1242+ if dev {
1243+ element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
1244+ } else {
1245+ element_css.push_str("@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}");
1246+ }
1247+ }
1248+
1249+ let push_block = |css: &mut String, block: &str| {
13561250 if dev {
1357− element_css.push_str(block);
1358− element_css.push('\n');
1251+ css.push_str(block);
1252+ css.push('\n');
13591253 } else {
13601254 let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1361− element_css.push_str(&minified);
1255+ css.push_str(&minified);
13621256 }
1257+ };
1258+
1259+ // @style and @scope blocks (custom CSS)
1260+ for block in doc.custom_css.iter().chain(&doc.scope_blocks) {
1261+ push_block(&mut element_css, block);
1262+ }
1263+
1264+ // @starting-style blocks
1265+ if !doc.starting_style_blocks.is_empty() {
1266+ element_css.push_str(if dev { "@starting-style {\n" } else { "@starting-style{" });
1267+ for block in &doc.starting_style_blocks {
1268+ push_block(&mut element_css, block);
1269+ }
1270+ element_css.push_str(if dev { "}\n" } else { "}" });
1271+ }
1272+
1273+ element_css
1274+}
1275+
1276+/// Generated class rules go in `@layer htmlang` unless the page has custom
1277+/// `@style` CSS (which is unlayered and would then always win).
1278+fn uses_layer(doc: &Document) -> bool {
1279+ doc.custom_css.is_empty()
1280+}
1281+
1282+/// Built-in reset rules. When generated rules are layered, the reset must be
1283+/// layered too (in an earlier layer): unlayered CSS beats every layer, so an
1284+/// unlayered `a{color:inherit}` would override `@link [color red]`.
1285+fn reset_css(doc: &Document, dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
1286+ let base = if dev {
1287+ "*, *::before, *::after { box-sizing: border-box; }\nbody { margin: 0; font-family: system-ui, -apple-system, sans-serif; }\nimg { display: block; }\na { text-decoration: none; color: inherit; }\n"
1288+ } else {
1289+ "*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}"
1290+ };
1291+ let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
1292+ if !uses_layer(doc) {
1293+ rules
1294+ } else if dev {
1295+ format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
1296+ } else {
1297+ format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
13631298 }
1299+}
1300+
1301+/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1302+fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1303+ let mut styles = StyleCollector::new();
1304+ let mut ctx = GenContext {
1305+ dev,
1306+ depth: 0,
1307+ image_count: 0,
1308+ has_interactive: false,
1309+ has_defer: false,
1310+ };
1311+ let mut body = String::new();
1312+
1313+ for node in &doc.nodes {
1314+ generate_node(node, None, &mut body, &mut styles, &mut ctx);
1315+ }
1316+
1317+ let element_css = build_element_css(doc, &styles, dev);
13641318
13651319 if element_css.is_empty() {
13661320 body
@@ -2216,11 +2170,11 @@ fn generate_self_closing(
22162170 && !src.starts_with("data:")
22172171 && let Some((w, h)) = read_image_dimensions(src)
22182172 {
2219− if !has_width {
2220− out.push_str(&format!(" width=\"{}\"", w));
2221− }
2222− if !has_height {
2223− out.push_str(&format!(" height=\"{}\"", h));
2173+ // Intrinsic size attributes only when neither dimension is set in
2174+ // CSS; with one CSS dimension, a leftover intrinsic attribute for
2175+ // the other would distort the image (200 wide but 1000 tall).
2176+ if !has_width && !has_height {
2177+ out.push_str(&format!(" width=\"{}\" height=\"{}\"", w, h));
22242178 }
22252179 // Auto aspect-ratio to prevent CLS
22262180 if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
@@ -2877,6 +2831,12 @@ fn attrs_to_css(
28772831 }
28782832 "font" => {
28792833 if let Some(v) = val {
2834+ // `font "Inter, sans-serif"` quotes a whole font stack so
2835+ // its commas don't split the attribute list.
2836+ let v = match v.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
2837+ Some(stack) if stack.contains(',') => stack,
2838+ _ => v,
2839+ };
28802840 push_css(&mut css, "font-family", v);
28812841 }
28822842 }
@@ -3788,30 +3748,22 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
37883748}
37893749
37903750/// Known CSS units — if a value ends with one, skip appending `px`.
3791−const CSS_UNITS: &[&str] = &[
3792− "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm", "mm",
3793− "in", "pt", "pc", "fr",
3794−];
3795−
3796−/// Format a numeric value: if it already has a CSS unit, pass through as-is;
3797−/// otherwise append `px`.
3751+/// Format a length value: a bare number gets `px` appended; anything else
3752+/// (values with units, keywords like `auto`, functions like `calc(...)`) is
3753+/// passed through unchanged.
37983754fn css_px(value: &str) -> String {
37993755 let v = value.trim();
38003756 if v == "0" {
38013757 return "0".to_string();
38023758 }
3803− if CSS_UNITS.iter().any(|u| v.ends_with(u)) {
3804− return v.to_string();
3805− }
3806− if v.starts_with("var(")
3807− || v.starts_with("calc(")
3808− || v.starts_with("clamp(")
3809− || v.starts_with("min(")
3810− || v.starts_with("max(")
3811− {
3812− return v.to_string();
3759+ let is_bare_number = !v.is_empty()
3760+ && v.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
3761+ && v.parse::<f64>().is_ok();
3762+ if is_bare_number {
3763+ format!("{}px", v)
3764+ } else {
3765+ v.to_string()
38133766 }
3814− format!("{}px", v)
38153767}
38163768
38173769/// Rewrite literal values that match a declared CSS custom property (from
@@ -3848,8 +3800,34 @@ fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
38483800 let mut out = String::with_capacity(css.len());
38493801 let mut i = 0;
38503802 let mut prev: Option<u8> = None;
3803+ // Only declaration values are rewritten — never selectors or at-rule
3804+ // preludes such as `@media (min-width:768px)`. `blocks` records, for
3805+ // each open `{`, whether it holds declarations (a style rule) or nested
3806+ // rules (an at-rule like `@media` / `@layer`).
3807+ let mut blocks: Vec<bool> = Vec::new();
3808+ let mut prelude_start = 0;
3809+ let mut in_value = false;
38513810 while i < bytes.len() {
3852− if is_boundary_before(prev) {
3811+ match bytes[i] {
3812+ b'{' => {
3813+ let prelude = css[prelude_start..i].trim_start();
3814+ blocks.push(!prelude.starts_with('@'));
3815+ in_value = false;
3816+ prelude_start = i + 1;
3817+ }
3818+ b'}' => {
3819+ blocks.pop();
3820+ in_value = false;
3821+ prelude_start = i + 1;
3822+ }
3823+ b';' => {
3824+ in_value = false;
3825+ prelude_start = i + 1;
3826+ }
3827+ b':' if blocks.last() == Some(&true) => in_value = true,
3828+ _ => {}
3829+ }
3830+ if in_value && is_boundary_before(prev) {
38533831 let mut matched = false;
38543832 for (val, repl) in &pairs {
38553833 let vb = val.as_bytes();
@@ -4004,22 +3982,9 @@ fn css_line_height(value: &str) -> String {
40043982 if v == "0" || v == "1" {
40053983 return v.to_string();
40063984 }
4007− if v.contains('.') {
4008− return v.to_string();
4009− }
4010− if CSS_UNITS.iter().any(|u| v.ends_with(u)) || v.ends_with("px") {
4011− return v.to_string();
4012− }
4013− if v.starts_with("var(")
4014− || v.starts_with("calc(")
4015− || v.starts_with("clamp(")
4016− || v.starts_with("min(")
4017− || v.starts_with("max(")
4018− {
4019− return v.to_string();
4020− }
4021− // Plain integer — treat as pixel length.
4022− if v.chars().all(|c| c.is_ascii_digit()) {
3985+ // Plain integer — treat as pixel length. Decimals, units, keywords and
3986+ // functions pass through.
3987+ if !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()) {
40233988 return format!("{}px", v);
40243989 }
40253990 v.to_string()
@@ -4232,10 +4197,24 @@ fn base64_encode(data: &[u8]) -> String {
42324197/// Generate a standard v3 source map with VLQ-encoded mappings.
42334198/// Compatible with browser devtools and source map tooling.
42344199pub fn generate_source_map(doc: &Document, source_file: &str) -> String {
4200+ source_map_for_html(&generate_dev(doc), source_file)
4201+}
4202+
4203+/// Build a source map for dev-mode HTML by reading the `data-hl-line`
4204+/// markers each element carries, so generated line numbers are exact.
4205+pub fn source_map_for_html(html: &str, source_file: &str) -> String {
42354206 let mut mappings: Vec<(usize, usize)> = Vec::new(); // (html_line, hl_line)
4236− collect_source_lines(&doc.nodes, &mut mappings);
4237− mappings.sort_by_key(|m| m.0);
4238− mappings.dedup_by_key(|m| m.0);
4207+ for (idx, line) in html.lines().enumerate() {
4208+ if let Some(pos) = line.find("data-hl-line=\"") {
4209+ let digits: String = line[pos + "data-hl-line=\"".len()..]
4210+ .chars()
4211+ .take_while(|c| c.is_ascii_digit())
4212+ .collect();
4213+ if let Ok(hl_line) = digits.parse::<usize>() {
4214+ mappings.push((idx + 1, hl_line));
4215+ }
4216+ }
4217+ }
42394218
42404219 // Build VLQ-encoded mappings string.
42414220 // Each generated line is separated by ';'. Each segment within a line is
@@ -4295,17 +4274,6 @@ fn vlq_encode(value: i64, out: &mut String) {
42954274 }
42964275}
42974276
4298−fn collect_source_lines(nodes: &[Node], mappings: &mut Vec<(usize, usize)>) {
4299− for node in nodes {
4300− if let Node::Element(elem) = node {
4301− if elem.line_num > 0 {
4302− mappings.push((mappings.len() + 1, elem.line_num));
4303− }
4304− collect_source_lines(&elem.children, mappings);
4305− }
4306− }
4307−}
4308−
43094277/// Check if a specific element kind exists anywhere in the node tree.
43104278fn has_element_kind(nodes: &[Node], target: &ElementKind) -> bool {
43114279 for node in nodes {
crates/htmlang-core/src/codegen_tests.rs+9−3
@@ -1,6 +1,6 @@
11#[cfg(test)]
22mod tests {
3− use crate::codegen::{generate, generate_dev, generate_partial, short_class_name};
3+ use crate::codegen::{generate, generate_dev, generate_partial, json_str, short_class_name};
44 use crate::parser::parse;
55
66 fn compile(src: &str) -> String {
@@ -58,8 +58,8 @@ mod tests {
5858 assert_eq!(short_class_name(0), "a");
5959 assert_eq!(short_class_name(25), "z");
6060 // Higher indexes must produce unique, stable names.
61− let names: std::collections::HashSet<_> = (0..200).map(short_class_name).collect();
62− assert_eq!(names.len(), 200, "short_class_name collisions");
61+ let names: std::collections::HashSet<_> = (0..50_000).map(short_class_name).collect();
62+ assert_eq!(names.len(), 50_000, "short_class_name collisions");
6363 }
6464
6565 #[test]
@@ -93,4 +93,10 @@ mod tests {
9393 "duplicate CSS rule emitted ({count} times) in:\n{out}"
9494 );
9595 }
96+
97+ #[test]
98+ fn json_str_escapes_quotes_and_controls() {
99+ assert_eq!(json_str(r#"My "App""#), r#""My \"App\"""#);
100+ assert_eq!(json_str("a\\b\n\t\u{1}"), r#""a\\b\n\t\u0001""#);
101+ }
96102}
crates/htmlang-core/src/parser.rs+316−145
@@ -260,6 +260,57 @@ fn preprocess(input: &str) -> Vec<Line> {
260260
261261 let indent = line.len() - line.trim_start().len();
262262
263+ // Inline `@markdown` and `@script` blocks keep their bodies verbatim:
264+ // in Markdown, blank lines separate paragraphs, `---` is a rule (not
265+ // a comment) and code indentation matters; in JavaScript, newlines,
266+ // `{...}` and `$` must reach the output untouched.
267+ let verbatim_body = trimmed == "@markdown"
268+ || trimmed == "@script"
269+ || trimmed.starts_with("@script ")
270+ || trimmed.starts_with("@script[");
271+ if verbatim_body {
272+ lines.push(Line {
273+ indent,
274+ content: LineContent::Normal(trimmed.to_string()),
275+ line_num: i + 1,
276+ });
277+ let body_start = i + 1;
278+ let mut body_end = body_start;
279+ let mut j = body_start;
280+ while j < raw_lines.len() {
281+ let l = raw_lines[j];
282+ if l.trim().is_empty() {
283+ j += 1;
284+ continue;
285+ }
286+ if l.len() - l.trim_start().len() <= indent {
287+ break;
288+ }
289+ j += 1;
290+ body_end = j;
291+ }
292+ let body = &raw_lines[body_start..body_end];
293+ let body_indent = body
294+ .iter()
295+ .filter(|l| !l.trim().is_empty())
296+ .map(|l| l.len() - l.trim_start().len())
297+ .min()
298+ .unwrap_or(0);
299+ if !body.is_empty() {
300+ let text: Vec<&str> = body
301+ .iter()
302+ .map(|l| l.get(body_indent..).unwrap_or("").trim_end())
303+ .collect();
304+ lines.push(Line {
305+ indent: indent + 1,
306+ content: LineContent::Raw(text.join("\n")),
307+ line_num: body_start + 1,
308+ });
309+ }
310+ i = body_end.max(body_start);
311+ continue;
312+ }
313+
263314 // Handle @raw """..."""
264315 if let Some(raw_rest) = trimmed.strip_prefix("@raw") {
265316 let after_raw = raw_rest.trim_start();
@@ -305,9 +356,7 @@ fn preprocess(input: &str) -> Vec<Line> {
305356 // Join continuation lines for multi-line attribute brackets
306357 let first_line_num = i + 1;
307358 let mut full = trimmed.to_string();
308− let mut depth: i32 = full.chars().filter(|&c| c == '[').count() as i32
309− - full.chars().filter(|&c| c == ']').count() as i32;
310− while depth > 0 && i + 1 < raw_lines.len() {
359+ while open_attr_depth(&full) > 0 && i + 1 < raw_lines.len() {
311360 i += 1;
312361 let next = raw_lines[i].trim();
313362 if next.is_empty() || next.starts_with("--") {
@@ -315,8 +364,6 @@ fn preprocess(input: &str) -> Vec<Line> {
315364 }
316365 full.push(' ');
317366 full.push_str(next);
318− depth += next.chars().filter(|&c| c == '[').count() as i32;
319− depth -= next.chars().filter(|&c| c == ']').count() as i32;
320367 }
321368
322369 lines.push(Line {
@@ -330,6 +377,48 @@ fn preprocess(input: &str) -> Vec<Line> {
330377 lines
331378}
332379
380+/// Bracket depth left open at the end of `line`, counting only attribute
381+/// lists: a `[` that starts the line or follows an `@name` token (optionally
382+/// with one more word, as in `@let name [`). Brackets in text content, such
383+/// as `@text [bold] Use [ to open`, are ignored.
384+fn open_attr_depth(line: &str) -> i32 {
385+ if !line.starts_with('@') && !line.starts_with('[') {
386+ return 0;
387+ }
388+ let bytes = line.as_bytes();
389+ let mut depth: i32 = 0;
390+ for (i, &b) in bytes.iter().enumerate() {
391+ match b {
392+ b'[' if depth > 0 => depth += 1,
393+ b'[' => {
394+ // Is this the start of an attribute list?
395+ let before = line[..i].trim_end();
396+ let last_directive = before
397+ .rsplit([']', '>'])
398+ .next()
399+ .unwrap_or("")
400+ .trim();
401+ let tokens: Vec<&str> = last_directive.split_whitespace().collect();
402+ let starts_list = match tokens.as_slice() {
403+ [] => before.is_empty() || before.ends_with('>'),
404+ [name] => name.starts_with('@'),
405+ [name, _] => name.starts_with('@'),
406+ _ => false,
407+ };
408+ if starts_list {
409+ depth = 1;
410+ } else if before.ends_with(']') {
411+ // Text after a closed attribute list: stop scanning.
412+ return 0;
413+ }
414+ }
415+ b']' if depth > 0 => depth -= 1,
416+ _ => {}
417+ }
418+ }
419+ depth
420+}
421+
333422// ---------------------------------------------------------------------------
334423// Parser
335424// ---------------------------------------------------------------------------
@@ -419,9 +508,16 @@ impl Parser {
419508 if let Some(rest) = content.strip_prefix("@let ") {
420509 let rest = rest.trim();
421510
511+ // A `"""` value opens a multi-line string, whose indented lines
512+ // are its content — not a function body.
513+ let opens_triple_quote = rest.split_once(' ').is_some_and(|(_, v)| {
514+ let v = v.trim();
515+ v.strip_prefix("= ").unwrap_or(v).trim_start().starts_with("\"\"\"")
516+ });
422517 // Check if next lines are indented (function/component definition)
423− let has_body =
424− self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent;
518+ let has_body = !opens_triple_quote
519+ && self.pos < self.lines.len()
520+ && self.lines[self.pos].indent > current_indent;
425521
426522 if has_body {
427523 // Function definition: @let name $param1 $param2=default
@@ -485,6 +581,8 @@ impl Parser {
485581
486582 // Multi-line @let with triple quotes: @let name """..."""
487583 let value_str;
584+ let quoted = value.starts_with('"') && value.ends_with('"') && value.len() >= 2
585+ || value.starts_with("\"\"\"");
488586 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
489587 if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
490588 // Single-line triple-quote: @let name """value"""
@@ -525,7 +623,13 @@ impl Parser {
525623 };
526624 track_var_refs(value, &mut ctx.used_variables);
527625 let value = substitute_vars(value, &ctx.variables);
528− let value = evaluate_arithmetic(&value);
626+ // Quoted values are literal strings; only bare values are
627+ // evaluated as arithmetic (so `"1 / -1"` stays as written).
628+ let value = if quoted {
629+ value
630+ } else {
631+ evaluate_arithmetic(&value)
632+ };
529633 if name.starts_with("--") {
530634 // CSS custom property
531635 ctx.css_vars.push((name.to_string(), value.clone()));
@@ -682,7 +786,7 @@ impl Parser {
682786 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
683787 match &self.lines[self.pos].content {
684788 LineContent::Normal(s) => md_lines.push(s.clone()),
685− LineContent::Raw(s) => md_lines.push(s.clone()),
789+ LineContent::Raw(s) => md_lines.extend(s.lines().map(String::from)),
686790 }
687791 self.pos += 1;
688792 }
@@ -1636,6 +1740,7 @@ impl Parser {
16361740
16371741 if let Some(rest) = content.strip_prefix("@if ") {
16381742 let rest = rest.trim();
1743+ track_var_refs(rest, &mut ctx.used_variables);
16391744 let condition = substitute_vars(rest, &ctx.variables);
16401745 let result = evaluate_condition(&condition);
16411746
@@ -1658,6 +1763,7 @@ impl Parser {
16581763 let trimmed = s.trim();
16591764 if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
16601765 self.pos += 1; // consume @else if
1766+ track_var_refs(else_if_cond, &mut ctx.used_variables);
16611767 let cond = substitute_vars(else_if_cond.trim(), &ctx.variables);
16621768 let cond_result = evaluate_condition(&cond);
16631769 let mut body = Vec::new();
@@ -1722,6 +1828,7 @@ impl Parser {
17221828
17231829 if let Some(rest) = content.strip_prefix("@unless ") {
17241830 let rest = rest.trim();
1831+ track_var_refs(rest, &mut ctx.used_variables);
17251832 let condition = substitute_vars(rest, &ctx.variables);
17261833 let result = !evaluate_condition(&condition);
17271834 let mut body_lines = Vec::new();
@@ -1781,8 +1888,16 @@ impl Parser {
17811888 let var_name = var_names[0].clone();
17821889 let index_var = var_names.get(1).cloned();
17831890
1784− let list_str = substitute_vars(&list_str, &ctx.variables);
1891+ // Strip a trailing `[page N]` pagination suffix so it doesn't end
1892+ // up in the last item; it's parsed from the raw line below.
1893+ let list_str = match list_str.rfind("[page ") {
1894+ Some(pos) if list_str.trim_end().ends_with(']') => {
1895+ list_str[..pos].trim_end().to_string()
1896+ }
1897+ _ => list_str,
1898+ };
17851899 track_var_refs(&list_str, &mut ctx.used_variables);
1900+ let list_str = substitute_vars(&list_str, &ctx.variables);
17861901 // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
17871902 let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
17881903 let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
@@ -1792,23 +1907,7 @@ impl Parser {
17921907 };
17931908 if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
17941909 {
1795− if start <= end {
1796− let mut items = Vec::new();
1797− let mut n = start;
1798− while n <= end {
1799− items.push(n.to_string());
1800− n += step;
1801− }
1802− items
1803− } else {
1804− let mut items = Vec::new();
1805− let mut n = start;
1806− while n >= end {
1807− items.push(n.to_string());
1808− n -= step;
1809− }
1810− items
1811− }
1910+ numeric_range(start, end, step)
18121911 } else {
18131912 list_str
18141913 .split(',')
@@ -1847,11 +1946,13 @@ impl Parser {
18471946 .variables
18481947 .get("_page")
18491948 .and_then(|v| v.parse().ok())
1850− .unwrap_or(1);
1949+ .unwrap_or(1)
1950+ .max(1);
1951+ let size = size.max(1);
18511952 let total_items = items.len();
18521953 let total_pages = total_items.div_ceil(size);
1853− let start = (current_page - 1) * size;
1854− let end = (start + size).min(total_items);
1954+ let start = (current_page - 1).saturating_mul(size);
1955+ let end = start.saturating_add(size).min(total_items);
18551956 let page_items: Vec<String> = if start < total_items {
18561957 items[start..end].to_vec()
18571958 } else {
@@ -1909,7 +2010,9 @@ impl Parser {
19092010 lines: adjusted,
19102011 pos: 0,
19112012 };
2013+ let saved_vars = ctx.variables.clone();
19122014 let nodes = body_parser.parse_children(0, ctx);
2015+ ctx.variables = saved_vars;
19132016 return Ok(Some(nodes));
19142017 }
19152018
@@ -1986,8 +2089,8 @@ impl Parser {
19862089 message: "@for requires: @for $var in start..end".to_string(),
19872090 });
19882091 };
1989− let range_str = substitute_vars(&range_str, &ctx.variables);
19902092 track_var_refs(&range_str, &mut ctx.used_variables);
2093+ let range_str = substitute_vars(&range_str, &ctx.variables);
19912094
19922095 let items: Vec<String> = if let Some((start_s, rest_range)) = range_str.split_once("..")
19932096 {
@@ -1998,23 +2101,7 @@ impl Parser {
19982101 };
19992102 if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
20002103 {
2001− if start <= end {
2002− let mut items = Vec::new();
2003− let mut n = start;
2004− while n <= end {
2005− items.push(n.to_string());
2006− n += step;
2007− }
2008− items
2009− } else {
2010− let mut items = Vec::new();
2011− let mut n = start;
2012− while n >= end {
2013− items.push(n.to_string());
2014− n -= step;
2015− }
2016− items
2017− }
2104+ numeric_range(start, end, step)
20182105 } else {
20192106 return Err(ParseError {
20202107 line: line_num,
@@ -2920,8 +3007,10 @@ impl Parser {
29203007
29213008 // --- Bare text ---
29223009
2923− let var_warnings = check_undefined_vars(&content, &ctx.variables, line_num);
3010+ let var_warnings =
3011+ check_undefined_vars(&content, &ctx.variables, line_num, current_indent);
29243012 ctx.diagnostics.extend(var_warnings);
3013+ track_var_refs(&content, &mut ctx.used_variables);
29253014 let segments = parse_text_segments(&content, ctx);
29263015 Ok(Some(vec![Node::Text(segments)]))
29273016 }
@@ -2997,7 +3086,13 @@ impl Parser {
29973086 .iter()
29983087 .find(|a| a.key == *param)
29993088 .and_then(|a| a.value.clone())
3000− .or_else(|| args.get(i).and_then(|a| a.value.clone()))
3089+ .or_else(|| {
3090+ // Positional fallback, unless that argument is named for
3091+ // a different parameter.
3092+ args.get(i)
3093+ .filter(|a| !fn_def.params.contains(&a.key))
3094+ .and_then(|a| a.value.clone())
3095+ })
30013096 .or_else(|| fn_def.defaults.get(param).cloned())
30023097 .unwrap_or_default();
30033098 ctx.variables.insert(param.clone(), value);
@@ -3196,6 +3291,7 @@ fn parse_single_element(
31963291 };
31973292
31983293 let rest = rest.trim().to_string();
3294+ track_var_refs(&rest, &mut ctx.used_variables);
31993295
32003296 // For @link, first token of rest is URL, remainder is inline text
32013297 let mut children = Vec::new();
@@ -3588,10 +3684,13 @@ fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<Strin
35883684}
35893685
35903686/// Check for undefined `$var` references and return "did you mean?" diagnostics.
3687+/// `indent` is the line's leading whitespace, which `input` has had trimmed;
3688+/// reported columns and source lines include it.
35913689fn check_undefined_vars(
35923690 input: &str,
35933691 vars: &HashMap<String, String>,
35943692 line_num: usize,
3693+ indent: usize,
35953694) -> Vec<Diagnostic> {
35963695 let mut warnings = Vec::new();
35973696 if !input.contains('$') {
@@ -3625,13 +3724,13 @@ fn check_undefined_vars(
36253724 {
36263725 warnings.push(Diagnostic {
36273726 line: line_num,
3628− column: Some(col),
3727+ column: Some(indent + col),
36293728 message: format!(
36303729 "undefined variable '${}', did you mean '${}'?",
36313730 name, closest
36323731 ),
36333732 severity: Severity::Warning,
3634− source_line: Some(input.to_string()),
3733+ source_line: Some(format!("{}{}", " ".repeat(indent), input)),
36353734 });
36363735 }
36373736 i = end;
@@ -4006,8 +4105,8 @@ const NUMERIC_ATTRS: &[&str] = &[
40064105];
40074106
40084107const CSS_UNIT_SUFFIXES: &[&str] = &[
4009− "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm", "mm",
4010− "in", "pt", "pc", "fr",
4108+ "px", "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm",
4109+ "mm", "in", "pt", "pc", "fr",
40114110];
40124111
40134112fn has_css_unit(value: &str) -> bool {
@@ -4559,9 +4658,12 @@ fn split_commas(input: &str) -> Vec<&str> {
45594658 let mut parts = Vec::new();
45604659 let mut start = 0;
45614660 let mut depth = 0;
4661+ let mut in_quotes = false;
45624662
45634663 for (i, c) in input.char_indices() {
45644664 match c {
4665+ '"' => in_quotes = !in_quotes,
4666+ _ if in_quotes => {}
45654667 '[' | '(' => depth += 1,
45664668 ']' | ')' => depth -= 1,
45674669 ',' if depth == 0 => {
@@ -4693,6 +4795,31 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
46934795// Condition evaluation for @if
46944796// ---------------------------------------------------------------------------
46954797
4798+/// Inclusive integer range from `start` to `end` (counting down when
4799+/// `start > end`), stepping by `step`. Stops instead of overflowing.
4800+fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
4801+ let mut items = Vec::new();
4802+ let mut n = start;
4803+ if start <= end {
4804+ while n <= end {
4805+ items.push(n.to_string());
4806+ match n.checked_add(step) {
4807+ Some(next) => n = next,
4808+ None => break,
4809+ }
4810+ }
4811+ } else {
4812+ while n >= end {
4813+ items.push(n.to_string());
4814+ match n.checked_sub(step) {
4815+ Some(next) => n = next,
4816+ None => break,
4817+ }
4818+ }
4819+ }
4820+ items
4821+}
4822+
46964823fn evaluate_condition(condition: &str) -> bool {
46974824 // Ternary expression support: condition ? true_val : false_val
46984825 // (just check the condition part, the ternary is handled in substitute_ternary)
@@ -5381,6 +5508,9 @@ fn validate_tree(
53815508
53825509fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
53835510 let s = s.strip_prefix('#')?;
5511+ if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
5512+ return None;
5513+ }
53845514 match s.len() {
53855515 3 => {
53865516 let r = u8::from_str_radix(&s[0..1], 16).ok()?;
@@ -5535,7 +5665,7 @@ fn parse_keyframe_line(line: &str) -> Option<String> {
55355665 let inner = &rest[1..rest.len() - 1];
55365666 // Parse comma-separated key-value pairs into CSS
55375667 let mut css = String::new();
5538− for part in inner.split(',') {
5668+ for part in split_commas(inner) {
55395669 let part = part.trim();
55405670 if part.is_empty() {
55415671 continue;
@@ -5584,9 +5714,9 @@ fn replace_extends_slots(nodes: Vec<Node>, slot_nodes: &HashMap<String, Vec<Node
55845714fn apply_filter(value: &str, filter: &str) -> String {
55855715 if let Some(arg) = filter.strip_prefix("truncate:") {
55865716 if let Ok(n) = arg.parse::<usize>()
5587− && value.len() > n
5717+ && value.chars().count() > n
55885718 {
5589− return format!("{}...", &value[..n]);
5719+ return format!("{}...", value.chars().take(n).collect::<String>());
55905720 }
55915721 return value.to_string();
55925722 }
@@ -5656,7 +5786,7 @@ fn apply_filter(value: &str, filter: &str) -> String {
56565786 }
56575787 }
56585788 "trim" => value.trim().to_string(),
5659− "length" | "len" => value.len().to_string(),
5789+ "length" | "len" => value.chars().count().to_string(),
56605790 "reverse" => value.chars().rev().collect(),
56615791 _ => value.to_string(),
56625792 }
@@ -5896,7 +6026,7 @@ fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)
58966026}
58976027
58986028fn parse_json_string(chars: &[char], mut pos: usize) -> Option<(String, usize)> {
5899− if chars[pos] != '"' {
6029+ if chars.get(pos) != Some(&'"') {
59006030 return None;
59016031 }
59026032 pos += 1;
@@ -5909,6 +6039,28 @@ fn parse_json_string(chars: &[char], mut pos: usize) -> Option<(String, usize)>
59096039 'n' => s.push('\n'),
59106040 't' => s.push('\t'),
59116041 'r' => s.push('\r'),
6042+ 'b' => s.push('\u{8}'),
6043+ 'f' => s.push('\u{c}'),
6044+ 'u' => {
6045+ let hex4 = |at: usize| -> Option<u32> {
6046+ let digits: String = chars.get(at..at + 4)?.iter().collect();
6047+ u32::from_str_radix(&digits, 16).ok()
6048+ };
6049+ let hi = hex4(pos + 1)?;
6050+ pos += 4;
6051+ let code = if (0xD800..0xDC00).contains(&hi)
6052+ && chars.get(pos + 1) == Some(&'\\')
6053+ && chars.get(pos + 2) == Some(&'u')
6054+ && let Some(lo) = hex4(pos + 3)
6055+ && (0xDC00..0xE000).contains(&lo)
6056+ {
6057+ pos += 6;
6058+ 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)
6059+ } else {
6060+ hi
6061+ };
6062+ s.push(char::from_u32(code).unwrap_or('\u{FFFD}'));
6063+ }
59126064 _ => {
59136065 s.push('\\');
59146066 s.push(chars[pos]);
@@ -6115,11 +6267,10 @@ fn fetch_url_blocking(url: &str) -> Result<String, String> {
61156267 .read_to_end(&mut response)
61166268 .map_err(|e| format!("read failed: {}", e))?;
61176269
6118− let response_str = String::from_utf8_lossy(&response);
6119− // Split headers and body
6120− if let Some(body_start) = response_str.find("\r\n\r\n") {
6121− let headers = &response_str[..body_start];
6122− let body = &response_str[body_start + 4..];
6270+ // Split headers and body (on bytes, so chunk sizes stay byte counts)
6271+ if let Some(body_start) = response.windows(4).position(|w| w == b"\r\n\r\n") {
6272+ let headers = String::from_utf8_lossy(&response[..body_start]);
6273+ let body = &response[body_start + 4..];
61236274
61246275 // Check status code
61256276 if let Some(first_line) = headers.lines().next()
@@ -6136,40 +6287,33 @@ fn fetch_url_blocking(url: &str) -> Result<String, String> {
61366287 .to_lowercase()
61376288 .contains("transfer-encoding: chunked")
61386289 {
6139− return Ok(decode_chunked(body));
6290+ return Ok(String::from_utf8_lossy(&decode_chunked(body)).into_owned());
61406291 }
61416292
6142− Ok(body.to_string())
6293+ Ok(String::from_utf8_lossy(body).into_owned())
61436294 } else {
61446295 Err("malformed HTTP response".to_string())
61456296 }
61466297}
61476298
6148−fn decode_chunked(body: &str) -> String {
6149− let mut result = String::new();
6299+fn decode_chunked(body: &[u8]) -> Vec<u8> {
6300+ let mut result = Vec::new();
61506301 let mut rest = body;
6151− loop {
6152− let rest_trimmed = rest.trim_start();
6153− if rest_trimmed.is_empty() {
6154− break;
6155− }
6156− let size_end = rest_trimmed.find("\r\n").unwrap_or(rest_trimmed.len());
6157− let size_str = &rest_trimmed[..size_end];
6158− let size = usize::from_str_radix(size_str.trim(), 16).unwrap_or(0);
6302+ while let Some(size_end) = rest.windows(2).position(|w| w == b"\r\n") {
6303+ let size_line = String::from_utf8_lossy(&rest[..size_end]);
6304+ // Chunk extensions (`;name=value`) may follow the size.
6305+ let size_str = size_line.split(';').next().unwrap_or("").trim();
6306+ let size = usize::from_str_radix(size_str, 16).unwrap_or(0);
61596307 if size == 0 {
61606308 break;
61616309 }
6162− let chunk_start = size_end + 2;
6163− if chunk_start + size <= rest_trimmed.len() {
6164− result.push_str(&rest_trimmed[chunk_start..chunk_start + size]);
6165− rest = &rest_trimmed[chunk_start + size..];
6166− if rest.starts_with("\r\n") {
6167− rest = &rest[2..];
6168− }
6169− } else {
6170− result.push_str(&rest_trimmed[chunk_start..]);
6310+ let chunk = &rest[size_end + 2..];
6311+ if size > chunk.len() {
6312+ result.extend_from_slice(chunk);
61716313 break;
61726314 }
6315+ result.extend_from_slice(&chunk[..size]);
6316+ rest = chunk[size..].strip_prefix(b"\r\n").unwrap_or(&chunk[size..]);
61736317 }
61746318 result
61756319}
@@ -6179,35 +6323,52 @@ fn decode_chunked(body: &str) -> String {
61796323// ---------------------------------------------------------------------------
61806324
61816325fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
6182− // If the SVG already has this attribute, replace it
6326+ // Only the opening <svg ...> tag is touched, so child attributes such as
6327+ // `stroke-width` are left alone.
6328+ let Some(tag_start) = svg.find("<svg") else {
6329+ return svg.to_string();
6330+ };
6331+ let tag_end = svg[tag_start..]
6332+ .find('>')
6333+ .map(|p| tag_start + p)
6334+ .unwrap_or(svg.len());
6335+ let tag = &svg[tag_start..tag_end];
6336+
6337+ // Replace an existing attribute (must be preceded by whitespace so that
6338+ // `width` doesn't match `stroke-width`).
61836339 let pattern = format!("{}=\"", attr_name);
6184− if let Some(pos) = svg.find(&pattern) {
6185− let after = &svg[pos + pattern.len()..];
6186− if let Some(end) = after.find('"') {
6340+ let existing = tag.match_indices(&pattern).find(|(pos, _)| {
6341+ tag[..*pos]
6342+ .chars()
6343+ .next_back()
6344+ .is_some_and(char::is_whitespace)
6345+ });
6346+ if let Some((pos, _)) = existing {
6347+ let value_start = tag_start + pos + pattern.len();
6348+ if let Some(end) = svg[value_start..tag_end].find('"') {
61876349 let mut result = String::with_capacity(svg.len());
6188− result.push_str(&svg[..pos]);
6189− result.push_str(attr_name);
6190− result.push_str("=\"");
6350+ result.push_str(&svg[..value_start]);
61916351 result.push_str(value);
6192− result.push('"');
6193− result.push_str(&after[end + 1..]);
6352+ result.push_str(&svg[value_start + end..]);
61946353 return result;
61956354 }
61966355 }
6197− // Otherwise inject it into the opening <svg tag
6198− if let Some(pos) = svg.find("<svg") {
6199− let tag_end = svg[pos..].find('>').map(|p| pos + p).unwrap_or(svg.len());
6200− let mut result = String::with_capacity(svg.len() + attr_name.len() + value.len() + 4);
6201− result.push_str(&svg[..tag_end]);
6202− result.push(' ');
6203− result.push_str(attr_name);
6204− result.push_str("=\"");
6205− result.push_str(value);
6206− result.push('"');
6207− result.push_str(&svg[tag_end..]);
6208− return result;
6209− }
6210− svg.to_string()
6356+
6357+ // Otherwise inject it into the opening tag (before a self-closing `/`).
6358+ let insert_at = if svg[..tag_end].ends_with('/') {
6359+ tag_end - 1
6360+ } else {
6361+ tag_end
6362+ };
6363+ let mut result = String::with_capacity(svg.len() + attr_name.len() + value.len() + 4);
6364+ result.push_str(svg[..insert_at].trim_end());
6365+ result.push(' ');
6366+ result.push_str(attr_name);
6367+ result.push_str("=\"");
6368+ result.push_str(value);
6369+ result.push('"');
6370+ result.push_str(&svg[insert_at..]);
6371+ result
62116372}
62126373
62136374// ---------------------------------------------------------------------------
@@ -6216,7 +6377,8 @@ fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
62166377
62176378fn markdown_to_html(lines: &[String]) -> String {
62186379 let mut html = String::new();
6219− let mut in_list = false;
6380+ // Tag of the list currently open ("ul" or "ol"), if any.
6381+ let mut list: Option<&'static str> = None;
62206382 let mut in_code_block = false;
62216383 let mut code_lang = String::new();
62226384 let mut code_buf = String::new();
@@ -6231,6 +6393,20 @@ fn markdown_to_html(lines: &[String]) -> String {
62316393 }
62326394 para.clear();
62336395 };
6396+ let close_list = |list: &mut Option<&'static str>, out: &mut String| {
6397+ if let Some(tag) = list.take() {
6398+ out.push_str(&format!("</{}>\n", tag));
6399+ }
6400+ };
6401+ let open_list = |list: &mut Option<&'static str>, tag: &'static str, out: &mut String| {
6402+ if *list != Some(tag) {
6403+ if let Some(prev) = list.take() {
6404+ out.push_str(&format!("</{}>\n", prev));
6405+ }
6406+ out.push_str(&format!("<{}>\n", tag));
6407+ *list = Some(tag);
6408+ }
6409+ };
62346410
62356411 for line in lines {
62366412 let trimmed = line.trim();
@@ -6250,6 +6426,7 @@ fn markdown_to_html(lines: &[String]) -> String {
62506426 in_code_block = false;
62516427 } else {
62526428 flush_para(&mut para_buf, &mut html);
6429+ close_list(&mut list, &mut html);
62536430 code_lang = after_fence.trim().to_string();
62546431 in_code_block = true;
62556432 }
@@ -6259,32 +6436,24 @@ fn markdown_to_html(lines: &[String]) -> String {
62596436 if !code_buf.is_empty() {
62606437 code_buf.push('\n');
62616438 }
6262− code_buf.push_str(trimmed);
6439+ // Keep indentation inside code blocks.
6440+ code_buf.push_str(line.trim_end());
62636441 continue;
62646442 }
62656443
62666444 // Blank line ends paragraph
62676445 if trimmed.is_empty() {
62686446 flush_para(&mut para_buf, &mut html);
6269− if in_list {
6270− html.push_str("</ul>\n");
6271− in_list = false;
6272− }
6447+ close_list(&mut list, &mut html);
62736448 continue;
62746449 }
62756450
62766451 // Headings
6277− if trimmed.starts_with("###### /* ") {
6278− // skip
6279− }
62806452 let heading_level = trimmed.bytes().take_while(|&b| b == b'#').count();
62816453 if (1..=6).contains(&heading_level) && trimmed.as_bytes().get(heading_level) == Some(&b' ')
62826454 {
62836455 flush_para(&mut para_buf, &mut html);
6284− if in_list {
6285− html.push_str("</ul>\n");
6286− in_list = false;
6287− }
6456+ close_list(&mut list, &mut html);
62886457 let text = &trimmed[heading_level + 1..];
62896458 html.push_str(&format!(
62906459 "<h{}>{}</h{}>\n",
@@ -6295,28 +6464,30 @@ fn markdown_to_html(lines: &[String]) -> String {
62956464 continue;
62966465 }
62976466
6467+ // Horizontal rule (checked before list items so `---`/`***` aren't
6468+ // mistaken for bullets)
6469+ if trimmed == "---" || trimmed == "***" || trimmed == "___" {
6470+ flush_para(&mut para_buf, &mut html);
6471+ close_list(&mut list, &mut html);
6472+ html.push_str("<hr>\n");
6473+ continue;
6474+ }
6475+
62986476 // Unordered list items
62996477 if (trimmed.starts_with("- ") || trimmed.starts_with("* ")) && trimmed.len() > 2 {
63006478 flush_para(&mut para_buf, &mut html);
6301− if !in_list {
6302− html.push_str("<ul>\n");
6303− in_list = true;
6304− }
6479+ open_list(&mut list, "ul", &mut html);
63056480 html.push_str(&format!("<li>{}</li>\n", md_inline(&trimmed[2..])));
63066481 continue;
63076482 }
63086483
63096484 // Ordered list items
63106485 if let Some(dot_pos) = trimmed.find(". ")
6311− && dot_pos <= 3
6486+ && (1..=3).contains(&dot_pos)
63126487 && trimmed[..dot_pos].chars().all(|c| c.is_ascii_digit())
63136488 {
63146489 flush_para(&mut para_buf, &mut html);
6315− if in_list {
6316− html.push_str("</ul>\n");
6317− in_list = false;
6318− }
6319− // We use <ol> but just emit <li> for simplicity
6490+ open_list(&mut list, "ol", &mut html);
63206491 html.push_str(&format!(
63216492 "<li>{}</li>\n",
63226493 md_inline(&trimmed[dot_pos + 2..])
@@ -6324,20 +6495,10 @@ fn markdown_to_html(lines: &[String]) -> String {
63246495 continue;
63256496 }
63266497
6327− // Horizontal rule
6328− if trimmed == "---" || trimmed == "***" || trimmed == "___" {
6329− flush_para(&mut para_buf, &mut html);
6330− if in_list {
6331− html.push_str("</ul>\n");
6332− in_list = false;
6333− }
6334− html.push_str("<hr>\n");
6335− continue;
6336− }
6337−
63386498 // Blockquote
63396499 if let Some(quote_content) = trimmed.strip_prefix("> ") {
63406500 flush_para(&mut para_buf, &mut html);
6501+ close_list(&mut list, &mut html);
63416502 html.push_str(&format!(
63426503 "<blockquote><p>{}</p></blockquote>\n",
63436504 md_inline(quote_content)
@@ -6346,6 +6507,7 @@ fn markdown_to_html(lines: &[String]) -> String {
63466507 }
63476508
63486509 // Otherwise, accumulate paragraph text
6510+ close_list(&mut list, &mut html);
63496511 if !para_buf.is_empty() {
63506512 para_buf.push(' ');
63516513 }
@@ -6353,9 +6515,7 @@ fn markdown_to_html(lines: &[String]) -> String {
63536515 }
63546516
63556517 // Flush remaining
6356− if in_list {
6357− html.push_str("</ul>\n");
6358− }
6518+ close_list(&mut list, &mut html);
63596519 flush_para(&mut para_buf, &mut html);
63606520
63616521 html
@@ -6576,4 +6736,15 @@ mod tests {
65766736 let r = parse("@let x 1 + 2\n");
65776737 assert_eq!(r.document.variables.get("x").map(|s| s.as_str()), Some("3"));
65786738 }
6739+
6740+ #[test]
6741+ fn decode_chunked_handles_bytes_and_malformed_input() {
6742+ // Chunk sizes are byte counts: "é" is 2 bytes.
6743+ let body = "3\r\naé\r\n2\r\nbc\r\n0\r\n\r\n".as_bytes();
6744+ assert_eq!(decode_chunked(body), "aébc".as_bytes());
6745+ // No CRLF after the size line — must not panic.
6746+ assert_eq!(decode_chunked(b"5"), b"");
6747+ // Declared size larger than the data — take what's there.
6748+ assert_eq!(decode_chunked(b"a\r\nabc"), b"abc");
6749+ }
65796750}
crates/htmlang-wasm/src/lib.rs+1−1
@@ -10,7 +10,7 @@ pub fn compile(source: &str) -> String {
1010 .filter(|d| d.severity == htmlang_core::parser::Severity::Error)
1111 .map(|d| {
1212 let loc = if let Some(col) = d.column {
13− format!("{}:{}", d.line, col)
13+ format!("{}:{}", d.line, col + 1)
1414 } else {
1515 d.line.to_string()
1616 };
src/bin/htmlang_lsp/completion.rs+57−57
@@ -682,63 +682,6 @@ pub(crate) fn use_symbol_completions(
682682 items
683683}
684684
685−#[cfg(test)]
686−mod tests {
687− use super::*;
688−
689− fn pos(line: u32, ch: u32) -> Position {
690− Position::new(line, ch)
691− }
692−
693− #[test]
694− fn owning_element_finds_element_on_same_line() {
695− let text = "@input [type text, ";
696− // Cursor at end of line — inside the unclosed `[`.
697− assert_eq!(
698− owning_element(text, pos(0, text.len() as u32)),
699− Some("input".to_string())
700− );
701− }
702−
703− #[test]
704− fn owning_element_finds_element_across_lines() {
705− let text = "@button [\n padding 10,\n ";
706− assert_eq!(
707− owning_element(text, pos(2, 2)),
708− Some("button".to_string())
709− );
710− }
711−
712− #[test]
713− fn owning_element_returns_none_when_not_in_brackets() {
714− let text = "@row\n";
715− assert_eq!(owning_element(text, pos(0, 4)), None);
716− }
717−
718− #[test]
719− fn owning_element_skips_nested_brackets() {
720− let text = "@el [transform translate(10, [20, 30]), ";
721− // Cursor sits inside the outermost bracket after the inner one closed.
722− assert_eq!(
723− owning_element(text, pos(0, text.len() as u32)),
724− Some("el".to_string())
725− );
726− }
727−
728− #[test]
729− fn theme_tokens_extracted_from_block() {
730− let text = "@theme\n primary #3b82f6\n spacing-md 16\n@row\n";
731− let tokens = collect_theme_tokens(text);
732− assert_eq!(
733− tokens,
734− vec![
735− ("primary".into(), "#3b82f6".into()),
736− ("spacing-md".into(), "16".into()),
737− ]
738− );
739− }
740−}
741−
742685/// Walk back from `position` to find the element directive that opened the
743686/// nearest unmatched `[`. Returns the bare name without the leading `@`
744687/// (e.g. `"input"`).
@@ -1963,3 +1906,60 @@ fn function_completions(text: &str, range: Range) -> Vec<CompletionItem> {
19631906
19641907 items
19651908}
1909+
1910+#[cfg(test)]
1911+mod tests {
1912+ use super::*;
1913+
1914+ fn pos(line: u32, ch: u32) -> Position {
1915+ Position::new(line, ch)
1916+ }
1917+
1918+ #[test]
1919+ fn owning_element_finds_element_on_same_line() {
1920+ let text = "@input [type text, ";
1921+ // Cursor at end of line — inside the unclosed `[`.
1922+ assert_eq!(
1923+ owning_element(text, pos(0, text.len() as u32)),
1924+ Some("input".to_string())
1925+ );
1926+ }
1927+
1928+ #[test]
1929+ fn owning_element_finds_element_across_lines() {
1930+ let text = "@button [\n padding 10,\n ";
1931+ assert_eq!(
1932+ owning_element(text, pos(2, 2)),
1933+ Some("button".to_string())
1934+ );
1935+ }
1936+
1937+ #[test]
1938+ fn owning_element_returns_none_when_not_in_brackets() {
1939+ let text = "@row\n";
1940+ assert_eq!(owning_element(text, pos(0, 4)), None);
1941+ }
1942+
1943+ #[test]
1944+ fn owning_element_skips_nested_brackets() {
1945+ let text = "@el [transform translate(10, [20, 30]), ";
1946+ // Cursor sits inside the outermost bracket after the inner one closed.
1947+ assert_eq!(
1948+ owning_element(text, pos(0, text.len() as u32)),
1949+ Some("el".to_string())
1950+ );
1951+ }
1952+
1953+ #[test]
1954+ fn theme_tokens_extracted_from_block() {
1955+ let text = "@theme\n primary #3b82f6\n spacing-md 16\n@row\n";
1956+ let tokens = collect_theme_tokens(text);
1957+ assert_eq!(
1958+ tokens,
1959+ vec![
1960+ ("primary".into(), "#3b82f6".into()),
1961+ ("spacing-md".into(), "16".into()),
1962+ ]
1963+ );
1964+ }
1965+}
src/bin/htmlang_lsp/main.rs+7−7
@@ -280,13 +280,13 @@ impl LanguageServer for Backend {
280280 async fn did_open(&self, params: DidOpenTextDocumentParams) {
281281 // If we still don't have a workspace root, fall back to the parent of
282282 // the first opened file. Common case for `code path/to/file.hl`.
283− if self.index.read().await.root.is_none() {
284− if let Ok(path) = params.text_document.uri.to_file_path()
285− && let Some(parent) = path.parent()
286− {
287− self.index.write().await.set_root(parent.to_path_buf());
288− self.index.write().await.scan();
289− }
283+ if self.index.read().await.root.is_none()
284+ && let Ok(path) = params.text_document.uri.to_file_path()
285+ && let Some(parent) = path.parent()
286+ {
287+ let mut idx = self.index.write().await;
288+ idx.set_root(parent.to_path_buf());
289+ idx.scan();
290290 }
291291 self.set_doc(
292292 params.text_document.uri,
src/convert.rs+239−86
@@ -41,10 +41,6 @@ impl<'a> HtmlParser<'a> {
4141 }
4242 }
4343
44− fn remaining(&self) -> &'a str {
45− std::str::from_utf8(&self.src[self.pos..]).unwrap_or("")
46− }
47−
4844 fn advance(&mut self) {
4945 if self.pos < self.src.len() {
5046 self.pos += 1;
@@ -58,25 +54,39 @@ impl<'a> HtmlParser<'a> {
5854 }
5955
6056 fn starts_with(&self, s: &str) -> bool {
61− self.remaining().starts_with(s)
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'!')
6268 }
6369
70+ /// Consume up to and including `needle`, returning the text before it.
71+ /// Without a match, consumes the rest of the input.
6472 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 {
6582 let start = self.pos;
66− while self.pos < self.src.len() {
67− if self.remaining().starts_with(needle) {
68− let text = std::str::from_utf8(&self.src[start..self.pos])
69− .unwrap_or("")
70− .to_string();
71− self.pos += needle.len();
72− return text;
73− }
74− self.pos += 1;
75− }
76− // Reached end without finding needle
77− std::str::from_utf8(&self.src[start..])
78− .unwrap_or("")
79− .to_string()
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()
8090 }
8191
8292 fn parse_nodes(&mut self, stop_tag: Option<&str>) -> Vec<HtmlNode> {
@@ -115,7 +125,7 @@ impl<'a> HtmlParser<'a> {
115125 } else if self.starts_with("</") {
116126 // Stray closing tag (no matching open) -- skip it
117127 self.consume_until(">");
118− } else if self.starts_with("<") {
128+ } else if self.at_markup() {
119129 if let Some(node) = self.parse_element() {
120130 nodes.push(node);
121131 }
@@ -132,11 +142,12 @@ impl<'a> HtmlParser<'a> {
132142
133143 fn parse_text(&mut self) -> String {
134144 let start = self.pos;
135− while self.pos < self.src.len() && self.src[self.pos] != b'<' {
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()) {
136147 self.pos += 1;
137148 }
138− let raw = std::str::from_utf8(&self.src[start..self.pos]).unwrap_or("");
139− decode_entities(raw)
149+ let raw = String::from_utf8_lossy(&self.src[start..self.pos]);
150+ decode_entities(&raw)
140151 }
141152
142153 fn parse_tag_name(&mut self) -> String {
@@ -288,35 +299,80 @@ impl<'a> HtmlParser<'a> {
288299 }
289300 }
290301
291− /// Case-insensitive consume-until.
292− fn consume_until_ci(&mut self, needle: &str) -> String {
293− let start = self.pos;
294− let needle_lower = needle.to_ascii_lowercase();
295− while self.pos < self.src.len() {
296− let rem = self.remaining().to_ascii_lowercase();
297− if rem.starts_with(&needle_lower) {
298− let text = std::str::from_utf8(&self.src[start..self.pos])
299− .unwrap_or("")
300− .to_string();
301− self.pos += needle.len();
302− return text;
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..];
303325 }
304− self.pos += 1;
305326 }
306− std::str::from_utf8(&self.src[start..])
307− .unwrap_or("")
308− .to_string()
309327 }
328+ out.push_str(rest);
329+ out
310330}
311331
312−fn decode_entities(s: &str) -> String {
313− s.replace("&amp;", "&")
314− .replace("&lt;", "<")
315− .replace("&gt;", ">")
316− .replace("&quot;", "\"")
317− .replace("&#39;", "'")
318− .replace("&apos;", "'")
319− .replace("&nbsp;", " ")
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+ })
320376}
321377
322378// ---------------------------------------------------------------------------
@@ -747,6 +803,11 @@ fn emit_node(node: &HtmlNode, depth: usize, out: &mut String) {
747803 let l = line.trim();
748804 if !l.is_empty() {
749805 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+ }
750811 out.push_str(l);
751812 out.push('\n');
752813 }
@@ -836,14 +897,12 @@ fn emit_element(
836897 "input" => "@input",
837898 "button" => "@button",
838899 "select" => "@select",
839− "textarea" => "@textarea",
840900 "label" => "@label",
841901 "details" => "@details",
842902 "summary" => "@summary",
843903 "blockquote" => "@blockquote",
844904 "cite" => "@cite",
845905 "code" => "@code",
846− "pre" => "@pre",
847906 "hr" => "@hr",
848907 "figure" => "@figure",
849908 "figcaption" => "@figcaption",
@@ -872,9 +931,10 @@ fn emit_element(
872931 "b" | "strong" => "@text",
873932 "i" | "em" => "@text",
874933 "u" => "@text",
875− "br" => {
876− out.push_str(&indent);
877− out.push('\n');
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);
878938 return;
879939 }
880940 "iframe" => "@iframe",
@@ -898,6 +958,25 @@ fn emit_element(
898958 // Build htmlang attributes from CSS + HTML attrs
899959 let (mut hl_attrs, leftover_css) = css_to_hl_attrs(&css_props);
900960
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+
901980 // Add id if present
902981 if let Some(id) = &id_str {
903982 hl_attrs.insert(0, ("id".into(), Some(id.clone())));
@@ -974,21 +1053,7 @@ fn emit_element(
9741053 out.push_str(src_val);
9751054 }
9761055
977− // Attributes block
978− if !hl_attrs.is_empty() {
979− out.push_str(" [");
980− for (i, (key, val)) in hl_attrs.iter().enumerate() {
981− if i > 0 {
982− out.push_str(", ");
983− }
984− out.push_str(key);
985− if let Some(v) = val {
986− out.push(' ');
987− out.push_str(v);
988− }
989− }
990− out.push(']');
991− }
1056+ push_attr_block(&hl_attrs, out);
9921057 out.push('\n');
9931058
9941059 // Comment with original classes (if any)
@@ -1011,22 +1076,46 @@ fn emit_element(
10111076 out.push('\n');
10121077 }
10131078
1014− // Alt text for images
1015− if tag == "img"
1016− && let Some(alt_text) = &alt
1017− && !alt_text.is_empty()
1018− {
1019− // Alt is emitted as an attribute already? No -- let's add it.
1020− // Actually we should add it to hl_attrs above. But since we
1021− // already flushed the line, add a comment.
1022− // Re-check: the image alt is typically an attribute.
1023− // We'll add it inline. Let's fix this by checking above.
1024− }
1025−
10261079 // Emit children
1080+ let child_depth = match &nested_flex {
1081+ Some((kind, flex_attrs)) => {
1082+ out.push_str(&indent_str(depth + 1));
1083+ out.push_str(kind);
1084+ push_attr_block(flex_attrs, out);
1085+ out.push('\n');
1086+ depth + 2
1087+ }
1088+ None => depth + 1,
1089+ };
10271090 for child in children {
1028− emit_node(child, depth + 1, out);
1091+ emit_node(child, child_depth, out);
1092+ }
1093+}
1094+
1095+/// Append ` [key value, ...]` for a non-empty attribute list. Values that
1096+/// contain a `,` or `]` are quoted so they stay one attribute.
1097+fn push_attr_block(attrs: &[HlAttr], out: &mut String) {
1098+ if attrs.is_empty() {
1099+ return;
1100+ }
1101+ out.push_str(" [");
1102+ for (i, (key, val)) in attrs.iter().enumerate() {
1103+ if i > 0 {
1104+ out.push_str(", ");
1105+ }
1106+ out.push_str(key);
1107+ if let Some(v) = val {
1108+ out.push(' ');
1109+ if v.contains(',') || v.contains(']') {
1110+ out.push('"');
1111+ out.push_str(&v.replace('"', "'"));
1112+ out.push('"');
1113+ } else {
1114+ out.push_str(v);
1115+ }
1116+ }
10291117 }
1118+ out.push(']');
10301119}
10311120
10321121fn emit_raw_element(
@@ -1051,11 +1140,13 @@ fn emit_raw_element(
10511140 html.push('"');
10521141 }
10531142 }
1054− if children.is_empty() {
1055− html.push_str(" />");
1143+ if VOID_TAGS.contains(&tag) {
1144+ html.push('>');
1145+ } else if children.is_empty() {
1146+ html.push_str(&format!("></{}>", tag));
10561147 } else {
10571148 html.push('>');
1058− reconstruct_html(children, &mut html);
1149+ reconstruct_html(children, is_raw_text_tag(tag), &mut html);
10591150 html.push_str(&format!("</{}>", tag));
10601151 }
10611152
@@ -1063,8 +1154,9 @@ fn emit_raw_element(
10631154 if trimmed.contains('\n') || trimmed.len() > 60 {
10641155 out.push_str(&indent);
10651156 out.push_str("@raw \"\"\"\n");
1157+ // Raw content is emitted verbatim by the compiler, so indenting it
1158+ // here would change the output (e.g. inside <pre>).
10661159 for line in trimmed.lines() {
1067− out.push_str(&indent_str(depth + 1));
10681160 out.push_str(line);
10691161 out.push('\n');
10701162 }
@@ -1078,9 +1170,15 @@ fn emit_raw_element(
10781170 }
10791171}
10801172
1081−fn reconstruct_html(nodes: &[HtmlNode], out: &mut String) {
1173+/// Elements whose text content is not HTML (and was not entity-decoded).
1174+fn is_raw_text_tag(tag: &str) -> bool {
1175+ tag == "script" || tag == "style"
1176+}
1177+
1178+fn reconstruct_html(nodes: &[HtmlNode], raw_text: bool, out: &mut String) {
10821179 for node in nodes {
10831180 match node {
1181+ HtmlNode::Text(t) if raw_text => out.push_str(t),
10841182 HtmlNode::Text(t) => out.push_str(&html_escape(t)),
10851183 HtmlNode::Comment(c) => {
10861184 out.push_str("<!--");
@@ -1109,7 +1207,7 @@ fn reconstruct_html(nodes: &[HtmlNode], out: &mut String) {
11091207 out.push_str(" />");
11101208 } else {
11111209 out.push('>');
1112− reconstruct_html(children, out);
1210+ reconstruct_html(children, is_raw_text_tag(tag), out);
11131211 out.push_str(&format!("</{}>", tag));
11141212 }
11151213 }
@@ -1278,4 +1376,59 @@ mod tests {
12781376 let result = convert(r#"<div style="border-radius: 8px">X</div>"#);
12791377 assert!(result.contains("rounded 8"));
12801378 }
1379+
1380+ #[test]
1381+ fn bare_less_than_is_text() {
1382+ let result = convert("<p>a < b and 1 <2 ok</p><p>after</p>");
1383+ assert!(result.contains("a < b and 1 <2 ok"), "{result}");
1384+ assert!(result.contains("after"), "{result}");
1385+ assert!(!result.contains("@raw"), "{result}");
1386+ }
1387+
1388+ #[test]
1389+ fn script_content_is_not_escaped() {
1390+ let result = convert("<script>if (a < b && c > d) {}</script>");
1391+ assert!(result.contains("if (a < b && c > d) {}"), "{result}");
1392+ }
1393+
1394+ #[test]
1395+ fn entities_decode_once_and_fully() {
1396+ assert_eq!(decode_entities("&amp;lt;tag&amp;gt;"), "&lt;tag&gt;");
1397+ assert_eq!(decode_entities("&copy; &#8212; &#x2014; &bogus; a&b"), "© — — &bogus; a&b");
1398+ }
1399+
1400+ #[test]
1401+ fn values_with_commas_are_quoted() {
1402+ let result = convert(r#"<input type="text" placeholder="Name, email">"#);
1403+ assert!(result.contains(r#"placeholder "Name, email""#), "{result}");
1404+ }
1405+
1406+ #[test]
1407+ fn text_that_looks_like_syntax_is_escaped() {
1408+ let result = convert("<p>-- not a comment</p><p>@mention</p>");
1409+ assert!(result.contains("@text -- not a comment"), "{result}");
1410+ assert!(result.contains("@text @mention"), "{result}");
1411+ }
1412+
1413+ #[test]
1414+ fn whitespace_sensitive_elements_stay_raw() {
1415+ let result = convert("<div>line1<br>line2</div><pre> indented\n more</pre>");
1416+ assert!(result.contains(r#"@raw """<br>""""#), "{result}");
1417+ assert!(result.contains("<pre> indented\n more</pre>"), "{result}");
1418+ }
1419+
1420+ #[test]
1421+ fn flex_on_semantic_element_nests_a_row() {
1422+ let result = convert(r#"<nav style="display:flex; gap: 8px"><a href="/">Home</a></nav>"#);
1423+ assert!(result.contains("@nav\n @row [spacing 8]\n @link /"), "{result}");
1424+ }
1425+
1426+ #[test]
1427+ fn large_script_converts_quickly() {
1428+ let body = "x".repeat(200_000);
1429+ let start = std::time::Instant::now();
1430+ let result = convert(&format!("<script>{body}</script>"));
1431+ assert!(result.contains(&body[..100]));
1432+ assert!(start.elapsed() < std::time::Duration::from_secs(2));
1433+ }
12811434}
src/fmt.rs+294−239
@@ -4,152 +4,112 @@ const MAX_LINE_WIDTH: usize = 100;
44/// prevents format→format→format oscillation around the wrap threshold.
55const WRAP_MIN_WIDTH: usize = 80;
66
7−/// Attribute category for sorting.
8−fn attr_category(key: &str) -> u8 {
9− // Strip pseudo-state/responsive/media prefixes for categorization
10− let base = key
11− .strip_prefix("hover:")
12− .or_else(|| key.strip_prefix("active:"))
13− .or_else(|| key.strip_prefix("focus:"))
14− .or_else(|| key.strip_prefix("focus-visible:"))
15− .or_else(|| key.strip_prefix("focus-within:"))
16− .or_else(|| key.strip_prefix("disabled:"))
17− .or_else(|| key.strip_prefix("checked:"))
18− .or_else(|| key.strip_prefix("placeholder:"))
19− .or_else(|| key.strip_prefix("first:"))
20− .or_else(|| key.strip_prefix("last:"))
21− .or_else(|| key.strip_prefix("odd:"))
22− .or_else(|| key.strip_prefix("even:"))
23− .or_else(|| key.strip_prefix("sm:"))
24− .or_else(|| key.strip_prefix("md:"))
25− .or_else(|| key.strip_prefix("lg:"))
26− .or_else(|| key.strip_prefix("xl:"))
27− .or_else(|| key.strip_prefix("dark:"))
28− .or_else(|| key.strip_prefix("print:"))
29− .or_else(|| key.strip_prefix("2xl:"))
30− .or_else(|| key.strip_prefix("motion-safe:"))
31− .or_else(|| key.strip_prefix("motion-reduce:"))
32− .or_else(|| key.strip_prefix("landscape:"))
33− .or_else(|| key.strip_prefix("portrait:"))
34− .or_else(|| key.strip_prefix("visited:"))
35− .or_else(|| key.strip_prefix("empty:"))
36− .or_else(|| key.strip_prefix("target:"))
37− .or_else(|| key.strip_prefix("valid:"))
38− .or_else(|| key.strip_prefix("invalid:"))
39− .unwrap_or(key);
40−
41− match base {
42− // Layout (parent)
43− "spacing" | "gap" | "gap-x" | "gap-y" | "wrap" | "grid" | "grid-cols" | "grid-rows"
44− | "column-count" | "column-gap" | "column-width" | "column-rule" => 0,
45− // Sizing
46− "width" | "height" | "min-width" | "max-width" | "min-height" | "max-height"
47− | "flex-grow" | "flex-shrink" | "flex-basis" => 1,
48− // Padding & margin
49− "padding" | "padding-x" | "padding-y" | "padding-inline" | "padding-block" | "margin"
50− | "margin-x" | "margin-y" | "margin-inline" | "margin-block" => 2,
51− // Alignment
52− "center-x" | "center-y" | "align-left" | "align-right" | "align-top" | "align-bottom"
53− | "justify-content" | "align-items" | "place-items" | "place-self" | "place-content" => 3,
54− // Positioning
55− "position" | "top" | "right" | "bottom" | "left" | "z-index" | "order" | "inset"
56− | "col-span" | "row-span" | "display" | "visibility" | "overflow" | "overflow-x"
57− | "overflow-y" | "hidden" => 4,
58− // Visual style
59− "background"
60− | "background-size"
61− | "background-position"
62− | "background-repeat"
63− | "color"
64− | "opacity"
65− | "accent-color"
66− | "caret-color"
67− | "background-image"
68− | "background-blend-mode"
69− | "mix-blend-mode"
70− | "isolation" => 5,
71− // Border & shape
72− "border" | "border-top" | "border-bottom" | "border-left" | "border-right" | "rounded"
73− | "outline" | "shadow" | "text-shadow" | "border-collapse" | "border-spacing" => 6,
74− // Typography
75− "bold"
76− | "italic"
77− | "underline"
78− | "size"
79− | "font"
80− | "text-align"
81− | "line-height"
82− | "letter-spacing"
83− | "text-transform"
84− | "white-space"
85− | "text-overflow"
86− | "word-break"
87− | "overflow-wrap"
88− | "text-decoration"
89− | "text-decoration-color"
90− | "text-decoration-thickness"
91− | "text-decoration-style"
92− | "text-underline-offset"
93− | "list-style"
94− | "text-indent"
95− | "hyphens"
96− | "writing-mode" => 7,
97− // Effects & interaction
98− "transform" | "transition" | "animation" | "cursor" | "backdrop-filter" | "filter"
99− | "pointer-events" | "user-select" | "aspect-ratio" | "object-fit" | "object-position"
100− | "resize" | "clip-path" => 8,
101− // Container queries & scroll
102− "container" | "container-name" | "container-type" | "scroll-snap-type"
103− | "scroll-snap-align" | "scroll-behavior" => 9,
104− // Identity
105− "id" | "class" => 10,
106− // HTML passthrough / form / accessibility
107− _ => 11,
7+/// Split an attribute list on top-level commas: commas inside `(...)`,
8+/// `[...]`, `{...}` or `"..."` belong to the value (e.g. `rgba(0,0,0,0.1)`).
9+fn split_attrs(attrs_str: &str) -> Vec<&str> {
10+ let mut parts = Vec::new();
11+ let mut depth = 0i32;
12+ let mut in_str = false;
13+ let mut start = 0;
14+ for (i, c) in attrs_str.char_indices() {
15+ match c {
16+ '"' => in_str = !in_str,
17+ '(' | '[' | '{' if !in_str => depth += 1,
18+ ')' | ']' | '}' if !in_str => depth -= 1,
19+ ',' if !in_str && depth <= 0 => {
20+ parts.push(&attrs_str[start..i]);
21+ start = i + 1;
22+ }
23+ _ => {}
24+ }
10825 }
109−}
110−
111−/// Sort a comma-separated attribute string by category.
112−fn sort_attrs(attrs_str: &str) -> String {
113− let mut parts: Vec<&str> = attrs_str.split(',').collect();
114− // Preserve order within same category (stable sort)
115− parts.sort_by_key(|part| {
116− let key = part.split_whitespace().next().unwrap_or("");
117− attr_category(key)
118− });
26+ parts.push(&attrs_str[start..]);
11927 parts
120− .iter()
28+ .into_iter()
12129 .map(|p| p.trim())
12230 .filter(|p| !p.is_empty())
123− .collect::<Vec<_>>()
124− .join(", ")
31+ .collect()
12532}
12633
127−/// Count unescaped `[` and `]` outside quoted strings. Handles both `"..."` and
128−/// `'...'` with `\"` / `\'` escapes. Returns (open_count, close_count).
129−fn count_brackets(s: &str) -> (i32, i32) {
130− let mut open = 0i32;
131− let mut close = 0i32;
132− let mut chars = s.chars().peekable();
133− let mut in_str: Option<char> = None;
134− while let Some(c) = chars.next() {
135− match in_str {
136− Some(q) => {
137− if c == '\\' {
138− // consume next char (escape)
139− chars.next();
140− } else if c == q {
141− in_str = None;
142− }
143− }
144− None => match c {
145− '"' | '\'' => in_str = Some(c),
146− '[' => open += 1,
147− ']' => close += 1,
34+/// Normalize spacing in a comma-separated attribute string. Attribute order
35+/// is preserved: later attributes can override earlier ones (e.g.
36+/// `center-x, margin 0`), so reordering could change the output.
37+fn normalize_attrs(attrs_str: &str) -> String {
38+ split_attrs(attrs_str).join(", ")
39+}
40+
41+/// Bracket depth left open by the attribute lists on a (possibly joined)
42+/// line. Only brackets that start an attribute list count: one at the start
43+/// of the line, one right after the `@name` (or `@let name`) head, or one
44+/// after a chained `> @name`. Brackets in trailing text are ignored, so
45+/// `@text Use [ to open` does not start a multi-line block.
46+fn open_attr_depth(line: &str) -> i32 {
47+ let mut depth = 0i32;
48+ let mut region_seen = false;
49+ for (i, b) in line.bytes().enumerate() {
50+ if depth > 0 {
51+ match b {
52+ b'[' => depth += 1,
53+ b']' => depth -= 1,
14854 _ => {}
149− },
55+ }
56+ continue;
57+ }
58+ if b != b'[' {
59+ continue;
15060 }
61+ let before = line[..i].trim_end();
62+ let starts_region = if !region_seen {
63+ // Only `@name` or `@name word` may precede the first list.
64+ let mut tokens = before.split_whitespace();
65+ match (tokens.next(), tokens.next(), tokens.next()) {
66+ (None, _, _) => true,
67+ (Some(t), None, _) | (Some(t), Some(_), None) => t.starts_with('@'),
68+ _ => false,
69+ }
70+ } else {
71+ // A later list belongs to a chained element: `... > @link [`.
72+ let mut tokens = before.split_whitespace().rev();
73+ matches!((tokens.next(), tokens.next()), (Some(t), Some(">")) if t.starts_with('@'))
74+ };
75+ if starts_region {
76+ region_seen = true;
77+ depth = 1;
78+ } else if !region_seen {
79+ // Text precedes the first bracket; nothing on this line opens an
80+ // attribute list.
81+ return 0;
82+ }
83+ }
84+ depth
85+}
86+
87+/// Directives whose indented body is foreign content (CSS, JS, Markdown,
88+/// JSON, raw HTML) that must not be reformatted.
89+fn is_raw_body_header(code: &str) -> bool {
90+ [
91+ "@style",
92+ "@script",
93+ "@markdown",
94+ "@json-ld",
95+ "@head",
96+ "@scope",
97+ "@starting-style",
98+ ]
99+ .iter()
100+ .any(|d| code == *d || code.strip_prefix(d).is_some_and(|r| r.starts_with(' ')))
101+}
102+
103+/// Re-indent a verbatim line by `delta` bytes, keeping its relative
104+/// indentation. Removes at most the existing leading whitespace.
105+fn shift_line(line: &str, delta: i32) -> String {
106+ if delta >= 0 {
107+ format!("{}{}", " ".repeat(delta as usize), line)
108+ } else {
109+ let lead = line.len() - line.trim_start().len();
110+ let remove = (delta.unsigned_abs() as usize).min(lead);
111+ line[remove..].to_string()
151112 }
152− (open, close)
153113}
154114
155115/// Split a line into (code, trailing_comment). The comment starts at the first `--`
@@ -192,62 +152,103 @@ fn split_trailing_comment(line: &str) -> (&str, Option<&str>) {
192152 (line, None)
193153}
194154
195−/// Format an htmlang source file with normalized indentation (2 spaces per level),
196−/// sorted attributes, and cleaned-up whitespace.
155+/// Format an htmlang source file with normalized indentation (2 spaces per level)
156+/// and cleaned-up whitespace. Bodies of `"""` strings and raw-content directives
157+/// (`@style`, `@script`, ...) are kept verbatim apart from a uniform shift.
197158pub fn format(input: &str) -> String {
198159 let mut output = String::new();
199160 let mut indent_stack: Vec<i32> = vec![-1]; // sentinel
200161 let mut bracket_depth: i32 = 0;
201162 let mut bracket_base_level: usize = 0;
202163 let mut bracket_content = String::new();
203− let mut bracket_was_wrapped = false;
164+ // Original lines of the pending multi-line bracket block, emitted as-is
165+ // (re-indented) if the block holds comments, or verbatim if it never closes.
166+ let mut bracket_raw: Vec<&str> = Vec::new();
167+ let mut bracket_has_comment = false;
168+ let mut bracket_delta: i32 = 0;
204169 let mut pending_comment: Option<String> = None;
170+ // Inside a `"""` string: copy lines verbatim until the closing `"""`.
171+ let mut in_triple_quote = false;
172+ // Inside a raw-content body: (header raw indent, indent delta).
173+ let mut raw_body: Option<(i32, i32)> = None;
205174
206175 for line in input.lines() {
207176 let trimmed = line.trim();
208177
209− // Preserve blank lines
210− if trimmed.is_empty() {
178+ if in_triple_quote {
179+ output.push_str(line);
211180 output.push('\n');
181+ if trimmed.contains("\"\"\"") {
182+ in_triple_quote = false;
183+ }
212184 continue;
213185 }
214186
215− // Preserve comments at current indent level
216− if trimmed.starts_with("--") {
187+ if let Some((header_indent, delta)) = raw_body {
188+ if trimmed.is_empty() {
189+ output.push('\n');
190+ continue;
191+ }
217192 let raw_indent = (line.len() - line.trim_start().len()) as i32;
218− while indent_stack.len() > 1 && *indent_stack.last().unwrap() >= raw_indent {
219− indent_stack.pop();
193+ if raw_indent > header_indent {
194+ output.push_str(&shift_line(line, delta));
195+ output.push('\n');
196+ continue;
220197 }
221− let level = indent_stack.len() - 1;
222− output.push_str(&" ".repeat(level));
223− output.push_str(trimmed);
224− output.push('\n');
225− indent_stack.push(raw_indent);
226− continue;
198+ raw_body = None;
227199 }
228200
229− // Inside multi-line bracket continuation — collect content (no comment
230− // handling here; trailing comments inside a multi-line bracket block are
231− // uncommon and intentionally preserved by the collector).
201+ // Inside multi-line bracket continuation — collect content. Blank and
202+ // comment lines are skipped by the parser here, so they must not end
203+ // up in the attribute list.
232204 if bracket_depth > 0 {
205+ bracket_raw.push(line);
206+ if trimmed.is_empty() {
207+ continue;
208+ }
209+ if trimmed.starts_with("--") {
210+ bracket_has_comment = true;
211+ continue;
212+ }
233213 bracket_content.push(' ');
234214 bracket_content.push_str(trimmed);
235− let (open, close) = count_brackets(trimmed);
236− bracket_depth += open - close;
215+ bracket_depth = open_attr_depth(&bracket_content);
237216 if bracket_depth <= 0 {
238− // Bracket block is complete — format it
239217 let level = bracket_base_level;
240− let full = bracket_content.clone();
241− bracket_content.clear();
218+ let raw_lines = std::mem::take(&mut bracket_raw);
219+ let full = std::mem::take(&mut bracket_content);
220+ if bracket_has_comment {
221+ // Keep the block as written (re-indented) so comments stay
222+ // where the author put them.
223+ bracket_has_comment = false;
224+ pending_comment = None;
225+ for (i, raw) in raw_lines.iter().enumerate() {
226+ if i == 0 {
227+ output.push_str(&" ".repeat(level));
228+ output.push_str(raw.trim());
229+ } else if !raw.trim().is_empty() {
230+ output.push_str(&shift_line(raw, bracket_delta));
231+ }
232+ output.push('\n');
233+ }
234+ continue;
235+ }
242236 let formatted = format_line_with_brackets(&full);
243237 let attrs_count = count_attrs(&formatted);
244238 let indented_len = level * 2 + formatted.len();
245− // Stay wrapped if the original was wrapped (hysteresis).
239+ // The original was wrapped, so stay wrapped above WRAP_MIN_WIDTH (hysteresis).
246240 let should_wrap = (indented_len > MAX_LINE_WIDTH)
247− || (bracket_was_wrapped && attrs_count > 1 && indented_len > WRAP_MIN_WIDTH);
248− if should_wrap && let Some(wrapped) = wrap_attrs(&formatted, level) {
241+ || (attrs_count > 1 && indented_len > WRAP_MIN_WIDTH);
242+ if should_wrap && let Some(mut wrapped) = wrap_attrs(&formatted, level) {
243+ if let Some(cmt) = pending_comment.take() {
244+ if wrapped.ends_with('\n') {
245+ wrapped.pop();
246+ }
247+ wrapped.push(' ');
248+ wrapped.push_str(&cmt);
249+ wrapped.push('\n');
250+ }
249251 output.push_str(&wrapped);
250− bracket_was_wrapped = false;
251252 continue;
252253 }
253254 output.push_str(&" ".repeat(level));
@@ -257,11 +258,30 @@ pub fn format(input: &str) -> String {
257258 output.push_str(&cmt);
258259 }
259260 output.push('\n');
260− bracket_was_wrapped = false;
261261 }
262262 continue;
263263 }
264264
265+ // Preserve blank lines
266+ if trimmed.is_empty() {
267+ output.push('\n');
268+ continue;
269+ }
270+
271+ // Preserve comments at current indent level
272+ if trimmed.starts_with("--") {
273+ let raw_indent = (line.len() - line.trim_start().len()) as i32;
274+ while indent_stack.len() > 1 && *indent_stack.last().unwrap() >= raw_indent {
275+ indent_stack.pop();
276+ }
277+ let level = indent_stack.len() - 1;
278+ output.push_str(&" ".repeat(level));
279+ output.push_str(trimmed);
280+ output.push('\n');
281+ indent_stack.push(raw_indent);
282+ continue;
283+ }
284+
265285 let raw_indent = (line.len() - line.trim_start().len()) as i32;
266286
267287 // Pop stack to find parent
@@ -270,31 +290,52 @@ pub fn format(input: &str) -> String {
270290 }
271291
272292 let level = indent_stack.len() - 1;
293+ let is_code = trimmed.starts_with('@') || trimmed.starts_with('[');
273294
274− // Split off a trailing `-- comment` before doing bracket math / sorting.
295+ // An opening `"""` without its closing partner starts a verbatim block.
296+ if trimmed.matches("\"\"\"").count() % 2 == 1 {
297+ output.push_str(&" ".repeat(level));
298+ output.push_str(trimmed);
299+ output.push('\n');
300+ indent_stack.push(raw_indent);
301+ in_triple_quote = true;
302+ continue;
303+ }
304+
305+ // Split off a trailing `-- comment` before doing bracket math.
275306 let (code, trailing) = split_trailing_comment(trimmed);
276307 let code = code.trim_end();
277308 let trailing = trailing.map(|s| s.trim().to_string());
278309
279− let (open, close) = count_brackets(code);
280− bracket_depth = open - close;
310+ bracket_depth = if is_code { open_attr_depth(code) } else { 0 };
281311 if bracket_depth > 0 {
282312 // Start of multi-line bracket
283313 bracket_base_level = level;
284314 bracket_content = code.to_string();
285− bracket_was_wrapped = true;
315+ bracket_raw = vec![line];
316+ bracket_has_comment = false;
317+ bracket_delta = (level * 2) as i32 - raw_indent;
286318 pending_comment = trailing;
287319 indent_stack.push(raw_indent);
288320 continue;
289321 }
290322
291− // Single-line: format brackets inline
292− let formatted = format_line_with_brackets(code);
293− let attrs_count = count_attrs(&formatted);
323+ // Only element/attribute lines have attribute lists; text lines are
324+ // left untouched so bracketed prose isn't rewritten.
325+ let formatted = if is_code {
326+ format_line_with_brackets(code)
327+ } else {
328+ code.to_string()
329+ };
330+
331+ if is_raw_body_header(code) {
332+ raw_body = Some((raw_indent, (level * 2) as i32 - raw_indent));
333+ }
294334
295335 let indented_len = level * 2 + formatted.len();
296336 // Single-line emission — only wrap when we strictly exceed the ceiling.
297− if indented_len > MAX_LINE_WIDTH
337+ if is_code
338+ && indented_len > MAX_LINE_WIDTH
298339 && formatted.contains('[')
299340 && let Some(mut wrapped) = wrap_attrs(&formatted, level)
300341 {
@@ -311,7 +352,6 @@ pub fn format(input: &str) -> String {
311352 indent_stack.push(raw_indent);
312353 continue;
313354 }
314− let _ = attrs_count;
315355
316356 output.push_str(&" ".repeat(level));
317357 output.push_str(&formatted);
@@ -324,33 +364,36 @@ pub fn format(input: &str) -> String {
324364 indent_stack.push(raw_indent);
325365 }
326366
367+ // An attribute list that never closed: emit what we collected unchanged
368+ // rather than dropping it.
369+ for raw in bracket_raw {
370+ output.push_str(raw);
371+ output.push('\n');
372+ }
373+
327374 output
328375}
329376
330−/// Format a single line, sorting attributes inside [...] brackets.
331−fn format_line_with_brackets(line: &str) -> String {
332− // Find bracket boundaries
333− let Some(bracket_start) = line.find('[') else {
334− return line.to_string();
335− };
377+/// Find the byte range of the first top-level `[...]` in `line`, ignoring
378+/// brackets inside double-quoted strings.
379+fn find_attr_brackets(line: &str) -> Option<(usize, usize)> {
380+ let bracket_start = line.find('[')?;
336381 let mut depth = 0;
337− let mut bracket_end = None;
338− let mut in_str: Option<char> = None;
382+ let mut in_str = false;
339383 let mut prev = '\0';
340− for (i, c) in line.char_indices() {
341− if let Some(q) = in_str {
342− if prev != '\\' && c == q {
343− in_str = None;
384+ for (i, c) in line.char_indices().skip_while(|&(i, _)| i < bracket_start) {
385+ if in_str {
386+ if prev != '\\' && c == '"' {
387+ in_str = false;
344388 }
345389 } else {
346390 match c {
347− '"' | '\'' => in_str = Some(c),
391+ '"' => in_str = true,
348392 '[' => depth += 1,
349393 ']' => {
350394 depth -= 1;
351395 if depth == 0 {
352− bracket_end = Some(i);
353− break;
396+ return Some((bracket_start, i));
354397 }
355398 }
356399 _ => {}
@@ -358,72 +401,34 @@ fn format_line_with_brackets(line: &str) -> String {
358401 }
359402 prev = c;
360403 }
361− let Some(bracket_end) = bracket_end else {
404+ None
405+}
406+
407+/// Format a single line, normalizing the attribute list inside [...] brackets.
408+fn format_line_with_brackets(line: &str) -> String {
409+ let Some((bracket_start, bracket_end)) = find_attr_brackets(line) else {
362410 return line.to_string();
363411 };
364−
365412 let before = &line[..bracket_start];
366413 let attrs_inner = &line[bracket_start + 1..bracket_end];
367414 let after = &line[bracket_end + 1..];
368−
369− let sorted = sort_attrs(attrs_inner);
370−
371− format!("{}[{}]{}", before, sorted, after)
415+ format!("{}[{}]{}", before, normalize_attrs(attrs_inner), after)
372416}
373417
374418fn count_attrs(line: &str) -> usize {
375− let Some(start) = line.find('[') else {
376− return 0;
377− };
378− let Some(end) = line.rfind(']') else { return 0 };
379− if end <= start {
380− return 0;
419+ match find_attr_brackets(line) {
420+ Some((start, end)) => split_attrs(&line[start + 1..end]).len(),
421+ None => 0,
381422 }
382− line[start + 1..end]
383− .split(',')
384− .map(|p| p.trim())
385− .filter(|p| !p.is_empty())
386− .count()
387423}
388424
389425fn wrap_attrs(line: &str, indent_level: usize) -> Option<String> {
390− let bracket_start = line.find('[')?;
391− let mut depth = 0;
392− let mut bracket_end = None;
393− let mut in_str: Option<char> = None;
394− let mut prev = '\0';
395− for (i, c) in line.char_indices() {
396− if let Some(q) = in_str {
397− if prev != '\\' && c == q {
398− in_str = None;
399− }
400− } else {
401− match c {
402− '"' | '\'' => in_str = Some(c),
403− '[' => depth += 1,
404− ']' => {
405− depth -= 1;
406− if depth == 0 {
407− bracket_end = Some(i);
408− break;
409− }
410− }
411− _ => {}
412− }
413− }
414− prev = c;
415− }
416− let bracket_end = bracket_end?;
417−
426+ let (bracket_start, bracket_end) = find_attr_brackets(line)?;
418427 let before = &line[..bracket_start];
419428 let attrs_inner = &line[bracket_start + 1..bracket_end];
420429 let after = &line[bracket_end + 1..];
421430
422− let parts: Vec<&str> = attrs_inner
423− .split(',')
424− .map(|s| s.trim())
425− .filter(|s| !s.is_empty())
426− .collect();
431+ let parts = split_attrs(attrs_inner);
427432 if parts.len() <= 1 {
428433 return None;
429434 }
@@ -492,4 +497,54 @@ mod tests {
492497 let out = format(src);
493498 assert!(out.contains("\"--not a comment\""));
494499 }
500+
501+ #[test]
502+ fn apostrophe_does_not_swallow_file() {
503+ let src = "@button [aria-label Don't click, padding 10] Go\n@text after\n";
504+ assert_eq!(format(src), src);
505+ }
506+
507+ #[test]
508+ fn unclosed_bracket_is_kept_verbatim() {
509+ let src = "@el [padding 10,\n color red\n@text x\n";
510+ assert_eq!(format(src), src);
511+ }
512+
513+ #[test]
514+ fn bracket_in_text_does_not_join_lines() {
515+ let src = "@column\n Use [ to open a list\n @text [bold] Second\n";
516+ assert_eq!(format(src), src);
517+ }
518+
519+ #[test]
520+ fn commas_inside_parens_stay_in_value() {
521+ let src = "@el [shadow 0 1px 3px rgba(0,0,0,0.1),bold] hi\n";
522+ assert_eq!(format(src), "@el [shadow 0 1px 3px rgba(0,0,0,0.1), bold] hi\n");
523+ }
524+
525+ #[test]
526+ fn attribute_order_is_preserved() {
527+ let src = "@el [center-x, margin 0, width 200]\n";
528+ assert_eq!(format(src), src);
529+ }
530+
531+ #[test]
532+ fn raw_blocks_are_verbatim() {
533+ let src = "@raw \"\"\"\n<pre>\n deeply indented\n</pre>\n\"\"\"\n";
534+ assert_eq!(format(src), src);
535+ }
536+
537+ #[test]
538+ fn script_body_is_not_reformatted() {
539+ let src = "@el\n @script\n let [x,width] = f();\n";
540+ assert_eq!(format(src), "@el\n @script\n let [x,width] = f();\n");
541+ }
542+
543+ #[test]
544+ fn comment_inside_multiline_brackets_is_idempotent() {
545+ let src = "@el [\n padding 10,\n -- note\n color red\n]\n @text hi\n";
546+ let once = format(src);
547+ assert_eq!(once, src);
548+ assert_eq!(format(&once), once);
549+ }
495550}
src/main.rs+379−157
@@ -77,7 +77,7 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
7777 for d in &result.diagnostics {
7878 let prefix = severity_label(d.severity);
7979 if let Some(col) = d.column {
80− eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
80+ eprintln!("{}: line {}:{}: {}", prefix, d.line, col + 1, d.message);
8181 } else {
8282 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
8383 }
@@ -123,8 +123,10 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
123123 // Generate source map alongside HTML
124124 if cfg.dev {
125125 let map_path = out_path.with_extension("html.map");
126− let source_map = htmlang::codegen::generate_source_map(
127− &result.document,
126+ // Map the HTML that was actually written, so line numbers
127+ // match even with --compat or --minify.
128+ let source_map = htmlang::codegen::source_map_for_html(
129+ &html,
128130 &Path::new(input_path)
129131 .file_name()
130132 .unwrap_or_default()
@@ -139,11 +141,48 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
139141}
140142
141143fn json_escape_string(s: &str) -> String {
142− let escaped = s
143− .replace('\\', "\\\\")
144− .replace('"', "\\\"")
145− .replace('\n', "\\n");
146− format!("\"{}\"", escaped)
144+ let mut out = String::with_capacity(s.len() + 2);
145+ out.push('"');
146+ for c in s.chars() {
147+ match c {
148+ '\\' => out.push_str("\\\\"),
149+ '"' => out.push_str("\\\""),
150+ '\n' => out.push_str("\\n"),
151+ '\r' => out.push_str("\\r"),
152+ '\t' => out.push_str("\\t"),
153+ c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
154+ c => out.push(c),
155+ }
156+ }
157+ out.push('"');
158+ out
159+}
160+
161+/// Rename attribute key `from` to `to` wherever it appears as a whole key:
162+/// preceded by `[`, `,`, whitespace or a `prefix:` and followed by a space.
163+fn rename_attr_key(line: &str, from: &str, to: &str) -> String {
164+ let needle = format!("{from} ");
165+ let mut out = String::with_capacity(line.len());
166+ let mut rest = line;
167+ while let Some(pos) = rest.find(&needle) {
168+ let whole_key = rest[..pos]
169+ .chars()
170+ .next_back()
171+ .is_none_or(|c| matches!(c, '[' | ',' | ':') || c.is_whitespace());
172+ out.push_str(&rest[..pos]);
173+ out.push_str(if whole_key { to } else { from });
174+ out.push(' ');
175+ rest = &rest[pos + needle.len()..];
176+ }
177+ out.push_str(rest);
178+ out
179+}
180+
181+fn xml_escape(s: &str) -> String {
182+ s.replace('&', "&amp;")
183+ .replace('<', "&lt;")
184+ .replace('>', "&gt;")
185+ .replace('"', "&quot;")
147186}
148187
149188fn json_array(items: impl Iterator<Item = String>) -> String {
@@ -291,9 +330,26 @@ fn base64_encode(data: &[u8]) -> String {
291330
292331fn copy_non_hl_files(src_dir: &Path, out_dir: &Path) {
293332 let skip = out_dir.canonicalize().ok();
333+ // Building in place: the assets are already where they belong, and
334+ // copying a file onto itself truncates it.
335+ if skip.is_some() && src_dir.canonicalize().ok() == skip {
336+ return;
337+ }
294338 copy_non_hl_recursive(src_dir, src_dir, out_dir, skip.as_deref());
295339}
296340
341+/// True for `page.html` / `page.html.map` files that sit next to a
342+/// `page.hl` source — stale compiler output, not a static asset.
343+fn is_generated_output(path: &Path) -> bool {
344+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
345+ return false;
346+ };
347+ let stem = name
348+ .strip_suffix(".html.map")
349+ .or_else(|| name.strip_suffix(".html"));
350+ stem.is_some_and(|stem| path.with_file_name(format!("{stem}.hl")).is_file())
351+}
352+
297353fn copy_non_hl_recursive(base: &Path, dir: &Path, out_dir: &Path, skip_canonical: Option<&Path>) {
298354 if let Ok(entries) = fs::read_dir(dir) {
299355 for entry in entries.flatten() {
@@ -313,12 +369,18 @@ fn copy_non_hl_recursive(base: &Path, dir: &Path, out_dir: &Path, skip_canonical
313369 continue;
314370 }
315371 copy_non_hl_recursive(base, &path, out_dir, skip_canonical);
316− } else if path.is_file() && path.extension().is_none_or(|e| e != "hl") {
372+ } else if path.is_file()
373+ && path.extension().is_none_or(|e| e != "hl")
374+ && !is_generated_output(&path)
375+ {
317376 let rel = path.strip_prefix(base).unwrap_or(&path);
318377 let dest = out_dir.join(rel);
319378 if let Some(parent) = dest.parent() {
320379 let _ = fs::create_dir_all(parent);
321380 }
381+ if dest.canonicalize().ok() == path.canonicalize().ok() {
382+ continue;
383+ }
322384 match fs::copy(&path, &dest) {
323385 Ok(_) => eprintln!("copied {}", dest.display()),
324386 Err(e) => eprintln!("error: copy {}: {}", dest.display(), e),
@@ -338,7 +400,7 @@ fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &st
338400 .replace('<', "&lt;")
339401 .replace('>', "&gt;");
340402 let location = match d.column {
341− Some(col) => format!("line {}:{}", d.line, col),
403+ Some(col) => format!("line {}:{}", d.line, col + 1),
342404 None => format!("line {}", d.line),
343405 };
344406 errors.push_str(&format!(
@@ -614,7 +676,7 @@ fn generate_sitemap(dir: &str, base_url: &str) {
614676 };
615677
616678 xml.push_str(" <url>\n");
617− xml.push_str(&format!(" <loc>{}</loc>\n", url));
679+ xml.push_str(&format!(" <loc>{}</loc>\n", xml_escape(&url)));
618680 if let Some(ref date) = lastmod {
619681 xml.push_str(&format!(" <lastmod>{}</lastmod>\n", date));
620682 }
@@ -862,70 +924,112 @@ fn count_elements(
862924 }
863925}
864926
865−/// Extract shared CSS rules across multiple HTML files and write shared.css
866−fn extract_shared_css(html_files: &[PathBuf], out_dir: &Path) {
867− // Parse <style> blocks from each HTML file and count rule occurrences
868− let mut rule_counts: HashMap<String, usize> = HashMap::new();
869− let total = html_files.len();
870− for file in html_files {
871− if let Ok(html) = fs::read_to_string(file) {
872− // Extract CSS between <style> and </style>
873− if let Some(start) = html.find("<style>")
874− && let Some(end) = html[start..].find("</style>")
875− {
876− let css = &html[start + 7..start + end];
877− // Extract individual rules (class-based)
878− let mut seen_in_file = std::collections::HashSet::new();
879− let mut i = 0;
880− let bytes = css.as_bytes();
881− while i < bytes.len() {
882− if bytes[i] == b'.' || bytes[i] == b'@' {
883− // Find end of rule block
884− let start_pos = i;
885− let mut depth = 0;
886− let mut found_open = false;
887− while i < bytes.len() {
888− if bytes[i] == b'{' {
889− depth += 1;
890− found_open = true;
891− } else if bytes[i] == b'}' {
892− depth -= 1;
893− if found_open && depth == 0 {
894− i += 1;
895− break;
896− }
897− }
898− i += 1;
899− }
900− let rule = &css[start_pos..i];
901− if !rule.is_empty() && !seen_in_file.contains(rule) {
902− seen_in_file.insert(rule.to_string());
903− *rule_counts.entry(rule.to_string()).or_insert(0) += 1;
904− }
905− } else {
906− i += 1;
907− }
927+/// Whether the build cache record at `path` matches `key` and every
928+/// dependency it lists still has the recorded content hash.
929+fn build_cache_is_fresh(path: &Path, key: u64) -> bool {
930+ let Ok(record) = fs::read_to_string(path) else {
931+ return false;
932+ };
933+ let mut lines = record.lines();
934+ if lines.next() != Some(key.to_string().as_str()) {
935+ return false;
936+ }
937+ lines.all(|line| {
938+ line.split_once(' ').is_some_and(|(hash, dep)| {
939+ fs::read(dep).is_ok_and(|content| hash_bytes(&content).to_string() == hash)
940+ })
941+ })
942+}
943+
944+/// Record a successful build: the source+flags `key`, then one
945+/// `<content hash> <path>` line per included file.
946+fn write_build_cache(path: &Path, key: u64, deps: &[PathBuf]) {
947+ let mut record = key.to_string();
948+ for dep in deps {
949+ if let Ok(content) = fs::read(dep) {
950+ record.push_str(&format!("\n{} {}", hash_bytes(&content), dep.display()));
951+ }
952+ }
953+ if let Some(parent) = path.parent() {
954+ let _ = fs::create_dir_all(parent);
955+ }
956+ let _ = fs::write(path, record);
957+}
958+
959+fn hash_bytes(bytes: &[u8]) -> u64 {
960+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
961+ bytes.hash(&mut hasher);
962+ hasher.finish()
963+}
964+
965+/// Split a stylesheet into top-level segments: each rule or at-rule block,
966+/// together with the whitespace before it, is one segment, so the segments
967+/// concatenate back to the original text.
968+fn css_segments(css: &str) -> Vec<&str> {
969+ let mut segments = Vec::new();
970+ let mut start = 0;
971+ let mut depth = 0i32;
972+ for (i, c) in css.char_indices() {
973+ match c {
974+ '{' => depth += 1,
975+ '}' => {
976+ depth -= 1;
977+ if depth == 0 {
978+ segments.push(&css[start..=i]);
979+ start = i + 1;
908980 }
909981 }
982+ _ => {}
910983 }
911984 }
912− // Rules appearing in ALL files are shared
913− let shared_rules: Vec<&String> = rule_counts
985+ if start < css.len() {
986+ segments.push(&css[start..]);
987+ }
988+ segments
989+}
990+
991+/// Locate the first `<style>...</style>` body in `html` as a byte range.
992+fn style_body_range(html: &str) -> Option<(usize, usize)> {
993+ let start = html.find("<style>")? + "<style>".len();
994+ let end = start + html[start..].find("</style>")?;
995+ Some((start, end))
996+}
997+
998+/// Extract shared CSS rules across multiple HTML files and write shared.css
999+fn extract_shared_css(html_files: &[PathBuf], out_dir: &Path) {
1000+ let pages: Vec<(&PathBuf, String)> = html_files
9141001 .iter()
915− .filter(|(_, count)| **count == total)
916− .map(|(rule, _)| rule)
1002+ .filter_map(|f| fs::read_to_string(f).ok().map(|html| (f, html)))
9171003 .collect();
1004+ if pages.len() < 2 {
1005+ return;
1006+ }
1007+
1008+ // A rule is shared when it appears (verbatim) in every page. Keep the
1009+ // order of the first page so the cascade stays deterministic.
1010+ let rule_sets: Vec<std::collections::HashSet<&str>> = pages
1011+ .iter()
1012+ .map(|(_, html)| {
1013+ style_body_range(html)
1014+ .map(|(s, e)| css_segments(&html[s..e]).into_iter().map(str::trim).collect())
1015+ .unwrap_or_default()
1016+ })
1017+ .collect();
1018+ let first_css = style_body_range(&pages[0].1).map_or("", |(s, e)| &pages[0].1[s..e]);
1019+ let mut shared_rules: Vec<&str> = Vec::new();
1020+ for rule in css_segments(first_css).into_iter().map(str::trim) {
1021+ if rule.ends_with('}')
1022+ && !shared_rules.contains(&rule)
1023+ && rule_sets.iter().all(|set| set.contains(rule))
1024+ {
1025+ shared_rules.push(rule);
1026+ }
1027+ }
9181028 if shared_rules.is_empty() {
9191029 return;
9201030 }
921− let shared_set: std::collections::HashSet<&String> = shared_rules.iter().copied().collect();
9221031 let shared_css_path = out_dir.join("shared.css");
923− let shared_css: String = shared_rules
924− .iter()
925− .map(|r| r.as_str())
926− .collect::<Vec<_>>()
927− .join("\n");
928− if fs::write(&shared_css_path, &shared_css).is_err() {
1032+ if fs::write(&shared_css_path, shared_rules.join("\n")).is_err() {
9291033 return;
9301034 }
9311035 eprintln!(
@@ -935,53 +1039,30 @@ fn extract_shared_css(html_files: &[PathBuf], out_dir: &Path) {
9351039 );
9361040
9371041 // Remove shared rules from individual files and inject <link> tag
938− for file in html_files {
939− if let Ok(html) = fs::read_to_string(file)
940− && let Some(style_start) = html.find("<style>")
941− && let Some(style_end_rel) = html[style_start..].find("</style>")
942− {
943− let css = &html[style_start + 7..style_start + style_end_rel];
944− // Rebuild CSS without shared rules
945− let mut filtered = String::new();
946− let mut i = 0;
947− let bytes = css.as_bytes();
948− while i < bytes.len() {
949− if bytes[i] == b'.' || bytes[i] == b'@' {
950− let start_pos = i;
951− let mut depth = 0;
952− let mut found_open = false;
953− while i < bytes.len() {
954− if bytes[i] == b'{' {
955− depth += 1;
956− found_open = true;
957− } else if bytes[i] == b'}' {
958− depth -= 1;
959− if found_open && depth == 0 {
960− i += 1;
961− break;
962− }
963− }
964− i += 1;
965− }
966− let rule = &css[start_pos..i];
967− if !shared_set.contains(&rule.to_string()) {
968− filtered.push_str(rule);
969− }
970− } else {
971− filtered.push(css.as_bytes()[i] as char);
972− i += 1;
973− }
974− }
975− let link_tag = "<link rel=\"stylesheet\" href=\"shared.css\">";
976− let new_html = format!(
977− "{}{}<style>{}</style>{}",
978− &html[..style_start],
979− link_tag,
980− filtered,
981− &html[style_start + style_end_rel + 8..],
982− );
983− let _ = fs::write(file, new_html);
984− }
1042+ for (file, html) in &pages {
1043+ let Some((style_start, style_end)) = style_body_range(html) else {
1044+ continue;
1045+ };
1046+ let filtered: String = css_segments(&html[style_start..style_end])
1047+ .into_iter()
1048+ .filter(|seg| !shared_rules.contains(&seg.trim()))
1049+ .collect();
1050+ // Link relative to the page so nested pages (blog/post.html) resolve.
1051+ let depth = file
1052+ .strip_prefix(out_dir)
1053+ .map_or(0, |rel| rel.components().count().saturating_sub(1));
1054+ let href = format!("{}shared.css", "../".repeat(depth));
1055+ let link_tag = format!("<link rel=\"stylesheet\" href=\"{}\">", href);
1056+ let head = &html[..style_start - "<style>".len()];
1057+ let link = if head.contains(&link_tag) { "" } else { &link_tag };
1058+ let new_html = format!(
1059+ "{}{}<style>{}</style>{}",
1060+ head,
1061+ link,
1062+ filtered,
1063+ &html[style_end + "</style>".len()..],
1064+ );
1065+ let _ = fs::write(file, new_html);
9851066 }
9861067}
9871068
@@ -1212,6 +1293,7 @@ fn main() {
12121293 let mut build_minify = false;
12131294 let mut build_compat = false;
12141295 let mut build_strict = false;
1296+ let mut shared_css = false;
12151297 let mut i = 2;
12161298 while i < args.len() {
12171299 match args[i].as_str() {
@@ -1222,6 +1304,7 @@ fn main() {
12221304 "--minify" => build_minify = true,
12231305 "--compat" => build_compat = true,
12241306 "--strict" => build_strict = true,
1307+ "--shared-css" => shared_css = true,
12251308 _ if src_dir.is_none() => src_dir = Some(args[i].as_str()),
12261309 _ => {
12271310 eprintln!("unknown argument: {}", args[i]);
@@ -1280,37 +1363,33 @@ fn main() {
12801363 let skipped = &skipped;
12811364 let cache_dir = &cache_dir;
12821365 s.spawn(move || {
1283− // Content hash-based caching: skip if file content hasn't changed
1284− let hash_file_name = file
1285− .file_name()
1286− .unwrap_or_default()
1287− .to_string_lossy()
1288− .to_string()
1289− + ".hash";
1290− let hash_path = cache_dir.join(&hash_file_name);
1291− if let Ok(content) = fs::read(file) {
1366+ // Content hash-based caching: skip if neither the file, the
1367+ // files it includes, nor the build flags have changed.
1368+ let rel = file.strip_prefix(dir).unwrap_or(file);
1369+ let hash_path = cache_dir.join(rel).with_extension("hl.hash");
1370+ let cache_key = fs::read(file).ok().map(|content| {
12921371 let mut hasher = std::collections::hash_map::DefaultHasher::new();
12931372 content.hash(&mut hasher);
1294− let current_hash = hasher.finish().to_string();
1295− if let Ok(cached_hash) = fs::read_to_string(&hash_path)
1296− && cached_hash.trim() == current_hash
1297− {
1298− // Also verify output exists
1299− let out_exists = effective_out
1300− .as_ref()
1301− .map_or(file.with_extension("html").exists(), |p| {
1302− Path::new(p.as_str()).exists()
1303− });
1304− if out_exists {
1305− skipped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1306− return;
1307− }
1373+ (build_minify, build_compat, build_strict, effective_out).hash(&mut hasher);
1374+ hasher.finish()
1375+ });
1376+ if !shared_css
1377+ && let Some(key) = cache_key
1378+ && build_cache_is_fresh(&hash_path, key)
1379+ {
1380+ // Also verify output exists
1381+ let out_exists = effective_out
1382+ .as_ref()
1383+ .map_or(file.with_extension("html").exists(), |p| {
1384+ Path::new(p.as_str()).exists()
1385+ });
1386+ if out_exists {
1387+ skipped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1388+ return;
13081389 }
1309− // Update hash cache after compilation
1310− let _ = fs::write(&hash_path, &current_hash);
13111390 }
13121391 let path_str = file.to_string_lossy().to_string();
1313− let (has_errors, _) = compile(
1392+ let (has_errors, included) = compile(
13141393 &path_str,
13151394 &CompileConfig {
13161395 output_path: effective_out.as_deref(),
@@ -1322,6 +1401,10 @@ fn main() {
13221401 );
13231402 if has_errors {
13241403 any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
1404+ // Never let a failed build be skipped next time.
1405+ let _ = fs::remove_file(&hash_path);
1406+ } else if let Some(key) = cache_key {
1407+ write_build_cache(&hash_path, key, &included);
13251408 }
13261409 });
13271410 }
@@ -1356,7 +1439,9 @@ fn main() {
13561439 if let Some(out) = out_dir {
13571440 copy_non_hl_files(dir, Path::new(out));
13581441
1359− // Shared CSS extraction: find duplicate CSS rules across pages
1442+ // Shared CSS extraction (opt-in): find duplicate CSS rules across
1443+ // pages. It rewrites the output, so it needs freshly compiled
1444+ // pages — the incremental cache is bypassed when it's enabled.
13601445 let out_path = Path::new(out);
13611446 let html_files: Vec<PathBuf> = hl_files
13621447 .iter()
@@ -1366,7 +1451,7 @@ fn main() {
13661451 })
13671452 .filter(|p| p.exists())
13681453 .collect();
1369− if html_files.len() > 1 {
1454+ if shared_css && html_files.len() > 1 {
13701455 extract_shared_css(&html_files, out_path);
13711456 }
13721457 }
@@ -1921,19 +2006,21 @@ fn main() {
19212006 copy_non_hl_files(target_path, Path::new(out_dir));
19222007 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
19232008 let serve_dir = PathBuf::from(out_dir);
1924− let index_path = serve_dir.join("index.html");
2009+ let server_dir = serve_dir.clone();
19252010 let server_tx = tx.clone();
19262011 let tls_for_thread = tls_config;
19272012 std::thread::spawn(move || {
19282013 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
19292014 match tls_for_thread {
1930− Some(tls) => rt.block_on(htmlang::serve::run_https(
2015+ Some(tls) => rt.block_on(htmlang::serve::run_dir_https(
19312016 effective_port,
1932− index_path,
2017+ server_dir,
19332018 server_tx,
19342019 tls,
19352020 )),
1936− None => rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx)),
2021+ None => {
2022+ rt.block_on(htmlang::serve::run_dir(effective_port, server_dir, server_tx))
2023+ }
19372024 }
19382025 });
19392026 if serve_open {
@@ -1948,6 +2035,11 @@ fn main() {
19482035 Some(tx),
19492036 effective_port,
19502037 config.debounce_ms,
2038+ &WatchBuild {
2039+ out_dirs: Some((target_path.to_path_buf(), serve_dir)),
2040+ discover_new_files: true,
2041+ ..Default::default()
2042+ },
19512043 );
19522044 } else {
19532045 let out_dir = config.output.as_deref().unwrap_or("out");
@@ -1957,6 +2049,7 @@ fn main() {
19572049 .map(|s| s.to_os_string())
19582050 .unwrap_or_default();
19592051 let out_path = Path::new(out_dir).join(&file_stem).with_extension("html");
2052+ let watch_out = out_path.clone();
19602053 let out_path_str = out_path.to_string_lossy().to_string();
19612054 let (_, included) = compile(
19622055 &target,
@@ -1995,6 +2088,10 @@ fn main() {
19952088 Some(tx),
19962089 effective_port,
19972090 config.debounce_ms,
2091+ &WatchBuild {
2092+ out_file: Some(watch_out),
2093+ ..Default::default()
2094+ },
19982095 );
19992096 }
20002097 return;
@@ -2058,6 +2155,13 @@ fn main() {
20582155 None,
20592156 0,
20602157 config.debounce_ms,
2158+ &WatchBuild {
2159+ out_dirs: effective_output
2160+ .as_ref()
2161+ .map(|o| (target_path.to_path_buf(), PathBuf::from(o))),
2162+ discover_new_files: true,
2163+ ..Default::default()
2164+ },
20612165 );
20622166 } else {
20632167 let (_, included) = compile(
@@ -2077,6 +2181,10 @@ fn main() {
20772181 None,
20782182 0,
20792183 config.debounce_ms,
2184+ &WatchBuild {
2185+ out_file: effective_output.as_ref().map(PathBuf::from),
2186+ ..Default::default()
2187+ },
20802188 );
20812189 }
20822190 return;
@@ -2191,12 +2299,15 @@ fn main() {
21912299 <title>{}</title>\n\
21922300 <link>{}</link>\n\
21932301 <description>RSS feed generated by htmlang</description>\n",
2194− site_title, base_url
2302+ xml_escape(&site_title),
2303+ xml_escape(base_url)
21952304 );
21962305 for (title, url, description) in &items {
21972306 rss.push_str(&format!(
21982307 "<item>\n<title>{}</title>\n<link>{}</link>\n<description>{}</description>\n</item>\n",
2199− title, url, description
2308+ xml_escape(title),
2309+ xml_escape(url),
2310+ xml_escape(description)
22002311 ));
22012312 }
22022313 rss.push_str("</channel>\n</rss>\n");
@@ -2888,8 +2999,8 @@ compile();
28882999 })
28893000 .collect();
28903001 if !text.trim().is_empty() {
2891− let display = if text.len() > 40 {
2892− format!("{}...", &text[..37])
3002+ let display = if text.chars().count() > 40 {
3003+ format!("{}...", text.chars().take(37).collect::<String>())
28933004 } else {
28943005 text
28953006 };
@@ -3480,10 +3591,14 @@ compile();
34803591 migrated = migrated.replace("align-center", "center-x");
34813592 changes += 1;
34823593 }
3483− // Migrate spacing -> gap (modern naming)
3484− if migrated.contains("spacing ") && migrated.contains('[') {
3485− migrated = migrated.replace("spacing ", "gap ");
3486− changes += 1;
3594+ // Migrate spacing -> gap (modern naming). Only the whole
3595+ // attribute key: `letter-spacing` must stay as it is.
3596+ if migrated.contains('[') {
3597+ let renamed = rename_attr_key(&migrated, "spacing", "gap");
3598+ if renamed != migrated {
3599+ migrated = renamed;
3600+ changes += 1;
3601+ }
34873602 }
34883603 output.push_str(&migrated);
34893604 output.push('\n');
@@ -3559,7 +3674,7 @@ compile();
35593674 template.push_str(" @el [padding 16, rounded 8, border 1 #e5e7eb]\n");
35603675 if !params.is_empty() {
35613676 for p in &params {
3562− template.push_str(&format!(" @text ${}\\n", p));
3677+ template.push_str(&format!(" @text ${}\n", p));
35633678 }
35643679 }
35653680 template.push_str(" @children\n");
@@ -3739,10 +3854,12 @@ compile();
37393854 return;
37403855 }
37413856
3742− // For directory serve mode, serve the directory with route mapping
3857+ // For directory serve mode, serve the output directory with route mapping
37433858 let reload_tx = if serve {
37443859 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
3745− let serve_dir = dir.to_path_buf();
3860+ let serve_dir = output_path
3861+ .as_ref()
3862+ .map_or_else(|| dir.to_path_buf(), PathBuf::from);
37463863 let server_tx = tx.clone();
37473864 std::thread::spawn(move || {
37483865 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
@@ -3765,6 +3882,16 @@ compile();
37653882 reload_tx,
37663883 port,
37673884 50,
3885+ &WatchBuild {
3886+ out_dirs: output_path
3887+ .as_ref()
3888+ .map(|o| (dir.to_path_buf(), PathBuf::from(o))),
3889+ discover_new_files: true,
3890+ compat,
3891+ strict,
3892+ partial,
3893+ ..Default::default()
3894+ },
37683895 );
37693896 return;
37703897 }
@@ -3831,6 +3958,13 @@ compile();
38313958 reload_tx,
38323959 port,
38333960 50,
3961+ &WatchBuild {
3962+ out_file: output_path.as_ref().map(PathBuf::from),
3963+ compat,
3964+ strict,
3965+ partial,
3966+ ..Default::default()
3967+ },
38343968 );
38353969}
38363970
@@ -3850,6 +3984,38 @@ fn collect_hl_files(dir: &Path) -> Vec<PathBuf> {
38503984 files
38513985}
38523986
3987+/// How watch-mode recompiles are built: where each source's output goes and
3988+/// which flags apply, so recompiles match the initial build.
3989+#[derive(Default)]
3990+struct WatchBuild {
3991+ /// `(source root, output root)`: `<src>/a/b.hl` compiles to `<out>/a/b.html`.
3992+ out_dirs: Option<(PathBuf, PathBuf)>,
3993+ /// Output file for a single watched source (`-o page.html`).
3994+ out_file: Option<PathBuf>,
3995+ /// Pick up `.hl` files created in the watch directory (directory mode).
3996+ discover_new_files: bool,
3997+ minify: bool,
3998+ compat: bool,
3999+ strict: bool,
4000+ partial: bool,
4001+}
4002+
4003+impl WatchBuild {
4004+ /// Output path for `source`, or `None` to write next to the source.
4005+ fn output_for(&self, source: &Path) -> Option<PathBuf> {
4006+ if let Some(file) = &self.out_file {
4007+ return Some(file.clone());
4008+ }
4009+ let (src_root, out_root) = self.out_dirs.as_ref()?;
4010+ let src_root = fs::canonicalize(src_root).unwrap_or_else(|_| src_root.clone());
4011+ let rel = source
4012+ .strip_prefix(&src_root)
4013+ .ok()
4014+ .or_else(|| source.file_name().map(Path::new))?;
4015+ Some(out_root.join(rel).with_extension("html"))
4016+ }
4017+}
4018+
38534019#[allow(clippy::too_many_arguments)]
38544020fn watch_loop(
38554021 watch_dir: &Path,
@@ -3860,10 +4026,18 @@ fn watch_loop(
38604026 reload_tx: Option<tokio::sync::broadcast::Sender<()>>,
38614027 serve_port: u16,
38624028 debounce_ms: u64,
4029+ build: &WatchBuild,
38634030) {
38644031 use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
38654032 use std::sync::mpsc;
38664033
4034+ // `Path::new("page.hl").parent()` is `Some("")`, which can't be watched.
4035+ let watch_dir = if watch_dir.as_os_str().is_empty() {
4036+ Path::new(".")
4037+ } else {
4038+ watch_dir
4039+ };
4040+
38674041 let (tx, rx) = mpsc::channel();
38684042 let mut watcher = RecommendedWatcher::new(
38694043 move |res| {
@@ -3975,7 +4149,7 @@ fn watch_loop(
39754149 }
39764150
39774151 // Check for new .hl files in directory
3978− if watch_dir.is_dir() {
4152+ if build.discover_new_files && watch_dir.is_dir() {
39794153 let current_files = collect_hl_files(watch_dir);
39804154 for file in &current_files {
39814155 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
@@ -4001,7 +4175,7 @@ fn watch_loop(
40014175 .iter()
40024176 .map(|f| fs::canonicalize(f).unwrap_or_else(|_| f.clone()))
40034177 .collect();
4004− if watch_dir.is_dir() {
4178+ if build.discover_new_files && watch_dir.is_dir() {
40054179 for file in &collect_hl_files(watch_dir) {
40064180 let c = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
40074181 if !s.contains(&c) {
@@ -4037,11 +4211,21 @@ fn watch_loop(
40374211 let mut recompiled = 0usize;
40384212 for file in &files_to_compile {
40394213 let path_str = file.to_string_lossy().to_string();
4214+ let out_path = build.output_for(file);
4215+ if let Some(parent) = out_path.as_deref().and_then(Path::parent) {
4216+ let _ = fs::create_dir_all(parent);
4217+ }
4218+ let out_str = out_path.map(|p| p.to_string_lossy().to_string());
40404219 let (_, new_includes) = compile(
40414220 &path_str,
40424221 &CompileConfig {
40434222 dev,
40444223 error_overlay: serve,
4224+ output_path: out_str.as_deref(),
4225+ minify: build.minify,
4226+ compat: build.compat,
4227+ strict: build.strict,
4228+ partial: build.partial,
40454229 ..Default::default()
40464230 },
40474231 );
@@ -4083,3 +4267,41 @@ fn watch_loop(
40834267 }
40844268 }
40854269}
4270+
4271+#[cfg(test)]
4272+mod tests {
4273+ use super::*;
4274+
4275+ #[test]
4276+ fn rename_attr_key_only_touches_whole_keys() {
4277+ assert_eq!(
4278+ rename_attr_key(
4279+ "@row [spacing 10, letter-spacing 2, hover:spacing 4]",
4280+ "spacing",
4281+ "gap"
4282+ ),
4283+ "@row [gap 10, letter-spacing 2, hover:gap 4]"
4284+ );
4285+ }
4286+
4287+ #[test]
4288+ fn json_escape_handles_control_characters() {
4289+ assert_eq!(
4290+ json_escape_string("a\tb\r\u{1}\"\\"),
4291+ "\"a\\tb\\r\\u0001\\\"\\\\\""
4292+ );
4293+ }
4294+
4295+ #[test]
4296+ fn css_segments_split_on_rule_boundaries_only() {
4297+ let css = "body{line-height:1.5}.a{content:\"→\"}@media(x){.a{b:c}}";
4298+ assert_eq!(
4299+ css_segments(css),
4300+ vec![
4301+ "body{line-height:1.5}",
4302+ ".a{content:\"→\"}",
4303+ "@media(x){.a{b:c}}"
4304+ ]
4305+ );
4306+ }
4307+}
src/serve.rs+100−31
@@ -1,4 +1,4 @@
1−use std::path::{Path, PathBuf};
1+use std::path::{Component, Path, PathBuf};
22use std::sync::Arc;
33
44use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
@@ -181,17 +181,16 @@ where
181181 return handle_sse(stream, reload_rx).await;
182182 }
183183
184− if path.contains("..") {
184+ let Some(requested) = resolve_request_path(&root_dir, path) else {
185185 stream
186186 .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
187187 .await?;
188188 return Ok(());
189− }
190−
189+ };
191190 let clean_path = path.trim_start_matches('/');
192191
193192 // Try to resolve the request to a file
194− let file_path = if clean_path.is_empty() {
193+ let file_path = if requested == root_dir {
195194 // Root: try index.html
196195 let index = root_dir.join("index.html");
197196 if index.exists() {
@@ -208,26 +207,19 @@ where
208207 return Ok(());
209208 }
210209 } else {
211− let requested = root_dir.join(clean_path);
212− if requested.is_file() {
213− Some(requested)
214− } else {
215− // Try .html extension
216− let with_html = requested.with_extension("html");
217− if with_html.is_file() {
218− Some(with_html)
219− } else {
220− // Try as directory with index.html
221− let dir_index = requested.join("index.html");
222− if dir_index.is_file() {
223− Some(dir_index)
224− } else {
225− None
226− }
227− }
228− }
210+ // The file itself, then `<path>.html`, then `<path>/index.html`.
211+ [
212+ requested.clone(),
213+ requested.with_extension("html"),
214+ requested.join("index.html"),
215+ ]
216+ .into_iter()
217+ .find(|p| p.is_file())
229218 };
230219
220+ // Reject symlinks that lead outside the served directory.
221+ let file_path = file_path.filter(|fp| is_within(&root_dir, fp));
222+
231223 match file_path {
232224 Some(fp) => {
233225 let body = match std::fs::read(&fp) {
@@ -247,7 +239,7 @@ where
247239 };
248240 let content_type = content_type_for(&fp);
249241 let header = format!(
250− "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
242+ "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
251243 content_type,
252244 body.len(),
253245 );
@@ -400,18 +392,25 @@ async fn handle_file<S>(
400392where
401393 S: AsyncRead + AsyncWrite + Unpin + Send,
402394{
403− if request_path.contains("..") {
395+ let dir = root_dir.unwrap_or_else(|| {
396+ html_path
397+ .parent()
398+ .filter(|p| !p.as_os_str().is_empty())
399+ .unwrap_or(Path::new("."))
400+ });
401+ let Some(requested) = resolve_request_path(dir, request_path) else {
404402 stream
405403 .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
406404 .await?;
407405 return Ok(());
408− }
409−
410− let file_path = if request_path == "/" {
406+ };
407+ let file_path = if requested == dir {
411408 html_path.to_path_buf()
409+ } else if is_within(dir, &requested) {
410+ requested
412411 } else {
413− let dir = root_dir.unwrap_or_else(|| html_path.parent().unwrap_or(Path::new(".")));
414− dir.join(&request_path[1..])
412+ send_404(&mut stream, request_path).await?;
413+ return Ok(());
415414 };
416415
417416 let body = match std::fs::read(&file_path) {
@@ -430,7 +429,7 @@ where
430429
431430 let content_type = content_type_for(&file_path);
432431 let header = format!(
433− "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
432+ "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
434433 body.len(),
435434 );
436435 stream.write_all(header.as_bytes()).await?;
@@ -439,6 +438,48 @@ where
439438 Ok(())
440439}
441440
441+/// Map a request target (`/a/b.css?v=2`) to a path under `root`. Strips the
442+/// query and fragment, percent-decodes, and rejects anything but a plain
443+/// relative path (`..`, absolute paths, drive prefixes). `/` maps to `root`.
444+fn resolve_request_path(root: &Path, target: &str) -> Option<PathBuf> {
445+ let path = target.split(['?', '#']).next().unwrap_or("");
446+ let decoded = percent_decode(path)?;
447+ let mut resolved = root.to_path_buf();
448+ for component in Path::new(decoded.trim_start_matches('/')).components() {
449+ match component {
450+ Component::Normal(part) => resolved.push(part),
451+ Component::CurDir => {}
452+ _ => return None,
453+ }
454+ }
455+ Some(resolved)
456+}
457+
458+/// Whether `file`, with symlinks resolved, lies inside `root`.
459+fn is_within(root: &Path, file: &Path) -> bool {
460+ match (root.canonicalize(), file.canonicalize()) {
461+ (Ok(root), Ok(file)) => file.starts_with(root),
462+ _ => false,
463+ }
464+}
465+
466+fn percent_decode(s: &str) -> Option<String> {
467+ let bytes = s.as_bytes();
468+ let mut out = Vec::with_capacity(bytes.len());
469+ let mut i = 0;
470+ while i < bytes.len() {
471+ if bytes[i] == b'%' {
472+ let hex = s.get(i + 1..i + 3)?;
473+ out.push(u8::from_str_radix(hex, 16).ok()?);
474+ i += 3;
475+ } else {
476+ out.push(bytes[i]);
477+ i += 1;
478+ }
479+ }
480+ String::from_utf8(out).ok()
481+}
482+
442483async fn send_404<S>(stream: &mut S, path: &str) -> std::io::Result<()>
443484where
444485 S: AsyncRead + AsyncWrite + Unpin + Send,
@@ -516,3 +557,31 @@ fn content_type_for(path: &Path) -> &'static str {
516557 _ => "application/octet-stream",
517558 }
518559}
560+
561+#[cfg(test)]
562+mod tests {
563+ use super::*;
564+
565+ #[test]
566+ fn request_paths_stay_inside_root() {
567+ let root = Path::new("/srv/site");
568+ assert_eq!(resolve_request_path(root, "/"), Some(root.to_path_buf()));
569+ assert_eq!(
570+ resolve_request_path(root, "/css/a.css?v=2#x"),
571+ Some(root.join("css/a.css"))
572+ );
573+ assert_eq!(
574+ resolve_request_path(root, "/my%20photo.png"),
575+ Some(root.join("my photo.png"))
576+ );
577+ // A doubled leading slash must not turn into an absolute path.
578+ assert_eq!(
579+ resolve_request_path(root, "//etc/passwd"),
580+ Some(root.join("etc/passwd"))
581+ );
582+ assert_eq!(resolve_request_path(root, "/../secret"), None);
583+ assert_eq!(resolve_request_path(root, "/a/%2e%2e/%2e%2e/secret"), None);
584+ assert_eq!(resolve_request_path(root, "/bad%zz"), None);
585+ assert_eq!(resolve_request_path(root, "é"), Some(root.join("é")));
586+ }
587+}
tests/regressions.rsnew file+286
@@ -0,0 +1,286 @@
1+//! Regression tests for bugs found in review. Each test names the behavior
2+//! that used to be wrong.
3+
4+use std::path::PathBuf;
5+
6+use htmlang::codegen;
7+use htmlang::parser::{self, Severity};
8+
9+fn compile(input: &str) -> String {
10+ let result = parser::parse(input);
11+ assert!(
12+ result.diagnostics.iter().all(|d| d.severity != Severity::Error),
13+ "unexpected parse errors: {:?}",
14+ result.diagnostics
15+ );
16+ codegen::generate(&result.document)
17+}
18+
19+/// A fresh scratch directory for tests that need files on disk.
20+fn scratch_dir(name: &str) -> PathBuf {
21+ let dir = std::env::temp_dir().join(format!("htmlang_regress_{}", name));
22+ let _ = std::fs::remove_dir_all(&dir);
23+ std::fs::create_dir_all(&dir).unwrap();
24+ dir
25+}
26+
27+fn compile_in(dir: &std::path::Path, input: &str) -> String {
28+ let result = parser::parse_with_base(input, Some(dir));
29+ codegen::generate(&result.document)
30+}
31+
32+// --- Panics ---
33+
34+#[test]
35+fn truncate_filter_handles_multibyte_text() {
36+ let out = compile("@let s héllo wörld\n@text $s|truncate:2");
37+ assert!(out.contains("hé..."), "{}", out);
38+}
39+
40+#[test]
41+fn length_filter_counts_characters() {
42+ let out = compile("@let s héllo\n@text $s|length");
43+ assert!(out.contains(">5<"), "{}", out);
44+}
45+
46+#[test]
47+fn non_ascii_hex_color_does_not_panic() {
48+ compile("@el [background #é1, color #fff] hi");
49+}
50+
51+#[test]
52+fn truncated_json_data_does_not_panic() {
53+ let dir = scratch_dir("json_trunc");
54+ std::fs::write(dir.join("d.json"), "{\"a\": 1,").unwrap();
55+ compile_in(&dir, "@data d.json\n@text ok");
56+ std::fs::write(dir.join("d.json"), "{").unwrap();
57+ compile_in(&dir, "@data d.json\n@text ok");
58+}
59+
60+#[test]
61+fn json_unicode_escapes_are_decoded() {
62+ let dir = scratch_dir("json_unicode");
63+ std::fs::write(
64+ dir.join("d.json"),
65+ r#"{"name": "Caf\u00e9", "emoji": "\ud83d\ude00"}"#,
66+ )
67+ .unwrap();
68+ let out = compile_in(&dir, "@data d.json\n@text $name $emoji");
69+ assert!(out.contains("Café 😀"), "{}", out);
70+}
71+
72+#[test]
73+fn page_zero_does_not_underflow() {
74+ let out = compile("@let _page 0\n@each $x in a, b, c [page 2]\n @text $x");
75+ assert!(out.contains(">a<") && out.contains(">b<"), "{}", out);
76+}
77+
78+#[test]
79+fn range_at_integer_limit_terminates() {
80+ let out = compile("@for $i in 9223372036854775806..9223372036854775807\n @text $i");
81+ assert!(out.contains("9223372036854775807"), "{}", out);
82+ let out = compile("@each $i in -9223372036854775807..-9223372036854775808\n @text $i");
83+ assert!(out.contains("-9223372036854775808"), "{}", out);
84+}
85+
86+// --- CSS / codegen ---
87+
88+#[test]
89+fn reset_css_is_layered_below_generated_rules() {
90+ let out = compile("@page T\n@link [color red, underline] /x Home");
91+ assert!(
92+ out.contains("@layer hl-reset,htmlang;@layer hl-reset{"),
93+ "reset must be layered so class rules can override it: {}",
94+ out
95+ );
96+}
97+
98+#[test]
99+fn px_values_are_not_doubled() {
100+ let out = compile("@el [padding 10px, margin 0 auto, max-width none, blur 4px] x");
101+ assert!(out.contains("padding:10px"), "{}", out);
102+ assert!(out.contains("margin:0 auto"), "{}", out);
103+ assert!(out.contains("max-width:none"), "{}", out);
104+ assert!(!out.contains("pxpx"), "{}", out);
105+}
106+
107+#[test]
108+fn minify_keeps_significant_spaces() {
109+ let result = parser::parse("@page T\n@paragraph\n Built with {@text [bold] htmlang}.");
110+ let out = codegen::generate_minified(&result.document);
111+ assert!(out.contains("Built with <span"), "{}", out);
112+}
113+
114+#[test]
115+fn css_vars_are_not_substituted_into_media_queries() {
116+ let out = compile("@let --bp 768px\n@el [md:padding 20] x");
117+ assert!(!out.contains("min-width:var("), "{}", out);
118+ assert!(out.contains("768px"), "{}", out);
119+}
120+
121+#[test]
122+fn partial_output_includes_scope_and_starting_style() {
123+ let result = parser::parse(
124+ "@scope .card\n .title { font-weight: bold; }\n@starting-style\n .x { opacity: 0; }\n@text hi",
125+ );
126+ let out = codegen::generate_partial(&result.document);
127+ assert!(out.contains(".title"), "{}", out);
128+ assert!(out.contains("@starting-style"), "{}", out);
129+}
130+
131+#[test]
132+fn script_body_is_verbatim() {
133+ let out = compile("@script\n let a = 1\n let b = `${a}`\n if (a) { go(); }");
134+ assert!(
135+ out.contains("<script>let a = 1\nlet b = `${a}`\nif (a) { go(); }</script>"),
136+ "{}",
137+ out
138+ );
139+}
140+
141+#[test]
142+fn image_with_css_width_gets_no_intrinsic_height() {
143+ let dir = scratch_dir("image_dims");
144+ let svg = dir.join("big.svg");
145+ std::fs::write(
146+ &svg,
147+ r#"<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000"></svg>"#,
148+ )
149+ .unwrap();
150+ let out = compile(&format!("@image [width 200, alt x] {}", svg.display()));
151+ assert!(!out.contains("height=\"1000\""), "{}", out);
152+}
153+
154+#[test]
155+fn source_map_uses_real_output_lines() {
156+ let html = "<!DOCTYPE html>\n<html>\n<div data-hl-line=\"3\">\n<span data-hl-line=\"4\">x</span>\n";
157+ let map = codegen::source_map_for_html(html, "a.hl");
158+ // Lines 1-2 have no mapping; line 3 maps to source line 3 (0-based 2 =
159+ // VLQ "E"), line 4 to source line 4 (delta 1 = "C").
160+ assert!(map.contains("\"mappings\":\";;AAEA;AACA\""), "{}", map);
161+}
162+
163+// --- Parser logic ---
164+
165+#[test]
166+fn bracket_in_text_does_not_join_lines() {
167+ let out = compile("@column\n Use [ to open a list\n @text [bold] Second\n @text Third");
168+ assert!(out.contains("Use [ to open a list"), "{}", out);
169+ assert!(out.contains(">Second<"), "{}", out);
170+ assert!(out.contains(">Third<"), "{}", out);
171+}
172+
173+#[test]
174+fn multiline_attributes_still_join() {
175+ let out = compile("@el [\n padding 20,\n background white\n]\n Content");
176+ assert!(out.contains("padding:20px"), "{}", out);
177+ assert!(out.contains("background:white"), "{}", out);
178+}
179+
180+#[test]
181+fn triple_quoted_let_is_a_string_not_a_function() {
182+ let out = compile("@let msg \"\"\"\n hello\n world\n \"\"\"\n@text $msg");
183+ assert!(out.contains("hello"), "{}", out);
184+ assert!(!out.contains("\"\"\""), "{}", out);
185+ assert!(!out.contains("$msg"), "{}", out);
186+}
187+
188+#[test]
189+fn pagination_suffix_is_not_an_item() {
190+ let out = compile("@each $x in a, b [page 5]\n @text $x");
191+ assert!(!out.contains("[page"), "{}", out);
192+}
193+
194+#[test]
195+fn each_else_does_not_leak_variables() {
196+ let out = compile(
197+ "@let empty \"\"\n@each $x in $empty\n @text $x\n@else\n @let leaked yes\n @text none\n@text v=$leaked",
198+ );
199+ assert!(!out.contains("v=yes"), "{}", out);
200+}
201+
202+#[test]
203+fn named_argument_is_not_used_positionally_for_another_param() {
204+ let out = compile(
205+ "@let card $title $variant=primary\n @text t=$title v=$variant\n@card [variant danger]",
206+ );
207+ assert!(out.contains("v=danger"), "{}", out);
208+ assert!(!out.contains("t=danger"), "{}", out);
209+}
210+
211+#[test]
212+fn keyframe_values_keep_commas_inside_parens() {
213+ let out = compile("@keyframes k\n from [transform translate(0, 0)]\n to [opacity 1]\n@text x");
214+ assert!(out.contains("transform:translate(0, 0)"), "{}", out);
215+}
216+
217+#[test]
218+fn svg_width_does_not_touch_stroke_width() {
219+ let dir = scratch_dir("svg_attr");
220+ std::fs::write(
221+ dir.join("icon.svg"),
222+ r#"<svg viewBox="0 0 24 24"><path stroke-width="2" d="M0 0"/></svg>"#,
223+ )
224+ .unwrap();
225+ let out = compile_in(&dir, "@svg [width 48] icon.svg");
226+ assert!(out.contains("stroke-width=\"2\""), "{}", out);
227+ assert!(out.contains("width=\"48\""), "{}", out);
228+}
229+
230+#[test]
231+fn markdown_keeps_paragraphs_rules_and_ordered_lists() {
232+ let out = compile(
233+ "@markdown\n First para.\n\n Second para.\n\n ---\n\n 1. one\n 2. two\n\n ```\n if x:\n y\n ```",
234+ );
235+ assert!(out.contains("<p>First para.</p>"), "{}", out);
236+ assert!(out.contains("<p>Second para.</p>"), "{}", out);
237+ assert!(out.contains("<hr>"), "{}", out);
238+ assert!(out.contains("<ol>\n<li>one</li>\n<li>two</li>\n</ol>"), "{}", out);
239+ assert!(out.contains("if x:\n y"), "{}", out);
240+}
241+
242+#[test]
243+fn quoted_let_values_are_not_evaluated_as_arithmetic() {
244+ let out = compile("@let span \"1 / -1\"\n@el [grid-area $span] x");
245+ assert!(out.contains("grid-area:1 / -1"), "{}", out);
246+}
247+
248+#[test]
249+fn quoted_font_stack_is_one_attribute() {
250+ let out = compile("@el [font \"Inter, sans-serif\", bold] x");
251+ assert!(out.contains("font-family:Inter, sans-serif"), "{}", out);
252+ assert!(out.contains("font-weight:bold"), "{}", out);
253+}
254+
255+#[test]
256+fn diagnostic_column_includes_indentation() {
257+ let result = parser::parse("@let name x\n@column\n Hello $nmae");
258+ let d = result
259+ .diagnostics
260+ .iter()
261+ .find(|d| d.message.contains("nmae"))
262+ .expect("undefined-variable warning");
263+ assert_eq!(d.column, Some(10));
264+}
265+
266+#[test]
267+fn variables_used_in_loops_conditions_and_text_are_not_unused() {
268+ for src in [
269+ "@let items a, b\n@each $x in $items\n @text $x",
270+ "@let on true\n@if $on\n @text y",
271+ "@let n 3\n@for $i in 1..$n\n @text $i",
272+ "@let score \"10 - 2\"\n@text Score: $score",
273+ "@let who World\nHello $who",
274+ ] {
275+ let result = parser::parse(src);
276+ assert!(
277+ !result
278+ .diagnostics
279+ .iter()
280+ .any(|d| d.message.contains("unused variable")),
281+ "false unused warning for {:?}: {:?}",
282+ src,
283+ result.diagnostics
284+ );
285+ }
286+}
tests/snapshots.rs+26−5
@@ -1485,20 +1485,41 @@ fn no_warning_different_attributes() {
14851485// ---------------------------------------------------------------------------
14861486
14871487#[test]
1488−fn fmt_sorts_attributes() {
1489− let formatted = htmlang::fmt::format("@el [color red, width 200, padding 10]");
1490− // width (sizing) should come before padding (spacing) which should come before color (visual)
1491− assert!(formatted.contains("[width 200, padding 10, color red]"));
1488+fn fmt_preserves_attribute_order() {
1489+ // Later attributes may override earlier ones, so order is significant.
1490+ let formatted = htmlang::fmt::format("@el [color red,width 200, padding 10]");
1491+ assert!(formatted.contains("[color red, width 200, padding 10]"));
14921492}
14931493
14941494#[test]
14951495fn fmt_multiline_brackets() {
14961496 let input = "@el [\n color red,\n width 200\n]\n @text hello";
14971497 let formatted = htmlang::fmt::format(input);
1498− assert!(formatted.contains("[width 200, color red]"));
1498+ assert!(formatted.contains("[color red, width 200]"));
14991499 assert!(formatted.contains(" @text hello"));
15001500}
15011501
1502+#[test]
1503+fn fmt_examples_idempotent_and_semantics_preserving() {
1504+ let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
1505+ for entry in fs::read_dir(&dir).unwrap() {
1506+ let path = entry.unwrap().path();
1507+ if path.extension().is_none_or(|e| e != "hl") {
1508+ continue;
1509+ }
1510+ let src = fs::read_to_string(&path).unwrap();
1511+ let once = htmlang::fmt::format(&src);
1512+ let twice = htmlang::fmt::format(&once);
1513+ assert_eq!(once, twice, "formatter not idempotent on {}", path.display());
1514+ assert_eq!(
1515+ compile_with_base(&src, &dir),
1516+ compile_with_base(&once, &dir),
1517+ "formatting changed the output of {}",
1518+ path.display()
1519+ );
1520+ }
1521+}
1522+
15021523// ---------------------------------------------------------------------------
15031524// New features: pseudo-states, child selectors, fragment, hidden, CSS props
15041525// ---------------------------------------------------------------------------
tests/snapshots/accessibility.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Accessibility</title><link rel="preload" href="https://example.com/sunset.jpg" as="image"><link rel="dns-prefetch" href="//example.com"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{width:200px;}.b{display:flex;flex-direction:column;}}</style></head><body><img src="https://example.com/sunset.jpg" class="a" alt="A photo of sunset" fetchpriority="high"><div class="b" role="navigation" aria-label="Main menu"><a href="/" tabindex="0" aria-current="page">Home</a><a href="/about" tabindex="0">About</a></div><button aria-pressed="false" data-action="toggle">Toggle</button></body></html>
tests/snapshots/alignment.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Alignment</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;height:400px;}.b{display:flex;flex-direction:column;align-self:center;}.c{display:flex;flex-direction:column;margin-top:auto;margin-bottom:auto;}.d{display:flex;flex-direction:column;align-self:flex-start;}.e{display:flex;flex-direction:column;align-self:flex-end;}.f{display:flex;flex-direction:column;margin-bottom:auto;}.g{display:flex;flex-direction:column;margin-top:auto;}.h{display:flex;flex-direction:row;height:200px;}}</style></head><body><div class="a"><div class="b"></div><div class="c"></div><div class="d"></div><div class="e"></div><div class="f"></div><div class="g"></div></div><div class="h"><div class="b"></div><div class="d"></div><div class="e"></div></div></body></html>
tests/snapshots/anchor_positioning.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{display:flex;flex-direction:column;anchor-name:--tooltip-anchor;position:relative;}.b{display:flex;flex-direction:column;position-anchor:--tooltip-anchor;position:absolute;position-area:top;}}</style><div class="a"><span>Hover me</span></div><div class="b"><span>Tooltip content</span></div>
tests/snapshots/animate_shorthand.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Animate</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;padding:20px;}.b{display:flex;flex-direction:column;animation:fade-in 0.3s ease;padding:20px;}.c{display:flex;flex-direction:column;animation:slide-up 0.5s ease-out;padding:20px;background:#e5e7eb;}}@keyframes fade-in{from{opacity:0;}to{opacity:1;}}@keyframes slide-up{from{transform:translateY(20px);opacity:0;}to{transform:translateY(0);opacity:1;}}</style></head><body><div class="a"><div class="b"><span>Faded in</span></div><div class="c"><span>Slid up</span></div></div></body></html>
tests/snapshots/animations.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Animations</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{display:flex;flex-direction:column;animation:fade-in 0.3s ease;}.c{display:flex;flex-direction:column;animation:slide-up 0.5s ease-out;}}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-up{0%{transform:translateY(20px);opacity:0}100%{transform:translateY(0);opacity:1}}</style></head><body><div class="a"><div class="b"><span>Fading in</span></div><div class="c"><span>Sliding up</span></div></div></body></html>
tests/snapshots/arithmetic.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Arithmetic</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;width:20px;}.b{display:flex;flex-direction:column;padding:15px;}.c{display:flex;flex-direction:column;margin:70px;}.d{display:flex;flex-direction:column;height:50px;}}</style></head><body><div class="a"><span>double is 20</span></div><div class="b"><span>sum is 15</span></div><div class="c"><span>diff is 70</span></div><div class="d"><span>half is 50</span></div></body></html>
tests/snapshots/assert_directive.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Assert Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:16px;color:#3b82f6;}}</style></head><body><div class="a"><span>Assertions passed</span></div></body></html>
tests/snapshots/attributes.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Attributes</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;width:200px;height:100px;padding:10px 20px;background:#fff;color:#333;border:1px solid #ccc;border-radius:8px;opacity:0.9;}.b{font-weight:bold;font-style:italic;text-decoration:underline;font-size:16px;font-family:monospace;}.c{display:flex;flex-direction:row;flex-wrap:wrap;column-gap:10px;row-gap:5px;gap:8px;}.d{display:flex;flex-direction:column;min-width:50px;max-width:200px;min-height:30px;max-height:100px;}.e{display:flex;flex-direction:column;overflow:hidden;position:relative;z-index:10;}.f{text-align:center;line-height:1.5;}.g{display:flex;flex-direction:column;box-shadow:0 2px 4px rgba(0,0,0,0.1);transition:all 0.2s;cursor:pointer;}}</style></head><body><div class="a"><span class="b">Styled</span></div><div class="c"><div class="d"></div></div><div class="e"><span class="f">Aligned</span></div><div class="g"></div></body></html>
tests/snapshots/basic_elements.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Basic Elements</title><link rel="preload" href="https://example.com/img.png" as="image"><link rel="dns-prefetch" href="//example.com"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{display:flex;flex-direction:row;gap:10px;}.b{font-weight:bold;}.c{display:flex;flex-direction:column;gap:5px;}.d{display:flex;flex-direction:column;padding:10px;}.e{margin:0;}.f{width:100px;}}</style></head><body><div class="a"><span class="b">Hello</span><span>World</span></div><div class="c"><div class="d"><span>Inner</span></div></div><p class="e">Some text here</p><img src="https://example.com/img.png" class="f" fetchpriority="high"><a href="https://example.com" rel="noopener noreferrer" target="_blank">Click me</a></body></html>
tests/snapshots/breadcrumb.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{gap:10px;}}</style><nav aria-label="breadcrumb" class="a"><ol><li><a href="/">Home</a></li><li><a href="/docs">Docs</a></li><li><span>Current Page</span></li></ol></nav>
tests/snapshots/chain_operator.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Chain</title><link rel="dns-prefetch" href="//example.com"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{display:flex;flex-direction:column;padding:10px;}.b{font-weight:bold;}.c{display:flex;flex-direction:row;gap:5px;}.d{display:flex;flex-direction:column;padding:20px;}.e{color:red;}}</style></head><body><div class="a"><a href="https://example.com" rel="noopener noreferrer" target="_blank"><span class="b">Click</span></a></div><div class="c"><div class="d"><span class="e">Deep chain</span></div></div></body></html>
tests/snapshots/clamp_css.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Clamp CSS Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;font-size:clamp(16px, 2vw, 24px);width:min(100%, 800px);padding:max(10px, 2vw);}}</style></head><body><div class="a"><span>Fluid typography and sizing</span></div></body></html>
tests/snapshots/color_scheme.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Color Scheme</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;color-scheme:light dark;padding:20px;}.b{appearance:none;padding:10px;border:1px solid #ccc;border-radius:4px;}}</style></head><body><div class="a"><span>Adapts to system theme</span></div><input class="b"></body></html>
tests/snapshots/computed_let.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Computed Let</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;padding:20px;}.b{font-size:16px;}.c{font-size:32px;}}</style></head><body><div class="a"><span class="b">Base size</span><span class="c">Large size</span></div></body></html>
tests/snapshots/conditionals.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Conditionals</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;background:#333;color:white;padding:20px;}}</style></head><body><div class="a"><span>Dark mode</span></div><span>Visible</span><span>They differ</span></body></html>
tests/snapshots/container_queries.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Container Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;container-type:inline-size;container-name:sidebar;}.b{display:flex;flex-direction:column;width:200px;}.c{display:flex;flex-direction:column;container-type:inline-size;max-width:800px;padding:20px;}.d{display:flex;flex-direction:column;background:#f0f0f0;padding:16px;}@container(min-width:640px){.d{padding:24px;}}@container(min-width:1024px){.d{padding:32px;}}}</style></head><body><div class="a"><div class="b"><span>Sidebar content</span></div></div><div class="c"><div class="d"><span>Responsive to container width</span></div></div></body></html>
tests/snapshots/css_property.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CSS Property Test</title><style>*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}.a{display:flex;flex-direction:column;background:var(--accent);padding:20px;}@property --accent {syntax:"<color>";inherits:true;initial-value:#3b82f6;}</style></head><body><div class="a"><span>Custom property element</span></div></body></html>
tests/snapshots/css_shorthands.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CSS Shorthands</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.c{margin:0;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}.d{display:flex;flex-direction:column;filter:blur(4px);padding:20px;}.e{display:flex;flex-direction:column;backdrop-filter:blur(10px);padding:20px;background:rgba(255,255,255,0.5);}.f{display:flex;flex-direction:column;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none;max-width:300px;}.g{display:flex;flex-direction:row;gap:20px;}.h{display:flex;flex-direction:column;width:200px;padding:20px;}.i{display:flex;flex-direction:column;width:100%;height:20px;border-radius:4px;background:linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%);background-size:200% 100%;animation:hl-skeleton 1.5s ease-in-out infinite;}.j{display:flex;flex-direction:column;background:linear-gradient(#3b82f6,#8b5cf6);padding:20px;border-radius:8px;}.k{color:white;}.l{display:flex;flex-direction:column;background:linear-gradient(45deg,#ff6b6b,#feca57);padding:20px;border-radius:8px;}.f::-webkit-scrollbar{display:none;}}@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}</style></head><body><div class="a"><span class="b">This text will be truncated with an ellipsis</span><p class="c">This is a long paragraph that spans multiple lines and should be clamped at three lines.</p><div class="d"><span>Blurred content</span></div><div class="e"><span>Frosted glass effect</span></div><div class="f"><div class="g"><div class="h"></div><div class="h"></div><div class="h"></div></div></div><div class="i"></div><div class="j"><span class="k">Gradient background</span></div><div class="l"><span class="k">Angled gradient</span></div></div></body></html>
tests/snapshots/css_units.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CSS Units</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:2rem;gap:1.5em;}.b{display:flex;flex-direction:column;width:50%;height:100vh;max-width:80ch;}.c{font-size:1.2rem;}.d{display:flex;flex-direction:column;padding:10px 2rem;border-radius:0.5rem;}.e{display:flex;flex-direction:column;min-height:50vh;gap:2rem;}}</style></head><body><div class="a"><div class="b"><span class="c">Hello</span></div><div class="d"><span>Responsive padding</span></div><div class="e"><span>Viewport units</span></div></div></body></html>
tests/snapshots/css_vars.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CSS Variables</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}:root{--primary:#3b82f6;--spacing:20;--radius:8;}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{display:flex;flex-direction:column;background:var(--primary);border-radius:8px;padding:20px;}.c{color:white;}.d{display:flex;flex-direction:column;border:1px solid var(--primary);border-radius:8px;padding:20px;}.e{color:var(--primary);}}</style></head><body><div class="a"><div class="b"><span class="c">Using CSS vars</span></div><div class="d"><span class="e">Border with var</span></div></div></body></html>
tests/snapshots/dark_mode.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Dark Mode</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;background:white;color:#333;padding:20px;}.b{font-size:24px;font-weight:bold;}.c{display:flex;flex-direction:column;border:1px solid #ccc;border-radius:8px;padding:16px;}.d{display:flex;flex-direction:column;background:#f0f0f0;padding:10px;}@media(prefers-color-scheme:dark){.a{background:#1a1a2e;color:#eee;}.c{border:1px solid #444;}.d{background:#333;}}@media print{.d{display:none;}}}</style></head><body><div class="a"><span class="b">Hello World</span></div><div class="c"><span>Theme-aware card</span></div><div class="d"><span>Not printed</span></div></body></html>
tests/snapshots/data_directive.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Data Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{font-weight:bold;font-size:24px;}.b{color:blue;}}</style></head><body><span class="a">My Site</span><span>Version: 1.0</span><span class="b">rust</span><span class="b">html</span><span class="b">css</span><a href="/">Home</a><a href="/about">About</a><a href="/contact">Contact</a></body></html>
tests/snapshots/direction_attr.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Direction</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;direction:rtl;padding:20px;}.b{display:flex;flex-direction:column;direction:ltr;padding:20px;}}</style></head><body><div class="a"><span>This text should be right-to-left</span></div><div class="b"><span>This text is explicitly left-to-right</span></div></body></html>
tests/snapshots/each_destructuring.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Destructuring</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:row;gap:10px;}.b{font-weight:bold;}}</style></head><body><div class="a"><span class="b">Alice</span><span>Admin</span></div><div class="a"><span class="b">Bob</span><span>User</span></div><div class="a"><span class="b">Carol</span><span>Editor</span></div></body></html>
tests/snapshots/each_else.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Each Else</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:10px;}.b{color:#888;}}</style></head><body><div class="a"><span class="b">No items found.</span></div><div class="a"><span>apple</span><span>banana</span></div></body></html>
tests/snapshots/each_index.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{}</style><span>0: apple</span><span>1: banana</span><span>2: cherry</span>
tests/snapshots/each_range.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{}</style><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
tests/snapshots/each_step.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Each Step</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:4px;}.b{display:flex;flex-direction:row;gap:8px;}.c{display:flex;flex-direction:column;width:50px;height:50px;background:#3b82f6;border-radius:4px;}.d{color:white;font-size:12px;}}</style></head><body><div class="a"><span>Value: 0</span><span>Value: 5</span><span>Value: 10</span><span>Value: 15</span><span>Value: 20</span></div><div class="b"><div class="c"><span class="d">0</span></div><div class="c"><span class="d">25</span></div><div class="c"><span class="d">50</span></div><div class="c"><span class="d">75</span></div><div class="c"><span class="d">100</span></div></div><div class="a"><span>Countdown: 10</span><span>Countdown: 8</span><span>Countdown: 6</span><span>Countdown: 4</span><span>Countdown: 2</span></div></body></html>
tests/snapshots/else_if.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{}</style><span>Dark mode</span>
tests/snapshots/env_directive.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Env Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{}</style></head><body><span>default_val</span></body></html>
tests/snapshots/extra_css.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{flex-direction:column;display:none;}.c{display:flex;flex-direction:column;visibility:hidden;}.d{display:flex;flex-direction:column;transform:rotate(45deg);backdrop-filter:blur(10px);}.e{display:flex;flex-direction:column;letter-spacing:2px;text-transform:uppercase;white-space:nowrap;}.f{display:flex;flex-direction:column;border-bottom:1px solid #ccc;border-top:2px solid red;}.g{flex-direction:column;display:grid;grid-template-columns:repeat(3,1fr);gap:10px;}.h{display:flex;flex-direction:column;grid-column:span 2;}.i{display:flex;flex-direction:column;}}</style><div class="a"><div class="b"><span>Hidden</span></div><div class="c"><span>Also hidden</span></div><div class="d"><span>Styled</span></div><div class="e"><span>Typography</span></div><div class="f"><span>Borders</span></div><div class="g"><div class="h"><span>Wide</span></div><div class="i"><span>Narrow</span></div></div></div>
tests/snapshots/fn_defaults.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{display:flex;flex-direction:column;padding:4px 8px;background:blue;border-radius:4px;}.b{display:flex;flex-direction:column;padding:4px 8px;background:red;border-radius:4px;}}</style><div class="a"><span>Hello</span></div><div class="b"><span>Warning</span></div>
tests/snapshots/form_elements.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Form Elements</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{display:flex;flex-direction:row;gap:10px;}.b{display:flex;flex-direction:column;gap:5px;}}</style></head><body><div class="a"><button type="submit">Click me</button><input type="text" placeholder="Enter name" name="username"><input type="email" required disabled></div><div class="b"><label for="bio">Biography</label><textarea name="bio" rows="4">Default text</textarea></div><select name="color"><option value="red">Red</option><option value="green">Green</option><option>Blue</option></select></body></html>
tests/snapshots/fragment.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Fragment Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:10px;}}</style></head><body><div class="a"><span>First</span><span>Second</span><span>Third</span></div></body></html>
tests/snapshots/functions.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Functions</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:10px;}.b{display:flex;flex-direction:column;padding:20px;border-radius:8px;}.c{font-weight:bold;}}</style></head><body><div class="a"><div class="b"><span class="c">Hello</span><span>Content inside card</span></div><div class="b"><span class="c">World</span></div></div></body></html>
tests/snapshots/grid_areas.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Grid Areas</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{flex-direction:column;display:grid;grid-template-areas:"header header" "sidebar main" "footer footer";gap:20px;}.b{display:flex;flex-direction:column;grid-area:header;padding:10px;background:#3b82f6;color:white;}.c{display:flex;flex-direction:column;grid-area:sidebar;padding:10px;background:#e5e7eb;}.d{display:flex;flex-direction:column;grid-area:main;padding:10px;}.e{display:flex;flex-direction:column;grid-area:footer;padding:10px;background:#1a1a2e;color:white;}}</style></head><body><div class="a"><div class="b"><span>Header</span></div><div class="c"><span>Sidebar</span></div><div class="d"><span>Main content</span></div><div class="e"><span>Footer</span></div></div></body></html>
tests/snapshots/has_pseudo.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Has Pseudo</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;padding:20px;}.b{display:flex;flex-direction:column;padding:20px;background:white;}.c{display:flex;flex-direction:column;border:1px solid #e5e7eb;border-radius:8px;}.b:has(.active){background:#dbeafe;}.b:has(img){padding:0;}.c:has(.selected){border:2px solid #3b82f6;}}</style></head><body><div class="a"><div class="b"><span>Content</span></div><div class="c"><span>Container</span></div></div></body></html>
tests/snapshots/hidden_attr.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Hidden Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{flex-direction:column;display:none;}.b{display:flex;flex-direction:column;padding:10px;}}</style></head><body><div class="a"><span>This is hidden</span></div><div class="b"><span>This is visible</span></div></body></html>
tests/snapshots/if_expr.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>If Expression</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;background:blue;}.b{display:flex;flex-direction:column;background:#3b82f6;color:white;}.c{display:flex;flex-direction:column;padding:20px;}}</style></head><body><div class="a"><span>Active element</span></div><div class="b"><span>Themed element</span></div><div class="c"><span>Inactive padding</span></div></body></html>
tests/snapshots/implicit_el.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Implicit El</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;background:#f5f5f5;}.b{display:flex;flex-direction:column;border-radius:8px;border:1px solid #ccc;}.c{display:flex;flex-direction:row;gap:10px;}}</style></head><body><div class="a"><span>Hello from implicit el</span></div><div class="b"><div class="c"><span>A</span><span>B</span></div></div></body></html>
tests/snapshots/initial_letter.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{margin:0;line-height:1.6;}.b{initial-letter:3;}}</style><p class="a"><span class="b">O</span> nce upon a time in a land far away.</p>
tests/snapshots/inline_text.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Inline Text</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{margin:0;}.b{font-weight:bold;}.c{font-style:italic;color:red;}.d{text-decoration:underline;}.e{font-size:20px;}}</style></head><body><p class="a">This is <span class="b">bold</span> and <span class="c">styled</span> text.</p><p class="a">Multiple <span class="d">inline</span> elements <span class="e">here</span>.</p></body></html>
tests/snapshots/lang_directive.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Lang Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{}</style></head><body><span>Hello World</span></body></html>
tests/snapshots/layer_wrapping.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Layer Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;background:white;}.b{display:flex;flex-direction:column;padding:10px;}.b:hover{background:#f0f0f0;}}</style></head><body><div class="a"><span>Hello</span></div><div class="b"><span>Hoverable</span></div></body></html>
tests/snapshots/list_elements.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>List Elements</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{margin:0;padding-left:0;list-style:none;gap:8px;}.b{display:flex;flex-direction:column;}.c{margin:0;padding-left:0;list-style:none;gap:4px;}.d{margin:0;padding-left:0;list-style:none;}.e{display:flex;flex-direction:column;padding:10px;background:#f0f0f0;border-radius:4px;}.f{font-weight:bold;}}</style></head><body><ul class="a"><li class="b">First item</li><li class="b">Second item</li><li class="b">Third item</li></ul><ol class="c"><li class="b">Step one</li><li class="b">Step two</li><li class="b">Step three</li></ol><ul class="d"><li class="e"><span class="f">Styled item</span></li></ul></body></html>
tests/snapshots/loops.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Loops</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:row;gap:10px;}.b{display:flex;flex-direction:column;background:red;width:50px;height:50px;}.c{display:flex;flex-direction:column;background:green;width:50px;height:50px;}.d{display:flex;flex-direction:column;background:blue;width:50px;height:50px;}}</style></head><body><div class="a"><div class="b"></div><div class="c"></div><div class="d"></div></div><span>apple</span><span>banana</span><span>cherry</span></body></html>
tests/snapshots/manifest_directive.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>My App</title><link rel="manifest" href="data:application/manifest+json;base64,eyJuYW1lIjoiTXkgQXBwIiwic2hvcnRfbmFtZSI6IkFwcCIsInN0YXJ0X3VybCI6Ii8iLCJkaXNwbGF5Ijoic3RhbmRhbG9uZSIsImJhY2tncm91bmRfY29sb3IiOiIjZmZmZmZmIiwidGhlbWVfY29sb3IiOiIjM2I4MmY2IiwiZGVzY3JpcHRpb24iOiJBIHNhbXBsZSBQV0EifQ=="><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}}</style></head><body><div class="a"><span>Hello</span></div></body></html>
tests/snapshots/markdown_block.htmlnew file+10
@@ -0,0 +1,10 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Markdown Test</title><link rel="dns-prefetch" href="//example.com"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}}</style></head><body><div class="a"><h1>Hello World</h1>
2+<p>This is a <strong>bold</strong> paragraph with <em>italic</em> text. Here is <code>inline code</code> and a <a href="https://example.com">link</a>.</p>
3+<ul>
4+<li>Item one</li>
5+<li>Item two</li>
6+<li>Item three</li>
7+</ul>
8+<hr>
9+<blockquote><p>A blockquote here.</p></blockquote>
10+</div></body></html>
tests/snapshots/match_directive.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Match</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;background:#333;color:white;}}</style></head><body><div class="a"><span>Dark theme</span></div></body></html>
tests/snapshots/media_elements.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Media Elements</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{width:640px;}}</style></head><body><video src="demo.mp4" controls></video><audio src="music.mp3" controls autoplay loop></audio><video class="a" controls muted poster="poster.jpg"><span>Your browser does not support video</span></video></body></html>
tests/snapshots/media_extended.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Media Extended</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;width:100%;}.b{display:flex;flex-direction:column;transition:all 0.3s ease;}.c{display:flex;flex-direction:column;animation:spin 1s linear infinite;}.d{display:flex;flex-direction:column;padding:20px;}@media(min-width:1536px){.a{width:1536px;padding:40px;}}@media(prefers-reduced-motion:no-preference){.c{animation:spin 1s linear infinite;}}@media(prefers-reduced-motion:reduce){.b{transition:none;}.c{animation:none;}}@media(orientation:landscape){.d{padding:10px;}}@media(orientation:portrait){.d{padding:40px;}}}</style></head><body><div class="a"><span>Wide screen layout</span></div><div class="b"><span>Respects motion preferences</span></div><div class="c"><span>Animation with motion safety</span></div><div class="d"><span>Orientation-aware padding</span></div></body></html>
tests/snapshots/meta_head.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>My Site</title><meta name="description" content="A portfolio site"><meta name="og:image" content="https://example.com/preview.png"><link rel="icon" href="favicon.ico"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}}</style></head><body><div class="a"><span>Hello</span></div></body></html>
tests/snapshots/mixin_spread.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mixin Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;border-radius:8px;background:#f3f4f6;cursor:pointer;transition:all 0.15s ease;}.b{display:flex;flex-direction:column;padding:20px;border-radius:8px;background:#f3f4f6;border:1px solid #ccc;}.a:hover{background:#e5e7eb;}}</style></head><body><div class="a"><span>Hello mixin world</span></div><div class="b"><span>Using $define syntax too</span></div></body></html>
tests/snapshots/named_slots.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}.b{font-weight:bold;}}</style><div class="a"><span class="b">Custom Header</span><span>Main body</span><span>Footer content</span></div>
tests/snapshots/new_css.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New CSS</title><link rel="preload" href="photo.jpg" as="image"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;margin:10px;margin-inline:20px;margin-block:30px;}.b{width:200px;height:200px;object-fit:cover;object-position:center;}.c{display:flex;flex-direction:column;filter:blur(5px);pointer-events:none;user-select:none;}.d{display:flex;flex-direction:row;justify-content:space-between;align-items:center;}.e{display:flex;flex-direction:column;order:2;}.f{display:flex;flex-direction:column;background:url(bg.jpg);background-size:cover;background-position:center;background-repeat:no-repeat;}.g{text-shadow:1px 1px 2px black;text-overflow:ellipsis;}.h{display:flex;flex-direction:column;word-break:break-all;overflow-wrap:break-word;}}</style></head><body><div class="a"><span>Margins</span></div><img src="photo.jpg" class="b" fetchpriority="high"><div class="c"><span>Filtered</span></div><div class="d"><span>Left</span><span>Right</span></div><div class="e"><span>Second</span></div><div class="f"><span>Background</span></div><span class="g">Shadowed</span><div class="h"><span>Longtext</span></div></body></html>
tests/snapshots/new_css_attrs.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CSS Attrs</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;aspect-ratio:16/9;width:400px;background:#eee;}.b{display:flex;flex-direction:column;outline:2px solid red;padding:10px;}.c{display:flex;flex-direction:column;padding-inline:20px;padding-block:10px;}.d{display:flex;flex-direction:column;margin-inline:20px;margin-block:10px;}.e{display:flex;flex-direction:column;scroll-snap-type:x mandatory;overflow:scroll;}.f{display:flex;flex-direction:column;scroll-snap-align:center;width:300px;}}</style></head><body><div class="a"><span>Widescreen</span></div><div class="b"><span>Outlined</span></div><div class="c"><span>Logical padding</span></div><div class="d"><span>Logical margin</span></div><div class="e"><div class="f"><span>Snap target</span></div></div></body></html>
tests/snapshots/new_css_properties.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New CSS Properties</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{display:flex;flex-direction:column;overflow-x:hidden;overflow-y:auto;}.b{display:flex;flex-direction:column;position:absolute;inset:0;}.c{accent-color:#3b82f6;}.d{caret-color:red;}.e{margin:0;padding-left:0;list-style:disc;}.f{display:flex;flex-direction:column;}.g{border-collapse:collapse;border-spacing:0;}.h{text-decoration:underline;text-decoration-color:red;text-decoration-thickness:2px;text-decoration-style:wavy;}.i{flex-direction:column;display:grid;place-items:center;}.j{display:flex;flex-direction:column;place-self:center;}.k{display:flex;flex-direction:column;overflow:auto;scroll-behavior:smooth;}.l{resize:vertical;}}</style></head><body><div class="a"><span>Overflow</span></div><div class="b"><span>Overlay</span></div><input class="c" type="checkbox"><input class="d" type="text"><ul class="e"><li class="f">One</li><li class="f">Two</li></ul><table class="g"><tr><td>Cell</td></tr></table><span class="h">Decorated</span><div class="i"><span>Centered</span></div><div class="j"><span>Self centered</span></div><div class="k"><span>Smooth scroll</span></div><textarea class="l" rows="4">Resizable</textarea></body></html>
tests/snapshots/new_css_properties_2.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New CSS Properties 2</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;clip-path:polygon(50% 0%, 0% 100%, 100% 100%);}.b{display:flex;flex-direction:column;mix-blend-mode:multiply;background:red;}.c{display:flex;flex-direction:column;background-blend-mode:overlay;background-image:linear-gradient(red, blue);}.d{display:flex;flex-direction:column;writing-mode:vertical-rl;}.e{display:flex;flex-direction:column;column-count:3;column-gap:20px;}.f{margin:0;text-indent:2em;}.g{margin:0;hyphens:auto;}.h{display:flex;flex-direction:row;}.i{display:flex;flex-direction:column;flex-grow:1;}.j{display:flex;flex-direction:column;flex-shrink:0;}.k{display:flex;flex-direction:column;flex-basis:200px;}.l{display:flex;flex-direction:column;isolation:isolate;}.m{flex-direction:column;display:grid;place-content:center;}.n{display:flex;flex-direction:column;background-image:url(bg.png);background-size:cover;}}</style></head><body><div class="a"><span>Triangle</span></div><div class="b"><span>Blended</span></div><div class="c"><span>Background blend</span></div><div class="d"><span>Vertical text</span></div><div class="e"><span>Multi-column layout content here</span></div><p class="f">Indented first line of the paragraph.</p><p class="g">A very long word that might need hyphenation in narrow containers.</p><div class="h"><div class="i"></div><div class="j"></div><div class="k"></div></div><div class="l"><span>Isolated stacking</span></div><div class="m"><span>Centered grid content</span></div><div class="n"><span>With background image</span></div></body></html>
tests/snapshots/new_css_properties_3.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New CSS Properties 3</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{font-weight:600;}.c{font-style:oblique;}.d{text-wrap:balance;font-size:32px;}.e{display:flex;flex-direction:column;will-change:transform;transition:transform 0.3s ease;}.f{display:flex;flex-direction:column;touch-action:none;}.g{vertical-align:middle;}.h{display:flex;flex-direction:column;contain:content;}.i{display:flex;flex-direction:column;scroll-margin-top:80px;}.j{display:flex;flex-direction:column;content-visibility:auto;}.k{display:flex;flex-direction:column;scroll-padding:20px;}}</style></head><body><div class="a"><span class="b">Semi-bold text</span><span class="c">Oblique text</span><span class="d">A heading that wraps nicely</span><div class="e"><span>Optimized for animation</span></div><div class="f"><span>No touch scrolling</span></div><span class="g">Middle aligned</span><div class="h"><span>Contained element</span></div><div class="i"><span>Offset for fixed header</span></div><div class="j"><span>Lazy rendered content</span></div><div class="k"><span>Scroll padding container</span></div></div></body></html>
tests/snapshots/new_css_properties_4.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{text-decoration:underline;text-underline-offset:4px;text-decoration-thickness:2px;}.b{display:flex;flex-direction:column;column-count:3;column-width:200px;column-rule:1px solid #ccc;}}</style><span class="a">Styled underline</span><div class="b"><span>Multi-column layout content</span></div>
tests/snapshots/new_directives.htmlnew file+5
@@ -0,0 +1,5 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>My Site</title><base href="https://example.com/"><link rel="canonical" href="https://example.com/page"><link rel="preload" href="fonts/inter.woff2" as="font" type="font/woff2" crossorigin><link rel="preload" href="fonts/mono.ttf" as="font" type="font/woff2" crossorigin><script type="application/ld+json">{
2+"@context": "https://schema.org",
3+"@type": "WebPage",
4+"name": "My Site"
5+}</script><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@font-face{font-family:'Inter';src:url('fonts/inter.woff2') format('woff2');font-display:swap}@font-face{font-family:'Mono';src:url('fonts/mono.ttf') format('truetype');font-display:swap}@layer htmlang{.a{display:flex;flex-direction:column;gap:10px;}.b{font-family:Inter;}}</style></head><body><div class="a"><span class="b">Hello with custom fonts</span></div></body></html>
tests/snapshots/new_elements.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New Elements</title><link rel="preload" href="example.png" as="image"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{display:flex;flex-direction:column;}.b{display:flex;flex-direction:column;margin:0;padding:20px;}.c{display:flex;flex-direction:column;margin:0;}.d{width:200px;}.e{margin:0;white-space:pre;font-family:ui-monospace,monospace;}.f{font-family:ui-monospace,monospace;}}</style></head><body><form class="a" method="post" action="/submit"><label for="email">Email</label><input type="email" name="email" required><button type="submit">Submit</button></form><hr><details class="a" open><summary>Click to expand</summary><span>Hidden content here</span></details><blockquote class="b"><span>To be or not to be</span><cite>Shakespeare</cite></blockquote><figure class="c"><img src="example.png" class="d" alt="A code example" fetchpriority="high"><figcaption>A code example</figcaption></figure><pre class="e"><code class="f">console.log(&quot;hello&quot;)</code></pre><progress value="70" max="100"></progress><meter value="6" min="0" max="10" low="3" high="8"></meter></body></html>
tests/snapshots/new_elements_2.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New Elements 2</title><link rel="preload" href="photo.jpg" as="image"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{display:flex;flex-direction:column;}.b{font-weight:bold;}.c{margin:0;}.d{margin:0;display:flex;flex-direction:column;}.e{display:flex;flex-direction:column;border:1px solid currentColor;padding:8px;margin:0;}}</style></head><body><dialog class="a" id="modal" open><span class="b">Dialog Title</span><span>Dialog content here</span><button>Close</button></dialog><dl class="c"><dt>Term One</dt><dd class="d">Definition of term one</dd><dt>Term Two</dt><dd class="d">Definition of term two</dd></dl><fieldset class="e"><legend>Personal Info</legend><label for="name">Name</label><input type="text" name="name"></fieldset><picture><source srcset="photo-wide.jpg" media="(min-width: 800px)"><img src="photo.jpg" alt="A photo" fetchpriority="high"></picture><time datetime="2026-04-15">April 15, 2026</time><span>This is {@mark highlighted} text</span><span>Press {@kbd Ctrl+C} to copy</span><abbr title="HyperText Markup Language">HTML</abbr><input type="text" name="browser" list="browsers"><datalist id="browsers"><option>Chrome</option><option>Firefox</option><option>Safari</option></datalist></body></html>
tests/snapshots/new_elements_3.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New Elements 3</title><link rel="dns-prefetch" href="//example.com"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{width:100%;height:400px;}.b{width:400px;height:300px;}}</style></head><body><iframe src="https://example.com" class="a"></iframe><output id="result" for="a b">42</output><canvas class="b" id="myCanvas"></canvas></body></html>
tests/snapshots/new_elements_4.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New Elements 4</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;}.b{display:flex;flex-direction:column;background:#f0f0f0;padding:16px;}.c{display:flex;flex-direction:column;background:#e0e0e0;padding:16px;}.d{display:flex;flex-direction:column;background:#d0d0d0;padding:16px;}.e{position:relative;width:200px;height:200px;}.f{display:flex;flex-direction:column;position:absolute;inset:0;background:#3b82f6;}.g{display:flex;flex-direction:column;position:absolute;bottom:10px;right:10px;}.h{color:white;}.i{display:flex;flex-direction:row;height:100px;}.j{flex:1;}.k{display:flex;flex-direction:row;gap:8px;}.l{display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;justify-content:center;border-radius:9999px;line-height:1;background:#ef4444;color:white;}.m{display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;justify-content:center;border-radius:9999px;line-height:1;background:#22c55e;color:white;}.n{display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;justify-content:center;border-radius:9999px;line-height:1;background:#3b82f6;color:white;}.o{display:flex;flex-direction:row;gap:16px;}.p{position:relative;cursor:help;}.q{text-decoration:underline;}.r{font-weight:bold;}}</style></head><body><div class="a"><div class="b"><span>First</span></div><div class="c"><span>Second</span></div><div class="d"><span>Third</span></div></div><div class="e"><div class="f"></div><div class="g"><span class="h">Overlay</span></div></div><div class="i"><span>Left</span><div class="j"><span>Right</span></div><div class="k"><span class="l">3</span><span class="m">New</span><span class="n">Pro</span></div><div class="o"><span title="Hover me for info" class="p">Hover me for info<span class="q">Helpful text</span></span><span title="This is a tooltip message" class="p">This is a tooltip message<span class="r">Important</span></span></div></body></html>
tests/snapshots/new_elements_5.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New Elements 5</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;width:48px;height:48px;background:#e5e7eb;}.c{display:flex;flex-direction:row;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;gap:16px;}.d{display:flex;flex-direction:column;scroll-snap-align:start;flex-shrink:0;width:200px;padding:20px;background:#f3f4f6;border-radius:8px;}.e{display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:9999px;font-size:0.875rem;border:1px solid currentColor;background:#e5e7eb;}.f{display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;border-radius:4px;background:#dbeafe;color:#1e40af;}}</style></head><body><div class="a"><div class="b"><span>AB</span></div><div class="c"><div class="d"><span>Slide 1</span></div><div class="d"><span>Slide 2</span></div></div><span class="e">Category</span><span class="f">v2.0</span></div></body></html>
tests/snapshots/new_elements_6.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{font-weight:bold;}}</style><noscript><span>JavaScript is required for this page.</span></noscript><address><span class="a">John Doe</span><a href="mailto:john@example.com">john@example.com</a></address><search><input type="search" placeholder="Search..."><button type="submit">Go</button></search>
tests/snapshots/new_html_attrs.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HTML Attrs</title><link rel="preload" href="hero.jpg" as="image"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{display:flex;flex-direction:column;}.b{width:200px;}}</style></head><body><form action="/search" class="a" method="post"><input type="search" inputmode="search" enterkeyhint="search" placeholder="Search..." spellcheck="false"><input type="email" inputmode="email" enterkeyhint="done" translate="no"><img src="hero.jpg" class="b" fetchpriority="high"><button type="submit">Go</button></form></body></html>
tests/snapshots/new_pseudos.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.b{display:flex;flex-direction:column;}.a:visited{color:purple;}.b:empty{display:none;}.b:target{background:yellow;}.c:valid{border:2px solid green;}.d:invalid{border:2px solid red;}}</style><a href="https://example.com" rel="noopener noreferrer" target="_blank" class="a">Already visited</a><div class="b"><span>Conditional content</span></div><input class="c" type="email"><input class="d" type="text">
tests/snapshots/no_page.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{display:flex;flex-direction:column;padding:10px;background:#fff;}.b{font-weight:bold;}}</style><div class="a"><span class="b">Fragment without @page</span></div>
tests/snapshots/nth_pseudo.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Nth Pseudo</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:4px;}.b{display:flex;flex-direction:column;padding:8px;}.c{display:flex;flex-direction:row;gap:4px;flex-wrap:wrap;}.d{display:flex;flex-direction:column;padding:12px;background:#f0f0f0;}.b:nth-child(3){background:#3b82f6;color:white;}.d:nth-child(2n){background:#e0e0e0;}}</style></head><body><div class="a"><div class="b"><span>Item 1</span></div><div class="b"><span>Item 2</span></div><div class="b"><span>Item 3</span></div><div class="b"><span>Item 4</span></div><div class="b"><span>Item 5</span></div></div><div class="c"><div class="d"><span>1</span></div><div class="d"><span>2</span></div><div class="d"><span>3</span></div><div class="d"><span>4</span></div><div class="d"><span>5</span></div><div class="d"><span>6</span></div><div class="d"><span>7</span></div><div class="d"><span>8</span></div></div></body></html>
tests/snapshots/og_directive.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>OG Test</title><meta property="og:title" content="My Page Title"><meta property="og:description" content="A description of the page"><meta property="og:image" content="https://example.com/image.png"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{}</style></head><body><span>Hello</span></body></html>
tests/snapshots/pagination_each.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{}</style><span>a</span><span>b</span>
tests/snapshots/popover_api.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Popover Demo</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #ccc;}}</style></head><body><button popovertarget="my-popover">Toggle Info</button><div class="a" id="my-popover" popover><span>This is a popover panel</span></div></body></html>
tests/snapshots/positional.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Positional</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;position:relative;width:300px;height:300px;}.b{display:flex;flex-direction:column;position:absolute;top:0;right:0;width:40px;height:40px;background:red;}.c{display:flex;flex-direction:column;position:absolute;bottom:10px;left:10px;background:blue;}}</style></head><body><div class="a"><div class="b"><span>X</span></div><div class="c"><span>Y</span></div></div></body></html>
tests/snapshots/pseudo_elements.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Pseudo Elements</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b,.c,.d{display:flex;flex-direction:column;}.b::before{content:"→ ";color:red;}.d::before{content:none;}.c::after{content:" ✓";color:green;}}</style></head><body><div class="a"><div class="b"><span>Item with arrow prefix</span></div><div class="c"><span>Completed item</span></div><div class="d"><span>No pseudo element</span></div></div></body></html>
tests/snapshots/pseudo_states.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Pseudo States</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;background:white;transition:all 0.2s;}.b{color:black;}.a:hover{background:#eee;}.b:hover{color:blue;}.a:active{background:#ddd;}.a:focus{background:#ccc;}}</style></head><body><div class="a"><span class="b">Hover me</span></div></body></html>
tests/snapshots/pseudo_states_extended.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Pseudo States Extended</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a,.f{display:flex;flex-direction:column;padding:10px;}.b,.d{display:flex;flex-direction:column;}.e{display:flex;flex-direction:row;gap:10px;}.a:focus-visible{border:2px solid blue;}.a:focus-within{background:#eee;}.b:disabled{opacity:0.5;}.b:checked{background:green;}.c::placeholder{color:#999;}.d:first-child{border-top:0 solid currentColor;}.d:last-child{border-bottom:0 solid currentColor;}.f:nth-child(odd){background:#f5f5f5;}.f:nth-child(even){background:white;}}</style></head><body><div class="a"><input type="text" placeholder="Name"></div><div class="b"></div><input class="c" type="text"><div class="d"></div><div class="e"><div class="f"><span>Item</span></div></div></body></html>
tests/snapshots/raw_html.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Raw HTML</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:10px;}}</style></head><body><div class="a"><span>Before raw</span><div class="custom">Raw HTML content</div><span>After raw</span></div></body></html>
tests/snapshots/repeat_directive.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{font-weight:bold;}}</style><span class="a">Item 0</span><span class="a">Item 1</span><span class="a">Item 2</span>
tests/snapshots/responsive.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Responsive</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:row;gap:10px;}.b{display:flex;flex-direction:column;flex:1;min-width:0;}.c{font-size:14px;}.d{display:flex;flex-direction:column;padding:10px;}@media(min-width:640px){.a{gap:20px;}.b{width:200px;}.d{padding:20px;}}@media(min-width:768px){.b{width:300px;}.c{font-size:18px;}}@media(min-width:1024px){.a{gap:40px;}.b{width:400px;}.c{font-size:24px;}}@media(min-width:1280px){.b{width:500px;}}}</style></head><body><div class="a"><div class="b"><span class="c">Responsive text</span></div><div class="d"></div></div></body></html>
tests/snapshots/responsive_images.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Responsive Images</title><link rel="preload" href="hero.jpg" as="image"><link rel="preload" href="icon.png" as="image"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{}</style></head><body><img src="hero.jpg" alt="Hero photo" srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w" sizes="(max-width: 1200px) 100vw, 1200px" fetchpriority="high"><img src="icon.png" alt="Small icon" fetchpriority="high"></body></html>
tests/snapshots/scope_css.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Scope Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}}@scope (.card) {.title { font-weight: bold; }.body { color: #333; }}</style></head><body><div class="a"><span>Hello</span></div></body></html>
tests/snapshots/script_element.htmlnew file+4
@@ -0,0 +1,4 @@
1+<style>@layer htmlang{}</style><script src="app.js" defer></script><script>console.log("hello world");
2+document.addEventListener("DOMContentLoaded", () => {
3+ alert("loaded");
4+});</script>
tests/snapshots/scroll_driven_animations.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Scroll Animation Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}.b{display:flex;flex-direction:column;animation:fade-in linear;animation-timeline:scroll();animation-range:entry 0% cover 50%;}}@keyframes fade-in{from{opacity:0;}to{opacity:1;}}</style></head><body><div class="a"><div class="b"><span>Animated on scroll</span></div></div></body></html>
tests/snapshots/selection_pseudo.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Selection Pseudo</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{margin:0;}.b{display:flex;flex-direction:column;gap:16px;}.a::selection{background:#3b82f6;color:white;}.c::selection{background:#ef4444;color:white;}.d::selection{background:#22c55e;color:black;}}</style></head><body><p class="a">Select this text to see custom selection colors.</p><div class="b"><span class="c">Red selection</span><span class="d">Green selection</span></div></body></html>
tests/snapshots/semantic_elements.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Semantic Elements</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}.hl-skip{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;z-index:9999;padding:8px 16px;background:#000;color:#fff;text-decoration:none;font-size:14px}.hl-skip:focus{left:8px;top:8px;width:auto;height:auto;overflow:visible}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;background:#f0f0f0;}.b{font-weight:bold;font-size:24px;}.c{display:flex;flex-direction:column;gap:10px;}.d{display:flex;flex-direction:column;padding:20px;}.e{display:flex;flex-direction:column;padding:16px;border:1px solid #ddd;border-radius:8px;}.f{font-weight:bold;}.g{margin:0;}.h{display:flex;flex-direction:column;padding:16px;}.i{display:flex;flex-direction:column;padding:16px;background:#f8f8f8;}.j{display:flex;flex-direction:column;padding:20px;background:#333;color:white;}}</style></head><body><a href="#hl-main" class="hl-skip">Skip to content</a><header class="a"><span class="b">My Site</span></header><nav class="c"><a href="/">Home</a><a href="/about">About</a><a href="/contact">Contact</a></nav><main id="hl-main" class="d"><article class="e"><span class="f">Article Title</span><p class="g"></p></article><section class="h"><span class="f">Section Title</span><p class="g"></p></section></main><aside class="i"><span>Related links</span></aside><footer class="j"><span>Copyright 2024</span></footer></body></html>
tests/snapshots/sizing.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sizing</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:row;height:200px;}.b{display:flex;flex-direction:column;flex:1;min-width:0;background:#eee;}.c{display:flex;flex-direction:column;width:100px;background:#ddd;}.d{display:flex;flex-direction:column;flex-shrink:0;background:#ccc;}.e{display:flex;flex-direction:column;width:400px;}.f{display:flex;flex-direction:column;flex:1;min-height:0;background:#eee;}.g{display:flex;flex-direction:column;height:50px;background:#ddd;}}</style></head><body><div class="a"><div class="b"></div><div class="c"></div><div class="d"></div></div><div class="e"><div class="f"></div><div class="g"></div></div></body></html>
tests/snapshots/starting_style.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Starting Style Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}.b{opacity:1;transition:opacity 0.3s ease;}}@starting-style{.fade-in { opacity: 0; }}</style></head><body><div class="a"><span class="b">Hello</span></div></body></html>
tests/snapshots/stress_deeply_nested.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Deeply Nested Stress Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}:root{--primary:#3b82f6;--border:#e2e8f0;--radius:8;}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;padding:24px;}.b{display:flex;flex-direction:column;background:#f0f9ff;border-radius:12px;padding:20px;border:1px solid #bae6fd;}.c{font-weight:bold;font-size:20px;color:#0369a1;}.d{display:flex;flex-direction:row;gap:16px;}.e{display:flex;flex-direction:column;background:white;border-radius:8px;padding:16px;border:1px solid var(--border);flex:1;min-width:0;}.f{font-weight:bold;font-size:16px;color:#1e293b;}.g{display:flex;flex-direction:column;gap:8px;}.h{display:flex;flex-direction:column;background:#f1f5f9;border-radius:6px;padding:12px;}.i{font-size:14px;color:#475569;}.j{display:flex;flex-direction:column;gap:4px;}.k{display:flex;flex-direction:column;padding:8px 12px;border-radius:4px;transition:all 0.15s;}.l{font-size:13px;color:#64748b;}.m{display:flex;flex-direction:row;gap:6px;align-items:center;}.n{display:flex;flex-direction:column;width:6px;height:6px;border-radius:50%;background:#10b981;}.o{display:flex;flex-direction:column;padding:4px 8px;background:#dbeafe;border-radius:4px;}.p{font-size:11px;color:#1d4ed8;}.q{flex-direction:column;display:inline-block;}.r{font-size:10px;color:var(--primary);}.s{display:flex;flex-direction:column;}.t{font-size:9px;color:#93c5fd;}.u{display:flex;flex-direction:column;padding:2px 4px;background:#eff6ff;border-radius:2px;}.v{font-size:8px;color:#bfdbfe;}.w{display:flex;flex-direction:column;padding:8px 12px;border-radius:4px;}.x{display:flex;flex-direction:column;padding:6px;background:#f0fdf4;border-radius:4px;}.y{font-size:12px;color:#166534;}.z{display:flex;flex-direction:row;gap:4px;}.aa{display:flex;flex-direction:column;padding:2px 6px;background:#bbf7d0;border-radius:2px;}.ab{font-size:10px;color:#15803d;}.ac{display:flex;flex-direction:column;padding:2px 6px;background:#fef3c7;border-radius:2px;}.ad{font-size:10px;color:#92400e;}.ae{display:flex;flex-direction:column;gap:12px;}.af,.bk{display:flex;flex-direction:column;background:white;border-radius:8px;padding:16px;border:1px solid var(--border);}.ag{font-weight:bold;font-size:16px;}.ah,.bl{display:flex;flex-direction:column;background:#f8fafc;border-radius:6px;padding:12px;}.ai{display:flex;flex-direction:row;gap:8px;}.aj{display:flex;flex-direction:column;flex:1;min-width:0;background:#eff6ff;border-radius:4px;padding:8px;}.ak{font-size:12px;color:#1d4ed8;font-weight:bold;}.al{display:flex;flex-direction:column;height:60px;background:#dbeafe;border-radius:4px;padding:8px;}.am{font-size:20px;font-weight:bold;color:#1e40af;}.an{display:flex;flex-direction:row;gap:2px;}.ao{display:flex;flex-direction:column;width:4px;height:12px;background:#93c5fd;border-radius:1px;}.ap{display:flex;flex-direction:column;width:4px;height:18px;background:#60a5fa;border-radius:1px;}.aq{display:flex;flex-direction:column;width:4px;height:8px;background:#93c5fd;border-radius:1px;}.ar{display:flex;flex-direction:column;width:4px;height:22px;background:var(--primary);border-radius:1px;}.as{display:flex;flex-direction:column;width:2px;height:2px;border-radius:50%;background:white;}.at{font-size:6px;color:white;}.au{display:flex;flex-direction:column;flex:1;min-width:0;background:#f0fdf4;border-radius:4px;padding:8px;}.av{font-size:12px;color:#166534;font-weight:bold;}.aw{display:flex;flex-direction:column;height:60px;background:#dcfce7;border-radius:4px;padding:8px;}.ax{font-size:20px;font-weight:bold;color:#166534;}.ay{font-size:10px;color:#16a34a;}.az{flex-direction:column;padding:1px 4px;background:#bbf7d0;border-radius:2px;display:inline-block;}.a0{font-size:8px;color:#15803d;}.a1,.ba{display:flex;flex-direction:column;padding:8px;border:1px solid var(--border);border-radius:4px;}.a2{font-size:12px;color:#64748b;}.a3{padding:6px 10px;border-radius:4px;border:1px solid var(--border);width:100%;}.a4{display:flex;flex-direction:column;padding:4px 8px;background:#fef2f2;border-radius:4px;}.a5{font-size:11px;color:#991b1b;}.a6{font-size:10px;color:#dc2626;}.a7{display:flex;flex-direction:column;padding:2px 4px;background:#fee2e2;border-radius:2px;}.a8{font-size:9px;color:#b91c1c;}.a9{display:flex;flex-direction:column;gap:6px;}.bb{font-size:14px;font-weight:bold;color:#1e293b;}.bc{display:flex;flex-direction:column;padding:6px;background:#f8fafc;border-radius:4px;}.bd{display:flex;flex-direction:row;gap:8px;align-items:center;}.be{display:flex;flex-direction:column;width:8px;height:8px;border-radius:2px;background:var(--primary);}.bf{font-size:12px;color:#475569;}.bg{font-size:11px;color:#94a3b8;}.bh{display:flex;flex-direction:column;padding:2px 6px;background:#eff6ff;border-radius:2px;}.bi{display:flex;flex-direction:column;background:#fefce8;border-radius:12px;padding:20px;border:1px solid #fde68a;}.bj{font-weight:bold;font-size:20px;color:#854d0e;}.bm{display:flex;flex-direction:column;padding:10px;border:1px solid var(--border);border-radius:6px;}.bn{font-size:13px;color:#1e293b;}.bo{flex-direction:column;width:80px;height:80px;border-radius:50%;background:var(--border);display:flex;align-items:center;justify-content:center;}.bp{font-size:24px;color:#94a3b8;font-weight:bold;}.bq{flex-direction:column;position:absolute;bottom:0;right:0;width:24px;height:24px;border-radius:50%;background:var(--primary);display:flex;align-items:center;justify-content:center;}.br{font-size:10px;color:white;}.bs{display:flex;flex-direction:column;padding:1px 3px;background:#1e3a5f;border-radius:2px;}.bt{font-size:7px;color:white;}.bu{display:flex;flex-direction:row;gap:8px;align-items:center;padding:6px 8px;border:1px solid var(--border);border-radius:4px;}.bv{font-size:13px;color:#1e293b;flex:1;min-width:0;}.bw{display:flex;flex-direction:column;padding:4px 12px;background:#f1f5f9;border-radius:4px;}.bx{display:flex;flex-direction:row;gap:8px;align-items:center;padding:8px;border-bottom:1px solid var(--border);}.by{font-size:14px;color:#1e293b;flex:1;min-width:0;}.bz{display:flex;flex-direction:column;width:36px;height:20px;border-radius:10px;background:var(--primary);padding:2px;}.b0{display:flex;flex-direction:column;width:16px;height:16px;border-radius:50%;background:white;}.b1{display:flex;flex-direction:column;background:#fdf2f8;border-radius:12px;padding:20px;border:1px solid #fbcfe8;}.b2{font-weight:bold;font-size:20px;color:#9d174d;}.b3{display:flex;flex-direction:column;background:white;border-radius:8px;padding:12px;border:1px solid var(--border);flex:1;min-width:0;}.b4{font-weight:bold;font-size:14px;color:#1e293b;}.b5{display:flex;flex-direction:column;background:#f0fdf4;border-radius:6px;padding:8px;}.b6{display:flex;flex-direction:column;flex:1;min-width:0;height:4px;background:#86efac;border-radius:2px;}.b7{display:flex;flex-direction:column;flex:1;min-width:0;height:4px;background:#4ade80;border-radius:2px;}.b8{display:flex;flex-direction:column;flex:1;min-width:0;height:4px;background:#22c55e;border-radius:2px;}.b9{font-size:11px;color:#16a34a;}.ca{font-size:10px;color:#22c55e;}.cb{display:flex;flex-direction:column;padding:2px 6px;background:#dcfce7;border-radius:2px;}.cc{font-size:9px;color:#15803d;}.cd{font-size:8px;color:#4ade80;}.ce{display:flex;flex-direction:column;padding:1px 3px;background:#bbf7d0;border-radius:1px;}.cf{font-size:7px;color:#166534;}.k:hover,.w:hover{background:var(--border);}.ba:hover{background:#f8fafc;}@media(prefers-color-scheme:dark){.af{background:#1e293b;}.ah{background:#0f172a;}}}</style></head><body><div class="a"><div class="b"><span class="c">Level 1 - App Shell</span><div class="d"><div class="e"><span class="f">Level 2 - Sidebar</span><div class="g"><div class="h"><span class="i">Level 3 - Nav Group</span><div class="j"><div class="k"><span class="l">Level 4 - Nav Item</span><div class="m"><div class="n"></div><div class="o"><span class="p">Level 5 - Badge</span><div class="q"><span class="r">Level 6 - Badge Detail</span><div class="s"><span class="t">Level 7 - Sub Detail</span><div class="u"><span class="v">Level 8 - Deepest Badge</span></div></div></div></div></div></div><div class="w"><span class="l">Level 4 - Second Nav Item</span><div class="x"><span class="y">Level 5 - Status</span><div class="z"><div class="aa"><span class="ab">Level 6 - Online</span></div><div class="ac"><span class="ad">Level 6 - Away</span></div></div></div></div></div></div><div class="h"><span class="i">Level 3 - Second Group</span><div class="j"><div class="w"><span class="l">Settings</span></div><div class="w"><span class="l">Profile</span></div><div class="w"><span class="l">Security</span></div></div></div></div></div><div class="e"><span class="f">Level 2 - Main Content</span><div class="ae"><div class="af"><span class="ag">Level 3 - Dashboard Card</span><div class="ah"><span class="l">Level 4 - Chart Area</span><div class="ai"><div class="aj"><span class="ak">Level 5 - Metric A</span><div class="al"><span class="am">Level 6 - Value</span><div class="s"><span class="r">Level 7 - Trend</span><div class="an"><div class="ao"></div><div class="ap"></div><div class="aq"></div><div class="ar"><div class="as"><span class="at">Level 8 - Spark Dot</span></div></div></div></div></div></div><div class="au"><span class="av">Level 5 - Metric B</span><div class="aw"><span class="ax">Level 6 - Value</span><div class="s"><span class="ay">Level 7 - Change</span><div class="az"><span class="a0">Level 8 - Delta</span></div></div></div></div></div></div></div><div class="af"><span class="ag">Level 3 - Form Card</span><div class="ah"><span class="l">Level 4 - Form Section</span><div class="g"><div class="a1"><span class="a2">Level 5 - Field Group</span><input class="a3" type="text" placeholder="Name"><div class="a4"><span class="a5">Level 6 - Validation Error</span><div class="s"><span class="a6">Level 7 - Error Detail</span><div class="a7"><span class="a8">Level 8 - Error Code</span></div></div></div></div><div class="a1"><span class="a2">Level 5 - Select Group</span><select class="a3"><option value="a">Option A</option><option value="b">Option B</option><option value="c">Option C</option></select></div></div></div></div><div class="af"><span class="ag">Level 3 - Nested Lists Card</span><div class="ah"><span class="l">Level 4 - Category Browser</span><div class="a9"><div class="ba"><span class="bb">Level 5 - Electronics</span><div class="j"><div class="bc"><div class="bd"><div class="be"></div><span class="bf">Level 6 - Subcategory</span></div><div class="s"><span class="bg">Level 7 - Item</span><div class="bh"><span class="r">Level 8 - Variant</span></div></div></div></div></div><div class="ba"><span class="bb">Level 5 - Clothing</span><div class="j"><div class="bc"><div class="bd"><div class="be"></div><span class="bf">Level 6 - Subcategory</span></div><div class="s"><span class="bg">Level 7 - Item</span><div class="bh"><span class="r">Level 8 - Variant</span></div></div></div></div></div><div class="ba"><span class="bb">Level 5 - Books</span><div class="j"><div class="bc"><div class="bd"><div class="be"></div><span class="bf">Level 6 - Subcategory</span></div><div class="s"><span class="bg">Level 7 - Item</span><div class="bh"><span class="r">Level 8 - Variant</span></div></div></div></div></div></div></div></div></div></div></div></div><div class="bi"><span class="bj">Level 1 - Settings Panel</span><div class="ae"><div class="bk"><span class="f">Level 2 - Account Settings</span><div class="g"><div class="bl"><span class="i">Level 3 - Profile Section</span><div class="bm"><span class="bn">Level 4 - Avatar Upload</span><div class="bo"><span class="bp">Level 5 - Initials</span><div class="bq"><span class="br">Level 6 - Edit Icon</span><div class="s"><span class="v">Level 7 - Tooltip</span><div class="bs"><span class="bt">Level 8 - Tooltip Arrow</span></div></div></div></div></div></div><div class="bl"><span class="i">Level 3 - Preferences</span><div class="a9"><div class="bu"><span class="bv">Level 4 - Language</span><div class="bw"><span class="a2">Level 5 - Current Value</span></div></div><div class="bu"><span class="bv">Level 4 - Timezone</span><div class="bw"><span class="a2">Level 5 - Current Value</span></div></div><div class="bu"><span class="bv">Level 4 - Currency</span><div class="bw"><span class="a2">Level 5 - Current Value</span></div></div></div></div></div></div><div class="bk"><span class="f">Level 2 - Notification Preferences</span><div class="a9"><div class="bx"><span class="by">Email Notifications</span><div class="bz"><div class="b0"></div></div></div><div class="bx"><span class="by">Push Notifications</span><div class="bz"><div class="b0"></div></div></div><div class="bx"><span class="by">SMS Notifications</span><div class="bz"><div class="b0"></div></div></div><div class="bx"><span class="by">Slack Notifications</span><div class="bz"><div class="b0"></div></div></div></div></div></div></div><div class="b1"><span class="b2">Level 1 - Monitoring</span><div class="d"><div class="b3"><span class="b4">Level 2 - API</span><div class="b5"><span class="y">Level 3 - Healthy</span><div class="s"><div class="z"><div class="b6"></div><div class="b7"></div><div class="b8"></div><div class="b6"></div></div><div class="s"><span class="b9">Level 4 - Uptime 99.9%</span><div class="s"><span class="ca">Level 5 - Response 42ms</span><div class="cb"><span class="cc">Level 6 - p99 120ms</span><div class="s"><span class="cd">Level 7 - Throughput 1.2k/s</span><div class="ce"><span class="cf">Level 8 - Peak 2.4k/s</span></div></div></div></div></div></div></div></div><div class="b3"><span class="b4">Level 2 - Database</span><div class="b5"><span class="y">Level 3 - Healthy</span><div class="s"><div class="z"><div class="b6"></div><div class="b7"></div><div class="b8"></div><div class="b6"></div></div><div class="s"><span class="b9">Level 4 - Uptime 99.9%</span><div class="s"><span class="ca">Level 5 - Response 42ms</span><div class="cb"><span class="cc">Level 6 - p99 120ms</span><div class="s"><span class="cd">Level 7 - Throughput 1.2k/s</span><div class="ce"><span class="cf">Level 8 - Peak 2.4k/s</span></div></div></div></div></div></div></div></div><div class="b3"><span class="b4">Level 2 - Cache</span><div class="b5"><span class="y">Level 3 - Healthy</span><div class="s"><div class="z"><div class="b6"></div><div class="b7"></div><div class="b8"></div><div class="b6"></div></div><div class="s"><span class="b9">Level 4 - Uptime 99.9%</span><div class="s"><span class="ca">Level 5 - Response 42ms</span><div class="cb"><span class="cc">Level 6 - p99 120ms</span><div class="s"><span class="cd">Level 7 - Throughput 1.2k/s</span><div class="ce"><span class="cf">Level 8 - Peak 2.4k/s</span></div></div></div></div></div></div></div></div><div class="b3"><span class="b4">Level 2 - Queue</span><div class="b5"><span class="y">Level 3 - Healthy</span><div class="s"><div class="z"><div class="b6"></div><div class="b7"></div><div class="b8"></div><div class="b6"></div></div><div class="s"><span class="b9">Level 4 - Uptime 99.9%</span><div class="s"><span class="ca">Level 5 - Response 42ms</span><div class="cb"><span class="cc">Level 6 - p99 120ms</span><div class="s"><span class="cd">Level 7 - Throughput 1.2k/s</span><div class="ce"><span class="cf">Level 8 - Peak 2.4k/s</span></div></div></div></div></div></div></div></div></div></div></div></body></html>
tests/snapshots/stress_large.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Stress Test Large Document</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}:root{--primary:#3b82f6;--secondary:#10b981;--accent:#f59e0b;--danger:#ef4444;--dark:#1e293b;--light:#f8fafc;--gray:#64748b;--border:#e2e8f0;--radius:8;--gap:16;--gap-sm:8;--gap-lg:24;--h0:none solid currentColor;}@layer htmlang{.a{display:flex;flex-direction:row;gap:0;min-height:100vh;}.b{display:flex;flex-direction:column;width:260px;background:white;border-right:1px solid var(--border);padding:16px;gap:8px;}.c{display:flex;flex-direction:row;gap:12px;padding:8px;align-items:center;}.d{flex-direction:column;width:36px;height:36px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-weight:bold;}.e{display:flex;flex-direction:column;gap:2px;}.f{font-weight:bold;font-size:16px;color:var(--dark);}.g{font-size:12px;color:var(--gray);}.h{display:flex;flex-direction:column;width:100%;height:1px;background:var(--border);margin:8px 0;}.i{display:flex;flex-direction:column;}.j{font-weight:bold;font-size:12px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;}.k{color:var(--primary);font-weight:bold;padding:8px 12px;background:#eff6ff;border-radius:6px;}.l{color:var(--gray);padding:8px 12px;border-radius:6px;transition:all 0.15s;}.m{display:flex;flex-direction:column;gap:4px;}.n{display:flex;flex-direction:row;gap:8px;align-items:center;}.o{display:flex;flex-direction:column;width:6px;height:6px;border-radius:50%;background:var(--secondary);}.p{font-size:13px;color:var(--gray);}.q{display:flex;flex-direction:column;width:6px;height:6px;border-radius:50%;background:var(--accent);}.r{display:flex;flex-direction:column;width:6px;height:6px;border-radius:50%;background:#cbd5e1;}.s{display:flex;flex-direction:column;padding:12px;background:#f1f5f9;border-radius:8px;}.t{flex-direction:column;width:28px;height:28px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-weight:bold;}.u{display:flex;flex-direction:column;gap:0;}.v{font-size:13px;font-weight:bold;color:var(--dark);}.w{font-size:11px;color:var(--gray);}.x{display:flex;flex-direction:column;flex:1;min-width:0;background:var(--light);}.y{display:flex;flex-direction:column;padding:12px 24px;background:white;border-bottom:1px solid var(--border);}.z{display:flex;flex-direction:row;gap:16px;align-items:center;}.aa{display:flex;flex-direction:row;gap:8px;}.ab{color:var(--gray);font-size:14px;}.ac{color:var(--border);font-size:14px;}.ad{color:var(--dark);font-size:14px;}.ae{font-weight:bold;font-size:22px;color:var(--dark);}.af{display:flex;flex-direction:column;flex:1;min-width:0;}.ag{display:flex;flex-direction:row;gap:12px;align-items:center;}.ah{padding:8px 12px;border-radius:6px;border:1px solid var(--border);width:240px;transition:all 0.2s;}.ai{display:flex;flex-direction:column;position:relative;}.aj{padding:8px 16px;background:var(--primary);color:white;border-radius:6px;border:var(--h0);cursor:pointer;transition:all 0.2s;}.ak{display:flex;flex-direction:column;position:absolute;top:-2px;right:-2px;}.al{display:flex;flex-direction:column;width:8px;height:8px;border-radius:50%;background:var(--danger);}.am{padding:8px 16px;background:var(--gray);color:white;border-radius:6px;border:var(--h0);cursor:pointer;transition:all 0.2s;}.an{display:flex;flex-direction:column;padding:24px;overflow-y:auto;}.ao{display:flex;flex-direction:column;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:12px 16px;}.ap{color:#1e40af;font-size:14px;}.aq{display:flex;flex-direction:row;gap:16px;}.ar{display:flex;flex-direction:column;background:white;border-radius:8px;padding:16px;border:1px solid var(--border);}.as{font-size:14px;color:var(--gray);}.at{font-weight:bold;font-size:28px;color:var(--dark);}.au{font-size:12px;color:var(--secondary);}.av{display:flex;flex-direction:column;background:white;border-radius:8px;padding:16px;border:1px solid var(--border);transition:all 0.2s;animation:fade-in 0.3s ease;}.aw{font-weight:bold;font-size:18px;color:var(--dark);}.ax{flex-direction:column;height:200px;background:var(--light);border-radius:6px;display:flex;align-items:flex-end;padding:16px;gap:8px;}.ay{display:flex;flex-direction:column;width:100%;height:40%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.az{display:flex;flex-direction:column;width:100%;height:65%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a0{display:flex;flex-direction:column;width:100%;height:45%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a1{display:flex;flex-direction:column;width:100%;height:80%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a2{display:flex;flex-direction:column;width:100%;height:55%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a3{display:flex;flex-direction:column;width:100%;height:70%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a4{display:flex;flex-direction:column;width:100%;height:90%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a5{display:flex;flex-direction:column;width:100%;height:60%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a6{display:flex;flex-direction:column;width:100%;height:75%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a7{display:flex;flex-direction:column;width:100%;height:85%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a8{display:flex;flex-direction:column;width:100%;height:50%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a9{display:flex;flex-direction:column;width:100%;height:95%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.ba{display:flex;flex-direction:column;gap:12px;}.bb{display:flex;flex-direction:row;gap:12px;align-items:center;padding:8px;border-radius:6px;}.bc{flex-direction:column;width:32px;height:32px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-weight:bold;}.bd{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;}.be{font-size:14px;font-weight:bold;color:var(--dark);}.bf{display:flex;flex-direction:column;flex:1;min-width:0;gap:16px;}.bg{display:flex;flex-direction:row;gap:0;padding:8px 16px;background:var(--light);border-bottom:1px solid var(--border);}.bh{display:flex;flex-direction:column;width:200px;}.bi{font-weight:bold;font-size:12px;color:var(--gray);text-transform:uppercase;}.bj{display:flex;flex-direction:column;width:150px;}.bk{display:flex;flex-direction:column;width:120px;}.bl{display:flex;flex-direction:row;gap:0;padding:12px 16px;border-bottom:1px solid var(--border);}.bm{font-size:14px;}.bn{display:flex;flex-direction:row;gap:0;}.bo{font-size:13px;color:var(--dark);flex:1;min-width:0;}.bp{display:flex;flex-direction:column;width:100%;height:8px;background:var(--border);border-radius:4px;overflow:hidden;}.bq{display:flex;flex-direction:column;width:67%;height:100%;background:var(--primary);border-radius:4px;}.br{display:flex;flex-direction:column;gap:8px;}.bs{display:flex;flex-direction:row;gap:12px;padding:8px 12px;align-items:center;}.bt{flex-direction:column;width:24px;height:24px;border-radius:50%;background:#eff6ff;display:flex;align-items:center;justify-content:center;}.bu{font-size:10px;color:var(--primary);font-weight:bold;}.bv{font-size:14px;color:var(--dark);flex:1;min-width:0;}.bw{display:flex;flex-direction:column;width:340px;gap:16px;}.bx{padding:8px 16px;background:var(--secondary);color:white;border-radius:6px;border:var(--h0);cursor:pointer;transition:all 0.2s;}.by{padding:8px 16px;background:var(--accent);color:white;border-radius:6px;border:var(--h0);cursor:pointer;transition:all 0.2s;}.bz{padding:8px 16px;background:var(--gray);color:white;border-radius:6px;border:var(--h0);cursor:pointer;transition:all 0.2s;}.b0{display:flex;flex-direction:row;gap:10px;align-items:center;padding:6px 8px;border-radius:6px;transition:all 0.15s;}.b1{display:flex;flex-direction:column;gap:0;flex:1;min-width:0;}.b2{display:flex;flex-direction:column;width:8px;height:8px;border-radius:50%;background:var(--secondary);}.b3{display:flex;flex-direction:row;gap:0;flex-wrap:wrap;}.b4{flex-direction:column;display:inline-block;padding:4px 10px;background:#f1f5f9;color:var(--gray);border-radius:4px;margin:2px;}.b5{display:flex;flex-direction:row;gap:10px;padding:10px 12px;border-bottom:1px solid var(--border);align-items:center;}.b6{display:flex;flex-direction:row;gap:0;align-items:center;}.b7{display:flex;flex-direction:column;width:40px;height:22px;border-radius:11px;background:var(--primary);padding:2px;cursor:pointer;transition:all 0.2s;}.b8{display:flex;flex-direction:column;width:18px;height:18px;border-radius:50%;background:white;transition:all 0.2s;}.b9{display:flex;flex-direction:column;width:40px;height:22px;border-radius:11px;background:#cbd5e1;padding:2px;cursor:pointer;transition:all 0.2s;}.ca{padding:8px 12px;border-radius:6px;border:1px solid var(--border);width:240px;}.cb{padding:6px 12px;border-radius:4px;border:1px solid var(--border);background:white;color:var(--gray);cursor:pointer;transition:all 0.15s;}.cc{display:flex;flex-direction:row;gap:0;padding:8px 16px;background:var(--light);border-bottom:2px solid var(--border);border-radius:6 6 0 0;}.cd{display:flex;flex-direction:column;width:60px;}.ce{display:flex;flex-direction:row;gap:0;padding:10px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:all 0.1s;}.cf{flex-direction:column;width:24px;height:24px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-weight:bold;}.cg{font-size:14px;color:var(--dark);}.ch{flex-direction:column;display:inline-block;padding:2px 8px;background:var(--secondary);color:white;border-radius:12px;}.ci{display:flex;flex-direction:row;gap:16px;padding:12px 16px;align-items:center;border-top:1px solid var(--border);}.cj{display:flex;flex-direction:row;gap:4px;}.ck{padding:6px 10px;border-radius:4px;border:1px solid var(--border);background:white;color:var(--gray);cursor:pointer;}.cl{padding:6px 10px;border-radius:4px;border:1px solid var(--primary);background:var(--primary);color:white;cursor:pointer;}.cm{display:flex;flex-direction:column;padding:24px 24px;background:white;border-top:1px solid var(--border);}.cn{display:flex;flex-direction:row;gap:24px;}.co{display:flex;flex-direction:column;gap:8px;flex:1;min-width:0;}.cp{font-weight:bold;font-size:14px;color:var(--dark);}.cq{color:var(--gray);font-size:13px;}.l:hover{color:var(--primary);background:#f1f5f9;}.ab:hover,.cq:hover{color:var(--primary);}.aj:hover,.am:hover,.bx:hover,.by:hover,.bz:hover{background:var(--dark);}.av:hover{box-shadow:0 4 12 rgba(0,0,0,0.1);}.ay:hover,.az:hover,.a0:hover,.a1:hover,.a2:hover,.a3:hover,.a4:hover,.a5:hover,.a6:hover,.a7:hover,.a8:hover,.a9:hover{background:#2563eb;}.bb:hover,.bl:hover,.ce:hover{background:var(--light);}.b0:hover{background:#f1f5f9;}.cb:hover{background:#f1f5f9;color:var(--dark);}.ah:focus{border:1px solid var(--primary);}@media(min-width:640px){.bw{flex:1;min-width:0;}}@media(prefers-color-scheme:dark){.b{background:#0f172a;border-right:1px solid var(--dark);}.f,.v,.ad,.ae,.at,.aw,.be,.bo,.bv,.cg,.cp{color:white;}.h,.bp{background:#334155;}.k,.bt{background:#1e3a5f;}.s,.cb,.ck{background:var(--dark);}.x,.ax,.bg,.cc{background:#0f172a;}.y{background:#0f172a;border-bottom:1px solid var(--dark);}.ah{border:1px solid #334155;background:var(--dark);color:white;}.ar,.av{background:var(--dark);border:1px solid #334155;}.bl,.b5,.ce{border-bottom:1px solid #334155;}.b4{background:#334155;color:#94a3b8;}.b9{background:#475569;}.cm{background:#0f172a;border-top:1px solid var(--dark);}}}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-down{0%{transform:translateY(-10px);opacity:0}100%{transform:translateY(0);opacity:1}}</style></head><body><div class="a"><div class="b"><div class="c"><div class="d"><span>HL</span></div><div class="e"><span class="f">htmlang</span><span class="g">Dashboard v2.0</span></div></div><div class="h"></div><div class="i"><span class="j">Navigation</span><div class="e"><a href="/dashboard" class="k">Dashboard</a><a href="/analytics" class="l">Analytics</a><a href="/projects" class="l">Projects</a><a href="/team" class="l">Team</a><a href="/messages" class="l">Messages</a><a href="/calendar" class="l">Calendar</a><a href="/documents" class="l">Documents</a><a href="/settings" class="l">Settings</a></div></div><div class="i"><span class="j">Favorites</span><div class="e"><a href="/project/alpha" class="l">Project Alpha</a><a href="/project/beta" class="l">Project Beta</a><a href="/reports/q4" class="l">Q4 Reports</a></div></div><div class="i"><span class="j">Shortcuts</span><div class="m"><div class="n"><div class="o"></div><span class="p">Online (12)</span></div><div class="n"><div class="q"></div><span class="p">Away (3)</span></div><div class="n"><div class="r"></div><span class="p">Offline (5)</span></div></div></div><div class="s"><div class="n"><div class="t"><span>JD</span></div><div class="u"><span class="v">Jane Doe</span><span class="w">jane@example.com</span></div></div></div></div><div class="x"><header class="y"><div class="z"><div class="e"><div class="aa"><div class="aa"><a href="/" class="ab">Home</a><span class="ac">/</span></div><span class="ad">Dashboard</span></div><span class="ae">Dashboard Overview</span></div><div class="af"></div><div class="ag"><input class="ah" type="search" placeholder="Search..."><div class="ai"><button class="aj">Alerts</button><div class="ak"><div class="al"></div></div></div><button class="am">Settings</button></div></div></header><div class="an"><div class="ao"><span class="ap">Welcome back! You have 3 new notifications and 2 pending tasks.</span></div><div class="aq"><div class="ar"><span class="as">Total Revenue</span><span class="at">$48</span><span class="au">+12.5% from last month</span></div><div class="ar"><span class="as">Active Users</span><span class="at">2</span><span class="au">+8.2% from last week</span></div><div class="ar"><span class="as">Conversion Rate</span><span class="at">3.24%</span><span class="au">+0.4% from yesterday</span></div><div class="ar"><span class="as">Open Tickets</span><span class="at">142</span><span class="au">-5.1% from last month</span></div></div><div class="aq"><div class="av"><span class="aw">Revenue Overview</span><div class="i"><div class="ax"><div class="ay"></div><div class="az"></div><div class="a0"></div><div class="a1"></div><div class="a2"></div><div class="a3"></div><div class="a4"></div><div class="a5"></div><div class="a6"></div><div class="a7"></div><div class="a8"></div><div class="a9"></div></div></div></div><div class="av"><span class="aw">Activity Feed</span><div class="i"><div class="ba"><div class="bb"><div class="bc"><span>Alice</span></div><div class="bd"><span class="be">Alice</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bb"><div class="bc"><span>Bob</span></div><div class="bd"><span class="be">Bob</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bb"><div class="bc"><span>Carol</span></div><div class="bd"><span class="be">Carol</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bb"><div class="bc"><span>Dave</span></div><div class="bd"><span class="be">Dave</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bb"><div class="bc"><span>Eve</span></div><div class="bd"><span class="be">Eve</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bb"><div class="bc"><span>Frank</span></div><div class="bd"><span class="be">Frank</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bb"><div class="bc"><span>Grace</span></div><div class="bd"><span class="be">Grace</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bb"><div class="bc"><span>Hank</span></div><div class="bd"><span class="be">Hank</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div></div></div></div></div><div class="aq"><div class="bf"><div class="av"><span class="aw">Project Status</span><div class="i"><div class="u"><div class="bg"><div class="bh"><span class="bi">Project</span></div><div class="bj"><span class="bi">Status</span></div><div class="bk"><span class="bi">Progress</span></div><div class="af"><span class="bi">Due Date</span></div></div><div class="bl"><div class="bh"><span class="bm">Project Alpha</span></div><div class="bj"><span class="as">In Progress</span></div><div class="bk"><span class="bm">75%</span></div><div class="af"><span class="as">Dec 15 2025</span></div></div><div class="bl"><div class="bh"><span class="bm">Project Beta</span></div><div class="bj"><span class="as">Review</span></div><div class="bk"><span class="bm">90%</span></div><div class="af"><span class="as">Nov 30 2025</span></div></div><div class="bl"><div class="bh"><span class="bm">Project Gamma</span></div><div class="bj"><span class="as">Planning</span></div><div class="bk"><span class="bm">20%</span></div><div class="af"><span class="as">Jan 20 2026</span></div></div><div class="bl"><div class="bh"><span class="bm">Project Delta</span></div><div class="bj"><span class="as">Completed</span></div><div class="bk"><span class="bm">100%</span></div><div class="af"><span class="as">Oct 10 2025</span></div></div><div class="bl"><div class="bh"><span class="bm">Project Epsilon</span></div><div class="bj"><span class="as">On Hold</span></div><div class="bk"><span class="bm">45%</span></div><div class="af"><span class="as">Feb 28 2026</span></div></div></div></div></div><div class="av"><span class="aw">Progress Breakdown</span><div class="i"><div class="ba"><div class="m"><div class="bn"><span class="bo">Alpha</span><span class="p">67%</span></div><div class="bp"><div class="bq"></div></div></div><div class="m"><div class="bn"><span class="bo">Beta</span><span class="p">67%</span></div><div class="bp"><div class="bq"></div></div></div><div class="m"><div class="bn"><span class="bo">Gamma</span><span class="p">67%</span></div><div class="bp"><div class="bq"></div></div></div><div class="m"><div class="bn"><span class="bo">Delta</span><span class="p">67%</span></div><div class="bp"><div class="bq"></div></div></div><div class="m"><div class="bn"><span class="bo">Epsilon</span><span class="p">67%</span></div><div class="bp"><div class="bq"></div></div></div></div></div></div><div class="av"><span class="aw">Recent Activity Log</span><div class="i"><div class="br"><div class="bs"><div class="bt"><span class="bu">v2.3.1 to production</span></div><span class="bv">Deployed</span><span class="w">just now</span></div><div class="bs"><div class="bt"><span class="bu">feature/auth-redesign</span></div><span class="bv">Merged</span><span class="w">just now</span></div><div class="bs"><div class="bt"><span class="bu">issue #482: Login timeout</span></div><span class="bv">Closed</span><span class="w">just now</span></div><div class="bs"><div class="bt"><span class="bu">CI/CD pipeline config</span></div><span class="bv">Updated</span><span class="w">just now</span></div><div class="bs"><div class="bt"><span class="bu">unit tests for payment module</span></div><span class="bv">Added</span><span class="w">just now</span></div><div class="bs"><div class="bt"><span class="bu">PR #156: API refactor</span></div><span class="bv">Reviewed</span><span class="w">just now</span></div><div class="bs"><div class="bt"><span class="bu">memory leak in worker pool</span></div><span class="bv">Fixed</span><span class="w">just now</span></div><div class="bs"><div class="bt"><span class="bu">hotfix 2.3.2</span></div><span class="bv">Released</span><span class="w">just now</span></div></div></div></div></div><div class="bw"><div class="av"><span class="aw">Quick Actions</span><div class="i"><div class="br"><button class="aj">New Project</button><button class="bx">Create Report</button><button class="by">Invite Member</button><button class="bz">View Backlog</button></div></div></div><div class="av"><span class="aw">Team Members</span><div class="i"><div class="br"><div class="b0"><div class="t"><span>Alice</span></div><div class="b1"><span class="v">Alice</span><span class="w">Developer</span></div><div class="b2"></div></div><div class="b0"><div class="t"><span>Bob</span></div><div class="b1"><span class="v">Bob</span><span class="w">Developer</span></div><div class="b2"></div></div><div class="b0"><div class="t"><span>Carol</span></div><div class="b1"><span class="v">Carol</span><span class="w">Developer</span></div><div class="b2"></div></div><div class="b0"><div class="t"><span>Dave</span></div><div class="b1"><span class="v">Dave</span><span class="w">Developer</span></div><div class="b2"></div></div><div class="b0"><div class="t"><span>Eve</span></div><div class="b1"><span class="v">Eve</span><span class="w">Developer</span></div><div class="b2"></div></div><div class="b0"><div class="t"><span>Frank</span></div><div class="b1"><span class="v">Frank</span><span class="w">Developer</span></div><div class="b2"></div></div><div class="b0"><div class="t"><span>Grace</span></div><div class="b1"><span class="v">Grace</span><span class="w">Developer</span></div><div class="b2"></div></div><div class="b0"><div class="t"><span>Hank</span></div><div class="b1"><span class="v">Hank</span><span class="w">Developer</span></div><div class="b2"></div></div></div></div></div><div class="av"><span class="aw">Tags</span><div class="i"><div class="b3"><div class="b4"><span>frontend</span></div><div class="b4"><span>backend</span></div><div class="b4"><span>devops</span></div><div class="b4"><span>design</span></div><div class="b4"><span>mobile</span></div><div class="b4"><span>security</span></div><div class="b4"><span>performance</span></div><div class="b4"><span>testing</span></div><div class="b4"><span>docs</span></div><div class="b4"><span>infra</span></div><div class="b4"><span>ux</span></div><div class="b4"><span>api</span></div></div></div></div><div class="av"><span class="aw">Notifications</span><div class="i"><div class="u"><div class="b5"><div class="al"></div><span class="bo">New deployment completed</span><span class="w">now</span></div><div class="b5"><div class="al"></div><span class="bo">PR review requested</span><span class="w">now</span></div><div class="b5"><div class="al"></div><span class="bo">Build failed on staging</span><span class="w">now</span></div><div class="b5"><div class="al"></div><span class="bo">Meeting in 30 minutes</span><span class="w">now</span></div><div class="b5"><div class="al"></div><span class="bo">Subscription renewal due</span><span class="w">now</span></div></div></div></div><div class="av"><span class="aw">Settings</span><div class="i"><div class="ba"><div class="b6"><span class="bv">Dark Mode</span><div class="b7"><div class="b8"></div></div></div><div class="b6"><span class="bv">Notifications</span><div class="b7"><div class="b8"></div></div></div><div class="b6"><span class="bv">Auto-save</span><div class="b9"><div class="b8"></div></div></div><div class="b6"><span class="bv">Compact View</span><div class="b9"><div class="b8"></div></div></div></div></div></div></div></div><div class="i"><div class="av"><span class="aw">Data Table</span><div class="i"><div class="u"><div class="z"><input class="ca" type="search" placeholder="Filter results..."><div class="af"></div><div class="aa"><button class="cb">all</button><button class="cb">design</button><button class="cb">development</button><button class="cb">marketing</button><button class="cb">analytics</button></div></div><div class="cc"><div class="cd"><span class="bi">ID</span></div><div class="bh"><span class="bi">Name</span></div><div class="bj"><span class="bi">Category</span></div><div class="bk"><span class="bi">Status</span></div><div class="af"><span class="bi">Updated</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Alpha</span></div><span class="cg">Alpha</span></div></div><div class="bj"><span class="p">active</span></div><div class="bk"><div class="ch"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Beta</span></div><span class="cg">Beta</span></div></div><div class="bj"><span class="p">active</span></div><div class="bk"><div class="ch"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Gamma</span></div><span class="cg">Gamma</span></div></div><div class="bj"><span class="p">active</span></div><div class="bk"><div class="ch"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Delta</span></div><span class="cg">Delta</span></div></div><div class="bj"><span class="p">active</span></div><div class="bk"><div class="ch"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Epsilon</span></div><span class="cg">Epsilon</span></div></div><div class="bj"><span class="p">active</span></div><div class="bk"><div class="ch"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Alpha</span></div><span class="cg">Alpha</span></div></div><div class="bj"><span class="p">pending</span></div><div class="bk"><div class="ch"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Beta</span></div><span class="cg">Beta</span></div></div><div class="bj"><span class="p">pending</span></div><div class="bk"><div class="ch"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Gamma</span></div><span class="cg">Gamma</span></div></div><div class="bj"><span class="p">pending</span></div><div class="bk"><div class="ch"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Delta</span></div><span class="cg">Delta</span></div></div><div class="bj"><span class="p">pending</span></div><div class="bk"><div class="ch"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Epsilon</span></div><span class="cg">Epsilon</span></div></div><div class="bj"><span class="p">pending</span></div><div class="bk"><div class="ch"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Alpha</span></div><span class="cg">Alpha</span></div></div><div class="bj"><span class="p">completed</span></div><div class="bk"><div class="ch"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Beta</span></div><span class="cg">Beta</span></div></div><div class="bj"><span class="p">completed</span></div><div class="bk"><div class="ch"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Gamma</span></div><span class="cg">Gamma</span></div></div><div class="bj"><span class="p">completed</span></div><div class="bk"><div class="ch"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Delta</span></div><span class="cg">Delta</span></div></div><div class="bj"><span class="p">completed</span></div><div class="bk"><div class="ch"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Epsilon</span></div><span class="cg">Epsilon</span></div></div><div class="bj"><span class="p">completed</span></div><div class="bk"><div class="ch"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Alpha</span></div><span class="cg">Alpha</span></div></div><div class="bj"><span class="p">archived</span></div><div class="bk"><div class="ch"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Beta</span></div><span class="cg">Beta</span></div></div><div class="bj"><span class="p">archived</span></div><div class="bk"><div class="ch"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Gamma</span></div><span class="cg">Gamma</span></div></div><div class="bj"><span class="p">archived</span></div><div class="bk"><div class="ch"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Delta</span></div><span class="cg">Delta</span></div></div><div class="bj"><span class="p">archived</span></div><div class="bk"><div class="ch"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ce"><div class="cd"><span class="p">#42</span></div><div class="bh"><div class="n"><div class="cf"><span>Epsilon</span></div><span class="cg">Epsilon</span></div></div><div class="bj"><span class="p">archived</span></div><div class="bk"><div class="ch"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ci"><span class="p">Showing 1-20 of 142 results</span><div class="af"></div><div class="cj"><button class="ck">Prev</button><button class="cl">1</button><button class="ck">2</button><button class="ck">3</button><button class="ck">Next</button></div></div></div></div></div></div></div><footer class="cm"><div class="cn"><div class="co"><span class="cp">htmlang Dashboard</span><span class="g">Built with htmlang - a declarative HTML language</span><span class="w">Copyright 2025 htmlang contributors</span></div><div class="m"><span class="bi">Product</span><a href="/features" class="cq">Features</a><a href="/pricing" class="cq">Pricing</a><a href="/changelog" class="cq">Changelog</a><a href="/roadmap" class="cq">Roadmap</a></div><div class="m"><span class="bi">Resources</span><a href="/docs" class="cq">Documentation</a><a href="/api" class="cq">API Reference</a><a href="/guides" class="cq">Guides</a><a href="/examples" class="cq">Examples</a></div><div class="m"><span class="bi">Company</span><a href="/about" class="cq">About</a><a href="/blog" class="cq">Blog</a><a href="/careers" class="cq">Careers</a><a href="/contact" class="cq">Contact</a></div><div class="m"><span class="bi">Legal</span><a href="/privacy" class="cq">Privacy Policy</a><a href="/terms" class="cq">Terms of Service</a><a href="/cookies" class="cq">Cookie Policy</a></div></div></footer></div></div></body></html>
tests/snapshots/string_interpolation.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Interpolation</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{}</style></head><body><span>Hello World</span><a href="/api/users">API Users</a></body></html>
tests/snapshots/style_block.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Style Test</title><style>*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}.a{display:flex;flex-direction:column;}.highlight { background: yellow; border: 2px solid orange; }.highlight:hover { background: gold; }</style></head><body><div class="a highlight"><span>Custom styled</span></div></body></html>
tests/snapshots/subgrid_css.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{display:grid;grid-template-columns:1fr 2fr 1fr;gap:10px;}.b{display:flex;flex-direction:column;grid-template-rows:subgrid;grid-row:span 2;}}</style><div class="a"><div class="b"><span>First</span></div><span>Second</span><span>Third</span></div>
tests/snapshots/table_elements.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Table Elements</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{width:100%;}}</style></head><body><table class="a"><thead><tr><th>Name</th><th>Age</th><th>City</th></tr></thead><tbody><tr><td>Alice</td><td>30</td><td>New York</td></tr><tr><td>Bob</td><td>25</td><td>London</td></tr></tbody></table></body></html>
tests/snapshots/translations_i18n.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}}</style><div class="a"><span>Hello</span><span>Goodbye</span></div>
tests/snapshots/unless_directive.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Unless</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{}</style></head><body><span>This shows because show is false</span></body></html>
tests/snapshots/variable_filters.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Variable Filters</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:10px;}}</style></head><body><div class="a"><span>HELLO WORLD</span><span>hello world</span><span>Hello world</span><span>11</span><span>dlrow olleh</span><span>hello...</span><span>hi</span></div></body></html>
tests/snapshots/variables_defines.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Variables</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{display:flex;flex-direction:column;padding:20px;border-radius:8px;background:white;}.c{color:#3b82f6;}.d{display:flex;flex-direction:column;padding:20px;border-radius:8px;background:white;border:1px solid #3b82f6;}}</style></head><body><div class="a"><div class="b"><span class="c">Hello</span></div><div class="d"><span>World</span></div></div></body></html>
tests/snapshots/view_transitions.htmlnew file+1
@@ -0,0 +1 @@
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>View Transitions</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;padding:20px;}.b{display:flex;flex-direction:column;view-transition-name:hero;padding:20px;background:#3b82f6;color:white;border-radius:8px;}.c{display:flex;flex-direction:column;view-transition-name:sidebar;padding:10px;}}</style></head><body><div class="a"><div class="b"><span>Hero element</span></div><div class="c"><span>Sidebar element</span></div></div></body></html>
tests/snapshots/with_directive.htmlnew file+1
@@ -0,0 +1 @@
1+<style>@layer htmlang{}</style><span>Alice says Hello</span>