on master help sign in
284 files changed+35156−22157
since splitsnapshots
unifiedsplit
.gitattributes+3 .github/workflows/playground.yml+4−5 .gitignore+2 CONTRIBUTING.md+113−9 Cargo.lock+1−211 Cargo.toml+1−2 DESIGN.md+1488−1156 README.md+196−166 crates/htmlang-core/src/ast.rs+722−117 crates/htmlang-core/src/codegen.rs+1697−3530 crates/htmlang-core/src/codegen_tests.rs+33−8 crates/htmlang-core/src/diagnostic.rs+245 crates/htmlang-core/src/expr.rs+801 crates/htmlang-core/src/interp.rs+651 crates/htmlang-core/src/lib.rs+6 crates/htmlang-core/src/parser.rs+6313−5233 crates/htmlang-core/src/std.hl+9 crates/htmlang-core/src/syntax.rs+2943 crates/htmlang-core/src/value.rs+406 crates/htmlang-core/src/vocab.rs+891 crates/htmlang-wasm/playground/index.html+113 crates/htmlang-wasm/src/lib.rs+77−16 editors/vscode/README.md+86−27 editors/vscode/package.json+33−1 editors/vscode/snippets/htmlang.json+256−192 editors/vscode/src/extension.ts+112−4 editors/vscode/syntaxes/htmlang.tmLanguage.json+346−64 examples/blog.hl+86−49 examples/blog.html.map+1−1 examples/demo.hl+276−303 examples/demo.html+800−3 examples/demo.html.map+1−1 examples/docs.hl+132−62 examples/docs.html.map+1−1 examples/landing.hl+131−66 examples/landing.html.map+1−1 examples/posts.json+27 src/bin/htmlang_lsp/analysis.rs+1308−972 src/bin/htmlang_lsp/completion.rs+1862−1134 src/bin/htmlang_lsp/docs.rs+616 src/bin/htmlang_lsp/hover.rs+345−949 src/bin/htmlang_lsp/main.rs+482−380 src/bin/htmlang_lsp/navigation.rs+585−249 src/bin/htmlang_lsp/state.rs+315 src/bin/htmlang_lsp/tree.rs+222 src/cli.rs+34−276 src/convert.rs−1287 src/fmt.rs+451−404 src/lib.rs+4−1 src/main.rs+570−2930 src/playground_default.hl−330 src/serve.rs+224−184 tests/common/mod.rs+140 tests/docs.rs+198 tests/editor.rs+157 tests/fmt.rs+55 tests/regressions.rs+2471 tests/snapshots.rs+4624−1187 tests/snapshots/accessibility.hl+5−5 tests/snapshots/accessibility.html+1 tests/snapshots/alignment.hl+1−1 tests/snapshots/alignment.html+1 tests/snapshots/anchor_positioning.html+1 tests/snapshots/animate_shorthand.hl+13−9 tests/snapshots/animate_shorthand.html+1 tests/snapshots/animations.hl+9−5 tests/snapshots/animations.html+1 tests/snapshots/arithmetic.hl+4−4 tests/snapshots/arithmetic.html+1 tests/snapshots/assert_directive.hl−10 tests/snapshots/attributes.hl+5−5 tests/snapshots/attributes.html+1 tests/snapshots/basic_elements.hl+2−2 tests/snapshots/basic_elements.html+1 tests/snapshots/breadcrumb.hl−4 tests/snapshots/chain_operator.hl+1−1 tests/snapshots/chain_operator.html+1 tests/snapshots/clamp_css.hl+1−1 tests/snapshots/clamp_css.html+1 tests/snapshots/code_samples.hl+28 tests/snapshots/code_samples.html+5 tests/snapshots/color_scheme.hl+1−1 tests/snapshots/color_scheme.html+1 tests/snapshots/colour_variants.hl+12 tests/snapshots/colour_variants.html+1 tests/snapshots/compat_test.hl+1−1 tests/snapshots/computed_let.hl+3−3 tests/snapshots/computed_let.html+1 tests/snapshots/conditionals.html+1 tests/snapshots/container_queries.hl+2−2 tests/snapshots/container_queries.html+1 tests/snapshots/convert_basic.hl+1−1 tests/snapshots/css_property.hl+6−4 tests/snapshots/css_property.html+1 tests/snapshots/css_shorthands.hl+7−10 tests/snapshots/css_shorthands.html+1 tests/snapshots/css_units.hl+3−3 tests/snapshots/css_units.html+1 tests/snapshots/css_vars.hl+5−5 tests/snapshots/css_vars.html+1 tests/snapshots/custom_properties.hl+47 tests/snapshots/custom_properties.html+1 tests/snapshots/dark_mode.hl+2−2 tests/snapshots/dark_mode.html+1 tests/snapshots/data_directive.hl+7−6 tests/snapshots/data_directive.html+1 tests/snapshots/direction_attr.html+1 tests/snapshots/each_destructuring.hl+4−3 tests/snapshots/each_destructuring.html+1 tests/snapshots/each_else.hl+2−2 tests/snapshots/each_else.html+1 tests/snapshots/each_index.html+1 tests/snapshots/each_range.html+1 tests/snapshots/each_step.hl+4−4 tests/snapshots/each_step.html+1 tests/snapshots/element_prefixes.hl+42 tests/snapshots/element_prefixes.html+3 tests/snapshots/else_if.html+1 tests/snapshots/env_directive.hl+1−1 tests/snapshots/env_directive.html+1 tests/snapshots/escapes_and_quotes.hl+19 tests/snapshots/escapes_and_quotes.html+1 tests/snapshots/extra_css.hl+3−3 tests/snapshots/extra_css.html+1 tests/snapshots/fn_defaults.hl+2−2 tests/snapshots/fn_defaults.html+1 tests/snapshots/form_elements.hl+9−9 tests/snapshots/form_elements.html+1 tests/snapshots/fragment.hl+1−1 tests/snapshots/fragment.html+1 tests/snapshots/function_calls.hl+66 tests/snapshots/function_calls.html+1 tests/snapshots/function_definitions.hl+35 tests/snapshots/function_definitions.html+1 tests/snapshots/functions.hl+5−5 tests/snapshots/functions.html+1 tests/snapshots/grid_areas.hl+1−1 tests/snapshots/grid_areas.html+1 tests/snapshots/has_pseudo.hl+2−2 tests/snapshots/has_pseudo.html+1 tests/snapshots/hidden_attr.hl+1−1 tests/snapshots/hidden_attr.html+1 tests/snapshots/html_elements.hl+42 tests/snapshots/html_elements.html+1 tests/snapshots/if_expr.hl+21−3 tests/snapshots/if_expr.html+1 tests/snapshots/implicit_el.hl−7 tests/snapshots/initial_letter.html+1 tests/snapshots/inline_text.hl+2−2 tests/snapshots/inline_text.html+1 tests/snapshots/lang_directive.hl+1−2 tests/snapshots/lang_directive.html+1 tests/snapshots/layer_wrapping.html+1 tests/snapshots/layout_direction.hl+33 tests/snapshots/layout_direction.html+1 tests/snapshots/layout_modes.hl+44 tests/snapshots/layout_modes.html+2 tests/snapshots/leading_argument.hl+50 tests/snapshots/leading_argument.html+1 tests/snapshots/list_elements.hl+11−11 tests/snapshots/list_elements.html+1 tests/snapshots/loops.html+1 tests/snapshots/manifest_directive.hl+3−8 tests/snapshots/manifest_directive.html+1 tests/snapshots/markdown_block.hl+1−1 tests/snapshots/markdown_block.html+10 tests/snapshots/match_directive.hl+9−10 tests/snapshots/match_directive.html+1 tests/snapshots/media_elements.hl+1−1 tests/snapshots/media_elements.html+1 tests/snapshots/media_extended.html+1 tests/snapshots/meta_head.hl+1−1 tests/snapshots/meta_head.html+1 tests/snapshots/mixin_spread.hl+4−4 tests/snapshots/mixin_spread.html+1 tests/snapshots/named_slots.hl+2−2 tests/snapshots/named_slots.html+1 tests/snapshots/names_and_values.hl+18 tests/snapshots/names_and_values.html+1 tests/snapshots/new_css.hl+1−1 tests/snapshots/new_css.html+1 tests/snapshots/new_css_attrs.hl+1−1 tests/snapshots/new_css_attrs.html+1 tests/snapshots/new_css_properties.hl+7−7 tests/snapshots/new_css_properties.html+1 tests/snapshots/new_css_properties_2.hl+2−2 tests/snapshots/new_css_properties_2.html+1 tests/snapshots/new_css_properties_3.hl+2−2 tests/snapshots/new_css_properties_3.html+1 tests/snapshots/new_css_properties_4.hl+1−1 tests/snapshots/new_css_properties_4.html+1 tests/snapshots/new_directives.hl+18−9 tests/snapshots/new_directives.html+8 tests/snapshots/new_elements.hl+7−7 tests/snapshots/new_elements.html+1 tests/snapshots/new_elements_2.hl+10−10 tests/snapshots/new_elements_2.html+1 tests/snapshots/new_elements_3.hl+2−2 tests/snapshots/new_elements_3.html+1 tests/snapshots/new_elements_4.hl+4−13 tests/snapshots/new_elements_4.html+1 tests/snapshots/new_elements_5.hl−19 tests/snapshots/new_elements_6.hl+3−3 tests/snapshots/new_elements_6.html+1 tests/snapshots/new_html_attrs.hl+5−5 tests/snapshots/new_html_attrs.html+1 tests/snapshots/new_pseudos.hl+2−2 tests/snapshots/new_pseudos.html+1 tests/snapshots/no_page.hl+1−1 tests/snapshots/no_page.html+1 tests/snapshots/nth_pseudo.hl+3−3 tests/snapshots/nth_pseudo.html+1 tests/snapshots/og_directive.hl+3−3 tests/snapshots/og_directive.html+1 tests/snapshots/one_css_path.hl+14 tests/snapshots/one_css_path.html+4 tests/snapshots/page_root.hl+15 tests/snapshots/page_root.html+1 tests/snapshots/pagination_each.hl−3 tests/snapshots/parameters.hl+31 tests/snapshots/parameters.html+1 tests/snapshots/popover_api.hl+2−2 tests/snapshots/popover_api.html+1 tests/snapshots/positional.html+1 tests/snapshots/prefixes_compose.hl+17 tests/snapshots/prefixes_compose.html+1 tests/snapshots/pseudo_elements.hl+1−1 tests/snapshots/pseudo_elements.html+1 tests/snapshots/pseudo_states.html+1 tests/snapshots/pseudo_states_extended.hl+5−5 tests/snapshots/pseudo_states_extended.html+1 tests/snapshots/px_rule.hl+20 tests/snapshots/px_rule.html+1 tests/snapshots/raw_html.hl+2−2 tests/snapshots/raw_html.html+1 tests/snapshots/repeat_directive.hl+2−2 tests/snapshots/repeat_directive.html+1 tests/snapshots/responsive.hl+1−1 tests/snapshots/responsive.html+1 tests/snapshots/responsive_images.hl+2−2 tests/snapshots/responsive_images.html+1 tests/snapshots/scope_css.hl+6−4 tests/snapshots/scope_css.html+1 tests/snapshots/script_element.hl+1−1 tests/snapshots/script_element.html+4 tests/snapshots/scroll_driven_animations.hl+6−4 tests/snapshots/scroll_driven_animations.html+1 tests/snapshots/selection_pseudo.hl+1−1 tests/snapshots/selection_pseudo.html+1 tests/snapshots/semantic_elements.hl+4−4 tests/snapshots/semantic_elements.html+1 tests/snapshots/sizing.hl+1−1 tests/snapshots/sizing.html+1 tests/snapshots/slots_and_children.hl+59 tests/snapshots/slots_and_children.html+1 tests/snapshots/starting_style.hl+5−3 tests/snapshots/starting_style.html+1 tests/snapshots/stress_deeply_nested.hl+132−132 tests/snapshots/stress_deeply_nested.html+1 tests/snapshots/stress_large.hl+194−190 tests/snapshots/stress_large.html+1 tests/snapshots/string_interpolation.html+1 tests/snapshots/style_block.hl+1−1 tests/snapshots/style_block.html+1 tests/snapshots/subgrid_css.html+1 tests/snapshots/table_elements.html+1 tests/snapshots/text_everywhere.hl+41 tests/snapshots/text_everywhere.html+3 tests/snapshots/translations_i18n.hl+3−8 tests/snapshots/translations_i18n.html+1 tests/snapshots/unless_directive.hl+2−2 tests/snapshots/unless_directive.html+1 tests/snapshots/values_and_scope.hl+62 tests/snapshots/values_and_scope.html+1 tests/snapshots/variable_filters.hl+8−8 tests/snapshots/variable_filters.html+1 tests/snapshots/variable_slots.hl+18 tests/snapshots/variable_slots.html+1 tests/snapshots/variables_defines.hl+3−3 tests/snapshots/variables_defines.html+1 tests/snapshots/view_transitions.hl+2−2 tests/snapshots/view_transitions.html+1 tests/snapshots/with_directive.hl+2−2 tests/snapshots/with_directive.html+1
.gitattributesnew file+3
@@ -0,0 +1,3 @@
1+# Check out text files with LF endings on every platform, so snapshot
2+# fixtures compare equal on Windows too.
3+* text=auto eol=lf
.github/workflows/playground.yml+4−5
@@ -40,13 +40,12 @@ jobs:
4040 --out-dir target/wasm-pkg \
4141 target/wasm32-unknown-unknown/release/htmlang_wasm.wasm
4242
43− - name: Build htmlang CLI
44− run: cargo build --release --bin htmlang
45−
46− - name: Generate playground HTML
43+ - name: Assemble the playground
4744 run: |
4845 mkdir -p dist
49− ./target/release/htmlang playground dist/index.html
46+ cp crates/htmlang-wasm/playground/index.html dist/
47+ cp target/wasm-pkg/htmlang_wasm.js target/wasm-pkg/htmlang_wasm_bg.wasm dist/
48+ cp examples/demo.hl dist/
5049
5150 - name: Upload Pages artifact
5251 uses: actions/upload-pages-artifact@v3
.gitignore+2
@@ -1,2 +1,4 @@
11/target
22*.html
3+!tests/snapshots/*.html
4+!crates/htmlang-wasm/playground/*.html
CONTRIBUTING.md+113−9
@@ -19,12 +19,100 @@ Linux, macOS, and Windows. Your change should pass all three.
1919## Repository layout
2020
2121- `crates/htmlang-core/` — parser, AST, code generator. No I/O lives here.
22+ - `syntax.rs` — the syntax tree: every line as written, with spans. The
23+ compiler evaluates it, the formatter prints it back, and the language
24+ server takes definitions, directives and verbatim bodies from it. It
25+ also holds `ESCAPES`, the one escape table of every htmlang string,
26+ and decides where `{@...}` is an inline element: in every line of text
27+ except the text of a `literal` element.
28+ - `parser.rs` — evaluates the tree: definitions, data, loops and
29+ conditions, and the checks over code that doesn't run. Names live in
30+ `Env`, one frame per block, so a definition is visible to the end of
31+ its block and a function keeps the frames of its definition.
32+ - `diagnostic.rs` — `Diagnostic` and the stable diagnostic codes.
33+ - `codegen.rs` — writes the HTML and the CSS. A parent's `Flow` (the
34+ direction its CSS sets, with the changes under media and container
35+ prefixes) is what its children's `fill` and `shrink` compile against;
36+ the rules for a change are keyed on the parent's class in that block.
37+ A page's `<body>` is written like an `@el` whose styles are `@page`'s
38+ (the reset makes it the column), so the top level of a page is laid
39+ out like any column; a fragment's top level has no layout of its own.
40+ Every CSS property goes through one generic path (its name, its value
41+ as written, with `vocab::with_px`'s pixels); only htmlang's layout
42+ words and `line-clamp` have code of their own. Generated classes are
43+ `hl-` + 7 base-36 digits of `stable_hash` of the style
44+ (`CLASS_DIGITS`), so a style has one name in every file, and
45+ `htmlang build` checks no name stands for two styles; an element's
46+ defaults (its layout,
47+ `ElementKind::css`) go with its class, as `:where(.hl-2k9xq0m)`, never a
48+ rule on a global element selector, so `@markdown`/`@raw` HTML and a
49+ page embedding a fragment are untouched. The rules are in cascade
50+ layers (`LAYERS`): `hl-reset`, `hl-kind` (the defaults), `hl-inside`
51+ (element prefixes, `@td:`, each an `@scope` on the class, and
52+ functions' scoped `@style`, written when a function is called) and
53+ `htmlang` (the elements' own styles and `children:`), so which one
54+ wins is the layer's order, not specificity.
55+ A style's prefixes are a `Condition`: its at-rule prefixes (sorted, so
56+ `dark:md:` is `md:dark:`, and written as nested `@media`/`@container`
57+ blocks) and its selector chain (read left to right; an element prefix
58+ splits it into the scope's root and the elements it styles). Each
59+ class keeps one list of (condition, declarations), and every block and
60+ chain is written in one fixed order, never the order of the source.
61+ - `ast.rs` — the element kinds; every plain HTML element is one row in
62+ `TAGS` with its one `Layout` (column, row, grid, text, native or void,
63+ which decides its text lines, `spacing` and its children's layout
64+ words), whether its text is shown as written (`literal`: `@code`,
65+ `@textarea`, whose indented body is verbatim text, HTML-escaped), the
66+ one HTML attribute its first word fills (`arg`: `src`, `action`, ...;
67+ `ElementKind::arg` gives `@link`'s and `@image`'s too) and whether its
68+ body is foreign text written into the page as it is (`verbatim`:
69+ `@script`), and every directive one row in `DIRECTIVES` (its argument
70+ and its kind of body; a verbatim directive's line is its one-line body
71+ or file, and it takes no attributes). The list of
72+ elements is fixed; `HTML_NAMES_WRITTEN_OTHERWISE` maps HTML's `a`, `img`,
73+ `span`, `p` and `div` to htmlang's own names for the unknown-element
74+ suggestion only.
75+ - `vocab.rs` — the attribute vocabulary (htmlang attributes, CSS properties,
76+ HTML attributes, `@page`'s own `favicon`), the pixel rule
77+ (`LENGTH_PROPERTIES`, the one table of properties whose bare numbers
78+ are px, and `with_px`, the one function that applies it) and the
79+ prefixes: `PSEUDOS`, CSS's pseudo-classes and pseudo-elements under
80+ their CSS names, each marked `:`, `::` or taking an argument, in the
81+ order their rules are written; `children:`; element prefixes (`@td:`,
82+ any name; the parser checks it is an element with a tag of its own,
83+ `ElementKind::own_tag`); and the width, media and container prefixes,
84+ with the rank that orders the at-rule blocks. A new pseudo-class is one
85+ row in `PSEUDOS` and one word in DESIGN.md's list.
86+ - `expr.rs` — the expression language for conditions, computed values and
87+ `${...}`. It evaluates only what decides the result (the branch `if()`
88+ takes, the side of `and`/`or` that decides) and only reads the rest.
89+ - `value.rs` — the value types (text, number, true/false, list,
90+ record) and the rules defined once on them: truthiness, comparison,
91+ how numbers and lists print, ranges.
92+ - `interp.rs` — `$name` and `${...}`: where a name ends and how one slot
93+ of text (a text run, an attribute's value, an argument, a path) is
94+ filled in, including what quoted text inserts in CSS and in text. The
95+ one place variables are interpolated.
96+ - `std.hl` — the standard library, written in htmlang and loaded before
97+ every file.
2298- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
23−- `src/` — CLI, dev server, formatter, and HTML-to-hl converter.
24−- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`).
99+- `src/` — CLI, dev server and formatter (`fmt.rs`, which prints the syntax
100+ tree back with its comments and blank lines).
101+- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`). Its
102+ completions come from the compiler's tables; hover and completion text live
103+ in `docs.rs`.
25104- `editors/vscode/` — VS Code extension.
26105- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
27−- `examples/` — sample `.hl` files used as smoke tests and documentation.
106+ These tests, and `tests/regressions.rs`, read the output with its
107+ hashed classes numbered (`hl-a`, `hl-b`, ..., see `tests/common`), so a
108+ snapshot changes when a style does, not when a hash does.
109+- `tests/regressions.rs` — one test per fixed bug.
110+- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`,
111+ and every page in `examples/`: any diagnostic fails it.
112+- `tests/fmt.rs` — formats every example and snapshot input: the result is
113+ stable, compiles to the same HTML and keeps every comment.
114+- `examples/` — complete pages: a landing page, a blog, a docs page, and a
115+ tour of the whole language (`demo.hl`).
28116
29117## Adding a feature
30118
@@ -32,19 +120,35 @@ Linux, macOS, and Windows. Your change should pass all three.
32120 `tests/snapshots.rs`; prefer integration tests that exercise the full
33121 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
34122 tests alongside the code.
35−2. Thread the feature through the parser, then codegen, then the LSP (hover,
36− completions, diagnostics).
37−3. Document it in `DESIGN.md`. If it's user-facing, also update `README.md`.
38−4. If it changes the CLI surface, update the `--help` output and the shell
39− completions.
123+2. Prefer the smallest mechanism: a component belongs in `std.hl`, an HTML
124+ element is a row in `TAGS` (and a line in DESIGN.md's table of layouts
125+ and a name in the VS Code grammar's element list, which tests check), and
126+ a CSS property needs no code at all. Only
127+ thread a feature through the parser and codegen when it needs to be, and
128+ describe it in the LSP's `docs.rs`.
129+3. Document it in `DESIGN.md` (the examples there are compiled by the tests).
130+ If it's user-facing, also update `README.md`, and show it in
131+ `examples/demo.hl` if the tour has a section it belongs in.
132+4. If it changes the CLI surface, update the `--help` output.
40133
41134## Style
42135
43136- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
44137- Keep `@` prefixes and bracket-attribute syntax consistent with existing
45− directives. Add `KNOWN_DIRECTIVES` entries when you add a new `@foo`.
138+ directives. A new `@foo` is a row in `ast::DIRECTIVES`; the parser, the
139+ formatter and the language server read that table, and `tests/editor.rs`
140+ fails when the VS Code grammar's word lists drift from it.
141+- Every diagnostic has a code from `diagnostic::code`; add one there when no
142+ existing code fits, and key tool behavior (quick fixes, tests) on the code
143+ rather than on the message. Put the name the diagnostic is about in
144+ `subject` and a replacement in `suggestion`.
46145- Diagnostics should include `line` and, when practical, `column` and a
47146 `source_line` excerpt. Use `Severity::Help` for suggestions, not `Warning`.
147+- Check names, not values, and never drop input silently. When something
148+ the author wrote can't go into the page, report an error; a warning means
149+ it went into the page as written. Check a CSS value only for what is
150+ wrong in any CSS (see `css_breakout` in `parser.rs`), not against a list
151+ of values the browser may know better.
48152- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
49153 diagnostic so the compiler keeps going.
50154
Cargo.lock+1−211
@@ -24,28 +24,6 @@ dependencies = [
2424 "syn",
2525]
2626
27−[[package]]
28−name = "aws-lc-rs"
29−version = "1.16.3"
30−source = "registry+https://github.com/rust-lang/crates.io-index"
31−checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
32−dependencies = [
33− "aws-lc-sys",
34− "zeroize",
35−]
36−
37−[[package]]
38−name = "aws-lc-sys"
39−version = "0.40.0"
40−source = "registry+https://github.com/rust-lang/crates.io-index"
41−checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
42−dependencies = [
43− "cc",
44− "cmake",
45− "dunce",
46− "fs_extra",
47−]
48−
4927[[package]]
5028name = "bitflags"
5129version = "1.3.2"
@@ -70,33 +48,12 @@ version = "1.11.1"
7048source = "registry+https://github.com/rust-lang/crates.io-index"
7149checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
7250
73−[[package]]
74−name = "cc"
75−version = "1.2.60"
76−source = "registry+https://github.com/rust-lang/crates.io-index"
77−checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
78−dependencies = [
79− "find-msvc-tools",
80− "jobserver",
81− "libc",
82− "shlex",
83−]
84−
8551[[package]]
8652name = "cfg-if"
8753version = "1.0.4"
8854source = "registry+https://github.com/rust-lang/crates.io-index"
8955checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
9056
91−[[package]]
92−name = "cmake"
93−version = "0.1.58"
94−source = "registry+https://github.com/rust-lang/crates.io-index"
95−checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
96−dependencies = [
97− "cc",
98−]
99−
10057[[package]]
10158name = "dashmap"
10259version = "5.5.3"
@@ -121,12 +78,6 @@ dependencies = [
12178 "syn",
12279]
12380
124−[[package]]
125−name = "dunce"
126−version = "1.0.5"
127−source = "registry+https://github.com/rust-lang/crates.io-index"
128−checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
129−
13081[[package]]
13182name = "filetime"
13283version = "0.2.27"
@@ -138,12 +89,6 @@ dependencies = [
13889 "libredox",
13990]
14091
141−[[package]]
142−name = "find-msvc-tools"
143−version = "0.1.9"
144−source = "registry+https://github.com/rust-lang/crates.io-index"
145−checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
146−
14792[[package]]
14893name = "form_urlencoded"
14994version = "1.2.2"
@@ -153,12 +98,6 @@ dependencies = [
15398 "percent-encoding",
15499]
155100
156−[[package]]
157−name = "fs_extra"
158−version = "1.3.0"
159−source = "registry+https://github.com/rust-lang/crates.io-index"
160−checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
161−
162101[[package]]
163102name = "fsevent-sys"
164103version = "4.1.0"
@@ -244,29 +183,6 @@ dependencies = [
244183 "slab",
245184]
246185
247−[[package]]
248−name = "getrandom"
249−version = "0.2.17"
250−source = "registry+https://github.com/rust-lang/crates.io-index"
251−checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
252−dependencies = [
253− "cfg-if",
254− "libc",
255− "wasi",
256−]
257−
258−[[package]]
259−name = "getrandom"
260−version = "0.3.4"
261−source = "registry+https://github.com/rust-lang/crates.io-index"
262−checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
263−dependencies = [
264− "cfg-if",
265− "libc",
266− "r-efi",
267− "wasip2",
268−]
269−
270186[[package]]
271187name = "hashbrown"
272188version = "0.14.5"
@@ -279,9 +195,8 @@ version = "0.1.0"
279195dependencies = [
280196 "htmlang-core",
281197 "notify",
282− "rustls-pemfile",
198+ "serde_json",
283199 "tokio",
284− "tokio-rustls",
285200 "tower-lsp",
286201]
287202
@@ -441,16 +356,6 @@ version = "1.0.18"
441356source = "registry+https://github.com/rust-lang/crates.io-index"
442357checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
443358
444−[[package]]
445−name = "jobserver"
446−version = "0.1.34"
447−source = "registry+https://github.com/rust-lang/crates.io-index"
448−checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
449−dependencies = [
450− "getrandom 0.3.4",
451− "libc",
452−]
453−
454359[[package]]
455360name = "kqueue"
456361version = "1.1.1"
@@ -653,12 +558,6 @@ dependencies = [
653558 "proc-macro2",
654559]
655560
656−[[package]]
657−name = "r-efi"
658−version = "5.3.0"
659−source = "registry+https://github.com/rust-lang/crates.io-index"
660−checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
661−
662561[[package]]
663562name = "redox_syscall"
664563version = "0.5.18"
@@ -677,66 +576,6 @@ dependencies = [
677576 "bitflags 2.11.1",
678577]
679578
680−[[package]]
681−name = "ring"
682−version = "0.17.14"
683−source = "registry+https://github.com/rust-lang/crates.io-index"
684−checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
685−dependencies = [
686− "cc",
687− "cfg-if",
688− "getrandom 0.2.17",
689− "libc",
690− "untrusted",
691− "windows-sys 0.52.0",
692−]
693−
694−[[package]]
695−name = "rustls"
696−version = "0.23.38"
697−source = "registry+https://github.com/rust-lang/crates.io-index"
698−checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
699−dependencies = [
700− "aws-lc-rs",
701− "log",
702− "once_cell",
703− "ring",
704− "rustls-pki-types",
705− "rustls-webpki",
706− "subtle",
707− "zeroize",
708−]
709−
710−[[package]]
711−name = "rustls-pemfile"
712−version = "2.2.0"
713−source = "registry+https://github.com/rust-lang/crates.io-index"
714−checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
715−dependencies = [
716− "rustls-pki-types",
717−]
718−
719−[[package]]
720−name = "rustls-pki-types"
721−version = "1.14.0"
722−source = "registry+https://github.com/rust-lang/crates.io-index"
723−checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
724−dependencies = [
725− "zeroize",
726−]
727−
728−[[package]]
729−name = "rustls-webpki"
730−version = "0.103.12"
731−source = "registry+https://github.com/rust-lang/crates.io-index"
732−checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
733−dependencies = [
734− "aws-lc-rs",
735− "ring",
736− "rustls-pki-types",
737− "untrusted",
738−]
739−
740579[[package]]
741580name = "rustversion"
742581version = "1.0.22"
@@ -812,12 +651,6 @@ dependencies = [
812651 "syn",
813652]
814653
815−[[package]]
816−name = "shlex"
817−version = "1.3.0"
818−source = "registry+https://github.com/rust-lang/crates.io-index"
819−checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
820−
821654[[package]]
822655name = "slab"
823656version = "0.4.12"
@@ -846,12 +679,6 @@ version = "1.2.1"
846679source = "registry+https://github.com/rust-lang/crates.io-index"
847680checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
848681
849−[[package]]
850−name = "subtle"
851−version = "2.6.1"
852−source = "registry+https://github.com/rust-lang/crates.io-index"
853−checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
854−
855682[[package]]
856683name = "syn"
857684version = "2.0.117"
@@ -910,16 +737,6 @@ dependencies = [
910737 "syn",
911738]
912739
913−[[package]]
914−name = "tokio-rustls"
915−version = "0.26.4"
916−source = "registry+https://github.com/rust-lang/crates.io-index"
917−checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
918−dependencies = [
919− "rustls",
920− "tokio",
921−]
922−
923740[[package]]
924741name = "tokio-util"
925742version = "0.7.18"
@@ -1030,12 +847,6 @@ version = "1.0.24"
1030847source = "registry+https://github.com/rust-lang/crates.io-index"
1031848checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1032849
1033−[[package]]
1034−name = "untrusted"
1035−version = "0.9.0"
1036−source = "registry+https://github.com/rust-lang/crates.io-index"
1037−checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1038−
1039850[[package]]
1040851name = "url"
1041852version = "2.5.8"
@@ -1071,15 +882,6 @@ version = "0.11.1+wasi-snapshot-preview1"
1071882source = "registry+https://github.com/rust-lang/crates.io-index"
1072883checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1073884
1074−[[package]]
1075−name = "wasip2"
1076−version = "1.0.2+wasi-0.2.9"
1077−source = "registry+https://github.com/rust-lang/crates.io-index"
1078−checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
1079−dependencies = [
1080− "wit-bindgen",
1081−]
1082−
1083885[[package]]
1084886name = "wasm-bindgen"
1085887version = "0.2.114"
@@ -1222,12 +1024,6 @@ version = "0.52.6"
12221024source = "registry+https://github.com/rust-lang/crates.io-index"
12231025checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
12241026
1225−[[package]]
1226−name = "wit-bindgen"
1227−version = "0.51.0"
1228−source = "registry+https://github.com/rust-lang/crates.io-index"
1229−checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
1230−
12311027[[package]]
12321028name = "writeable"
12331029version = "0.6.3"
@@ -1278,12 +1074,6 @@ dependencies = [
12781074 "synstructure",
12791075]
12801076
1281−[[package]]
1282−name = "zeroize"
1283−version = "1.8.2"
1284−source = "registry+https://github.com/rust-lang/crates.io-index"
1285−checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
1286−
12871077[[package]]
12881078name = "zerotrie"
12891079version = "0.2.4"
Cargo.toml+1−2
@@ -13,9 +13,8 @@ path = "src/bin/htmlang_lsp/main.rs"
1313[dependencies]
1414htmlang-core = { path = "crates/htmlang-core" }
1515notify = "7"
16−rustls-pemfile = "2.2.0"
16+serde_json = "1"
1717tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
18−tokio-rustls = { version = "0.26.4", features = ["ring"] }
1918tower-lsp = "0.20"
2019
2120[profile.release]
DESIGN.md+1488−1156
@@ -1,1287 +1,1619 @@
1−# htmlang (.hl)
1+# htmlang language reference
22
3−A minimalist layout language inspired by elm-ui that compiles to static HTML.
3+htmlang is a small layout language that compiles to static HTML. A page is a
4+tree of elements written one per line, with indentation for nesting:
5+
6+```
7+@page Hello
8+@el [width fill, max-width 640, center-x, padding 40, spacing 16]
9+ @h1 Hello
10+ @paragraph
11+ This page was written in {@b htmlang}.
12+```
13+
14+Every example block in this file is compiled by the test suite
15+(`tests/docs.rs`), so the examples stay correct.
416
517## Principles
618
7−- `@` means structure, bare lines mean content
8−- Layout is explicit and compositional — no CSS cascade
9−- Every element declares its own layout role
10−- Output is a self-contained HTML file with embedded CSS (flexbox)
11−
12−## Elements
13−
14−| Element | Output | Purpose |
15−|--------------|------------------|--------------------------------|
16−| `@row` | flex row | Horizontal layout |
17−| `@column` | flex column | Vertical layout |
18−| `@el` | div | Generic container |
19−| `@text` | span | Styled inline text |
20−| `@paragraph` | p with spans | Flowing/wrapping inline text |
21−| `@image` | img | Image |
22−| `@link` | a | Anchor wrapping children |
23−| `@raw` | verbatim HTML | Escape hatch |
24−| `@nav` | nav | Navigation landmark |
25−| `@header` | header | Page/section header |
26−| `@footer` | footer | Page/section footer |
27−| `@main` | main | Main content area |
28−| `@section` | section | Thematic section |
29−| `@article` | article | Self-contained content |
30−| `@aside` | aside | Sidebar/tangential content |
31−| `@list` | ul/ol | List (`[ordered]` for ol) |
32−| `@item` | li | List item |
33−| `@table` | table | Table |
34−| `@thead` | thead | Table head group |
35−| `@tbody` | tbody | Table body group |
36−| `@tr` | tr | Table row |
37−| `@td` | td | Table cell |
38−| `@th` | th | Table header cell |
39−| `@video` | video | Video element |
40−| `@audio` | audio | Audio element |
41−| `@form` | form | Form container |
42−| `@details` | details | Disclosure widget |
43−| `@summary` | summary | Summary for `@details` |
44−| `@blockquote`| blockquote | Block quotation |
45−| `@cite` | cite | Citation/source reference |
46−| `@code` | code | Inline code (monospace) |
47−| `@pre` | pre | Preformatted text |
48−| `@hr` | hr | Horizontal rule (self-closing) |
49−| `@figure` | figure | Figure with optional caption |
50−| `@figcaption`| figcaption | Caption for `@figure` |
51−| `@progress` | progress | Progress bar |
52−| `@meter` | meter | Meter/gauge element |
53−| `@iframe` | iframe | Embedded external page |
54−| `@output` | output | Form calculation result |
55−| `@canvas` | canvas | Drawing surface for scripts |
56−
57−| `@script` | script | JavaScript (inline or external) |
58−| `@noscript` | noscript | Fallback when JS is disabled |
59−| `@address` | address | Contact information |
60−| `@search` | search | Search landmark (HTML5) |
61−| `@breadcrumb` | nav>ol | Semantic breadcrumb navigation |
62−
63−Bare lines (not starting with `@` or `[`) are text nodes.
19+- **`@` starts structure, and any other line is content.** An element is `@name`,
20+ and a line without `@` is text.
21+- **htmlang's own vocabulary is about layout.** It comes from elm-ui. An
22+ element is a row, a column, a grid or text (or keeps HTML's own layout),
23+ `spacing` sets the gap between its children,
24+ and each child says how it sits in its parent (`width fill`, `center-x`,
25+ `align-right`). The layout attributes are the only styling words htmlang
26+ adds.
27+- **Everything else is CSS or HTML, under its own name.** A style is any CSS
28+ property with a CSS value. An element that isn't about layout or text has
29+ its HTML name, and an HTML attribute is written `key=value`. Nothing is
30+ renamed or abbreviated.
31+- **Each thing is done one way.** `@let` defines every reusable piece, `@data`
32+ loads data, `@include` brings in a file, and `if()` makes an attribute
33+ conditional. A layout is an ordinary function.
34+- **The output is what you wrote.** Each page compiles to one self-contained
35+ HTML file. Its CSS is inside, and it has no JavaScript unless you write
36+ some. The compiler doesn't add attributes, tags or rewritten values that
37+ the source doesn't ask for, and it doesn't leave out anything you wrote:
38+ what can't go into the page is an error. The one exception is a style
39+ whose value comes out empty, such as an optional field a record doesn't
40+ have: it is simply not written.
41+- **Everything runs at compile time.** Variables, expressions, loops and
42+ conditions are resolved when the page is built. What CSS computes in the
43+ browser (`var()`, `calc()`, `color-mix()`) stays CSS, written as it is.
6444
6545## Syntax
6646
67−### Basic structure
47+### Elements
6848
6949```
70−@element [attributes]
50+@element [attributes] argument
7151 children
7252```
7353
74−Children are indented under their parent. Attributes are optional, comma-separated inside `[...]`. Attribute lists can span multiple lines:
54+Children are indented under their parent, with spaces or with tabs (a
55+tab counts as one space, so a file that mixes them is a warning).
56+Attributes go inside `[...]`,
57+separated by commas, and the list may span several lines:
7558
7659```
7760@el [
7861 padding 20,
7962 background white,
80− rounded 8,
81− shadow 0 2px 4px rgba(0,0,0,0.1)
63+ border-radius 8,
64+ box-shadow 0 2 4 rgba(0,0,0,0.1)
8265]
8366 Content
8467```
8568
86−### Full example
87−
88−```
89−-- My Site
90−@page My Site
91−@let primary #3b82f6
92−@let gap 20
93−@define card [
94− padding 20,
95− background white,
96− rounded 8,
97− border 1 #e5e7eb,
98− shadow 0 2px 4px rgba(0,0,0,0.05)
99−]
100−
101−@include header.hl
102−
103−@column [max-width 800, center-x, padding 40, spacing $gap]
104− @row [spacing $gap]
105− @column [width fill, spacing 10]
106− @text [bold, size 32] Welcome
107− @paragraph [line-height 1.6]
108− This is a page built with {@text [bold, color $primary] htmlang}.
109− Read the {@link https://docs.example.com docs} to learn more.
110− @image [width 80, height 80, rounded 40] avatar.png
111−
112− -- cards
113− @row [wrap, spacing 10]
114− [$card]
115− First card
116− [$card, background #f9fafb]
117− Second card
118−
119− -- footer
120− @row [spacing 10]
121− @el [padding 16, background $primary, rounded 8] > @link https://example.com
122− @text [color white] Get Started
123− @el [width fill]
124− @text [color #888, text-align right] © 2026
125−
126− @raw """
127− <canvas id="chart"></canvas>
128− """
129−```
130−
131−## Directives
69+An attribute can take one of three forms:
13270
133−### `@page`
71+- `key value` is a **style**: a layout attribute or a CSS property
72+ (`spacing 20`, `color red`).
73+- `key=value` is an **HTML attribute** (`id=main`, `type=email`,
74+ `aria-label=Close`).
75+- A bare word is a **flag**: a layout attribute such as `center-x`, a
76+ boolean HTML attribute such as `required`, `disabled`, `hidden` or `open`,
77+ or a `data-` or `hx-` attribute that has no value (`data-open`,
78+ `hx-preserve`).
13479
135−Sets the HTML `<title>` and generates boilerplate (`<!DOCTYPE>`, `<html>`, `<head>`, `<body>`).
80+A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else,
81+`\,` keeps a comma in the value:
13682
13783```
138−@page My Site
84+@el [transition opacity 0.3s\, transform 0.3s, font-family "Open Sans"\, sans-serif]
85+@el [hx-get=/news, hx-trigger=load\, every 60s]
13986```
14087
141−### `@let`
142−
143−Defines a variable. Referenced with `$name`.
144−
145−```
146−@let primary #3b82f6
147−@let gap 20
148−
149−@el [background $primary, spacing $gap]
150−```
151−
152−Quoted values support string interpolation:
153−
154−```
155−@let name World
156−@let greeting "Hello $name" -- "Hello World"
157−@let base /api
158−@let url "$base/users" -- "/api/users"
159−```
160−
161−### `@include`
162−
163−Inlines another `.hl` file. The path is resolved relative to the current file. Variables defined with `@let`, attribute bundles from `@define`, and functions from `@fn` in the included file are available after the `@include` line.
164−
165−```
166−@include header.hl
167−@include components/card.hl
168−```
169−
170−Variables can be used in the filename:
171−
172−```
173−@let component card
174−@include $component.hl
175−```
176−
177−Circular includes are detected and reported as errors. Nested includes are supported.
178−
179−### `@import`
180−
181−Like `@include`, but only imports definitions (`@let`, `@define`, `@fn`) without emitting DOM nodes. Use this for shared theme/component libraries.
182−
183−```
184−@import theme.hl -- imports variables, defines, functions only
185−@include header.hl -- inlines everything including DOM nodes
186−```
187−
188−### `@data`
189−
190−Loads a JSON file and makes its values available as template variables. Useful for data-driven static sites.
191−
192−```
193−@data site.json -- top-level object keys become variables
194−@data $posts data/posts.json -- load with a prefix
195−```
196−
197−For a JSON file like:
198−```json
199−{
200− "title": "My Site",
201− "tags": ["rust", "html", "css"],
202− "links": [
203− {"label": "Home", "url": "/"},
204− {"label": "About", "url": "/about"}
205− ]
206−}
207−```
208−
209−Without prefix (`@data site.json`):
210−- `$title` → `"My Site"`
211−- `$tags` → iterable with `@each`
212−- `$links` → destructurable: `@each $label, $url in $links`
213−
214−With prefix (`@data $site site.json`):
215−- `$site.title` → `"My Site"`
216−- `$site.tags` → iterable
217−- `$site.links` → destructurable
88+Right after a `key=value`, a name htmlang doesn't know is most likely the
89+rest of that value, which a comma ended: `[hx-trigger=load, every 60s]` is
90+an error (`split-value`) that shows the value written with `\,`.
21891
219−Arrays of objects are automatically formatted for `@each` destructuring. Indexed access is also available: `$links.0.label`, `$links.1.url`, etc. The count is available as `$links._count`.
92+An attribute given twice, by a bundle and after it, by a function and its
93+call, or written twice, takes the later value. An HTML attribute is
94+written once, with that value.
22095
221−### `@meta`
96+A variable fills an attribute's value (`padding $gap`, `alt=$title`), never
97+its name or a whole attribute: attributes come from a
98+[bundle](#definitions), written `[$card]`, and a condition chooses whole
99+attributes with [`if()`](#conditional-attributes).
222100
223−Adds a `<meta>` tag to the document `<head>`. Requires `@page`.
101+The attribute list belongs to the element name right before it. Anywhere
102+else, `[` is an ordinary character, so a line of text can contain one:
224103
225104```
226−@meta description A portfolio site
227−@meta og:image https://example.com/preview.png
105+@text Use [ to open a list
106+@paragraph Arrays look like [1, 2, 3].
228107```
229108
230−### `@head`
109+### Text
231110
232−Adds raw content to the document `<head>`. Use for external fonts, favicons, or custom CSS/JS.
111+A line that doesn't start with `@` is text. Text after an element's
112+attributes is the element's content: its first line of text, read like any
113+other. Inside a line of text, `{...}` holds an inline element, and
114+[escapes](#escapes) and [`$names`](#variables) work:
233115
234116```
235−@head
236− <link rel="icon" href="favicon.ico">
237− <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
238−```
239−
240−### `@define`
241−
242−Creates a named attribute bundle. Referenced with `$name` inside attribute lists.
243−
244−```
245−@define card [padding 20, background white, rounded 8, border 1 #e5e7eb]
246−
247−@el [$card]
248− Content
249−@el [$card, background #f9fafb]
250− Overridden background
251−```
252−
253−Attributes listed after `$name` override those in the definition.
254−
255−### `@mixin`
256−
257−Creates a composable style group. Like `@define`, but intended for reusable style patterns. Referenced with `...$name` (spread syntax) or `$name` inside attribute lists.
258−
259−```
260−@mixin card-style [padding 20, rounded 8, background white]
261−@mixin interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
262−
263−@el [...$card-style, ...$interactive, border 1 #ccc]
264− Composed styles from two mixins
265−```
266−
267−### `@assert`
268−
269−Compile-time assertion. If the condition is false, a compile error is emitted. Useful for enforcing design system constraints.
270−
271−```
272−@let spacing 16
273−@let primary #3b82f6
274−
275−@assert $spacing == 16
276−@assert $primary != red
277−```
278−
279−### `@fn`
280−
281−Defines a pure function (reusable component). Parameters are prefixed with `$`. Default values can be specified with `=`.
282−
283−```
284−@fn card $title $variant=primary
285− @el [padding 20, background white, rounded 8, border 1 #e5e7eb]
286− @text [bold, size 18] $title
287− @children
288−```
289−
290−Call it like any element, passing parameters in `[...]`:
291−
292−```
293−@card [title Hello World]
294− This is the card body.
295− @text [italic] With styled text.
296−
297−@card [title Warning, variant danger]
298− Overridden variant.
299−```
300−
301−Parameters with defaults can be omitted in calls — the default value is used.
302−
303−### `@children`
304−
305−A slot inside a function body that expands to the caller's indented children.
306−
307−```
308−@fn layout $title
309− @column [max-width 800, center-x, padding 40]
310− @text [bold, size 32] $title
311− @children
312−
313−@layout [title My Page]
314− @row [spacing 10]
315− Content goes here
316−```
317−
318−Functions can call other functions. `@let` and `@define` variables are available inside function bodies. Function parameters shadow `@let` variables of the same name within the body.
319−
320−## Text
321−
322−### Bare text
323−
324−Any line not starting with `@` or `[` is a text node.
117+@paragraph
118+ This is {@strong important}, and this is {@link https://example.com a link}.
119+@text [font-weight bold, font-size 24, color #333] Hello world
120+@h2 Welcome to {@text [color #3b82f6] htmlang}
121+@ul
122+ @li Read {@link /docs the docs}
123+@section [padding 8] Text after the attributes is content too.
124+```
125+
126+How an element's lines combine is its [layout](#rows-columns-and-text)'s
127+business: in a row or column each line is a child; in a text element lines
128+flow. Text that `@if`, `@each`, `@fragment` or a function writes counts as
129+lines written in its place, so in a text element it flows with the rest,
130+joined with a space, and in a native element it is on lines of its own.
131+
132+The text of `@code` and `@textarea` is shown as written. On its line and
133+inline, a `{@...}` in it is text, while escapes and `$names` still work.
134+The lines indented under it are a [verbatim](#verbatim-bodies) sample:
135+nothing in them is htmlang (`@`, `$`, `{`, `\` and `--` are text), and
136+they are HTML-escaped with their line breaks and indentation kept. This
137+is how a page shows code, htmlang included. `@pre` is an ordinary element,
138+so `@pre > @code` is HTML's `<pre><code>`, and a function can wrap `@pre`
139+around a sample. A sample is text on the line or lines in the block, not
140+both.
325141
326142```
327−@column
328− Hello world
329− This is just text
143+@paragraph
144+ Write {@code {@link /docs docs}} for a link.
145+@pre [padding 16] > @code
146+ @el [padding 40]
147+ <b>shown as text</b>
148+@textarea [aria-label=Notes]
149+ First line
150+ Second line, indented
330151```
331152
332−### Styled text
153+A sample in the block has no `$names`, so a version number in it can't
154+come from a variable; a one-line sample on `@code`'s own line can
155+(`@code cargo install htmlang@$version`).
333156
334−Use `@text` when you need attributes.
157+### Escapes
335158
336−```
337−@text [bold, size 24, color #333] Hello world
338−```
159+A backslash before one of these characters stands for the character itself.
160+The table is the same in every htmlang string: text, arguments, attribute
161+values, `@let`, `@page` and `@meta`.
339162
340−### Inline elements
163+| Escape | Writes | For |
164+|---|---|---|
165+| `\@`, `\--` | `@`, `--` | a line of text that starts with `@` or `--` |
166+| `\$` | `$` | a `$` before a name, which would start a variable |
167+| `\{`, `\}` | `{`, `}` | braces that don't start or end an inline element |
168+| `\[`, `\]` | `[`, `]` | brackets that don't open or close an attribute list; a `'...'` in a value (`alert(']')`) doesn't hide one, so a list that seems to end inside one is reported (`unbalanced-quote`) |
169+| `\,` | `,` | a comma in an attribute's value |
170+| `\"` | `"` | a quote that doesn't start or end quoted text |
171+| `\\` | `\` | a backslash before one of these characters |
341172
342−Use `{...}` to embed elements inside text lines. Essential for mixed-style paragraphs.
173+A backslash before any other character is kept as written, so CSS's own
174+escapes and patterns pass through: `before:content "\201C"`,
175+`pattern=\d{3}`. A `$` that isn't followed by a letter or `_` is text
176+anyway: `$5`, `$$`.
343177
344178```
345−@paragraph
346− This is {@text [bold] important} and this is a {@link https://example.com link}.
179+\@htmlang on social media
180+@let price 5
181+@paragraph \$price is $price dollars: {@mark \{braces\}}, \[brackets\] and a comma\, too
182+@input [type=text, pattern=\d{3}-\d{4}, title=Costs \$5]
347183```
348184
349−## Comments
185+### Comments
350186
351−`--` starts a comment. The rest of the line is ignored.
187+A line whose first word is `--` is a comment: `--` followed by a space, or
188+alone on its line. A comment must be on its own line, because `--` later in
189+a line is ordinary text. A comment may sit between the lines of an attribute
190+list, and `--name` with no space is not a comment but a
191+[custom property](#custom-properties), so it can start a line of a list
192+(anywhere else, a line starting `--name` or `---` is text).
352193
353194```
354195-- this is a comment
355196@row [spacing 10]
356− -- todo: add nav items
357−```
358−
359−## Implicit `@el`
360−
361−A line starting with `[` (attributes) that has indented children is an anonymous container (implicit `@el`).
362−
363−```
364−[padding 20, background white]
365− Hello
366−
367−-- equivalent to:
368−@el [padding 20, background white]
369− Hello
197+ -- todo: more nav items
198+ @link / Home
199+@el [
200+ -- the gap, wider on larger screens
201+ --gap 12px,
202+ md:--gap 24px,
203+ spacing var(--gap)
204+]
205+ @text One
206+ @text Two
370207```
371208
372−## Single-child chaining
209+### Chains
373210
374−Use `>` to chain single-child elements on one line, reducing nesting depth.
211+`>` puts elements that have one child each on one line. The last element in
212+the chain gets the indented children. A `>` is a chain only between two
213+elements (`@name [attributes]`); in text it is just a character. A
214+function call can be a link of a chain, like any element. After an
215+element's leading argument (`@link /about`) the rest of the line is text,
216+so a link around an image is `@link [href=/] > @image logo.png` (the
217+compiler warns when `> @name` follows a leading argument).
375218
376219```
377−@el [padding 16, background blue, rounded 8] > @link https://example.com
220+@el [padding 16, background blue, border-radius 8] > @link https://example.com
378221 @text [color white] Get Started
379−
380−-- equivalent to:
381−@el [padding 16, background blue, rounded 8]
382− @link https://example.com
383− @text [color white] Get Started
384−```
385−
386−The last element in the chain takes the indented children.
387−
388−## `@raw`
389−
390−Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or JS.
391−
392−```
393−@raw """
394−<style>
395− @keyframes spin { to { transform: rotate(360deg); } }
396−</style>
397−<div class="custom-widget"></div>
398−"""
399−```
400−
401−## Units
402−
403−All numeric values default to pixels. You can use CSS units explicitly:
404−
405−```
406−@el [width 50%, height 100vh, padding 2rem, max-width 80ch, size 1.2em]
407−```
408−
409−Supported units: `%`, `rem`, `em`, `vh`, `vw`, `vmin`, `vmax`, `dvh`, `svh`, `ch`, `ex`, `cm`, `mm`, `in`, `pt`, `pc`, `fr`.
410−
411−## New elements
412−
413−### `@form`
414−
415−Form container. Argument is the `action` URL.
416−
417−```
418−@form [method post] /submit
419− @label [for email] Email
420− @input [type email, name email, required]
421− @button [type submit] Send
422−```
423−
424−### `@details` / `@summary`
425−
426−Native disclosure widget. Use `[open]` to expand by default.
427−
428−```
429−@details [open]
430− @summary FAQ Question
431− @text The answer is here.
432−```
433−
434−### `@blockquote` / `@cite`
435−
436−Semantic quotation with optional citation.
437−
438−```
439−@blockquote [padding 20, border-left 4 #ccc]
440− @text To be or not to be
441− @cite Shakespeare
442−```
443−
444−### `@code` / `@pre`
445−
446−Code and preformatted text. `@code` renders as `<code>` with monospace font. `@pre` preserves whitespace.
447−
448−```
449−@pre
450− @code console.log("hello")
451−```
452−
453−### `@hr`
454−
455−Horizontal rule / divider (self-closing). Alias: `@divider`.
456−
457−```
458−@hr [border-top 1 #e5e7eb]
459222```
460223
461−### `@figure` / `@figcaption`
224+### Verbatim bodies
225+
226+The bodies of `@raw`, `@style`, `@head`, `@markdown`, `@script`, `@code`
227+and `@textarea` are foreign text, kept exactly as written: nothing in them
228+is parsed as htmlang, and a `--` line in them is not a comment. `@raw`
229+writes its HTML into the page, `@style` its CSS and `@head` its HTML into
230+the `<head>`; `@code` and `@textarea` show theirs as text. Which lines are
231+verbatim is decided by the element or directive on the line, also at the
232+end of a chain (`@pre > @code`, `@el > @script`).
233+
234+The header's line follows one rule. For `@raw`, `@style` and `@head`, text
235+on the line is a one-line body; for `@markdown` it is the file and for
236+`@script` the `src`. Text on the line and an indented body together are an
237+error. Only `@script` takes attributes: an attribute list on `@raw`,
238+`@style`, `@head` or `@markdown` is an error, so content that starts with
239+`[` (a CSS attribute selector) goes in the indented block.
240+
241+```
242+@raw <hr class="fancy">
243+@raw
244+ <div class="custom-widget">
245+ <span>Hand-written HTML</span>
246+ </div>
247+@style .note { color: gray; }
248+```
249+
250+### Directives
251+
252+A directive is a built-in word that isn't an element. Each one takes a
253+fixed kind of argument and a fixed kind of body. `@page` is the one
254+directive that is also an element: it stands for the page's root, `<body>`,
255+whose content is the rest of the file (see [Page and head](#page-and-head)).
256+
257+| Directive | Argument | Body |
258+|---|---|---|
259+| `@page` | attributes (styles for `<body>`, `key=value` for `<html>`) and a title | none |
260+| `@let` | a name and a value or bundle, or `@name` and parameters | a function's body |
261+| `@include` | a file | none |
262+| `@data` | a variable and a source | none |
263+| `@meta` | a name and a value | none |
264+| `@if`, `@else`, `@each` | a condition or a loop | htmlang |
265+| `@raw`, `@style`, `@head` | the rest of the line: a one-line body | verbatim, instead of the line |
266+| `@markdown` | a file | verbatim Markdown, instead of the file |
267+
268+An indented line under a directive that takes no body is an error, because
269+it would otherwise silently become a sibling.
270+
271+### Diagnostics
272+
273+The compiler checks the whole file, including code that doesn't run: the
274+branch of an `@if` or an `if()` that isn't taken, the body of a function that is never
275+called and the body of a loop over an empty list. Unknown elements,
276+functions, attributes and prefixes are reported there too. In code that
277+doesn't run, a call is checked against every function in the file and the
278+files it includes; where the code runs, a name is looked up where it is
279+written, so a function's body sees only what is defined above the function
280+(a call to a function defined further down is reported when the body
281+runs, and two functions can't call each other). A `$name` is checked only
282+where the code runs, since any of them may come from data: an undefined
283+variable in a branch that isn't taken, or in a function that is never
284+called, isn't reported.
285+
286+**Names are checked, and values are CSS's.** A misspelled element, prefix,
287+parameter or slot is an error, and so is an undefined variable. A CSS
288+property htmlang doesn't know, but whose name CSS could have, is written to
289+the CSS as it is, with a warning that suggests the closest known name
290+(`text-grow`, or `colr`, which asks "did you mean `color`?"). Values go to
291+the CSS as written, so the browser decides what `z-index auto` or `color
292+rebeccapurple` means; only what is wrong in any CSS is reported (see
293+[CSS properties](#css-properties)).
294+
295+**Nothing you write is left out silently.** An error means something
296+couldn't go into the page: a flag htmlang doesn't know, an attribute
297+`@fragment` has no element for, content for a function without
298+`@children`. A warning means it went into the page as written, but may not
299+be what you meant. A warning is reported once, even from a line that runs
300+many times in a loop or a function.
301+
302+Every diagnostic has a stable code, such as `unknown-element` or
303+`unused-variable`. The command line prints it as `error[unknown-element]`,
304+`--format json` has it in a `code` field, and the editor's quick fixes are
305+keyed on it.
306+
307+## Layout
308+
309+### Rows, columns and text
310+
311+Every element has one layout, which decides what the lines written inside
312+it are. **In a row or column each line is a child; in a text element lines
313+flow.**
314+
315+| Layout | Elements | What is inside it |
316+|---|---|---|
317+| column | `@el`, and the containers: `@section`, `@nav`, `@form`, `@ul`, `@li`, ... | Each line of text is a child of its own, and the children are laid out top to bottom (side by side with `flex-direction row`) |
318+| row | `@row` | The same, side by side |
319+| grid | `@grid` | Each line of text is a cell |
320+| text | `@paragraph`, `@text`, `@link`, `@h1` … `@h6`, `@button`, `@label`, `@td`, `@strong`, `@em`, ... | The argument, the lines and the children flow as one run of text, joined with spaces |
321+| native | `@table`, `@caption`, `@select`, `@pre`, `@textarea`, `@video`, ... | HTML's own layout, which htmlang leaves alone |
322+| void | `@input`, `@hr`, `@br`, `@image`, `@source` | Nothing: it takes no content |
323+
324+The table under [HTML elements](#html-elements) gives every element's layout. The
325+page itself is a column too: `@page` is the [root element](#page-and-head),
326+`<body>`, so what is written at the top of a page stacks like the children
327+of an `@el`, `height fill` there takes the rest of the window and
328+`center-y` centres in it.
329+
330+```
331+@el [spacing 4]
332+ First line
333+ Second line
334+@button [type=button]
335+ Save
336+ changes
337+```
338+
339+The column shows two lines 4px apart; the button says "Save changes".
340+
341+These attributes are htmlang's own. They describe how an element lays out
342+its children, and how it sits in its parent:
462343
463−Figure with optional caption.
464−
465−```
466−@figure
467− @image [alt Sunset, width fill] sunset.jpg
468− @figcaption A beautiful sunset
469−```
470−
471−### `@progress` / `@meter`
472−
473−Progress bar and meter elements. Use `value`, `max`, `min` attributes.
474−
475−```
476−@progress [value 70, max 100]
477−@meter [value 6, min 0, max 10, low 3, high 8]
478−```
479−
480−## `@each` destructuring
481−
482−When `@each` has more than one variable and items contain spaces, values are destructured:
483−
484−```
485−@each $name, $url in Home /, About /about, Contact /contact
486− @link $url $name
487−```
488−
489−`var()` and `calc()` expressions are also passed through as-is.
490−
491−## `@each` with `@else`
492−
493−When the list is empty, the `@else` block is rendered as a fallback:
494−
495−```
496−@each $item in $items
497− @text $item
498−@else
499− @text [color #888] No items found.
500−```
501−
502−## `@iframe`
503−
504−Embedded external page. Argument is the `src` URL.
505−
506−```
507−@iframe [width fill, height 400, sandbox] https://example.com
508−```
509−
510−Attributes: `sandbox`, `allow`, `allowfullscreen`, `referrerpolicy`.
511−
512−## `@canvas`
513−
514−Drawing surface for JavaScript. Use with `@raw` for scripts.
515−
516−```
517−@canvas [width 400, height 300, id chart]
518−```
519−
520−## `@output`
521−
522−Form output element for displaying calculation results.
523−
524−```
525−@output [for a b] Result
526−```
527−
528−## Pseudo-elements
529−
530−Prefix any style attribute with `before:` or `after:` to apply it to the `::before` or `::after` pseudo-element. Use with `content` to set the generated content.
344+| Attribute | Effect |
345+|---|---|
346+| `spacing N` | Gap between children |
347+| `width fill` / `width shrink` / `width N` | In a row, take the remaining width or keep the content's width; in a column or anywhere else, take the full width or fit the content; or an exact size |
348+| `height fill` / `height shrink` / `height N` | In a column, take the remaining height or keep the content's height; in a row or anywhere else, take the full height or fit the content; or an exact size |
349+| `center-x`, `center-y` | Center the element in its parent (auto margins) |
350+| `align-left`, `align-right`, `align-top`, `align-bottom` | Align the element in its parent (an auto margin on the other side) |
351+| `wrap` | Let a row wrap onto more lines |
352+| `grid-cols N`, `grid-rows N` | Equal grid columns or rows (on `@grid`); any other value is a track list, as in `grid-template-columns` (`grid-cols 200 1fr`) |
353+| `col-span N`, `row-span N` | Cells a grid child spans |
354+
355+`spacing`, `wrap`, `grid-cols` and `grid-rows` lay out an element's
356+children, so they go on a row, column or grid. On a text, native or void
357+element they are an error: text has no gap between its lines. For a flex
358+layout of your own on such an element, write the CSS (`@label [display
359+flex, gap 8]`).
360+
361+A child's `fill` and `shrink` follow the direction its parent's CSS
362+actually sets: a row for `@row`, a column for `@el` and the other columns,
363+or whatever the parent's own `flex-direction` (or `flex-flow`) says. Along
364+that direction, `fill` takes the remaining space and `shrink` keeps the
365+content's size; across it, `fill` is the full size and `shrink` fits the
366+content. `center-x`, `center-y` and `align-*` are auto margins, which work
367+in either direction; a `margin` on the same element keeps its other sides
368+(`[center-x, margin 20]`). A layout word never overrides a CSS property
369+the element writes itself: `[width fill, min-width 200]` keeps its 200px,
370+and `[flex-shrink 0, width fill]` still grows but never shrinks.
371+
372+```
373+@row [spacing 8]
374+ @el [width fill, padding 12, background #f3f4f6] Fills the row
375+ @el [width 120, padding 12, background #e5e7eb] 120px
376+@el [height 200, spacing 8, background #f9fafb]
377+ @text [center-x] Centered
378+ @el [height fill, background #dbeafe] Fills the rest of the column
379+ @text [align-right] Right-aligned
380+@nav [flex-direction row, spacing 20]
381+ @link /features Features
382+ @el [width fill]
383+ @link /signup Sign up
384+@grid [grid-cols 3, spacing 8, md:grid-cols 4]
385+ @el [col-span 2, background #fef3c7] Two columns wide
386+ @el [background #fde68a] One
387+ @el [background #fcd34d] One
388+```
389+
390+A direction set under media or container prefixes (`md:flex-direction
391+row`, `print:`, `cq-md:`, or a stack of them such as `md:dark:`) switches
392+the children's `fill` and `shrink` in that same condition. As in elm-ui, whose child rules are keyed on the
393+parent's class (`.r > .wf`), each child gets a rule keyed on its parent's
394+class in the parent's `@media` or `@container` block. A width prefix holds
395+from its width up, so `sm:flex-direction column` still applies at `lg:`
396+unless `lg:` sets a direction of its own. A direction under a state prefix
397+(`hover:`, `first-child:`, `children:`, or a stack with one in it) leaves the
398+children's layout words as they are.
399+
400+```
401+@header [spacing 16, md:flex-direction row, md:align-items center]
402+ @text [font-weight 800] Launchpad
403+ @el [width fill]
404+ @nav [flex-direction row, spacing 20]
405+ @link /features Features
406+ @link /pricing Pricing
407+@section [padding 24]
408+ @el [width fill, max-width 800, center-x, spacing 16]
409+ The full width up to 800px, in the middle
410+```
411+
412+On a phone the header is a column and its `@el [width fill]` is the full
413+width; from md up it is a row and the `@el` takes the free space, pushing
414+the navigation to the right. The section's `@el` is the usual centred
415+column: the full width, at most 800px, with auto margins on both sides.
416+
417+Everything else about layout is plain CSS: `justify-content`, `align-items`,
418+`flex 1 1 240px`, `grid-template-areas`, `position`.
419+
420+### Layout inside text
421+
422+A row, column or grid written inside text, inline in a line (`{@el ...}`) or
423+as a child of a text element, is laid out inline (`display: inline-flex`, or
424+`inline-grid`), and keeps its attributes. Anywhere inside text, `@el`, `@row`
425+and `@grid` (and `@in-front` and `@behind`) are written as a `<span>` rather
426+than a `<div>`, since text can't hold a `<div>`.
531427
532428```
533−@el [before:content "→ ", before:color red, before:font-weight bold]
534− Item with arrow prefix
429+@paragraph
430+ Price: {@el [padding 2 6, background #fef3c7, border-radius 4] $9} a month.
431+@h2
432+ Plans
433+ @row [spacing 4, font-size 14] > @text New
434+```
435+
436+Any other element keeps its HTML element. One whose HTML ends a paragraph,
437+such as `@section`, `@ul` or `@h2`, is a warning inside `@paragraph`
438+(`block-in-paragraph`): the browser would move it, and what follows it, out
439+of the `<p>`.
440+
441+`@link` is text too, so a card written inside a link is an inline `<span>`
442+that shrinks to its content. A clickable card is the link itself, laid out
443+as a column with CSS:
535444
536−@el [after:content " ✓", after:color green]
537− Completed item
445+```
446+@link [href=/post, display flex, flex-direction column, gap 8, padding 16, border 1 solid #e5e7eb]
447+ @strong A post
448+ @text Its summary.
538449```
539450
540−Content values are automatically quoted unless they are CSS keywords (`none`, `normal`) or CSS functions (`attr()`, `counter()`).
541−
542−## Conditional attribute values
451+### Overlays: `@in-front` and `@behind`
543452
544−Use `if(condition, true_value, false_value)` inside attribute values for conditional styling:
453+These layers work like elm-ui's `inFront` and `behind`. Their children fill
454+the parent's bounds, and the parent becomes a positioning context
455+(`position: relative; isolation: isolate`). An explicit `position` on the
456+parent takes precedence.
457+
458+```
459+@el [width 200, height 200, background blue]
460+ @text [color white] Main content
461+ @in-front
462+ @text [color yellow, align-right, align-bottom] Painted on top
463+ @behind
464+ @el [background red, height fill]
465+```
466+
467+### Standard library
468+
469+Two definitions are written in htmlang (`std.hl`) and are available in every
470+file. A `@let` of your own with the same name replaces them. `@spacer` takes
471+up the remaining space in a row or column. The `$truncate` bundle cuts text
472+off at one line with an ellipsis. You define any other components yourself
473+with `@let`.
474+
475+```
476+@row [spacing 8, align-items center]
477+ @text Logo
478+ @spacer
479+ @text [$truncate, max-width 200] A long title that gets cut off
480+```
481+
482+## Style
483+
484+### CSS properties
485+
486+Any attribute that isn't a layout attribute is a **CSS property**, with the
487+same name and value as in CSS: `padding 20`, `font-weight bold`,
488+`border 1 solid #e5e7eb`, `border-radius 8`, `background red`,
489+`grid-template-areas "a b"`, `display none`.
490+
491+A quoted string in a value is CSS's own, so it keeps its quotes and its
492+commas: `font-family "Inter, sans-serif"` names one family called
493+"Inter, sans-serif", and the compiler warns about it. A font stack is
494+several values with commas between them, written `\,`:
495+`font-family Inter\, sans-serif`.
496+
497+Values mean what they mean in CSS, with one addition: **in a length
498+property, every bare number outside parentheses and quotes is pixels**. So
499+`padding 20` is `20px`, `border 1 solid red` is `1px solid red`,
500+`box-shadow 0 2 4 rgba(0,0,0,0.1)` is `0 2px 4px rgba(0,0,0,0.1)`, and
501+`margin 0 auto` is `0 auto` (a zero stays `0`). Numbers inside a function
502+belong to it (`rgb(255 128 0)`, `calc(100% - 20px)`), and values that have
503+a unit or are keywords pass through as written (`width 50%`, `max-width
504+min(100%, 800px)`). Every other property takes numbers as CSS does, so
505+`opacity 0.5`, `z-index 2`, `line-height 1.5`, `flex 1 1 240px`,
506+`grid-column 1 / 3`, `initial-letter 3` and `border-image-width 2` are
507+written as they are, and so are custom properties (`--cols 3`), which have
508+no type to go by.
509+
510+The length properties are these, and only these:
511+
512+| Group | Properties |
513+|---|---|
514+| Size | `width`, `height`, `min-*` and `max-*` of both, `block-size`, `inline-size` and their `min-`/`max-`, `flex-basis`, `column-width`, `contain-intrinsic-*` |
515+| Space | `margin`, `padding`, `scroll-margin`, `scroll-padding` and every side of them, `gap`, `row-gap`, `column-gap`, `border-spacing` |
516+| Position | `top`, `right`, `bottom`, `left`, `inset` and `inset-*`, `translate`, `transform-origin`, `perspective`, `perspective-origin`, `offset-position`, `offset-anchor`, `offset-distance` |
517+| Border | `border` and every `border-*` shorthand, width and radius (not `border-image-*`), `outline`, `outline-width`, `outline-offset`, `column-rule`, `column-rule-width` |
518+| Shadow | `box-shadow`, `text-shadow` |
519+| Grid | `grid-template-columns`, `grid-template-rows`, `grid-auto-columns`, `grid-auto-rows` |
520+| Background | `background-position` (and `-x`, `-y`), `background-size`, `object-position`, `mask-position`, `mask-size` |
521+| Text | `font-size`, `letter-spacing`, `word-spacing`, `text-indent`, `text-decoration-thickness`, `text-underline-offset`, `vertical-align`, `baseline-shift` |
522+| Other | `shape-margin`, `overflow-clip-margin`, `view-timeline-inset` |
523+
524+Shorthands that mix lengths with numbers of other kinds (`font`,
525+`background`, `mask`, `grid`, `grid-template`, `offset`, `flex`) are not in
526+the table, so the lengths in them are written with their unit:
527+`font 700 16px/1.5 Inter`.
528+
529+Nothing else is added to a value, and a shorthand means what it means in
530+CSS: `outline 2 solid red` is `2px solid red`, while `outline 2 red` names
531+no line style, so CSS draws no outline. `container card / inline-size`
532+and `contain content` are CSS's own shorthands too, and a bare `contain` is
533+an error, like any style without a value. `line-clamp N` alone also adds
534+the `-webkit-box` declarations that browsers still need to cut text off
535+after N lines.
536+
537+A property htmlang doesn't know is written to the CSS as it is, so new CSS
538+(`text-grow per-line`) works before htmlang lists it; the
539+compiler warns and suggests the closest known name, in case it is a typo.
540+Vendor-prefixed properties (`-webkit-tap-highlight-color`) pass without a
541+warning. The value of a property htmlang doesn't know is written exactly as
542+it is, without pixels added. A style needs a value: `[padding]` is an error.
543+
544+```
545+@el [-webkit-tap-highlight-color transparent, -webkit-text-stroke 1px black]
546+ Passed through as written
547+```
548+
549+A value is checked only for what is wrong in any CSS. A `;`, `{` or `}`
550+(outside a quoted string, and for `;` outside parentheses, as in CSS's own
551+`if()`) or an unclosed quote or parenthesis would break out of the rule, and
552+a `</style` (even quoted) would end the page's style element, so each is an
553+error, including in a value that comes from a variable or from `@data`. A hex color that doesn't have 3, 4, 6 or 8 digits is a warning. A
554+style whose value comes out empty, such as a field that a record doesn't
555+have, is left out. Since a value is CSS's, a missing comma between two
556+styles makes one value: `padding 8 color red` is `padding: 8px color red`,
557+which the browser ignores. A style written as an HTML attribute
558+(`padding=8`) is a warning that shows its style form.
559+
560+#### Custom properties
561+
562+A custom property (`--name`) is a CSS property like any other: it goes on
563+any element, under any prefix, and is written into the element's class.
564+Its name is yours, so it is never checked against a list.
565+CSS passes it down, so it holds for the element and everything inside it,
566+where `var(--name)` reads it. Its value is written exactly as it is, since
567+a custom property has no type for pixels to go by: `--hue 200` can feed
568+`hsl(var(--hue) 80% 50%)` and `--cols 3` can feed `repeat(var(--cols),
569+1fr)`. A length therefore takes its unit: `--gap 16px`, not `--gap 16`.
545570
546571```
572+@el [
573+ --gap 16px,
574+ md:--gap 24px,
575+ --hue 200,
576+ spacing var(--gap),
577+ background hsl(var(--hue) 80% 95%)
578+]
579+ @text One
580+ @text Two
581+```
582+
583+`@let --name value` declares a custom property for the whole page, on
584+`:root` (see [CSS custom properties](#css-custom-properties));
585+`[--name value]` sets it on one element and everything inside it. So a
586+design token is declared once with `@let`, and a theme redefines it where
587+it changes, once, rather than repeating a colour on every element that
588+uses it:
589+
590+```
591+@let --ink #0f172a
592+@let --surface white
593+@let --subtle #f8fafc
594+@page [color var(--ink), background var(--surface), dark:[--ink #e2e8f0, --surface #0b1220, --subtle #111a2e]] Tokens
595+@section [padding 96 24, background var(--subtle)]
596+ @article [padding 24, background var(--surface)]
597+ Both follow dark mode, from the one place that sets it.
598+```
599+
600+### Prefixes
601+
602+A prefix applies a style only in some condition:
603+
604+| Prefix | Applies |
605+|---|---|
606+| `hover:`, `focus:`, `first-child:`, `checked:`, `user-invalid:`, `open:`, ... | When that CSS pseudo-class matches (`:hover`, `:first-child`, ...) |
607+| `nth-child(odd):`, `not(.featured):`, `has(> img):`, `is(...):`, ... | When that pseudo-class, with its argument, matches |
608+| `before:`, `after:`, `marker:`, `placeholder:`, `backdrop:`, ... | To that pseudo-element (`::before`, `::marker`, ...) |
609+| `children:` | To each direct child |
610+| `@td:`, `@h2:`, `@link:`, ... | To every such element inside it, at any depth (see [Styling the elements inside](#styling-the-elements-inside)) |
611+| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640, 768, 1024, 1280, 1536px) |
612+| `cq-sm:` … `cq-2xl:` | From that container width up (the same widths, for an ancestor with `container-type inline-size`) |
613+| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Under that media condition |
614+
615+The selector prefixes are CSS's own pseudo-classes and pseudo-elements,
616+under their CSS names, without the colons: `link`, `visited`,
617+`first-child`, `last-child`, `only-child`, `first-of-type`,
618+`last-of-type`, `only-of-type`, `empty`, `target`, `open`,
619+`popover-open`, `default`, `checked`, `indeterminate`,
620+`placeholder-shown`, `autofill`, `required`, `optional`, `valid`,
621+`invalid`, `user-valid`, `user-invalid`, `read-only`, `read-write`,
622+`focus-within`, `hover`, `focus`, `focus-visible`, `active`, `enabled`
623+and `disabled`; with an argument, `nth-child()`, `nth-last-child()`,
624+`nth-of-type()`, `nth-last-of-type()`, `not()`, `is()`, `where()` and
625+`has()`; and the pseudo-elements `before`, `after`, `marker`,
626+`placeholder`, `selection`, `backdrop`, `first-line`, `first-letter` and
627+`file-selector-button`, which htmlang writes with CSS's `::`. The argument
628+is CSS, written as it is between balanced parentheses, and it may hold
629+spaces and colons: the key ends at the first space outside parentheses, so
630+`has(> img):padding 0` and `is(:hover, :focus-visible):color red` are one
631+key each. `children:` is htmlang's one combinator word, and an element's
632+name with `@` (`@td:`) picks out the elements of that kind inside. A name
633+that isn't one of these (`hvoer:`, or `first:` for `first-child:`) is an
634+unknown prefix, an error, not a selector that silently never matches.
635+
636+A prefix goes on a style or a layout flag, and prefixes stack: the key
637+is a chain of prefixes, then the property, and the style applies where
638+all of them hold. The selector prefixes (every row above the widths)
639+read left to right: `hover:children:` styles the children of a hovered
640+element, `children:hover:` a hovered child. A pseudo-element is not an
641+element with states of its own, so it comes last among them:
642+`hover:before:color` is the `::before` of a hovered element, and
643+`before:hover:color` is an error that says so. The width, media and
644+container prefixes go anywhere in the chain, in any order, and each is
645+an `@media` or `@container` rule around the ones inside it:
646+`md:dark:padding 32` and `dark:md:padding 32` are the same style, from
647+768px up in dark mode.
648+
649+A prefix written before a `[group]` applies to each style in it, and one
650+written before a `$bundle` to each style the bundle holds, so styles that
651+share a condition are written under it once. A prefix before an `if()`
652+applies to the branch it picks. The group or bundle holds styles only:
653+an HTML attribute (`md:[id=x]`) or a flag that is not a style has no
654+states, and it is an error there, as it is with a prefix of its own
655+(`md:id=x`).
656+
657+`children:` styles each direct child, as the parent sees it. They take
658+effect only where the child sets nothing itself, since what an element
659+says about itself wins over what its parent says about it. They do win
660+over the defaults of the child's kind, so `children:display list-item`
661+brings back a list's markers and `children:margin-bottom 8` spaces
662+headings. The words
663+that place an element in its parent (`width fill`, `width shrink`,
664+`center-x`, `align-*`) are errors under `children:`, because there the
665+parent they measure against is this element: they go on the children
666+themselves, or the CSS they stand for does (`children:flex 1`).
667+
668+#### Styling the elements inside
669+
670+An element prefix, `@NAME:`, styles every element of that kind inside
671+this one, at any depth. `@table [@td:padding 8]` pads every cell of the
672+table, and `@article [@h2:font-size 20]` sizes every heading in the
673+article. It is how content that has no attributes of its own is styled:
674+the HTML `@markdown` writes, and the rows a loop writes.
675+
676+```
677+@let team Ada, Grace, Linus
678+@table [border-collapse collapse, width 100%, @th:[padding 8, text-align left], @td:padding 8, @tr:border-bottom 1 solid #e5e7eb]
679+ @tr
680+ @th Name
681+ @th Team
682+ @each $name in $team
683+ @tr
684+ @td $name
685+ @td [padding 2] Platform
686+@article [spacing 12, @h2:font-size 20, @code:[padding 1 4, background #f1f5f9], @link:hover:color #2563eb]
687+ @markdown
688+ ## Notes
689+
690+ Run `htmlang check` before you [publish](/publish).
691+```
692+
693+- **The element has a tag of its own.** `@td:` is every `<td>`,
694+ `@paragraph:` every `<p>`, `@link:` every `<a>` and `@image:` every
695+ `<img>`, the ones `@markdown` and `@raw` write included. An element that
696+ shares its tag can't be picked out, so its prefix is an error: `@el`,
697+ `@row`, `@grid`, `@in-front` and `@behind` all write `<div>`, and `@text`
698+ writes the `<span>` that every line of text in a row or column is too.
699+ So is a function's name (`@card:`), since a function writes whatever its
700+ body says and has no tag of its own, and a name that isn't an element
701+ (`@a:` suggests `@link:`).
702+- **It takes CSS properties.** htmlang's layout words (`spacing`, `wrap`,
703+ `grid-cols`, `col-span`, `width fill`, `center-x`, `align-*`) compile
704+ against each element's own layout and its parent's, which the prefix
705+ doesn't know, so they are errors under it: write them on the elements,
706+ or the CSS they stand for (`@li:margin-inline auto`). HTML attributes
707+ have no states: `@td:colspan=2` is an error, like `md:colspan=2`.
708+- **It composes with the other prefixes, read left to right.** Selector
709+ prefixes after it choose among the elements:
710+ `@tr:nth-child(even):background #f8fafc` shades every other row, and
711+ `@link:hover:color red` colours a hovered link. Those before it are
712+ about the element itself: `hover:@link:color red` recolours its links
713+ while it is hovered. Width, media and container prefixes go anywhere
714+ (`md:@td:padding 12`). A `[group]` or a `$bundle` takes it like any
715+ prefix: `@th:[padding 8, text-align left]`. A style has one element
716+ prefix, and not `children:` as well: each says which elements the style
717+ is for.
718+- **What an element says about itself wins.** A cell's own `[padding 2]`
719+ wins over the table's `@td:padding 8`, and a parent's `children:` wins
720+ over it too. The prefix wins over the defaults of the element's kind, so
721+ `@main [@h2:margin-block-start 16]` spaces headings, which start with
722+ `margin: 0`. Where two elements around one both style it, the nearest
723+ wins, whatever the order they are written in: in an `@article
724+ [@td:padding 16]` around a `@table [@td:padding 8]`, the table's cells
725+ get 8.
726+- **It styles what is inside, not the element itself.** `@ul
727+ [@ul:padding-inline-start 16]` indents the lists inside the list, not
728+ the list itself.
729+
730+`children:` and an element prefix answer different questions:
731+`children:` is for the direct children, whatever they are, as the parent
732+sees them; `@td:` is for every cell inside, however deep. An element's
733+own attributes are for it alone.
734+
735+Each style under an element prefix compiles to a rule in CSS's `@scope`,
736+on the element's class, in the layer `hl-inside` (see [CSS](#css)):
737+`@scope (.hl-2k9xq0m) { :scope td { padding: 8px } }`. `@scope`'s proximity
738+rule is what makes the nearest element win. `@scope` is Baseline 2025,
739+supported by Chrome and Edge 118, Safari 17.4 and Firefox 146; an older
740+browser ignores these rules, so the elements inside keep the styles they
741+have without them.
742+
743+```
744+@let card [padding 16, border 1 solid #e5e7eb, border-radius 8]
547745@let active true
548−@let theme dark
549−
550−@el [background if($active, blue, gray)]
551− Conditionally styled
552−
553−@el [color if($theme == dark, white, black)]
554− Theme-aware text
555−```
556−
557−Supports equality (`==`), inequality (`!=`), and truthy checks.
558−
559−## Attributes reference
560−
561−### Layout (set on parent)
562−
563−| Attribute | Effect |
564−|------------------------|----------------------------------|
565−| `spacing N` | Gap between children |
566−| `gap N` | Alias for `spacing` |
567−| `gap-x N` | Horizontal gap (column-gap) |
568−| `gap-y N` | Vertical gap (row-gap) |
569−| `padding N` | Uniform padding |
570−| `padding Y X` | Vertical + horizontal padding |
571−| `padding T H B` | Top + horizontal + bottom |
572−| `padding T R B L` | Per-side padding |
573−| `padding-x N` | Horizontal padding |
574−| `padding-y N` | Vertical padding |
575−
576−### Sizing (set on child)
577−
578−| Attribute | Effect |
579−|------------------------|----------------------------------|
580−| `width fill` | Take remaining space (flex: 1) |
581−| `width N` | Exact pixels |
582−| `width shrink` | Fit to content (default) |
583−| `height fill` | Take remaining space |
584−| `height N` | Exact pixels |
585−| `height shrink` | Fit to content (default) |
586−| `min-width N` | Minimum width |
587−| `max-width N` | Maximum width |
588−| `min-height N` | Minimum height |
589−| `max-height N` | Maximum height |
590−
591−### Alignment (set on child)
592−
593−| Attribute | Effect |
594−|------------------------|----------------------------------|
595−| `center-x` | Center horizontally |
596−| `center-y` | Center vertically |
597−| `align-left` | Align to left |
598−| `align-right` | Align to right |
599−| `align-top` | Align to top |
600−| `align-bottom` | Align to bottom |
601−
602−### Style
603−
604−| Attribute | Effect |
605−|------------------------|----------------------------------|
606−| `background COLOR` | Background color |
607−| `color COLOR` | Text color |
608−| `border N COLOR` | Border width and color |
609−| `border-top N COLOR` | Top border |
610−| `border-bottom N COLOR`| Bottom border |
611−| `border-left N COLOR` | Left border |
612−| `border-right N COLOR` | Right border |
613−| `rounded N` | Border radius |
614−| `shadow VALUE` | Box shadow (CSS value) |
615−| `bold` | Bold text |
616−| `italic` | Italic text |
617−| `underline` | Underlined text |
618−| `size N` | Font size in px |
619−| `font NAME` | Font family |
620−| `text-align VALUE` | Text alignment (left/center/right/justify) |
621−| `line-height VALUE` | Line height (unitless or px) |
622−| `letter-spacing N` | Letter spacing |
623−| `text-transform VALUE` | Transform (uppercase/lowercase/capitalize) |
624−| `white-space VALUE` | White-space (nowrap/pre/normal) |
625−| `transition VALUE` | CSS transition |
626−| `cursor VALUE` | Cursor style |
627−| `opacity VALUE` | Opacity (0–1) |
628−| `overflow VALUE` | Overflow (hidden/scroll/auto/visible) |
629−| `position VALUE` | Position (relative/absolute/fixed/sticky) |
630−| `top N` | Top offset (positioned elements) |
631−| `right N` | Right offset |
632−| `bottom N` | Bottom offset |
633−| `left N` | Left offset |
634−| `z-index N` | Stack order |
635−| `display VALUE` | Display mode (none/block/flex/grid) |
636−| `visibility VALUE` | Visibility (visible/hidden) |
637−| `transform VALUE` | CSS transform (e.g., rotate(45deg)) |
638−| `backdrop-filter VALUE`| Backdrop filter (e.g., blur(10px)) |
639−
640−### Margin
641−
642−| Attribute | Effect |
643−|------------------------|----------------------------------|
644−| `margin N` | Uniform margin |
645−| `margin Y X` | Vertical + horizontal margin |
646−| `margin T R B L` | Per-side margin |
647−| `margin-x N` | Horizontal margin |
648−| `margin-y N` | Vertical margin |
649−
650−### Additional Style
651−
652−| Attribute | Effect |
653−|------------------------|----------------------------------|
654−| `filter VALUE` | CSS filter (blur, brightness) |
655−| `object-fit VALUE` | Object fit (cover/contain/fill) |
656−| `object-position VALUE`| Object position within container|
657−| `text-shadow VALUE` | Text shadow |
658−| `text-overflow VALUE` | Text overflow (ellipsis/clip) |
659−| `pointer-events VALUE` | Pointer events (none/auto) |
660−| `user-select VALUE` | User selection (none/text/all) |
661−| `justify-content VALUE`| Main axis alignment |
662−| `align-items VALUE` | Cross axis alignment |
663−| `order N` | Flex/grid item order |
664−| `background-size VALUE`| Background size |
665−| `background-position VALUE`| Background position |
666−| `background-repeat VALUE`| Background repeat |
667−| `word-break VALUE` | Word break (break-all/keep-all) |
668−| `overflow-wrap VALUE` | Overflow wrap (break-word) |
669−| `font-weight VALUE` | Font weight (100-900/bold/lighter)|
670−| `font-style VALUE` | Font style (normal/italic/oblique)|
671−| `text-wrap VALUE` | Text wrapping (balance/pretty) |
672−| `will-change VALUE` | Performance hint (transform) |
673−| `touch-action VALUE` | Touch behavior (none/pan-x) |
674−| `vertical-align VALUE` | Vertical alignment (middle/top) |
675−| `contain VALUE` | CSS containment (layout/paint) |
676−| `content-visibility VALUE` | Lazy rendering (auto) |
677−| `scroll-margin N` | Scroll margin (for anchors) |
678−| `scroll-padding N` | Scroll padding (scroll-snap) |
679−| `content VALUE` | Pseudo-element content (with before:/after:)|
680−
681−### Pseudo-states
682−
683−Prefix any style attribute with `hover:`, `active:`, or `focus:` to apply it on that state.
684−
685−```
686−@el [padding 16, background #3b82f6, hover:background #2563eb, active:background #1d4ed8, rounded 8, transition all 0.15s ease]
746+@el [padding 16, background #3b82f6, hover:background #2563eb, md:[padding 32, font-size 20], dark:[background #1e3a8a, hover:background #1e40af]]
687747 @text [color white] Click me
688−```
689−
690−All style attributes support state prefixes: `hover:color`, `active:rounded`, `focus:border`, etc.
691−
692−### Dark mode
693−
694−Prefix any style attribute with `dark:` to apply it when the user's system is in dark mode.
748+@row [spacing 4, children:flex 1, hover:children:opacity 0.8]
749+ @el [nth-child(odd):background #f3f4f6] A
750+ @el [nth-child(odd):background #f3f4f6] B
751+ @el [nth-child(odd):background #f3f4f6] C
752+@el [before:content "→ ", before:color red, hover:before:color blue]
753+ Item with an arrow
754+@section [spacing 16, md:$card, hover:if($active, [background #eef2ff, color #3730a3])]
755+ A card from md up
756+```
757+
758+The styles of one element follow a fixed order, whatever the order they
759+are written in: those without a prefix, then those with selector
760+prefixes only, then each width, media and container condition in the
761+order of the table (a stack like `md:dark:` after `dark:` on its own),
762+with its selector chains inside it. So where two styles both hold, the
763+one later in the table wins (`dark:` over `md:`, `lg:` over `md:`), and a
764+stack wins over each of its prefixes on its own (`md:dark:` over `dark:`
765+and `md:`). Selector chains follow CSS's usual order: `link:` and
766+`visited:`, the position and form states, then `hover:`, `focus:`,
767+`active:`, and `enabled:` and `disabled:` last. So a hovered row wins over
768+`nth-child(odd):`, and a pressed button's `active:` over its `focus:`.
769+
770+### Conditional attributes
771+
772+`if(CONDITION, A, B)`, written as a whole attribute, is `A` when the
773+condition holds and `B` otherwise. Each branch is an attribute, a `[group]`
774+of attributes or a `$bundle`. An empty branch leaves the attribute out, and
775+`B` itself can be left out. Several attributes that depend on one condition
776+are one `if()` with a group:
695777
696778```
697−@el [background white, dark:background #1a1a2e, color #333, dark:color #eee]
698− @text Theme-aware content
699−```
700−
701−This generates a `@media (prefers-color-scheme: dark)` rule.
702−
703−### Print styles
704−
705−Prefix any style attribute with `print:` to apply it when printing.
706−
707−```
708−@nav [print:display none]
709− @text Navigation (hidden in print)
710−```
711−
712−This generates a `@media print` rule.
713−
714−### Flow & Grid
715−
716−| Attribute | Effect |
717−|------------------------|----------------------------------|
718−| `wrap` | Enable flex-wrap (on `@row`) |
719−| `grid` | Enable CSS grid layout |
720−| `grid-cols N` | Grid template columns (N equal) |
721−| `grid-rows N` | Grid template rows (N equal) |
722−| `col-span N` | Span N columns in grid |
723−| `row-span N` | Span N rows in grid |
724−
725−### Identity
726−
727−| Attribute | Effect |
728−|------------------------|----------------------------------|
729−| `id NAME` | HTML id attribute |
730−| `class NAME` | HTML class attribute |
731−
732−### Modern CSS
733−
734−| Attribute | Effect |
735−|------------------------|----------------------------------|
736−| `color-scheme VALUE` | Color scheme (`light`, `dark`, `light dark`) |
737−| `appearance VALUE` | Form element appearance (`none`) |
738−
739−### Popover API
740−
741−Use HTML Popover API attributes for declarative popovers without JavaScript.
742−
743−```
744−@button [popovertarget info-panel] Show Info
745−@el [popover, id info-panel, padding 20, background white, rounded 8]
746− @text This is a popover
747−```
748−
749−| Attribute | Effect |
750−|-------------------------------|---------------------------------|
751−| `popover` | Make element a popover |
752−| `popovertarget ID` | Open popover with given ID |
753−| `popovertargetaction VALUE` | Action (toggle/show/hide) |
754−
755−### Input hints
756−
757−| Attribute | Effect |
758−|------------------------|----------------------------------|
759−| `inputmode VALUE` | Virtual keyboard type (numeric/email/search/tel/url) |
760−| `enterkeyhint VALUE` | Enter key label (enter/done/go/next/previous/search/send) |
761−| `fetchpriority VALUE` | Resource priority (high/low/auto) |
762−| `translate VALUE` | Whether to translate (yes/no) |
763−| `spellcheck VALUE` | Spell check (true/false) |
764−
765−## Asset inlining
766−
767−SVG images can be inlined directly into the HTML output using the `[inline]` attribute:
768−
769−```
770−@image [inline, width 24, height 24] icon.svg
771−```
772−
773−This reads the SVG file and embeds its content directly, keeping the output self-contained.
774−
775−## CLI
779+@let active true
780+@el [if($active, background blue, background gray), if($active, font-weight bold)]
781+ Conditionally styled
782+@link [if($active, [background #eef2ff, font-weight 600, aria-current=page])] /docs Docs
783+```
784+
785+A value that depends on a condition is an [expression](#expressions),
786+`${if(CONDITION, A, B)}`. Its branches are expressions, so a number or a
787+single word is written as it is, and other CSS text is quoted:
788+`padding ${if($active, 24, 0)}`, `color ${if($active, #10b981, "var(--muted)")}`.
789+An `if()` written in a value without `${...}` is CSS's own `if()`, which the
790+browser decides, and it is passed through like any CSS function:
791+`width if(media(width > 40em): 50%; else: 100%)`.
792+
793+## HTML
794+
795+### HTML elements
796+
797+The layout and text elements have elm-ui's names. Every other element has
798+its HTML name: `@strong`, `@em`, `@small`, `@br`, `@sub`, `@caption`,
799+`@tfoot`, `@optgroup`, `@hgroup` and the others in the table of layouts
800+below.
801+
802+| Element | Output | Purpose |
803+|---|---|---|
804+| `@el` | div, flex column | The container: children laid out top to bottom |
805+| `@row` | div, flex row | Children laid out side by side |
806+| `@grid` | div, grid | Grid container (with `grid-cols` or `grid-template-*`) |
807+| `@in-front` / `@behind` | div, absolute | Overlay layers that fill the parent |
808+| `@text` | span | Styled inline text |
809+| `@paragraph` | p | A paragraph of flowing text |
810+| `@link URL` | a | Link: the URL is its `href`, and the text after it is its content |
811+| `@image SRC` | img | Image: the one word after its attributes is its `src` |
812+| `@fragment` | (none) | Its children, without a wrapper element (so it takes no attributes) |
813+
814+Every element has one [layout](#rows-columns-and-text):
815+
816+| Layout | Elements |
817+|---|---|
818+| column | `@el`, `@in-front`, `@behind`, `@nav`, `@header`, `@footer`, `@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@noscript`, `@form`, `@details`, `@dialog`, `@figure`, `@blockquote`, `@fieldset`, `@hgroup`, `@ul`, `@ol`, `@menu`, `@li`, `@dl`, `@dd` |
819+| row | `@row` |
820+| grid | `@grid` |
821+| text | `@text`, `@paragraph`, `@link`, `@h1`, `@h2`, `@h3`, `@h4`, `@h5`, `@h6`, `@button`, `@label`, `@legend`, `@summary`, `@figcaption`, `@cite`, `@dt`, `@td`, `@th`, `@code`, `@kbd`, `@mark`, `@abbr`, `@time`, `@b`, `@i`, `@strong`, `@em`, `@small`, `@s`, `@u`, `@sub`, `@sup`, `@q`, `@var`, `@samp`, `@dfn`, `@bdi`, `@bdo`, `@ins`, `@del` |
822+| native | `@table`, `@caption`, `@colgroup`, `@thead`, `@tbody`, `@tfoot`, `@tr`, `@select`, `@optgroup`, `@option`, `@datalist`, `@textarea`, `@progress`, `@meter`, `@output`, `@pre`, `@ruby`, `@rt`, `@rp`, `@picture`, `@video`, `@audio`, `@iframe`, `@object`, `@map`, `@canvas`, `@script` |
823+| void | `@image`, `@input`, `@hr`, `@br`, `@wbr`, `@col`, `@source`, `@track`, `@embed`, `@area` |
824+
825+`@fragment`, `@children` and `@slot` have no element of their own: what
826+they hold takes the layout of the element they are in. The top of a page is
827+a column, `<body>` (see [Page and head](#page-and-head)). In a native
828+element, and at the top of a fragment, lines of text are separated by a line break,
829+which HTML shows as a space except where whitespace is kept, as in `@pre`
830+and `@textarea`.
831+
832+The text-level elements (`@strong`, `@em`, `@b`, `@i`, `@small`, `@sub`,
833+`@q`, `@del`, ...) are text with the browser's own style and no CSS of
834+htmlang's, so they work inside a line like any inline element. Table parts,
835+`@optgroup` and `@ruby` keep HTML's own layout:
776836
777837```
778−htmlang init # scaffold a new project
779−htmlang init my-site # scaffold in a new directory
780−htmlang new about-us # create about-us.hl from template
781−htmlang page.hl # compile page.hl → page.html
782−htmlang site/ # compile all .hl files in directory
783−htmlang --watch page.hl # compile and watch for changes
784−htmlang -w page.hl # short form
785−htmlang --serve site/ # serve a multi-page site with hot reload
786−htmlang -s --open site/ # serve and open browser
787−htmlang check page.hl # check for errors without writing output
788−htmlang check src/ # check all .hl files in a directory
789−htmlang convert page.html # convert HTML to .hl format (stdout)
790−htmlang build src/ -o dist/ # parallel compile + copy static assets
791−htmlang --compat page.hl # compile with vendor prefixes
792−htmlang --format json page.hl # output diagnostics as JSON
793−htmlang lint --format json src/ # lint with JSON output
794−htmlang dead-code --format json src/ # dead code report as JSON
795−htmlang lint page.hl # stricter lint checks (accessibility, nesting)
796−htmlang lint src/ # lint all .hl files in a directory
797−htmlang stats page.hl # show file statistics (elements, CSS, colors)
798−htmlang repl # interactive REPL (type .hl, get HTML)
799−htmlang feed src/ # generate RSS feed from @page metadata
800−htmlang feed src/ -b https://mysite.com # with custom base URL
801−htmlang components src/ # list all @fn definitions across project
802−htmlang deps src/ # show dependency graph (@include/@import)
803−htmlang dead-code src/ # find unused @fn, @define, @let across project
804−htmlang deploy src/ # build and deploy to GitHub Pages
805−htmlang playground # generate self-contained HTML playground
806−htmlang clean src/ # remove generated .html files
807−htmlang outline page.hl # show document structure tree
838+@paragraph
839+ {@strong Note:} water is H{@sub 2}O,{@br}
840+ and {@em this} is {@small fine print}.
841+@table
842+ @caption Team
843+ @thead
844+ @tr
845+ @th Name
846+ @tbody
847+ @tr
848+ @td Ada
849+ @tfoot
850+ @tr
851+ @td One person
852+@select [aria-label=Fruit]
853+ @optgroup Citrus
854+ @option Lemon
855+```
856+
857+The list of elements is fixed: a name that isn't in it is an unknown
858+element, not a new HTML tag, since a function is called with the same `@`
859+and a misspelled one would otherwise become a tag. HTML's `<div>`, `<span>`,
860+`<p>`, `<a>` and `<img>` are htmlang's own `@el`, `@text`, `@paragraph`,
861+`@link` and `@image`, so `@a` is an unknown element whose error suggests
862+`@link`. The head's elements (`<meta>`, `<style>`, `<title>`, `<link>`) are
863+written with [directives](#page-and-head). `<data>` and `<slot>` share their
864+names with `@data` and `@slot`, and `<template>` holds content that only a
865+script uses; these, and `<svg>` and `<math>`, are written with `@raw`.
866+
867+An element without a closing tag (`@input`, `@hr`, `@br`, `@image`,
868+`@source`, ...) takes no content.
869+
870+Browser default margins on headings, paragraphs, lists and figures are reset
871+to 0 (in the element's own class, so Markdown and raw HTML keep theirs; see
872+[CSS](#css)), so `spacing` controls the gaps. Lists (`@ul`, `@ol`, `@menu`) and list
873+items are columns like other containers, so `spacing` on a list is the gap between its items, and
874+a list shows no markers. To bring them back, write
875+`[list-style disc, padding-inline-start 20, children:display list-item]` on
876+the list, and `marker:` styles the markers themselves (`::marker`).
877+
878+```
879+@ol [spacing 4]
880+ @li First
881+ @li Second
882+@ul [list-style disc, padding-inline-start 20, children:display list-item, children:marker:color #2563eb]
883+ @li [first-child:font-weight 700] Markers
884+ @li Back
885+@form [method=post, spacing 8] /subscribe
886+ @label [for=email] Email
887+ @input [type=email, name=email, id=email, required]
888+ @button [type=submit] Send
889+@details [open]
890+ @summary Question
891+ @text The answer.
892+```
893+
894+### The leading argument
895+
896+An element that points at a URL or a file takes it as the first word after
897+its attributes, its leading argument, which fills one HTML attribute:
898+
899+| Element | Leading argument |
900+|---|---|
901+| `@link`, `@area` | `href` |
902+| `@image`, `@script`, `@iframe`, `@video`, `@audio`, `@track`, `@embed` | `src` |
903+| `@source` | `srcset` directly inside `@picture`, `src` inside `@video` and `@audio` |
904+| `@form` | `action` |
905+| `@object` | `data` |
906+| `@optgroup` | `label` |
907+
908+The first word is taken before any `$name` in it is filled in, and it ends
909+at the first space that isn't inside `"..."` or `${...}`: `@link $url More`
910+and `@link /page/${$n + 1} Next` take the whole `$url` and
911+`/page/${$n + 1}`, and `@link "/my page" Open` takes `/my page`. After it,
912+the rest of the text is the element's content, read like any text; an
913+element without content (`@image`, `@source`, `@track`, `@embed`, `@area`)
914+takes nothing more, and a word after its argument is an error (write a
915+text alternative as `alt=...`). So is a word after an `@optgroup`'s label,
916+since it holds only the `@option` lines under it. A value with a space in
917+it is quoted, as such a label: `@optgroup "Citrus fruits"`. The first word is
918+always the argument, so on an element whose attribute is often left out
919+(`@form`, `@video`, `@audio`), text goes on the lines under it: `@form Sign
920+in` would make `Sign` the form's action. A first word that reads as text,
921+a capitalized word with nothing of a URL in it, is taken with a warning
922+(`text-as-url`): `@link [hx-get=/panel] Open panel` makes `Open` the link's
923+`href`. A relative URL like that one is written `./Open`.
924+
925+The attribute can be written as an attribute instead, and it means the same:
926+`@link [href=/about]` with its text on the lines under it, or
927+`@image [src=logo.svg, inline]`. Both at once, as in
928+`@link [href=/a] About`, is an error that says which word was taken as the
929+`href`.
930+
931+```
932+@nav [flex-direction row, spacing 16]
933+ @link /docs The docs
934+ @link [aria-current=page] /pricing Pricing
935+@image [alt=The team, width 320] "team photo.jpg"
936+@picture
937+ @source [type=image/avif] hero.avif
938+ @image [alt=The office] hero.jpg
939+@video [controls] intro.mp4
940+ @track [kind=captions, srclang=en] intro.vtt
941+ Your browser doesn't play this video.
942+```
943+
944+`@script` is an element like the others: its leading argument is its `src`,
945+any HTML attribute passes through (`@script [type=module, defer] app.js`),
946+and without a `src` its indented body is its JavaScript, kept exactly as
947+written. A `src` and a body together are an error, since the browser runs
948+only the file. It isn't shown, so a style on it is an error.
949+
950+```
951+@script [defer] analytics.js
952+@script
953+ document.body.dataset.ready = "yes"
808954```
809955
810−Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
811−
812−The `build` command automatically extracts shared CSS rules across pages into a `shared.css` file when outputting to a directory.
813−
814−## Compilation target
815−
816−Each `.hl` file compiles to a single self-contained `.html` file:
817−
818−- Elements map to `<div>`, `<span>`, `<p>`, `<a>`, `<img>` as appropriate
819−- Layout uses flexbox (`display: flex`, `flex-direction`, `gap`, etc.)
820−- Styles are scoped via generated class names in an embedded `<style>` block
821−- No external CSS, no JavaScript (unless injected via `@raw`)
822−- `@page` generates the HTML boilerplate; without it, output is an HTML fragment
823−
824−## Template inheritance (`@extends`)
956+### HTML attributes
825957
826−A page can inherit a base layout and fill named `@slot` blocks:
958+HTML attributes are written `key=value`: `id=main`, `class=note`,
959+`href=/about`, `type=email`, `alt=Logo`, `target=_blank`,
960+`aria-label=Close menu`, `data-id=42`, `hx-get=/items`. Any name works,
961+one with a colon in it included (`xml:lang=en`, `hx-on:click=open()`):
962+before an `=` a colon is part of the name, not a prefix. Boolean attributes
963+are written bare: `required`, `disabled`, `checked`, `hidden`, `open`,
964+`popover`, and so are `data-` and `hx-` attributes that have no value
965+(`hx-preserve`). An HTML attribute written like a style (`type email`,
966+`hx-get /items`) is an error that shows the `key=value` form. A style
967+and an HTML attribute can share a name, because the `=` tells them apart:
827968
828−**layout.hl:**
829969```
830−@page My Site
831−@column [max-width 800, center-x, padding 40]
832− @slot header
833− @text [bold, size 32] Default Header
834− @slot content
835− @slot footer
836− @text [color #888] Default Footer
837−```
838−
839−**about.hl:**
840−```
841−@extends layout.hl
842−@slot header
843− @text [bold, size 32] About Us
844−@slot content
845− @paragraph We are a team of...
970+@image [width=800, width 200, alt=A photo] photo.jpg
971+@label
972+ Size
973+ @select [size=4, font-size 18]
974+ @option One
846975```
847976
848−Slots not filled by the extending page use the default children from the layout.
849−
850−## Design tokens (`@theme`)
977+## Definitions
851978
852−Centralized design token definitions. Each token becomes both a `$variable` and a `--css-custom-property`:
979+`@let` defines everything reusable. What it defines depends on its form:
853980
854981```
855−@theme
856− primary #3b82f6
857− secondary #10b981
858− spacing-sm 8
859− spacing-md 16
860− font-body system-ui, sans-serif
861−```
862−
863−Equivalent to:
864−```
982+-- A value, used as $primary
865983@let primary #3b82f6
866−@let --primary #3b82f6
867−@let secondary #10b981
868−@let --secondary #10b981
869−...
870−```
871−
872−## Deprecation (`@deprecated`)
873−
874−Mark a function as deprecated. Callers receive a compile-time warning:
984+-- A computed value: `=` makes the rest an expression
985+@let gap = 8 * 2
986+-- Quoted text, with $variables filled in (see Variables for its quotes)
987+@let greeting "Hello from $primary"
988+-- An attribute bundle, used as [$card]
989+@let card [padding 20, background white, border-radius 8]
990+-- A function: `@` before its name, its parameters in brackets, and a body
991+@let @panel [title]
992+ @el [$card, spacing $gap]
993+ @text [font-weight bold, color $primary] $title
994+ @children
875995
876−```
877−@deprecated Use @card-v2 instead
878−@fn old-card $title
879− @el [padding 20]
880− @text $title
996+@panel [title Welcome]
997+ $greeting
998+@el [$card, background #f9fafb]
999+ Attributes after a bundle override it.
1000+```
1001+
1002+The line alone says what a `@let` defines: an `@` before the name makes a
1003+function, a `[` after the name an attribute bundle, and anything else a
1004+value. A value or a bundle is named without `$` and used with it; a function
1005+is named with `@` and called with it. Only a function has a body, so an
1006+indented block under a value or a bundle is an error, and so are `@let $x`
1007+(write `@let x`) and a `@let` without a value.
1008+
1009+Values, bundles and functions share one namespace, and one rule says where
1010+a name is visible: **from the line that defines it to the end of its
1011+block**. A block is the file, the lines indented under an element, a branch
1012+of `@if`, one repetition of `@each` or a function's body. A `@let` gives its
1013+name a new meaning in its block, whatever the name meant before, and hides
1014+what the name means around the block until the block ends:
1015+
1016+```
1017+@let gap 8
1018+@el [spacing $gap]
1019+ @let gap 16
1020+ @el [padding $gap] Sixteen
1021+@el [padding $gap] Eight
1022+```
1023+
1024+Parameters, `@each` variables and `@data` names follow the same rule, and
1025+each repetition of `@each` starts afresh, so a `@let` in one doesn't carry
1026+over to the next. A function's body sees its parameters and what is
1027+visible where the function is defined: not the names at a call, and not a
1028+definition further down, so a function means the same wherever it is
1029+called. `@let t.greeting Hello` gives the record `$t` the field `greeting`
1030+(making `$t` a record when it isn't defined), and `@let items.0 first`
1031+replaces the first item of the list `$items`; a field of any other value is
1032+an error.
1033+
1034+### Variables
1035+
1036+`$name` inserts a value wherever htmlang holds text: a line of text, an
1037+attribute's value, an element's argument, a `@let` value, a file path, the
1038+title of `@page` and the value of `@meta`. Each line is read first, and then
1039+`$name` fills the one place it is written in. What it inserts is never read
1040+again as htmlang, so a value can't become an attribute, an attribute's name,
1041+a comma between attributes, an inline element or another `$name`. Attributes
1042+come from a bundle:
1043+
1044+```
1045+@let link-style [color #2563eb, text-decoration underline]
1046+@let label Docs
1047+@link [$link-style, aria-label=$label] /docs Read the $label
1048+```
1049+
1050+A name starts with a letter or `_` and goes on with letters, digits, `_`
1051+and `-`. It ends at the first other character, so after `@let lang fr`,
1052+`$lang.json` is `fr.json`. A `.field` continues the name only when the value
1053+is a record or a list (`$post.title`, `$tags.0`). `${name}` is the same name
1054+with explicit ends, for text that follows it directly, and `${EXPR}` inserts
1055+the value of an [expression](#expressions). A `$` followed by anything else
1056+is text.
1057+
1058+```
1059+@let lang fr
1060+@let size 4
1061+@data $post {"title": "Hello"}
1062+@text locales/$lang.json, ${size}px, $post.title, $5
1063+```
1064+
1065+A name that isn't defined is an error. A field that a record doesn't have
1066+is empty, and so is any field of it, so optional fields of `@data` records
1067+work: `@if $post.draft` is false, `$post.author.name` is empty when there is
1068+no `author`, and `${default($post.tag, none)}` gives `none`.
1069+
1070+### Values
1071+
1072+A value is text, a number, `true` or `false`, a list or a record, and a
1073+name holds a whole value: a `@let`, a parameter, `@each` and `@data` bind
1074+one, and binding the name again replaces it. What a `@let` holds is what it
1075+says:
1076+
1077+```
1078+-- Text, as written
1079+@let size 16px
1080+-- Commas make a list, which prints as it was written
1081+@let fruits apple, banana, cherry
1082+-- Quoted, or with `\,`, text with commas in it is one text
1083+@let tagline "Fast, simple"
1084+@let motto small\, fast
1085+-- A range of whole numbers
1086+@let steps 1..9 step 4
1087+-- One `$name` or `${...}` keeps the type of what it holds
1088+@data $posts [{"title": "Hello"}, {"title": "Again"}]
1089+@let first $posts.0
1090+@let backwards ${reverse($fruits)}
1091+@text [font-size $size] $fruits: ${length($fruits)}. $tagline: ${length($tagline)}. $motto. $steps
1092+@text $first.title, $backwards
1093+```
1094+
1095+The last lines give `apple, banana, cherry: 3. Fast, simple: 12. small,
1096+fast. 1..9 step 4` and `Hello, cherry, banana, apple`. A value that is
1097+exactly one `$name` or `${...}` keeps its type wherever it goes: a `@let`, a
1098+function's parameter or default, the list of `@each`. So a record can be
1099+passed to a function whole (`@post-card [post $p]`), and a list stays a
1100+list.
1101+
1102+- Text written without quotes reads as a number when it is one (`3`,
1103+ `0.5`), and as false when it is `false` or `0`: that is how htmlang writes
1104+ numbers and flags (`@let gap 8`, `@card [dot false]`). Quoted text is
1105+ always text.
1106+- **False** is `false`, empty text, `0` and an empty list (or record).
1107+ Everything else is true, including `no` and quoted `"0"`.
1108+- `==` and `!=` compare numbers as numbers (`1.0 == 1`), lists and records
1109+ item by item, and anything else as text, exactly (`#FFF` isn't `#fff`).
1110+ `<`, `>`, `<=` and `>=` compare numbers as numbers and text by character
1111+ order.
1112+- A computed number prints without float noise: whole numbers without
1113+ decimals, others with at most four (`${100 / 3}` is `33.3333`).
1114+- A list prints as written when the source wrote it, and otherwise as its
1115+ items joined with `, `. A record has no text of its own, so writing one
1116+ (or a list of records) where text goes is an error: write one of its
1117+ fields, or loop over the list with `@each`.
1118+- A `$list` in a comma list is one item, a list inside the list:
1119+ `@let more $items, d` has two items. htmlang has no list concatenation.
1120+
1121+**Quoted text remembers that it was quoted.** A value written `"..."` (in a
1122+`@let`, an attribute or a function's argument) keeps its quotes in a CSS
1123+value, where CSS needs them, and loses them in text, in HTML attribute
1124+values and in expressions (`${length($x)}` counts no quotes). It stays
1125+quoted when it passes through another `@let` or a parameter. Inside a quoted string of a CSS value, it inserts
1126+what it says, without its own quotes:
1127+
1128+```
1129+@let arrow "→ "
1130+@let label "Next, please"
1131+@el [before:content $arrow, after:content " ($label)", aria-label=$label] $label
1132+```
1133+
1134+This gives `content:"→ "`, `content:" (Next, please)"`,
1135+`aria-label="Next, please"` and the text `Next, please`. A value written
1136+without quotes is inserted as written everywhere.
1137+
1138+### Functions
1139+
1140+A function's parameters are listed in brackets after its name and written
1141+the way a call passes them: a name alone (`title`) is a required parameter,
1142+and a name and a value (`tone #f9fafb`) is a parameter with that default.
1143+The body uses them as `$title` and `$tone`. `@let @name` without brackets
1144+takes no parameters.
1145+
1146+A default is filled in at each call that leaves its parameter out, like a
1147+value passed for it: it may hold spaces, quotes, escapes, `${...}` and
1148+`$variables`. It sees what the body sees: the names visible where the
1149+function is defined and the parameters declared before it
1150+(`[title, heading "About $title"]`). A default that uses a parameter
1151+declared after it is an error.
1152+
1153+A function is called like an element, wherever an element can be: on a
1154+line of its own, in a [chain](#chains) (`@el > @card [title Hi]`) or
1155+inline in text (`{@key Ctrl+K}`):
1156+
1157+- Its parameters are passed by name, as attributes: `name value`, or the
1158+ name alone for `true`. A parameter with a default can be left out;
1159+ leaving out one without a default is an error that names it. `=` writes
1160+ an HTML attribute, so `title=Hi` for a parameter is an error too.
1161+- Every other attribute goes to the function's root element: a style, a
1162+ flag, or a `key=value` HTML attribute such as `id=intro`. It is checked
1163+ like an attribute written on that element, so `@panel [title Hi,
1164+ padding 40]` works the same as styling a built-in element, and
1165+ `[paddin 40]` gets the same warning. A name close to one of the
1166+ parameters (`titel Hi`) is a misspelled parameter, which is an error.
1167+ This needs the body to have a single root element: attributes on a call
1168+ whose body has several roots, or is text, are an error. A `class=` adds
1169+ to the root's own classes.
1170+- A parameter passed twice (in the call's list, or after a bundle that
1171+ passes it) takes the later value, as any attribute does.
1172+- Text after the attributes and the indented lines are the call's
1173+ content, and replace `@children` in the body. A `@slot NAME` block
1174+ directly under the call (or under an `@if`, `@else` or `@each` there)
1175+ fills the body's `@slot NAME` instead.
1176+- The lines indented under `@children` or `@slot NAME` in the body are its
1177+ fallback: they are shown when a call passes no content, or no block for
1178+ that slot.
1179+
1180+```
1181+@let @card [title, tone #f9fafb]
1182+ @article [padding 16, spacing 8, background $tone, border-radius 8]
1183+ @row [align-items center]
1184+ @h3 $title
1185+ @spacer
1186+ @slot actions
8811187 @children
1188+ @slot footer
1189+ @text [font-size 12, color gray] No footer
1190+
1191+@card [title Plain]
1192+ Just children.
1193+@card [title Full, tone #eff6ff, padding 24]
1194+ @slot actions
1195+ @link /edit Edit
1196+ The children go here.
1197+ @slot footer
1198+ @text Updated today
8821199```
8831200
884−When `@old-card` is called, the compiler emits: `warning: @old-card is deprecated: Use @card-v2 instead`
885−
886−## Color functions
887−
888−Color manipulation via variable filters:
1201+`@children` takes a fallback the same way:
8891202
8901203```
891−@let primary #3b82f6
1204+@let @notice
1205+ @el [padding 12, background #fef3c7]
1206+ @children
1207+ @text Nothing to report.
1208+
1209+@notice
1210+@notice Deploys are paused today.
1211+```
1212+
1213+Content that would go nowhere is an error, so a mistake can't drop it:
1214+
1215+- a `@slot NAME` block for a slot the function doesn't have (the message
1216+ lists the slots it has);
1217+- text or lines passed to a function whose body has no `@children`;
1218+- a `@slot NAME` block inside an element at the call, instead of directly
1219+ under the call;
1220+- `@slot` or `@children` outside a function's body, or inline in text;
1221+- `@children` or `@slot` after an element's attributes (`@td [padding 8]
1222+ @children`), where it would be text: it goes on a line of its own.
1223+
1224+A slot's name is one word of letters, digits, `-` and `_`, starting with a
1225+letter, like a function's. Inside a body, a call can pass on what the
1226+function got: `@children` under the call passes the content, and a
1227+`@slot footer` block holding `@slot footer` passes that slot.
1228+
1229+A parameter that is off unless the call names it is a flag: give it the
1230+default `false`, and name it alone to turn it on.
1231+
1232+```
1233+@let @post-card [post, featured false, label "About $post"]
1234+ @article [spacing 8, padding ${if($featured, 24, 0)}]
1235+ @if $featured
1236+ @text [font-weight bold] Featured
1237+ @h3 $label
1238+
1239+@post-card [post htmlang, featured]
1240+@post-card [post CSS]
1241+```
1242+
1243+An `@style` block at the top of a function body is **scoped** to the
1244+function. Its rules apply inside the function's root element, and `&` is the
1245+root itself. This requires the body to have a single root element. The
1246+stylesheet goes into the page once, when the function is first called, so
1247+a function that is never called adds nothing. It is in the layer
1248+`hl-inside`, with the styles of element prefixes (see [CSS](#css)): what an
1249+element says about itself wins over it, so a call's `[padding 20]` wins
1250+over the function's `& { padding: 12px; }`, as it would over the root's own
1251+defaults. Where CSS properties are all it needs, an element prefix on the
1252+root says the same without a stylesheet (`@aside [@h2:font-size 18]`).
1253+Only an `@style` at the top of the body is scoped: one under an `@if` or an
1254+element is the page's, where `&` is the whole page, so the compiler warns
1255+about a `&` there.
1256+
1257+```
1258+@let @note [kind Note]
1259+ @style
1260+ & { border-left: 4px solid #3b82f6; }
1261+ .title { font-weight: bold; }
1262+ @el [padding 12, border-radius 6, background #eff6ff]
1263+ @text [class=title] $kind
1264+ @children
8921265
893−@el [background $primary|lighten:20] -- 20% lighter
894−@el [background $primary|darken:10] -- 10% darker
895−@el [background $primary|alpha:0.5] -- 50% transparent (#3b82f67f)
896−@el [background $primary|mix:#ffffff:50] -- 50% mixed with white
1266+@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
8971267```
8981268
899−Filters: `lighten:N` (0-100), `darken:N` (0-100), `alpha:N` (0-1), `mix:COLOR:N` (0-100).
900−
901−## Enhanced `@keyframes`
902−
903−Keyframes support htmlang attribute syntax:
1269+A call in text or in a chain whose body has several roots, or is text,
1270+puts them in its place, like `@fragment`. A function whose body is text
1271+holds multi-line content, and a small function works inside a sentence:
9041272
9051273```
906−@keyframes fade-in
907− from [opacity 0]
908− to [opacity 1]
1274+@let @intro
1275+ htmlang is a layout language.
1276+ It compiles to {@strong static HTML}.
1277+@let @key
1278+ @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
1279+ @children
9091280
910−@keyframes slide
911− 0% [transform translateX(-100%)]
912− 50% [transform translateX(0), opacity 1]
913− 100% [transform translateX(100%), opacity 0]
1281+@paragraph
1282+ @intro
1283+@paragraph
1284+ In short: {@intro} Press {@key Ctrl+K} to search.
9141285```
9151286
916−Raw CSS syntax is also supported for backwards compatibility.
917−
918−## Additional attributes
919−
920−| Attribute | Effect |
921−|-----------|--------|
922−| `autofocus` | Auto-focus element on page load (boolean) |
923−
924−## Named grid areas
925−
926−Use CSS Grid's named template areas with the `grid-template-areas` and `grid-area` attributes:
1287+A function may call itself, under a condition that stops it, so it can
1288+render nested data such as a menu. A parameter passed one `$name` that
1289+holds a record or a list gets the whole value, fields and items
1290+included. Calls nest at most 64 deep: a function that calls itself with
1291+nothing to stop it is an error. Since a body sees only what is defined
1292+above its function, two functions can't call each other.
9271293
9281294```
929−@el [grid, grid-template-areas "header header" "sidebar main" "footer footer", gap 20]
930− @el [grid-area header, padding 10, background #3b82f6]
931− Header
932− @el [grid-area sidebar, padding 10]
933− Sidebar
934− @el [grid-area main, padding 10]
935− Main content
936− @el [grid-area footer, padding 10]
937− Footer
1295+@data $menu [
1296+ {"label": "Guide", "children": [{"label": "Install"}, {"label": "Usage"}]},
1297+ {"label": "Reference"}
1298+]
1299+@let @tree [items]
1300+ @ul [padding-left 16]
1301+ @each $item in $items
1302+ @li
1303+ @text $item.label
1304+ @if $item.children
1305+ @tree [items $item.children]
1306+
1307+@tree [items $menu]
1308+```
1309+
1310+The elements a function's body writes are the call's: a problem with
1311+one of them, such as low contrast or `spacing` on an element that isn't a
1312+row, column or grid, is reported at the call, once, and names the function. A
1313+problem in the text of a body is reported on its own line, or at the call
1314+when the function comes from `std.hl` or another file.
1315+
1316+Naming a function after a built-in element or a directive is a warning:
1317+`@let @button` replaces `@button` in every call after it, and a function
1318+named `@if` could never be called. Inside its own body `@button` is the
1319+function too, so a function that wraps the element it is named after,
1320+outside an `@if`, is an error: give it another name.
1321+
1322+### CSS custom properties
1323+
1324+`@let --name value` declares the CSS custom property `--name` on `:root`,
1325+for the whole page. Its value is written as it is, with no pixels added,
1326+so a length takes its unit (`@let --radius 8px`). A token is read one way,
1327+`var(--name)`, in the browser, so an element (`[dark:--name value]`, see
1328+[Custom properties](#custom-properties)) or a stylesheet can still
1329+redefine it. It is not a variable: `$--name` is an error that says to
1330+write `var(--name)`. Since `:root` is one place for the whole page, a
1331+`@let --name` goes at the top level of the file, once: inside an element,
1332+an `@if`, a loop or a function's body it is an error (set the property on an
1333+element with `[--name value]` instead), and declaring it again warns.
1334+
1335+A colour variant of a token is CSS too, so it follows the token wherever
1336+it is redefined: `color-mix()` mixes two colours, and a relative colour
1337+(`rgb(from ...)`) keeps a colour's channels except those you write (`/ 0.5`
1338+for its alpha). `in srgb` mixes the red, green and blue channels, as most
1339+colour tools do; `in oklab` gives steps that look more even, a good choice
1340+for new tokens.
1341+
1342+| For | Write |
1343+|---|---|
1344+| A darker colour | `color-mix(in srgb, var(--brand), black 10%)` |
1345+| A lighter colour | `color-mix(in srgb, var(--brand), white 10%)` |
1346+| A transparent colour | `rgb(from var(--brand) r g b / 0.5)` |
1347+| Two colours mixed | `color-mix(in srgb, var(--brand), #f43f5e 50%)` |
1348+
1349+```
1350+@let --brand #3b82f6
1351+@let --radius 8px
1352+@page [dark:--brand #60a5fa] Themed
1353+@el [background var(--brand), border-radius var(--radius), border 1 solid color-mix(in srgb, var(--brand), black 10%)] Themed
1354+@row
1355+ @each $shade in 0, 10, 20, 30
1356+ @el [background color-mix(in srgb, var(--brand), black $shade%)] $shade%
1357+```
1358+
1359+## Expressions
1360+
1361+Conditions and computed values (`@let x = ...`) are expressions:
1362+
1363+| | |
1364+|---|---|
1365+| Values | numbers, `"strings"` (with `$var` interpolation), `$variables` (with their type: a list or a record too), `$record.field`, `true`, `false`, ranges `A..B` and `A..B step N`, and bare words (`dark`, `#fff`) as text |
1366+| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
1367+| Comparison | `== != < > <= >=`: numbers as numbers, text exactly, lists and records item by item (see [Values](#values)) |
1368+| Logic | `and`, `or`, `not`. `false`, empty text, `0` and an empty list are false |
1369+| Choice | `if(CONDITION, A, B)`, where `B` may be left out (empty) |
1370+| Tests | `contains(s, x)` (in a text, or as an item of a list), `starts-with(s, x)`, `ends-with(s, x)` |
1371+| Text | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)` (the items of a list, or the characters of a text), `reverse(s)` (a list's items, or a text's characters), `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` (the fallback when `s` is empty text or an empty list) |
1372+
1373+Colour maths is CSS's, not an expression's: see
1374+[CSS custom properties](#css-custom-properties). A CSS function such as
1375+`color-mix()` goes in an attribute's value as it is, or in a plain
1376+`@let` value (`@let hover color-mix(in srgb, var(--brand), black 8%)`),
1377+not in `@let x = ...` or `${...}`.
1378+
1379+Variables are looked up during evaluation, so a value that contains `==` or
1380+spaces is still one value. An invalid expression is a compile error, and so
1381+is an undefined variable. A text function given a list is an error too,
1382+instead of a guess: `uppercase()` of `Fast, simple` (a list, because of its
1383+comma) says so, and the text is written `"Fast, simple"` or `Fast\, simple`.
1384+A variable's name ends at `..`, so `${$from..$to}` is a range.
1385+
1386+Only what decides the result is evaluated: `if()` evaluates the branch it
1387+takes, and `and` and `or` stop at the side that decides. So
1388+`${if($n != 0, 10 / $n, 0)}` and `@if $n != 0 and 10 / $n > 1` work when
1389+`$n` is 0. The rest is still read, and its syntax and function names are
1390+checked.
1391+
1392+In text, values and file paths, `${EXPR}` inserts the value of any
1393+expression (see [Variables](#variables)):
1394+
1395+```
1396+@let name htmlang
1397+@let size 12
1398+@text [font-size ${$size + 4}] ${uppercase($name)} has ${length($name)} letters
1399+```
1400+
1401+## Control flow
1402+
1403+All control flow runs at compile time.
1404+
1405+```
1406+@let count 3
1407+@let hidden false
1408+
1409+@if $count > 2 and not $hidden
1410+ @text Many
1411+@else if $count == 0
1412+ @text None
1413+@else
1414+ @text Few
9381415```
9391416
940−## Animate shorthand
941−
942−The `animate` attribute is shorthand for the CSS `animation` property:
1417+`@each $item in LIST` repeats its body for each item. Its variables are
1418+written with `$`, like `@data $name`. LIST is a value, read like the value
1419+of a `@let`: items written with commas (a quoted item or one with `\,` is
1420+one text), a range `A..B` (which counts down when its start is greater than
1421+its end, and takes `step N`), or one `$name` or `${...}` that holds a list,
1422+such as one loaded with `@data` or `${reverse(1..5)}`. A text is one item,
1423+and empty text none. Each item is bound whole, so a record keeps its fields.
1424+An optional second variable is the index, starting from 0. `@else` gives
1425+the content to show when the list is empty.
9431426
9441427```
945−@keyframes fade-in
946− from [opacity 0]
947− to [opacity 1]
1428+@let fruits apple, banana, cherry
1429+@each $fruit, $i in $fruits
1430+ @text ${$i + 1}. $fruit
1431+@else
1432+ @text No fruit.
9481433
949−@el [animate fade-in 0.3s ease]
950− Fades in on load
1434+@row [spacing 8]
1435+ @each $n in 10..0 step 5
1436+ @text $n
9511437```
9521438
953−## View transitions
954−
955−The `view-transition-name` attribute enables the View Transitions API for smooth page transitions:
1439+## Data and files
9561440
957−```
958−@el [view-transition-name hero]
959− Hero content that transitions between pages
960−```
1441+| Directive | Effect |
1442+|---|---|
1443+| `@data $name file.json` | Load a JSON file. Objects are records (`$name.key`) and arrays are lists |
1444+| `@data $name [...]` / `@data $name {...}` | Inline JSON, which may span several lines |
1445+| `@data $name dir/*.json` | A list with one record per file, in name order. `$item.file` is the file's name |
1446+| `@data $name env:NAME DEFAULT` | An environment variable, with an optional default |
1447+| `@include file.hl` | Insert another file: its content and its definitions. A file that holds only definitions outputs nothing (a [library](#page-layouts)) |
1448+| `@markdown` / `@markdown file.md` | Markdown (an indented body or a file), converted to HTML |
1449+| `@image [inline] file.svg` | Put the file inside the page: SVG as markup, other images as base64. The file is read next to the page, and one that can't be read is an error. The SVG's root gets `width` and `height` (lengths, not `fill` or `shrink`) and `color` or `fill` (as its `fill`) in its `style=`, after the file's own, where `var(--name)` works, and the HTML attributes (`class=`, `aria-label=`, ...); any other style, and `alt=`, is an error, since the markup has no generated class, and an element prefix (`@image:`) doesn't reach it. `inline` goes only on `@image`, without a prefix |
9611450
962−## `:has()` pseudo-selector
963−
964−Style elements based on their children using the `has()` prefix:
1451+Records keep their values whole, even when a value contains spaces or
1452+commas:
9651453
9661454```
967−@el [padding 20, has(.active):background #dbeafe, has(img):padding 0]
968− Content
1455+@data $team [
1456+ {"name": "Ada", "role": "Engineering, research"},
1457+ {"name": "Grace", "role": "Design"}
1458+]
1459+@ul [spacing 4]
1460+ @each $person in $team
1461+ @li
1462+ @text [font-weight bold] $person.name
1463+ @text $person.role
1464+@text ${length($team)} people
9691465```
9701466
971−Generates CSS `:has()` selectors: `.class:has(.active) { background:#dbeafe; }`
972−
973−## Computed `@let`
1467+### Page layouts
9741468
975−Variables support arithmetic expressions. The `=` sign is optional:
1469+A layout is an ordinary function in a file of its own. It marks where content
1470+goes with `@slot NAME` (named blocks) and `@children` (everything in the call
1471+that is outside a `@slot` block), each with fallback content indented under
1472+it.
9761473
9771474```
978−@let base 16
979−@let large = $base * 2 -- 32
980−@let gap $base + 4 -- 20 (= is optional)
1475+-- layout.hl
1476+@let @layout
1477+ @page [lang=en, background #f8fafc] My Site
1478+ @el [width fill, max-width 800, center-x, spacing 24]
1479+ @header
1480+ @slot header
1481+ @text Default header
1482+ @main
1483+ @children
9811484```
9821485
983−Supported operators: `+`, `-`, `*`, `/`.
984−
985−## Named slots in `@fn`
986−
987−Functions support named `@slot` blocks for multi-region components:
988−
9891486```
990−@fn layout $title
991− @column [max-width 800, center-x, padding 40]
992− @slot header
993− @text [bold, size 32] $title
994− @slot content
995− @slot footer
996− @text [color #888] Default Footer
997−
998−@layout [title My Page]
1487+-- page.hl
1488+@include layout.hl
1489+@layout
9991490 @slot header
1000− @text [bold, size 32] Custom Header
1001− @slot content
1002− @paragraph Main content here
1003−```
1004−
1005−Slots not filled by the caller use the default children from the function definition.
1006−
1007−## New elements
1008−
1009−### `@script`
1010−
1011−Embeds JavaScript. Supports inline code and external files via `src`. Children are raw JS (not HTML-escaped).
1012−
1013−```
1014−@script [src app.js, defer]
1015−
1016−@script
1017− console.log("hello world");
1018− document.addEventListener("DOMContentLoaded", () => {
1019− init();
1020− });
1021−```
1022−
1023−Attributes: `src`, `type`, `defer`, `async`, `crossorigin`, `integrity`, `nomodule`.
1024−
1025−### `@noscript`
1026−
1027−Fallback content displayed when JavaScript is disabled.
1028−
1029−```
1030−@noscript
1031− @text This page requires JavaScript.
1032−```
1033−
1034−### `@address`
1035−
1036−Contact information, rendered as `<address>`.
1037−
1038−```
1039−@address
1040− @text [bold] John Doe
1041− @link mailto:john@example.com john@example.com
1042−```
1043−
1044−### `@search`
1045−
1046−HTML5 `<search>` landmark for search functionality.
1047−
1048−```
1049−@search
1050− @input [type search, placeholder Search...]
1051− @button [type submit] Search
1052−```
1053−
1054−### `@breadcrumb`
1055−
1056−Semantic breadcrumb navigation. Generates `<nav aria-label="breadcrumb">` with an `<ol>` list. Each child becomes an `<li>`.
1057−
1058−```
1059−@breadcrumb [spacing 10]
1060− @link / Home
1061− @link /docs Documentation
1062− @text Current Page
1063−```
1064−
1065−## New directives
1066−
1067−### `@canonical`
1068−
1069−Sets the canonical URL. Generates `<link rel="canonical">` in the document head.
1070−
1071−```
1072−@canonical https://example.com/my-page
1073−```
1074−
1075−### `@base`
1076−
1077−Sets the base URL for relative links. Generates `<base href="...">`.
1078−
1079−```
1080−@base https://example.com/
1491+ @h1 About us
1492+ @paragraph This fills @children.
1493+```
1494+
1495+The layout holds the page's `@page`, whose attributes can come from its
1496+parameters (`@page [background $bg] $title`). A page has one `@page`, so a
1497+second one is an error (`duplicate-page`): the page's own next to its
1498+layout's, or a layout called twice. The same `@meta` tag twice is written
1499+once. A file that holds only `@let`s, such as `layout.hl`, is a library
1500+(it may also read `@data` and `@include` other libraries): its definitions
1501+aren't reported unused, in it or in a page that includes it and uses only
1502+some of them, and `htmlang build`, `serve` and `watch` don't build it into
1503+a page of its own. An included file's unused definitions are never
1504+reported in the file that includes it.
1505+
1506+## Page and head
1507+
1508+`@page TITLE` makes the output a full HTML document. Without it, the output
1509+is a fragment, which has no `<head>`: `@meta` and `@head` in it are a
1510+warning. `--partial` writes a fragment of a page too, and warns that
1511+`@page`, `@meta` and `@head` are left out. The page is the root element, and `@page`'s attributes are
1512+checked like any element's:
1513+
1514+- Styles style `<body>`, prefixes included, so a page's colour, font and
1515+ dark background go on `@page` and cover the whole window.
1516+- `key=value` attributes go on `<html>`: `lang=en`, `dir=rtl`, `class=x`.
1517+- `favicon FILE` is its one word of htmlang's own: the file is put into
1518+ the page as its icon (or linked, when it can't be read).
1519+
1520+`<body>` is a column that fills the window (`display: flex;
1521+flex-direction: column; min-height: 100dvh`, from the reset), so the
1522+page's top-level elements are laid out like the children of an `@el`:
1523+they stack, even a line of text or an inline element such as `@text` or
1524+`@link`, and each takes the full width unless it says otherwise. A
1525+centred column at the top writes `width fill` like anywhere else (see
1526+[Rows, columns and text](#rows-columns-and-text)).
1527+
1528+`@meta NAME VALUE` adds a meta tag, and names that start with `og:` become
1529+Open Graph `property` tags; a `@meta viewport` replaces the usual one.
1530+`@head` holds any other raw HTML for the `<head>`, such as a font link, a
1531+canonical URL or JSON-LD, on its line or in its indented block.
1532+
1533+```
1534+@page [lang=en, favicon favicon.png, color #1f2937, dark:background #0b1220, dark:color #e5e7eb] My Site
1535+@meta description A small site
1536+@meta og:title My Site
1537+@head <link rel="canonical" href="https://example.com/">
1538+@head
1539+ <script type="application/ld+json">{"@type": "WebSite"}</script>
1540+@header [padding 16] My Site
1541+@main [height fill, padding 16]
1542+ The main part takes the rest of the window.
1543+@footer [padding 16] © 2026
10811544```
10821545
1083−### `@font-face`
1546+Translations are a JSON file per locale: `@data $t locales/$lang.json`
1547+loads `locales/fr.json` when `$lang` is `fr`.
10841548
1085−Declares a custom font. Generates a CSS `@font-face` rule with `font-display: swap`. Format is auto-detected from the file extension.
1086−
1087−```
1088−@font-face Inter fonts/inter.woff2
1089−@font-face Mono fonts/jetbrains-mono.ttf
1090−```
1549+## CSS
10911550
1092−### `@json-ld`
1093−
1094−Structured data for SEO. Generates a `<script type="application/ld+json">` block. Indented body is raw JSON.
1551+`@style` holds raw CSS, including at-rules such as `@keyframes`,
1552+`@font-face`, `@property` and `@starting-style`, in its indented block or,
1553+for a single rule, on its line:
10951554
10961555```
1097−@json-ld
1098− {
1099− "@context": "https://schema.org",
1100− "@type": "WebPage",
1101− "name": "My Site"
1556+@style
1557+ @keyframes fade-in {
1558+ from { opacity: 0; }
1559+ to { opacity: 1; }
11021560 }
1103−```
1104−
1105−## Additional pseudo-states
1106−
1107−| Prefix | CSS Selector | Purpose |
1108−|--------|--------------|---------|
1109−| `visited:` | `:visited` | Visited links |
1110−| `empty:` | `:empty` | Empty elements |
1111−| `target:` | `:target` | URL fragment target |
1112−| `valid:` | `:valid` | Valid form inputs |
1113−| `invalid:` | `:invalid` | Invalid form inputs |
1114−
1115−```
1116−@link [visited:color purple] https://example.com
1117− Already visited
1118−
1119−@input [type email, valid:border 2 green]
1120−@el [target:background yellow, id section-1]
1121− Highlighted when navigated to
1122−```
1123−
1124−## Additional CSS properties
1125−
1126−| Attribute | Effect |
1127−|-----------|--------|
1128−| `text-underline-offset N` | Offset of text underline from text |
1129−| `column-width N` | Ideal column width for multi-column layout |
1130−| `column-rule VALUE` | Rule between columns (e.g., `1px solid #ccc`) |
1131−
1132−```
1133−@text [underline, text-underline-offset 4, text-decoration-thickness 2]
1134− Styled underline
1135−
1136−@el [column-count 3, column-width 200, column-rule 1px solid #ccc]
1137− Multi-column content
1138−```
1139−
1140−## CSS `@layer` wrapping
1141−
1142−Generated styles are wrapped in `@layer htmlang { ... }` for specificity management. This allows `@style` blocks and `@raw` CSS to easily override generated styles without specificity wars.
1143−
1144−## CLI commands
1145−
1146−### `htmlang deps`
1147−
1148−Show the file dependency graph (`@include`, `@import`, `@extends` relationships):
1149−
1150−```
1151−htmlang deps src/
1152−```
1153−
1154−### `htmlang dead-code`
1155−
1156−Find unused `@fn`, `@define`, and `@let` definitions across an entire project:
1157−
1158−```
1159−htmlang dead-code src/
1160−```
1161−
1162−### `htmlang deploy`
1163−
1164−Build and deploy to GitHub Pages:
1165−
1166−```
1167−htmlang deploy src/
1168−```
1169−
1170−Compiles all `.hl` files, copies static assets, and pushes to a `gh-pages` branch.
1171−
1172−### `htmlang clean`
1173−
1174−Remove generated `.html` files from a directory (matching `.hl` source files). Also removes `sitemap.xml` if present.
1175−
1176−```
1177−htmlang clean src/
1178−htmlang clean # clean current directory
1179−```
1180−
1181−### `htmlang outline`
1182−
1183−Show the document structure tree — a quick overview of elements and nesting without compiling.
1184−
1185−```
1186−htmlang outline page.hl
1187−```
1188−
1189−### `htmlang playground`
1190−
1191−Generate a self-contained HTML playground for experimenting with htmlang:
1192−
1193−```
1194−htmlang playground # writes playground.html
1195−htmlang playground my-play.html # custom output path
1196−htmlang clean src/ # remove generated .html files
1197−htmlang outline page.hl # show document structure tree
1198−```
1199−
1200−## Editor support
1201−
1202−A VS Code extension is available in `editors/vscode/` with:
1203−
1204−- Syntax highlighting via TextMate grammar
1205−- LSP integration via `htmlang-lsp` for:
1206− - Real-time diagnostics and error checking
1207− - Completions for elements, attributes, variables, and functions
1208− - Hover documentation for all elements and attributes
1209− - Go to definition for variables, defines, and functions
1210− - Find all references for variables and functions
1211− - Rename refactoring for variables and functions
1212− - Signature help for `@fn` parameters
1213− - Document formatting (format on save)
1214− - Code actions (quick-fixes for typos, unused variables, missing attributes)
1215− - Auto-import suggestions (scans project for `@fn` definitions)
1216− - Extract selection to `@fn` refactoring
1217− - Color picker for hex colors
1218− - Code folding and document symbols
1219− - Semantic tokens for syntax highlighting
1220− - VS Code snippets for common patterns (`@fn`, `@each`, `@grid`, `@form`, etc.)
1221−
1222−## Additional directives reference
1223−
1224−The compiler recognizes a number of directives that are documented only briefly
1225−above. A concise reference:
1226−
1227−### Content generation
1228−
1229−- `@markdown` — indented body is parsed as Markdown and emitted as HTML.
1230−- `@raw """..."""` — embed verbatim HTML / CSS / JS.
1231−- `@svg path/to/icon.svg` — inline an SVG file's contents.
1232−- `@fetch url -> $name` — fetch a JSON payload at build time and bind it.
1233−- `@data name path.json` — load a JSON file for iteration with `@each`.
1234−
1235−### Metadata & head
1236−
1237−- `@lang en` — set `<html lang>`.
1238−- `@favicon /favicon.png` — inject a favicon link.
1239−- `@meta name value` — add a `<meta>` tag.
1240−- `@og key value` — add an Open Graph `<meta property="og:key">` tag.
1241−- `@canonical https://…` — set `<link rel="canonical">`.
1242−- `@base /base/path/` — set `<base href>`.
1243−- `@manifest [Name]` — emit a PWA `manifest.json` reference and body.
1244−- `@breakpoint name width` — register a custom responsive prefix.
1245−
1246−### Styling
1247−
1248−- `@style` — indented body is raw CSS injected into `<style>`.
1249−- `@scope (selector)` — wrap body in CSS `@scope` for scoped styles.
1250−- `@starting-style` — CSS entry-animation starting values.
1251−- `@font-face name url` — register a web font.
1252−- `@theme` — design tokens emitted as CSS custom properties.
1253−- `@keyframes name` — define keyframe animations.
1254−- `@css-property name syntax value` — register a typed custom property.
1255−
1256−### Control flow
1257−
1258−- `@if cond` / `@unless cond` / `@else` / `@else if`.
1259−- `@match $value` / `@switch $value` — exhaustive branching.
1260−- `@each item in list` / `@each item, $_index in list` — iterate.
1261−- `@for $i in 0..10 [step 2]` — numeric ranges.
1262−- `@repeat N` — repeat a block N times.
1263−- `@defer` — defer block rendering below the fold.
1264−
1265−### Module system
1266−
1267−- `@include file.hl` — paste file contents here.
1268−- `@import file.hl` — import @fn / @let / @define (with optional `as` alias).
1269−- `@use "file.hl" fn1, fn2` — import a named subset.
1270−- `@extends template.hl` — template inheritance; override with `@slot`.
1271−- `@layout path.hl` — wrap output in a layout template.
1272−- `@collection path/ -> $items` — iterate over a directory.
1273−
1274−### Functions & composition
1275−
1276−- `@fn name $p1 $p2` — define a function; body uses `@children` / `@slot`.
1277−- `@component name $p` — like `@fn` but emits a scoped wrapper class.
1278−- `@mixin name [...attrs]` — attribute bundle you can merge elsewhere.
1279−- `@define name [...attrs]` — named attribute preset, used as `[$name]`.
1280−- `@with $source as $alias` — temporarily rebind a variable.
1281−
1282−### Debugging
1283−
1284−- `@assert cond [message]` — emit an error if a boolean check fails.
1285−- `@warn message` / `@debug expr` / `@log message` — compile-time output.
1286−- `@deprecated name [reason]` — mark a function as deprecated.
1287−- `@translations locale` — bind i18n strings available as `$t.key`.
1561+ .note { color: gray; }
1562+@style .quiet { opacity: 0.6; }
1563+@el [animation fade-in 0.3s ease, class=note] Fades in
1564+```
1565+
1566+Each element with styles gets a generated class, and elements with the
1567+same styles share one. A class is named by its style: `hl-` and seven
1568+characters of a hash of what it sets (`hl-2k9xq0m`), so one style has one
1569+name in every file. A fragment put into a page (`--partial` output that a
1570+server returns, or any HTML of one file put into another's page) means
1571+the same there, and its rules restyle nothing of the page's: a class of
1572+both is the same style. Two different styles of one file never share a
1573+name (the later one takes more characters), and `htmlang build` checks
1574+the whole site: two files whose different styles hash to one name are an
1575+error (`class-collision`), which one more attribute on either style
1576+fixes. A function's scoped `@style` is named by the function and a hash
1577+of the stylesheet, `hl-fn-card-1x0a9zq`. The `hl-` prefix is htmlang's:
1578+a class of your own that starts with it gets a warning, since the generated
1579+rules would apply to it too. Every style goes into the class under its own
1580+name, with its value as written (plus pixels, see
1581+[CSS properties](#css-properties)); only the layout words, and
1582+`line-clamp`'s fallback, write more than one declaration.
1583+
1584+An element's defaults go with its own class: its layout (a column is
1585+`display: flex`), the margins browsers give headings, paragraphs, lists,
1586+figures and blockquotes are 0, lists have no markers, `@fieldset` has a
1587+thin border and padding, `@code`, `@kbd` and `@pre` are monospace, `@link`
1588+has no underline and takes its parent's text colour, and `@image` is a
1589+block. They apply only to htmlang's own elements, so HTML that comes from
1590+`@markdown` or `@raw` keeps the browser's defaults: a Markdown list keeps
1591+its bullets and a Markdown link its underline.
1592+
1593+The generated rules live in three cascade layers, after a small reset in
1594+the layer `hl-reset`, and a later layer wins over an earlier one whatever
1595+the specificity:
1596+
1597+| Layer | Holds |
1598+|---|---|
1599+| `hl-reset` | The reset |
1600+| `hl-kind` | The defaults of each element's kind, as `:where(.hl-2k9xq0m)` |
1601+| `hl-inside` | The styles an element gets from an element prefix on an element around it (`@td:padding 8`, see [Styling the elements inside](#styling-the-elements-inside)), and functions' scoped `@style` |
1602+| `htmlang` | Each element's own styles, and its parent's `children:` styles |
1603+
1604+So an element's own styles win over everything else htmlang writes for
1605+it; its parent's `children:` styles and the element prefixes of the
1606+elements around it win over its defaults.
1607+
1608+A page's reset sets `box-sizing: border-box` everywhere and makes
1609+`<body>` a column that fills the window. A fragment (a file
1610+without `@page`, or `--partial` output) goes into a page it doesn't own, so
1611+its reset touches only htmlang's own elements (those with an `hl-` class),
1612+which get `box-sizing: border-box`; no other element on the page is
1613+touched. In both, an element
1614+htmlang lays out stays hidden while it has `hidden`, while an `@dialog` is
1615+closed, and while a popover isn't showing, even though its generated
1616+`display` would otherwise beat the browser's `display: none`. A page with
1617+links, buttons or form fields also gives htmlang's ones a focus outline.
1618+CSS outside any layer takes precedence over every layer, so rules in
1619+`@style` or `@raw` override the generated ones.
README.md+196−166
@@ -1,34 +1,123 @@
11# htmlang
22
3−A minimalist layout language inspired by [elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest/) that compiles to static HTML.
4−
5−`@` means structure. Bare lines mean content. No CSS required.
6−
7−## Example
3+A small layout language that compiles to static HTML. The layout model comes
4+from [elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest/):
5+rows and columns, spacing between children, and elements that fill or center
6+themselves. Styling uses CSS properties under their CSS names, and every other
7+element uses its HTML name.
88
99```
10−@page My Site
11−@let primary #3b82f6
10+@page [lang=en, background #f8fafc] My Site
11+@let --brand #3b82f6
1212
13−@fn card $title
14− @el [padding 20, background white, rounded 8, border 1 #e5e7eb, hover:border 1 $primary, transition all 0.15s ease]
15− @text [bold] $title
13+@let @card [title]
14+ @article [padding 20, spacing 8, border 1 solid #e5e7eb, border-radius 8, hover:border-color var(--brand)]
15+ @h3 $title
1616 @children
1717
18−@column [max-width 800, center-x, padding 40, spacing 20]
19− @text [bold, size 32] Hello
20−
18+@el [width fill, max-width 800, center-x, padding 40, spacing 24]
19+ @h1 [font-size 32] Hello
2120 @paragraph
22− Built with {@text [bold, color $primary] htmlang}.
23−
24− @row [wrap, spacing 10]
21+ Built with {@text [font-weight bold, color var(--brand)] htmlang}.
22+ @row [spacing 16, wrap]
2523 @card [title Simple]
26− Write layouts without CSS
27− @card [title Fast]
28− Compiles to a single HTML file
29−```
30−
31−Compiles to a self-contained `.html` file with flexbox layout and generated CSS classes. No JavaScript, no external dependencies.
24+ Layout without writing a stylesheet.
25+ @card [title Static]
26+ One HTML file, with its CSS inside.
27+```
28+
29+Each page compiles to one self-contained `.html` file. It has no JavaScript
30+unless you write some, and nothing is added that you didn't ask for.
31+
32+## The language in brief
33+
34+- **`@` starts structure, and any other line is content.** Indentation nests
35+ elements. Text after an element's attributes is its content, read like
36+ any line of text, and `{...}` puts an element inside a line of text
37+ (`@h1 Hello {@text [color red] world}`). The text of `@code` and
38+ `@textarea` is shown as written, and the lines indented under them are
39+ verbatim, HTML-escaped with their line breaks kept, so `@pre > @code`
40+ over an indented block shows a code sample. `@raw`, `@style` and `@head`
41+ take the rest of their line or an indented block, kept as written
42+ (`@style .note { color: gray; }`). An element that points at a URL or a
43+ file takes it as its first word, and the rest is its content: `@link
44+ /about About us`, `@image logo.png`, `@form /subscribe`, `@script app.js`.
45+- **Layout comes from elm-ui.** Every element has one layout. `@el` and the
46+ containers (`@section`, `@nav`, `@ul`, `@li`, ...) are columns, `@row` is a
47+ row and `@grid` a grid: in them each line of text is a child, and
48+ `spacing` is the gap between the children. Text elements (`@paragraph`,
49+ `@h1`, `@button`, `@td`) let their lines flow, joined with spaces, and an
50+ `@el` inside text is laid out inline. Layout attributes (`width fill`,
51+ `center-x`, `align-right`, `wrap`) say how an element sits inside its
52+ parent, and follow the direction the parent's CSS sets: `@nav
53+ [flex-direction row]` is a row, and `md:flex-direction row` makes a
54+ column a row from that width up, children included. The page is the root
55+ element: `@page`'s styles go on `<body>`, a column that fills the window,
56+ and its `key=value` attributes on `<html>` (`@page [lang=en, background
57+ #f8fafc] Home`).
58+- **Styling is CSS.** Any other attribute is a CSS property with its CSS
59+ name and value: `padding 20`, `border 1 solid #eee`,
60+ `grid-template-columns 1fr 2fr`. In a length property, every bare
61+ number is pixels (`box-shadow 0 2 4 black` is `0 2px 4px black`); numbers
62+ in any other property (`flex 1`, `z-index 2`) and in custom properties
63+ stay numbers.
64+ A custom property is a style like any other: `@let --surface white`
65+ declares it for the whole page, and `[--gap 16px, md:--gap 24px]` sets
66+ it on an element and everything inside it, so dark mode redefines a
67+ token once (`@page [dark:--surface #0b1220]`) instead of every colour
68+ that uses it. A token is read one way, `var(--surface)`, and a colour
69+ variant is CSS's too (`color-mix(in srgb, var(--brand), black 8%)`,
70+ `rgb(from var(--brand) r g b / 0.5)`), so it follows the token.
71+ Prefixes make a style conditional: `hover:`, `md:`, `dark:`, and CSS's
72+ common pseudo-classes and pseudo-elements under their CSS names
73+ (`first-child:`, `nth-child(odd):`, `has(> img):`, `marker:`,
74+ `backdrop:`; the list is in DESIGN.md).
75+ They stack (`md:dark:background`, `hover:children:opacity`), and one
76+ prefix covers a group or a bundle (`md:[padding 32, font-size 20]`,
77+ `md:$card`).
78+ An element prefix styles every such element inside, at any depth:
79+ `@table [@td:padding 8]` pads every cell, and
80+ `@article [@h2:font-size 20, @code:background #f1f5f9]` styles what
81+ `@markdown` writes. An element's own attributes still win, and the
82+ nearest element that styles it wins over one further out.
83+ A comma separates attributes, so a comma inside a value is written `\,`
84+ (`transition opacity 0.3s\, transform 0.3s`), and quoted text keeps its
85+ quotes only in CSS (`before:content "→ "`).
86+- **Names are checked, values are CSS's.** A misspelled element, prefix,
87+ parameter or slot is an error. A CSS property htmlang doesn't know is
88+ passed through with a "did you mean" warning, and values go to the CSS as
89+ written (`outline 2 solid red` is `outline: 2px solid red`, nothing
90+ added). Nothing you write is left out of the page without an error.
91+- **The generated CSS stays on htmlang's elements.** Its classes are
92+ named by their styles (`hl-2k9xq0m`), in cascade layers (`hl-reset`,
93+ `hl-kind`, `hl-inside`, `htmlang`), so your own CSS wins. An element's
94+ defaults (no heading margins, no list markers, links in the text colour)
95+ go with its own class, so `@markdown` and `@raw` HTML, and a page that
96+ embeds `--partial` output, keep theirs. One style has one name in every
97+ file, so a fragment a server swaps into a page (with htmx, say) means
98+ the same there and restyles nothing of the page's.
99+- **HTML stays HTML.** An element that isn't about layout or text has its
100+ HTML name (`@nav`, `@ul`, `@form`, `@details`, `@strong`, `@em`, `@br`,
101+ `@caption`), from a fixed list, so a typo is an error rather than a new
102+ tag. HTML attributes are written `key=value` (`id=main`, `type=email`) or
103+ bare (`required`).
104+- **`@let` defines everything.** It defines values, computed values,
105+ attribute bundles and functions. A function's body puts a call's content
106+ where `@children` is and fills `@slot NAME` from the call's `@slot NAME`
107+ blocks, and content that would go nowhere (a misspelled slot, content for
108+ a function without `@children`) is an error. A layout is just a function
109+ with slots.
110+ `$name` fills in a value exactly where it is written (a line of text, an
111+ attribute's value, a file path), whole attributes come from a bundle or
112+ from `if()` (`if($active, [font-weight bold, aria-current=page])`), and
113+ an undefined name is an error where the code runs.
114+- **Values are typed: text, numbers, `true`/`false`, lists and records.**
115+ Commas make a list (`@let fruits apple, banana`), `1..5` is a range,
116+ `@data` loads JSON as records and lists, and a value passed as one `$name`
117+ keeps its type, so a record can go to a function (`@post-card [post $p]`).
118+ `@each` and `@if` run at compile time. A definition is visible from its
119+ line to the end of its block, and a function sees what is visible where
120+ it is defined.
32121
33122## Install
34123
@@ -38,178 +127,119 @@ cargo install --path .
38127
39128## Usage
40129
41−Everyday workflow:
42−
43130```
44−htmlang init # scaffold a new project
45−htmlang page.hl # compile page.hl -> page.html
46−htmlang --watch page.hl # recompile on change
131+htmlang page.hl # compile page.hl to page.html
132+htmlang page.hl -o out.html # choose the output file
47133htmlang serve . # dev server with live reload
48−htmlang serve --https --cert cert.pem --key key.pem .
49−htmlang fmt page.hl # format a file in place
50−htmlang check page.hl # parse only, report diagnostics
51−```
52−
53−More commands (run with `--help` for flags):
54−
55−| Command | Purpose |
56−|--------------------|------------------------------------------------|
57−| `build [dir]` | Compile every `.hl` under a directory |
58−| `watch [path]` | Watch mode without a server |
59−| `serve [path]` | Dev server with live reload (`--https` ready) |
60−| `convert file.html`| Convert HTML back to htmlang |
61−| `new name` | Scaffold a new page from a template |
62−| `components` | List `@fn` / `@component` definitions |
63−| `deps file.hl` | Print the include / import graph |
64−| `dead-code [dir]` | Report unused `@fn` / `@let` / `@define` |
65−| `feed [dir]` | Generate an Atom feed from `@article` pages |
66−| `sitemap [dir]` | Generate `sitemap.xml` |
67−| `lint [path]` | Run warnings-only checks, optional JSON output |
68−| `stats file.hl` | Compile-time statistics |
69−| `preview file.hl` | Open a one-off preview |
70−| `diff a.hl b.hl` | Diff two files at the AST level |
71−| `export file.hl` | Export to plain HTML (`--format pdf` planned) |
72−| `repl` | Interactive REPL for expressions |
73−| `deploy` | Preset-driven deploy helper |
74−| `playground` | Write a self-contained playground HTML |
75−| `clean [dir]` | Remove generated `.html` files |
76−| `upgrade` | Apply mechanical migrations to out-of-date `.hl` |
77−| `outline file.hl` | Print the document tree |
78−| `explain CODE` | Longer explanation of a diagnostic code |
79−| `lsp` | Launch the language server (stdio) |
80−| `benchmark [dir]` | Measure parse / codegen timing |
81−
82−## Editor support
83−
84−A VS Code extension with syntax highlighting and LSP integration is available in [`editors/vscode`](editors/vscode). The language server (`htmlang-lsp`) provides diagnostics, completions, and hover documentation.
134+htmlang build src -o dist # compile a whole site
135+```
85136
86−## Language overview
137+| Command | Purpose |
138+|---|---|
139+| `build <dir> [-o out] [--minify] [--strict]` | Compile every `.hl` file under a directory (into `out/` by default), except libraries: files that hold only `@let`s. Two files whose different styles got one class name are an error (`class-collision`) |
140+| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload. It serves files, so it answers GET and HEAD (other methods get 405), and reloads pages, not fragments |
141+| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
142+| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
143+| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
144+| `fmt <file.hl>...` | Format files in place |
145+| `lsp` | Run the language server over stdio |
87146
88−### Elements
147+Compiling a file directly also takes `-w` / `--watch`, `--dev` (readable
148+output and source maps), `--strict`, `--partial` (a fragment, without the
149+document wrapper), `--format json`, `-s` / `--serve`, `-p` / `--port` and
150+`--open`.
89151
90−| Element | Purpose |
91−|--------------|------------------------------|
92−| `@row` | Horizontal flex layout |
93−| `@column` | Vertical flex layout |
94−| `@el` | Generic container |
95−| `@text` | Styled inline text |
96−| `@paragraph` | Flowing text with inline elements |
97−| `@image` | Image |
98−| `@link` | Anchor |
99−| `@raw` | Verbatim HTML escape hatch |
100−| `@form` | Form container |
101−| `@details` | Disclosure widget |
102−| `@summary` | Summary for `@details` |
103−| `@blockquote`| Block quotation |
104−| `@cite` | Citation reference |
105−| `@code` | Inline code (monospace) |
106−| `@pre` | Preformatted text |
107−| `@hr` | Horizontal rule / divider |
108−| `@figure` | Figure with caption |
109−| `@figcaption`| Caption for `@figure` |
110−| `@progress` | Progress bar |
111−| `@meter` | Meter/gauge |
152+## A short tour
112153
113−### Layout attributes
154+Layout attributes place an element. Everything else is CSS, and `key=value`
155+is an HTML attribute:
114156
115157```
116−@row [spacing 20] -- gap between children
117−@row [gap-x 10, gap-y 20] -- separate horizontal/vertical gaps
118−@el [padding 20] -- uniform padding (also padding-x, padding-y)
119−@el [width fill] -- take remaining space (also width 200, width shrink)
120−@el [center-x] -- center horizontally (also align-left, align-right)
121−@el [overflow hidden] -- overflow behavior (hidden, scroll, auto)
158+@row [spacing 12, align-items center, padding 12 20, border-bottom 1 solid #e5e7eb, id=toolbar]
159+ @text [font-weight 700] Acme
160+ @spacer
161+ @input [type=search, placeholder=Search, aria-label=Search, width 240, padding 6 10]
162+ @button [type=submit, padding 6 14, hover:background #f3f4f6] Go
122163```
123164
124−### Style attributes
165+A prefix applies a style only in a state, from a screen width up, or under a
166+media condition. Prefixes stack, and a prefix before a `[group]` applies to
167+each style in it:
125168
126169```
127−@el [background #3b82f6, color white, rounded 8, border 1 #e5e7eb]
128−@text [bold, italic, underline, size 18, font Inter]
129−@el [opacity 0.5, cursor pointer, transition all 0.15s ease]
130−@el [shadow 0 2px 4px rgba(0,0,0,0.1)]
131−@paragraph [text-align center, line-height 1.5]
170+@el [padding 16, md:[padding 32, font-size 20], background #3b82f6, hover:background #2563eb, dark:[background #1e3a8a, hover:background #1e40af]]
171+ @text [color white] Click me
132172```
133173
134−### Positioning
174+An element prefix, `@td:`, styles every such element inside, so rows a
175+loop writes and HTML from `@markdown` need no attributes of their own:
135176
136177```
137−@el [position relative]
138− @el [position absolute, z-index 10]
139− Overlay content
178+@table [border-collapse collapse, @th:[padding 8, text-align left], @td:padding 8, @tr:nth-child(even):background #f8fafc]
179+ @tr
180+ @th Name
181+ @tr
182+ @td Ada
140183```
141184
142−### Pseudo-states and media prefixes
185+`@let` defines values, bundles and functions, in one namespace. A function
186+has `@` before its name and its parameters in brackets (`label`, or
187+`href #` with a default). It is called like an element, on its own line,
188+in a chain or inline in text: its parameters are attributes, `name value`
189+(a name alone is `true`), a parameter without a default must be passed,
190+and any other attributes style its root element and are checked like
191+attributes written there.
143192
144−Prefix any style attribute with `hover:`, `active:`, or `focus:`:
145−
146−```
147−@el [background #3b82f6, hover:background #2563eb, active:background #1d4ed8]
148193```
194+@let --primary #3b82f6
195+@let gap = 8 * 2
196+@let rounded [border-radius 8, overflow hidden]
149197
150−Use `dark:` for dark mode and `print:` for print styles:
151−
152−```
153−@el [background white, dark:background #1a1a2e, print:display none]
154−```
155−
156−### Variables and defines
198+@let @cta [label, href #]
199+ @link [$rounded, padding 10 16, background var(--primary), color white] $href
200+ $label
201+@let @key
202+ @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
203+ @children
157204
205+@row [spacing $gap]
206+ @cta [label Sign up]
207+ @cta [label Learn more, href /about, background #64748b]
208+@paragraph
209+ Press {@key Ctrl+K} to search.
158210```
159−@let primary #3b82f6 -- simple variable, used as $primary
160−@let greeting "Hello $name" -- quoted string interpolation
161−@define card [padding 20, rounded 8] -- attribute bundle, used as [$card]
162−```
163−
164−### Functions
165211
166−```
167−@fn button $label
168− @el [padding 12, background $primary, rounded 8]
169− @text [color white, bold] $label
170− @children -- slot for caller's children
212+Data, loops and conditions run at compile time:
171213
172−@button [label Click me]
173214```
215+@data $links [
216+ {"label": "Home", "url": "/"},
217+ {"label": "Blog", "url": "/blog"},
218+ {"label": "About us", "url": "/about"}
219+]
220+@let current /blog
174221
175−### File includes
176−
177−```
178−@include header.hl -- inline another .hl file
222+@nav
223+ @row [spacing 16]
224+ @each $link in $links
225+ @link [if($link.url == $current, [font-weight bold, aria-current=page])] $link.url $link.label
179226```
180227
181−### Multi-line attributes
228+The [`examples/`](examples) directory has complete pages (a landing page, a
229+blog, a docs page, and a tour of the whole language). [DESIGN.md](DESIGN.md)
230+is the language reference.
182231
183−Attribute lists can span multiple lines:
232+## Editor support
184233
185−```
186−@el [
187− padding 20,
188− background white,
189− rounded 8,
190− shadow 0 2px 4px rgba(0,0,0,0.1)
191−]
192− Content here
193−```
194−
195−### Other features
196−
197−- `--` comments
198−- `{@text [bold] inline}` elements in text
199−- `@el [attrs] > @link url` single-child chaining
200−- `[padding 20]` bare attributes as implicit `@el`
201−- `@raw """..."""` for embedding arbitrary HTML/CSS/JS
202−- `@image [inline] logo.svg` inlines SVG content
203−- `@each $name, $url in Home /, About /about` destructuring
204−- `@let large = $base * 2` computed variables
205−- `[grid-template-areas "header main", grid-area header]` named grid areas
206−- `[animate fade-in 0.3s ease]` animation shorthand
207−- `[view-transition-name hero]` View Transitions API
208−- `[has(.active):background blue]` `:has()` pseudo-selector
209−- `@slot header` / `@slot content` named slots in `@fn`
210−- `@theme` design tokens as runtime CSS custom properties
211−- VS Code snippets for common patterns
212−
213−## Documentation
214−
215−See [DESIGN.md](DESIGN.md) for the full language specification.
234+The VS Code extension in [`editors/vscode`](editors/vscode) provides syntax
235+highlighting, snippets, and the language server (`htmlang-lsp`). The server
236+gives diagnostics, completions, hover documentation, go to definition, rename
237+and formatting.
238+
239+The compiler, the formatter and the language server read a file with the
240+same parser, so the editor sees exactly what the compiler sees, relative
241+`@include` and `@data` paths included. Every diagnostic has a stable code
242+(`error[unknown-element]` on the command line, a `code` field in
243+`--format json`), and the whole file is checked, including branches and
244+functions that don't run (a `$name` there is checked only where the code
245+runs, since it may come from data).
crates/htmlang-core/src/ast.rs+722−117
@@ -3,47 +3,33 @@ use std::collections::HashMap;
33#[derive(Debug)]
44#[allow(dead_code)]
55pub struct Document {
6− pub page_title: Option<String>,
7− pub lang: Option<String>,
8− pub favicon: Option<String>,
6+ /// The page `@page` makes: without one, the output is a fragment.
7+ pub page: Option<Page>,
98 pub meta_tags: Vec<(String, String)>,
109 pub head_blocks: Vec<String>,
1110 pub variables: HashMap<String, String>,
1211 pub defines: HashMap<String, Vec<Attribute>>,
13− pub keyframes: Vec<(String, String)>,
1412 pub css_vars: Vec<(String, String)>,
1513 pub custom_css: Vec<String>,
14+ /// The scoped `@style` of each function called, nested under its
15+ /// `.hl-fn-NAME-HASH` class, for `@layer hl-inside`.
16+ pub scoped_css: Vec<String>,
1617 pub og_tags: Vec<(String, String)>,
17− pub custom_breakpoints: Vec<(String, String)>,
18− pub theme_tokens: Vec<(String, String)>,
19− pub canonical: Option<String>,
20− pub base_url: Option<String>,
21− pub font_faces: Vec<(String, String)>,
22− pub json_ld_blocks: Vec<String>,
23− pub scope_blocks: Vec<String>,
24− pub starting_style_blocks: Vec<String>,
25− pub manifest: Option<ManifestConfig>,
26− pub preload_hints: Vec<PreloadHint>,
2718 pub nodes: Vec<Node>,
2819}
2920
30−#[derive(Debug, Clone)]
31−pub struct ManifestConfig {
32− pub name: String,
33− pub short_name: Option<String>,
34− pub start_url: String,
35− pub display: String,
36− pub background_color: Option<String>,
37− pub theme_color: Option<String>,
38− pub description: Option<String>,
39− pub icons: Vec<(String, String)>, // (src, sizes)
40−}
41−
42−#[derive(Debug, Clone)]
43−pub struct PreloadHint {
44− pub href: String,
45− pub as_type: String, // "font", "image", "style", "script"
46− pub crossorigin: bool,
21+/// What `@page` says: the page is the root element. Its styles style
22+/// `<body>`, which lays out the page's top-level elements as a column; its
23+/// HTML attributes (`lang=en`, `dir=rtl`, `class=x`) go on `<html>`.
24+#[derive(Debug, Clone, Default)]
25+pub struct Page {
26+ pub title: String,
27+ /// `key=value` attributes, and bare boolean HTML attributes, for `<html>`
28+ pub html_attrs: Vec<Attribute>,
29+ /// Styles, prefixed ones included, for `<body>`
30+ pub styles: Vec<Attribute>,
31+ /// `favicon FILE`, `@page`'s one word of htmlang's own
32+ pub favicon: Option<String>,
4733}
4834
4935#[derive(Debug, Clone)]
@@ -63,108 +49,727 @@ pub enum TextSegment {
6349pub struct Element {
6450 pub kind: ElementKind,
6551 pub attrs: Vec<Attribute>,
52+ /// The value of the element's leading attribute (see
53+ /// [`ElementKind::arg`]): the first token of `@link /about About`, or
54+ /// its `href=` when it has no argument. For `@slot`, the slot's name.
55+ /// Any other text after the attributes is content, a first child.
6656 pub argument: Option<String>,
6757 pub children: Vec<Node>,
58+ /// The line it is written on; for an element a function's body wrote,
59+ /// the line of the call, since a function is an element at its call.
6860 pub line_num: usize,
61+ /// The function whose body wrote it, when it comes from a call.
62+ pub function: Option<String>,
63+}
64+
65+#[derive(Debug, Clone)]
66+pub struct Attribute {
67+ pub key: String,
68+ /// The value as it goes into the output: a style's with the quotes of
69+ /// quoted text, an HTML attribute's without them.
70+ pub value: Option<String>,
71+ /// Written `key=value`: an HTML attribute rather than a style.
72+ pub html: bool,
73+ /// When the value is quoted text (`"..."`, or a variable holding
74+ /// quoted text), both of its forms, for a parameter it binds.
75+ pub quoted: Option<Quoted>,
76+}
77+
78+/// Quoted text, which remembers that it was quoted: a CSS value writes it
79+/// with its quotes, text and HTML attribute values without them.
80+#[derive(Debug, Clone, PartialEq)]
81+pub struct Quoted {
82+ /// What it says: `a"b` for `"a\"b"`.
83+ pub text: String,
84+ /// As CSS writes it, quotes included: `"a\"b"`.
85+ pub css: String,
6986}
7087
7188#[derive(Debug, Clone, PartialEq)]
7289pub enum ElementKind {
90+ // Layout and text: the core of the language
7391 Row,
74− Column,
7592 El,
7693 Text,
7794 Paragraph,
78− Image,
7995 Link,
96+ Image,
97+ // Elements generated in a special way
98+ Fragment,
99+ // Function bodies: placeholders for the caller's content
80100 Children,
81− // Form elements
82− Input,
83− Button,
84− Select,
85− Textarea,
86− Option,
87− Label,
88− // Slots
89101 Slot(String),
90− // Semantic elements
91− Nav,
92− Header,
93− Footer,
94− Main,
95− Section,
96− Article,
97− Aside,
98− // List elements
99− List,
100− ListItem,
101− // Table elements
102− Table,
103− TableHead,
104− TableBody,
105− TableRow,
106− TableCell,
107− TableHeaderCell,
108− // Media elements
109− Video,
110− Audio,
111− // Additional semantic elements
112− Form,
113− Details,
114− Summary,
115− Blockquote,
116− Cite,
117− Code,
118− Pre,
119− HorizontalRule,
120− Figure,
121− FigCaption,
122− Progress,
123− Meter,
124− Fragment,
125− Dialog,
126− DefinitionList,
127− DefinitionTerm,
128− DefinitionDescription,
129− Fieldset,
130− Legend,
131− Picture,
102+ /// Any other element, described by its row in [`TAGS`].
103+ Tag(&'static TagSpec),
104+}
105+
106+impl ElementKind {
107+ /// The element's name in htmlang source, without the `@`.
108+ pub fn name(&self) -> &str {
109+ match self {
110+ ElementKind::Row => "row",
111+ ElementKind::El => "el",
112+ ElementKind::Text => "text",
113+ ElementKind::Paragraph => "paragraph",
114+ ElementKind::Link => "link",
115+ ElementKind::Image => "image",
116+ ElementKind::Fragment => "fragment",
117+ ElementKind::Children => "children",
118+ ElementKind::Slot(_) => "slot",
119+ ElementKind::Tag(spec) => spec.name,
120+ }
121+ }
122+
123+ pub fn spec(&self) -> Option<&'static TagSpec> {
124+ match self {
125+ ElementKind::Tag(spec) => Some(spec),
126+ _ => None,
127+ }
128+ }
129+
130+ /// How the element lays out what is inside it. `@fragment`, `@children`
131+ /// and `@slot` have no element of their own: their content lands in the
132+ /// element around them and takes its layout.
133+ pub fn layout(&self) -> Layout {
134+ match self {
135+ ElementKind::Row => Layout::Row,
136+ ElementKind::El => Layout::Column,
137+ ElementKind::Text | ElementKind::Paragraph | ElementKind::Link => Layout::Text,
138+ ElementKind::Image => Layout::Void,
139+ ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_) => Layout::Native,
140+ ElementKind::Tag(spec) => spec.layout,
141+ }
142+ }
143+
144+ /// The CSS every such element starts with, besides its layout's
145+ /// `display` (browser margins reset, the list markers of a column, a
146+ /// link's colour). It goes into the element's own generated class,
147+ /// never onto a global element selector, so HTML from `@markdown` and
148+ /// `@raw` keeps the browser's defaults.
149+ pub fn css(&self) -> &'static str {
150+ match self {
151+ ElementKind::Paragraph => "margin:0;",
152+ ElementKind::Link => "text-decoration:none;color:inherit;",
153+ ElementKind::Image => "display:block;",
154+ ElementKind::Tag(spec) => spec.css,
155+ _ => "",
156+ }
157+ }
158+
159+ /// What the first token after its attributes is: `@link`'s `href`,
160+ /// `@image`'s `src`, or its row's [`TagArg`].
161+ pub fn arg(&self) -> TagArg {
162+ match self {
163+ ElementKind::Link => TagArg::Attr("href"),
164+ ElementKind::Image => TagArg::Attr("src"),
165+ ElementKind::Tag(spec) => spec.arg,
166+ _ => TagArg::Child,
167+ }
168+ }
169+
170+ /// The HTML attribute its leading argument fills, when it has one;
171+ /// `in_picture`: the element is directly inside `@picture`.
172+ pub fn leading_attribute(&self, in_picture: bool) -> Option<&'static str> {
173+ self.arg().attribute(in_picture)
174+ }
175+
176+ /// Whether its indented body is foreign text written into the page as
177+ /// it is (`@script`). (`@code` and `@textarea` also have a verbatim
178+ /// body, shown as text: see [`TagSpec::literal`].)
179+ pub fn is_verbatim(&self) -> bool {
180+ self.spec().is_some_and(|spec| spec.verbatim)
181+ }
182+
183+ /// The HTML element it writes (outside text, where htmlang's own
184+ /// `<div>`s are `<span>`s): `None` for `@fragment`, `@children` and
185+ /// `@slot`, which write none of their own.
186+ pub fn tag(&self) -> Option<&'static str> {
187+ match self {
188+ ElementKind::Row | ElementKind::El => Some("div"),
189+ ElementKind::Text => Some("span"),
190+ ElementKind::Paragraph => Some("p"),
191+ ElementKind::Link => Some("a"),
192+ ElementKind::Image => Some("img"),
193+ ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_) => None,
194+ ElementKind::Tag(spec) => Some(spec.html),
195+ }
196+ }
197+
198+ /// The HTML element only this element writes, which a selector can
199+ /// pick out: `@td` is every `<td>`, `@paragraph` every `<p>`. `None`
200+ /// for an element that shares its tag: `@el`, `@row`, `@grid`,
201+ /// `@in-front` and `@behind` are all `<div>`, and `@text` is a
202+ /// `<span>`, as is every line of text in a row or column. `@script`
203+ /// isn't shown, so there is nothing to style.
204+ pub fn own_tag(&self) -> Option<&'static str> {
205+ let tag = self.tag()?;
206+ let shared = matches!(tag, "div" | "span") || self.is_verbatim();
207+ (!shared).then_some(tag)
208+ }
209+
210+ /// Is this the table element named `name` (e.g. `"main"`)?
211+ pub fn is_tag(&self, name: &str) -> bool {
212+ self.spec().is_some_and(|spec| spec.name == name)
213+ }
214+
215+ /// Look up an element by its source name (without the `@`).
216+ pub fn from_name(name: &str) -> Option<ElementKind> {
217+ Some(match name {
218+ "row" => ElementKind::Row,
219+ "el" => ElementKind::El,
220+ "text" => ElementKind::Text,
221+ "paragraph" => ElementKind::Paragraph,
222+ "link" => ElementKind::Link,
223+ "image" => ElementKind::Image,
224+ "fragment" => ElementKind::Fragment,
225+ "children" => ElementKind::Children,
226+ "slot" => ElementKind::Slot(String::new()),
227+ _ => ElementKind::Tag(TAGS.iter().find(|spec| spec.name == name)?),
228+ })
229+ }
230+
231+ /// Every element name, for suggestions and completions.
232+ pub fn all_names() -> impl Iterator<Item = &'static str> {
233+ [
234+ "row",
235+ "el",
236+ "text",
237+ "paragraph",
238+ "link",
239+ "image",
240+ "fragment",
241+ "children",
242+ "slot",
243+ ]
244+ .into_iter()
245+ .chain(TAGS.iter().map(|spec| spec.name))
246+ }
247+}
248+
249+/// What an element does with the text after its name and attributes.
250+#[derive(Debug, Clone, Copy, PartialEq)]
251+pub enum TagArg {
252+ /// Its content: the first line of its text, parsed like any text
253+ /// (`@h1 Hello {@text [color red] world}`). The element's layout
254+ /// decides how it combines with the lines under it.
255+ Child,
256+ /// Its leading attribute: the first token fills this HTML attribute
257+ /// (`@iframe URL` sets `src`), and the rest of the text is content, or
258+ /// an error on an element without content. The token ends at a space
259+ /// outside `"..."` and `${...}`.
260+ Attr(&'static str),
261+ /// `@source`'s leading attribute, which depends on where it is:
262+ /// `srcset` directly inside `@picture`, `src` elsewhere (`@video`,
263+ /// `@audio`).
132264 Source,
133− Time,
134− Mark,
135− Kbd,
136− Abbr,
137− Datalist,
138− Iframe,
139− Output,
140− Canvas,
141− // Heading elements
142− H1,
143− H2,
144− H3,
145− H4,
146− H5,
147− H6,
148− // New convenience elements
265+}
266+
267+impl TagArg {
268+ /// The HTML attribute the leading argument fills; `in_picture`: the
269+ /// element is directly inside `@picture`.
270+ pub fn attribute(self, in_picture: bool) -> Option<&'static str> {
271+ match self {
272+ TagArg::Child => None,
273+ TagArg::Attr(attr) => Some(attr),
274+ TagArg::Source if in_picture => Some("srcset"),
275+ TagArg::Source => Some("src"),
276+ }
277+ }
278+
279+ /// The attributes the leading argument may fill, wherever the element
280+ /// is: one, or `src` and `srcset` for `@source`.
281+ pub fn attributes(self) -> Vec<&'static str> {
282+ let mut out: Vec<&'static str> = [self.attribute(false), self.attribute(true)]
283+ .into_iter()
284+ .flatten()
285+ .collect();
286+ out.dedup();
287+ out
288+ }
289+}
290+
291+/// How an element lays out what is inside it: one value per element,
292+/// after elm-ui's layouts. It decides what its text lines are, whether
293+/// `spacing` works on it, and how its children's `width fill`, `center-x`
294+/// and `align-*` compile.
295+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296+pub enum Layout {
297+ /// A flex column (`@el`, `@section`, `@ul`, `@li`): each text line is a
298+ /// child of its own, and `spacing` is the gap between the children.
299+ Column,
300+ /// A flex row (`@row`): like a column, side by side.
301+ Row,
302+ /// A CSS grid (`@grid`): each text line is a cell.
149303 Grid,
150− Stack,
151− Spacer,
152− Badge,
153− Tooltip,
154− Avatar,
155− Carousel,
156− Chip,
157− Tag,
158− // HTML5 elements
159− Script,
160− Noscript,
161− Address,
162− Search,
163− Breadcrumb,
304+ /// Flowing text (`@paragraph`, `@h1`, `@button`, `@td`): the argument,
305+ /// the text lines and the children are one run, joined with spaces. It
306+ /// has no gap for `spacing`, and a row, column or grid inside it is laid
307+ /// out inline (`@el` becomes an inline-flex `<span>`).
308+ Text,
309+ /// HTML's own layout, which htmlang leaves alone: tables, `@select`,
310+ /// `@picture`, media and form controls. Text lines are separated by a
311+ /// line break, which HTML shows as a space except in `@pre` and
312+ /// `@textarea`.
313+ Native,
314+ /// No content at all (`@input`, `@hr`, `@image`).
315+ Void,
164316}
165317
166−#[derive(Debug, Clone)]
167−pub struct Attribute {
168− pub key: String,
169− pub value: Option<String>,
318+impl Layout {
319+ /// A row, column or grid: an element that lays out its children, so
320+ /// `spacing`, `wrap` and `grid-cols` work on it.
321+ pub fn is_container(self) -> bool {
322+ matches!(self, Layout::Column | Layout::Row | Layout::Grid)
323+ }
324+
325+ /// The name used in messages and the reference: `column`, `text`, ...
326+ pub fn name(self) -> &'static str {
327+ match self {
328+ Layout::Column => "column",
329+ Layout::Row => "row",
330+ Layout::Grid => "grid",
331+ Layout::Text => "text",
332+ Layout::Native => "native",
333+ Layout::Void => "void",
334+ }
335+ }
336+}
337+
338+/// How an element compiles: the data behind [`ElementKind::Tag`].
339+#[derive(Debug, PartialEq)]
340+pub struct TagSpec {
341+ /// Name in htmlang source, without the `@`.
342+ pub name: &'static str,
343+ /// HTML element emitted.
344+ pub html: &'static str,
345+ /// Default CSS besides the layout's `display` (margin reset, ...).
346+ pub css: &'static str,
347+ pub arg: TagArg,
348+ /// How it lays out its content.
349+ pub layout: Layout,
350+ /// Its text is shown as written (`@code`, `@textarea`). On its line and
351+ /// inline, a `{@...}` is text, not an inline element (escapes and
352+ /// `$names` still work). Its indented body is verbatim: nothing in it is
353+ /// parsed, and it is shown HTML-escaped, its lines and indentation kept.
354+ pub literal: bool,
355+ /// Its indented body is foreign text written into the page exactly as
356+ /// it is (`@script`'s JavaScript): nothing in it is parsed as htmlang.
357+ pub verbatim: bool,
358+}
359+
360+impl TagSpec {
361+ const DEFAULT: TagSpec = TagSpec {
362+ name: "",
363+ html: "",
364+ css: "",
365+ arg: TagArg::Child,
366+ layout: Layout::Native,
367+ literal: false,
368+ verbatim: false,
369+ };
370+}
371+
372+/// Every element besides the core ones in [`ElementKind`].
373+#[rustfmt::skip]
374+pub static TAGS: &[TagSpec] = &[
375+ TagSpec { name: "nav", html: "nav", layout: Layout::Column, ..TagSpec::DEFAULT },
376+ TagSpec { name: "header", html: "header", layout: Layout::Column, ..TagSpec::DEFAULT },
377+ TagSpec { name: "footer", html: "footer", layout: Layout::Column, ..TagSpec::DEFAULT },
378+ TagSpec { name: "main", html: "main", layout: Layout::Column, ..TagSpec::DEFAULT },
379+ TagSpec { name: "section", html: "section", layout: Layout::Column, ..TagSpec::DEFAULT },
380+ TagSpec { name: "article", html: "article", layout: Layout::Column, ..TagSpec::DEFAULT },
381+ TagSpec { name: "aside", html: "aside", layout: Layout::Column, ..TagSpec::DEFAULT },
382+ TagSpec { name: "details", html: "details", layout: Layout::Column, ..TagSpec::DEFAULT },
383+ TagSpec { name: "dialog", html: "dialog", layout: Layout::Column, ..TagSpec::DEFAULT },
384+ TagSpec { name: "search", html: "search", layout: Layout::Column, ..TagSpec::DEFAULT },
385+ TagSpec { name: "address", html: "address", layout: Layout::Column, ..TagSpec::DEFAULT },
386+ TagSpec { name: "noscript", html: "noscript", layout: Layout::Column, ..TagSpec::DEFAULT },
387+ TagSpec { name: "form", html: "form", arg: TagArg::Attr("action"), layout: Layout::Column, ..TagSpec::DEFAULT },
388+ TagSpec { name: "figure", html: "figure", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
389+ TagSpec { name: "blockquote", html: "blockquote", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
390+ TagSpec { name: "fieldset", html: "fieldset", css: "border:1px solid currentColor;padding:8px;margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
391+ TagSpec { name: "datalist", html: "datalist", layout: Layout::Native, ..TagSpec::DEFAULT },
392+ TagSpec { name: "ul", html: "ul", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
393+ TagSpec { name: "ol", html: "ol", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
394+ TagSpec { name: "li", html: "li", layout: Layout::Column, ..TagSpec::DEFAULT },
395+ TagSpec { name: "dl", html: "dl", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
396+ TagSpec { name: "dt", html: "dt", layout: Layout::Text, ..TagSpec::DEFAULT },
397+ TagSpec { name: "dd", html: "dd", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
398+ TagSpec { name: "table", html: "table", layout: Layout::Native, ..TagSpec::DEFAULT },
399+ TagSpec { name: "thead", html: "thead", layout: Layout::Native, ..TagSpec::DEFAULT },
400+ TagSpec { name: "tbody", html: "tbody", layout: Layout::Native, ..TagSpec::DEFAULT },
401+ TagSpec { name: "tr", html: "tr", layout: Layout::Native, ..TagSpec::DEFAULT },
402+ TagSpec { name: "select", html: "select", layout: Layout::Native, ..TagSpec::DEFAULT },
403+ TagSpec { name: "picture", html: "picture", layout: Layout::Native, ..TagSpec::DEFAULT },
404+ TagSpec { name: "progress", html: "progress", layout: Layout::Native, ..TagSpec::DEFAULT },
405+ TagSpec { name: "meter", html: "meter", layout: Layout::Native, ..TagSpec::DEFAULT },
406+ TagSpec { name: "output", html: "output", layout: Layout::Native, ..TagSpec::DEFAULT },
407+ TagSpec { name: "canvas", html: "canvas", layout: Layout::Native, ..TagSpec::DEFAULT },
408+ TagSpec { name: "td", html: "td", layout: Layout::Text, ..TagSpec::DEFAULT },
409+ TagSpec { name: "th", html: "th", layout: Layout::Text, ..TagSpec::DEFAULT },
410+ TagSpec { name: "button", html: "button", layout: Layout::Text, ..TagSpec::DEFAULT },
411+ TagSpec { name: "label", html: "label", layout: Layout::Text, ..TagSpec::DEFAULT },
412+ TagSpec { name: "option", html: "option", layout: Layout::Native, ..TagSpec::DEFAULT },
413+ TagSpec { name: "textarea", html: "textarea", layout: Layout::Native, literal: true, ..TagSpec::DEFAULT },
414+ TagSpec { name: "summary", html: "summary", layout: Layout::Text, ..TagSpec::DEFAULT },
415+ TagSpec { name: "cite", html: "cite", layout: Layout::Text, ..TagSpec::DEFAULT },
416+ TagSpec { name: "figcaption", html: "figcaption", layout: Layout::Text, ..TagSpec::DEFAULT },
417+ TagSpec { name: "legend", html: "legend", layout: Layout::Text, ..TagSpec::DEFAULT },
418+ TagSpec { name: "time", html: "time", layout: Layout::Text, ..TagSpec::DEFAULT },
419+ TagSpec { name: "mark", html: "mark", layout: Layout::Text, ..TagSpec::DEFAULT },
420+ TagSpec { name: "abbr", html: "abbr", layout: Layout::Text, ..TagSpec::DEFAULT },
421+ TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, literal: true, ..TagSpec::DEFAULT },
422+ TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, ..TagSpec::DEFAULT },
423+ TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", layout: Layout::Native, ..TagSpec::DEFAULT },
424+ TagSpec { name: "h1", html: "h1", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
425+ TagSpec { name: "h2", html: "h2", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
426+ TagSpec { name: "h3", html: "h3", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
427+ TagSpec { name: "h4", html: "h4", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
428+ TagSpec { name: "h5", html: "h5", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
429+ TagSpec { name: "h6", html: "h6", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
430+ TagSpec { name: "input", html: "input", layout: Layout::Void, ..TagSpec::DEFAULT },
431+ TagSpec { name: "hr", html: "hr", layout: Layout::Void, ..TagSpec::DEFAULT },
432+ TagSpec { name: "source", html: "source", arg: TagArg::Source, layout: Layout::Void, ..TagSpec::DEFAULT },
433+ TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
434+ TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
435+ TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
436+ TagSpec { name: "script", html: "script", arg: TagArg::Attr("src"), layout: Layout::Native, verbatim: true, ..TagSpec::DEFAULT },
437+ // Flow containers
438+ TagSpec { name: "hgroup", html: "hgroup", layout: Layout::Column, ..TagSpec::DEFAULT },
439+ TagSpec { name: "menu", html: "menu", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
440+ // Phrasing: text with the browser's own style, and no CSS of htmlang's
441+ TagSpec { name: "b", html: "b", layout: Layout::Text, ..TagSpec::DEFAULT },
442+ TagSpec { name: "i", html: "i", layout: Layout::Text, ..TagSpec::DEFAULT },
443+ TagSpec { name: "strong", html: "strong", layout: Layout::Text, ..TagSpec::DEFAULT },
444+ TagSpec { name: "em", html: "em", layout: Layout::Text, ..TagSpec::DEFAULT },
445+ TagSpec { name: "small", html: "small", layout: Layout::Text, ..TagSpec::DEFAULT },
446+ TagSpec { name: "s", html: "s", layout: Layout::Text, ..TagSpec::DEFAULT },
447+ TagSpec { name: "u", html: "u", layout: Layout::Text, ..TagSpec::DEFAULT },
448+ TagSpec { name: "sub", html: "sub", layout: Layout::Text, ..TagSpec::DEFAULT },
449+ TagSpec { name: "sup", html: "sup", layout: Layout::Text, ..TagSpec::DEFAULT },
450+ TagSpec { name: "q", html: "q", layout: Layout::Text, ..TagSpec::DEFAULT },
451+ TagSpec { name: "var", html: "var", layout: Layout::Text, ..TagSpec::DEFAULT },
452+ TagSpec { name: "samp", html: "samp", layout: Layout::Text, ..TagSpec::DEFAULT },
453+ TagSpec { name: "dfn", html: "dfn", layout: Layout::Text, ..TagSpec::DEFAULT },
454+ TagSpec { name: "bdi", html: "bdi", layout: Layout::Text, ..TagSpec::DEFAULT },
455+ TagSpec { name: "bdo", html: "bdo", layout: Layout::Text, ..TagSpec::DEFAULT },
456+ TagSpec { name: "ins", html: "ins", layout: Layout::Text, ..TagSpec::DEFAULT },
457+ TagSpec { name: "del", html: "del", layout: Layout::Text, ..TagSpec::DEFAULT },
458+ TagSpec { name: "br", html: "br", layout: Layout::Void, ..TagSpec::DEFAULT },
459+ TagSpec { name: "wbr", html: "wbr", layout: Layout::Void, ..TagSpec::DEFAULT },
460+ // HTML's own display: table parts, option groups, ruby, embedded content
461+ TagSpec { name: "caption", html: "caption", layout: Layout::Native, ..TagSpec::DEFAULT },
462+ TagSpec { name: "colgroup", html: "colgroup", layout: Layout::Native, ..TagSpec::DEFAULT },
463+ TagSpec { name: "col", html: "col", layout: Layout::Void, ..TagSpec::DEFAULT },
464+ TagSpec { name: "tfoot", html: "tfoot", layout: Layout::Native, ..TagSpec::DEFAULT },
465+ TagSpec { name: "optgroup", html: "optgroup", arg: TagArg::Attr("label"), layout: Layout::Native, ..TagSpec::DEFAULT },
466+ TagSpec { name: "ruby", html: "ruby", layout: Layout::Native, ..TagSpec::DEFAULT },
467+ TagSpec { name: "rt", html: "rt", layout: Layout::Native, ..TagSpec::DEFAULT },
468+ TagSpec { name: "rp", html: "rp", layout: Layout::Native, ..TagSpec::DEFAULT },
469+ TagSpec { name: "track", html: "track", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
470+ TagSpec { name: "embed", html: "embed", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
471+ TagSpec { name: "object", html: "object", arg: TagArg::Attr("data"), layout: Layout::Native, ..TagSpec::DEFAULT },
472+ TagSpec { name: "map", html: "map", layout: Layout::Native, ..TagSpec::DEFAULT },
473+ TagSpec { name: "area", html: "area", arg: TagArg::Attr("href"), layout: Layout::Void, ..TagSpec::DEFAULT },
474+ // htmlang's own
475+ TagSpec { name: "grid", html: "div", layout: Layout::Grid, ..TagSpec::DEFAULT },
476+ TagSpec { name: "in-front", html: "div", css: "position:absolute;inset:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
477+ TagSpec { name: "behind", html: "div", css: "position:absolute;inset:0;z-index:-1;", layout: Layout::Column, ..TagSpec::DEFAULT },
478+];
479+
480+/// HTML elements htmlang writes under another name. The HTML name isn't an
481+/// element: the unknown-element error for `@a` suggests `@link`. (Not a
482+/// second spelling: only the suggestion reads this.)
483+pub const HTML_NAMES_WRITTEN_OTHERWISE: &[(&str, &str)] = &[
484+ ("a", "link"),
485+ ("img", "image"),
486+ ("span", "text"),
487+ ("p", "paragraph"),
488+ ("div", "el"),
489+];
490+
491+/// Whether the element named `name` (without the `@`) shows its text as
492+/// written: `{@code {@link /x y}}` prints the braces, and its indented body
493+/// is verbatim text, HTML-escaped. The syntax tree reads this, so every
494+/// tool agrees on it.
495+pub fn has_literal_text(name: &str) -> bool {
496+ ElementKind::from_name(name)
497+ .and_then(|kind| kind.spec())
498+ .is_some_and(|spec| spec.literal)
499+}
500+
501+/// How a directive reads the rest of its line.
502+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503+pub enum ArgGrammar {
504+ /// Free text, taken as written (`@include file.hl`, `@meta name value`).
505+ /// For a directive with a verbatim body it is the one-line form of the
506+ /// body (`@raw <hr>`, `@style .a { color: red }`) or, for `@markdown`,
507+ /// the file.
508+ Text,
509+ /// An optional `[attributes]` list, then text (`@page [lang=en] Title`).
510+ AttrsText,
511+ /// An expression (`@if $count > 2`).
512+ Expression,
513+ /// A name, then a value, `= EXPR`, `"text"`, `[attributes]` or the
514+ /// parameters of a function (`@let`).
515+ Definition,
516+ /// Nothing, or `if EXPR` (`@else`, `@else if`).
517+ Else,
518+ /// `$item in LIST` or `$item, $index in LIST` (`@each`).
519+ Loop,
520+ /// `$name SOURCE`; inline JSON may span several lines (`@data`).
521+ Data,
522+}
523+
524+/// What the lines indented under a directive or element are.
525+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526+pub enum BodyKind {
527+ /// Nothing: indented lines are an error.
528+ None,
529+ /// htmlang: elements, text and directives.
530+ Htmlang,
531+ /// Foreign text (CSS, HTML, JavaScript, Markdown, a code sample), kept
532+ /// exactly as written: nothing in it is parsed, and `--` is not a
533+ /// comment. The text on the header's line is its one-line form (or a
534+ /// file or URL: `@markdown notes.md`, `@script app.js`); the line and a
535+ /// body together are an error. A verbatim directive takes no
536+ /// attributes.
537+ Verbatim,
538+}
539+
540+/// A directive: a `@name` that isn't an element. The syntax tree
541+/// (`syntax.rs`), the evaluator, the formatter and the editor tools all
542+/// read the directives' shapes from this table.
543+#[derive(Debug, PartialEq)]
544+pub struct DirectiveSpec {
545+ /// Name in htmlang source, without the `@`.
546+ pub name: &'static str,
547+ pub args: ArgGrammar,
548+ pub body: BodyKind,
549+}
550+
551+/// Every directive.
552+#[rustfmt::skip]
553+pub static DIRECTIVES: &[DirectiveSpec] = &[
554+ DirectiveSpec { name: "page", args: ArgGrammar::AttrsText, body: BodyKind::None },
555+ DirectiveSpec { name: "let", args: ArgGrammar::Definition, body: BodyKind::Htmlang },
556+ DirectiveSpec { name: "include", args: ArgGrammar::Text, body: BodyKind::None },
557+ DirectiveSpec { name: "data", args: ArgGrammar::Data, body: BodyKind::None },
558+ DirectiveSpec { name: "meta", args: ArgGrammar::Text, body: BodyKind::None },
559+ DirectiveSpec { name: "if", args: ArgGrammar::Expression, body: BodyKind::Htmlang },
560+ DirectiveSpec { name: "else", args: ArgGrammar::Else, body: BodyKind::Htmlang },
561+ DirectiveSpec { name: "each", args: ArgGrammar::Loop, body: BodyKind::Htmlang },
562+ DirectiveSpec { name: "style", args: ArgGrammar::Text, body: BodyKind::Verbatim },
563+ DirectiveSpec { name: "head", args: ArgGrammar::Text, body: BodyKind::Verbatim },
564+ DirectiveSpec { name: "raw", args: ArgGrammar::Text, body: BodyKind::Verbatim },
565+ DirectiveSpec { name: "markdown", args: ArgGrammar::Text, body: BodyKind::Verbatim },
566+];
567+
568+/// The directive named `name` (without the `@`).
569+pub fn directive(name: &str) -> Option<&'static DirectiveSpec> {
570+ DIRECTIVES.iter().find(|d| d.name == name)
571+}
572+
573+/// What the lines indented under `@name` are: the directive's body kind,
574+/// or an element's (verbatim for a row that says so: `@script`, and
575+/// `@code` and `@textarea`, whose text is literal; htmlang for every other
576+/// element).
577+pub fn body_kind(name: &str) -> BodyKind {
578+ let verbatim = |spec: &TagSpec| spec.verbatim || spec.literal;
579+ match directive(name) {
580+ Some(spec) => spec.body,
581+ None if ElementKind::from_name(name)
582+ .and_then(|kind| kind.spec())
583+ .is_some_and(verbatim) =>
584+ {
585+ BodyKind::Verbatim
586+ }
587+ None => BodyKind::Htmlang,
588+ }
589+}
590+
591+#[cfg(test)]
592+mod tests {
593+ use super::{ElementKind, Layout};
594+
595+ fn layout(name: &str) -> Layout {
596+ ElementKind::from_name(name).unwrap().layout()
597+ }
598+
599+ #[test]
600+ fn every_element_has_the_layout_it_is_documented_with() {
601+ let text = [
602+ "text",
603+ "paragraph",
604+ "link",
605+ "h1",
606+ "h2",
607+ "h3",
608+ "h4",
609+ "h5",
610+ "h6",
611+ "button",
612+ "td",
613+ "th",
614+ "label",
615+ "summary",
616+ "legend",
617+ "figcaption",
618+ "dt",
619+ "cite",
620+ "code",
621+ "kbd",
622+ "mark",
623+ "abbr",
624+ "time",
625+ "b",
626+ "i",
627+ "strong",
628+ "em",
629+ "small",
630+ "sub",
631+ "sup",
632+ "q",
633+ "del",
634+ ];
635+ for name in text {
636+ assert_eq!(layout(name), Layout::Text, "@{}", name);
637+ }
638+ let columns = [
639+ "el", "li", "dd", "ul", "ol", "dl", "search", "address", "noscript", "section", "form",
640+ "in-front", "behind", "hgroup", "menu",
641+ ];
642+ for name in columns {
643+ assert_eq!(layout(name), Layout::Column, "@{}", name);
644+ }
645+ assert_eq!(layout("row"), Layout::Row);
646+ assert_eq!(layout("grid"), Layout::Grid);
647+ for name in [
648+ "table", "tr", "select", "option", "picture", "video", "pre", "canvas", "caption",
649+ "colgroup", "tfoot", "optgroup", "ruby", "rt", "object",
650+ ] {
651+ assert_eq!(layout(name), Layout::Native, "@{}", name);
652+ }
653+ for name in [
654+ "image", "input", "hr", "source", "br", "wbr", "col", "track", "embed", "area",
655+ ] {
656+ assert_eq!(layout(name), Layout::Void, "@{}", name);
657+ }
658+ }
659+
660+ #[test]
661+ fn html_names_written_otherwise_are_not_elements() {
662+ for (html, htmlang) in super::HTML_NAMES_WRITTEN_OTHERWISE {
663+ assert!(ElementKind::from_name(html).is_none(), "@{}", html);
664+ let kind = ElementKind::from_name(htmlang).unwrap();
665+ let written = match kind {
666+ ElementKind::El => "div",
667+ ElementKind::Text => "span",
668+ ElementKind::Paragraph => "p",
669+ ElementKind::Link => "a",
670+ ElementKind::Image => "img",
671+ _ => panic!("@{} is not one of htmlang's own", htmlang),
672+ };
673+ assert_eq!(written, *html);
674+ }
675+ // No two rows share a name, and none is a directive's
676+ let mut names: Vec<&str> = ElementKind::all_names().collect();
677+ let count = names.len();
678+ names.sort_unstable();
679+ names.dedup();
680+ assert_eq!(names.len(), count, "an element name is listed twice");
681+ for name in names {
682+ assert!(super::directive(name).is_none(), "@{}", name);
683+ }
684+ }
685+
686+ #[test]
687+ fn each_row_names_at_most_one_leading_attribute() {
688+ let leading = |name: &str, in_picture: bool| {
689+ ElementKind::from_name(name)
690+ .unwrap()
691+ .leading_attribute(in_picture)
692+ };
693+ for (name, attr) in [
694+ ("link", "href"),
695+ ("area", "href"),
696+ ("image", "src"),
697+ ("script", "src"),
698+ ("iframe", "src"),
699+ ("video", "src"),
700+ ("audio", "src"),
701+ ("track", "src"),
702+ ("embed", "src"),
703+ ("form", "action"),
704+ ("object", "data"),
705+ ("optgroup", "label"),
706+ ] {
707+ assert_eq!(leading(name, false), Some(attr), "@{}", name);
708+ assert_eq!(leading(name, true), Some(attr), "@{}", name);
709+ }
710+ assert_eq!(leading("source", false), Some("src"));
711+ assert_eq!(leading("source", true), Some("srcset"));
712+ for name in ["el", "text", "h1", "button", "slot", "fragment", "picture"] {
713+ assert_eq!(leading(name, false), None, "@{}", name);
714+ }
715+ // @script is an ordinary row whose body is kept as written
716+ let script = ElementKind::from_name("script").unwrap();
717+ assert!(script.is_verbatim() && script.spec().is_some());
718+ assert_eq!(super::body_kind("script"), super::BodyKind::Verbatim);
719+ assert_eq!(super::body_kind("el"), super::BodyKind::Htmlang);
720+ // @code and @textarea: a verbatim body, shown as text; @pre is an
721+ // ordinary element, so `@pre > @code` and wrapper functions work
722+ for name in ["code", "textarea"] {
723+ assert_eq!(
724+ super::body_kind(name),
725+ super::BodyKind::Verbatim,
726+ "@{}",
727+ name
728+ );
729+ assert!(super::has_literal_text(name), "@{}", name);
730+ }
731+ assert_eq!(super::body_kind("pre"), super::BodyKind::Htmlang);
732+ // Every verbatim directive takes its line as its one-line body or file
733+ for spec in super::DIRECTIVES {
734+ if spec.body == super::BodyKind::Verbatim {
735+ assert_eq!(spec.args, super::ArgGrammar::Text, "@{}", spec.name);
736+ }
737+ }
738+ }
739+
740+ #[test]
741+ fn an_element_has_a_tag_of_its_own_when_no_other_element_writes_it() {
742+ let own = |name: &str| ElementKind::from_name(name).unwrap().own_tag();
743+ for shared in [
744+ "el", "row", "grid", "in-front", "behind", "text", "script", "fragment",
745+ ] {
746+ assert_eq!(own(shared), None, "@{}", shared);
747+ }
748+ assert_eq!(own("td"), Some("td"));
749+ assert_eq!(own("link"), Some("a"));
750+ assert_eq!(own("paragraph"), Some("p"));
751+ assert_eq!(own("image"), Some("img"));
752+ // Every tag of its own is written by that one element
753+ let names: Vec<&str> = ElementKind::all_names().collect();
754+ for name in &names {
755+ let Some(tag) = own(name) else { continue };
756+ let writers: Vec<&&str> = names
757+ .iter()
758+ .filter(|other| ElementKind::from_name(other).unwrap().tag() == Some(tag))
759+ .collect();
760+ assert_eq!(writers, vec![name], "<{}>", tag);
761+ }
762+ }
763+
764+ #[test]
765+ fn a_layout_s_display_is_not_repeated_in_the_row_s_css() {
766+ for spec in super::TAGS {
767+ assert!(
768+ !spec.css.contains("display"),
769+ "@{}: {}",
770+ spec.name,
771+ spec.css
772+ );
773+ }
774+ }
170775}
crates/htmlang-core/src/codegen.rs+1697−3530
@@ -1,7 +1,8 @@
11use std::collections::HashMap;
2−use std::hash::{Hash, Hasher};
2+use std::hash::Hash;
33
44use crate::ast::*;
5+use crate::vocab::with_px;
56
67// ---------------------------------------------------------------------------
78// Style collector: deduplicates CSS and assigns class names
@@ -16,52 +17,315 @@ const BREAKPOINTS: &[(&str, &str)] = &[
1617 ("2xl", "1536px"),
1718];
1819
19−/// Generate short CSS class names: a, b, ..., z, a0, a1, ..., z9, aa, ab, ...
20−pub(crate) fn short_class_name(idx: usize) -> String {
21− if idx < 26 {
22− return String::from((b'a' + idx as u8) as char);
20+/// The prefix of every generated class (`hl-2k9xq0m`), which keeps them
21+/// out of the author's own class names.
22+pub const CLASS_PREFIX: &str = "hl-";
23+
24+/// How many base-36 digits a class's name has after `hl-`: 7, about 36
25+/// bits of its style's hash, so a site with 10,000 different styles has
26+/// odds of about 6 in 10,000 that two of them share a name, which
27+/// `htmlang build` reports (`class-collision`). Within one file, a style
28+/// whose name is taken takes more digits.
29+pub const CLASS_DIGITS: usize = 7;
30+
31+/// A stable hash of `text`: FNV-1a, then SplitMix64's finalizer, so every
32+/// digit of a name depends on all of the text. It is the same on every
33+/// platform and in every build, unlike `std`'s hasher.
34+pub fn stable_hash(text: &str) -> u64 {
35+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
36+ for byte in text.bytes() {
37+ hash ^= u64::from(byte);
38+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
39+ }
40+ hash = (hash ^ (hash >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
41+ hash = (hash ^ (hash >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
42+ hash ^ (hash >> 31)
43+}
44+
45+/// `digits` base-36 digits of `hash`: `2k9xq0m`. With 13, all of it.
46+pub fn hash_digits(hash: u64, digits: usize) -> String {
47+ const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
48+ let mut n = hash;
49+ (0..digits)
50+ .map(|_| {
51+ let digit = DIGITS[(n % 36) as usize] as char;
52+ n /= 36;
53+ digit
54+ })
55+ .collect()
56+}
57+
58+/// A class htmlang generated: its name, and the hash of the style it
59+/// stands for. One style has one name in every file, so a fragment's
60+/// classes mean the same in any page it goes into.
61+#[derive(Clone, Debug, PartialEq, Eq)]
62+pub struct Class {
63+ pub name: String,
64+ pub style: u64,
65+}
66+
67+/// When a style applies: the at-rule prefixes it is under (their ranks,
68+/// see [`crate::vocab::at_rule_rank`], sorted, so `dark:md:` and `md:dark:`
69+/// are one condition) and its selector prefixes, left to right as the
70+/// selector reads (`hover:children:` is `.a:hover > *`).
71+#[derive(Clone, Default, PartialEq, Eq, Hash, Debug)]
72+struct Condition {
73+ at: Vec<usize>,
74+ selector: Vec<String>,
75+}
76+
77+impl Condition {
78+ /// The condition of an attribute's key, and the name after its
79+ /// prefixes.
80+ fn of(key: &str) -> (Condition, &str) {
81+ let (prefixes, name) = crate::vocab::split_prefixes(key);
82+ let mut condition = Condition::default();
83+ for prefix in prefixes {
84+ match crate::vocab::at_rule_rank(prefix) {
85+ Some(rank) => condition.at.push(rank),
86+ None => condition.selector.push(prefix.to_string()),
87+ }
88+ }
89+ condition.at.sort_unstable();
90+ condition.at.dedup();
91+ (condition, name)
2392 }
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;
93+
94+ /// Only the at-rule part: a direction under `hover:` is the one without
95+ /// it.
96+ fn at_only(at: &[usize]) -> Condition {
97+ Condition {
98+ at: at.to_vec(),
99+ selector: Vec::new(),
100+ }
101+ }
102+
103+ /// The styles go on the element's children (`children:`).
104+ fn on_children(&self) -> bool {
105+ self.selector.iter().any(|p| p == crate::vocab::CHILDREN)
106+ }
107+
108+ /// The styles go on the elements of one kind inside the element
109+ /// (`@td:`): where the element prefix is in the selector chain.
110+ fn inside(&self) -> Option<usize> {
111+ self.selector
112+ .iter()
113+ .position(|p| crate::vocab::element_prefix(p).is_some())
114+ }
115+
116+ /// Whether this condition holds wherever `other` does: its at-rules
117+ /// are implied by `other`'s, and it has no selector or `other`'s.
118+ fn holds_at(&self, other: &Condition) -> bool {
119+ (self.selector.is_empty() || self.selector == other.selector)
120+ && self.at.iter().all(|&a| {
121+ other
122+ .at
123+ .iter()
124+ .any(|&b| crate::vocab::at_rule_implied(a, b))
125+ })
126+ }
127+
128+ /// The order the rules of each condition are written in, which the
129+ /// cascade follows: no prefix, then selectors, then each at-rule
130+ /// block (see [`at_order`]), with its selectors inside it.
131+ fn order(&self) -> (AtOrder, SelectorOrder) {
132+ (at_order(&self.at), selector_order(&self.selector))
133+ }
134+}
135+
136+/// Where a block of at-rules is written: after the rules without one; by
137+/// its last (innermost) prefix in the order of
138+/// [`crate::vocab::at_rule_prefixes`] (widths, media, container widths), so
139+/// `md:dark:` comes with `dark:`, after it; then by length.
140+type AtOrder = (bool, usize, usize, Vec<usize>);
141+
142+fn at_order(at: &[usize]) -> AtOrder {
143+ (
144+ !at.is_empty(),
145+ at.last().copied().unwrap_or(0),
146+ at.len(),
147+ at.to_vec(),
148+ )
149+}
150+
151+/// Selector chains in the order of [`crate::vocab::PSEUDOS`] (position,
152+/// then form state, then `hover:`, `focus:`, `active:`, `disabled:`), and
153+/// by their argument's text for the same pseudo-class; a chain before the
154+/// longer ones it starts. A chain through `children:` comes after the
155+/// others, ordered by what follows its last `children:` and then by what
156+/// comes before it: only that tail counts toward its specificity (see
157+/// [`selector`]), so `hover:children:` follows `children:` and wins over it
158+/// on hover.
159+type SelectorOrder = (bool, Vec<(usize, String)>, Vec<(usize, String)>);
160+
161+fn selector_order(selector: &[String]) -> SelectorOrder {
162+ let table = crate::vocab::PSEUDOS;
163+ let ranked = |chain: &[String]| -> Vec<(usize, String)> {
164+ chain
165+ .iter()
166+ .map(|p| {
167+ let name = crate::vocab::pseudo_name(p);
168+ let rank = table
169+ .iter()
170+ .position(|(q, _)| *q == name)
171+ .unwrap_or(table.len());
172+ (rank, p.clone())
173+ })
174+ .collect()
175+ };
176+ match selector.iter().rposition(|p| p == crate::vocab::CHILDREN) {
177+ None => (false, ranked(selector), Vec::new()),
178+ Some(i) => (true, ranked(&selector[i + 1..]), ranked(&selector[..i])),
179+ }
180+}
181+
182+/// The order of the chains through an element prefix (`@td:`, see
183+/// [`scoped_selector`]): by the prefixes before it, which select the root
184+/// of the scope and add nothing to the specificity, so `hover:@td:` comes
185+/// after `@td:` and wins on hover; then by the element; then by the
186+/// prefixes after it.
187+fn inside_order(chain: &[String]) -> (SelectorOrder, String, SelectorOrder) {
188+ let at = chain
189+ .iter()
190+ .position(|p| crate::vocab::element_prefix(p).is_some())
191+ .unwrap_or(chain.len());
192+ (
193+ selector_order(&chain[..at]),
194+ chain.get(at).cloned().unwrap_or_default(),
195+ selector_order(chain.get(at + 1..).unwrap_or_default()),
196+ )
197+}
198+
199+/// The selector for `class` under a chain of selector prefixes, read left
200+/// to right. Everything before the last `children:` goes in `:where()`, so
201+/// a parent's `children:` styles have no specificity, and a child's own
202+/// attributes win over them: `children:hover:` is `:where(.a)>*:hover`,
203+/// `hover:children:` is `:where(.a:hover)>*`.
204+fn selector(class: &str, chain: &[String]) -> String {
205+ let last_children = chain.iter().rposition(|p| p == crate::vocab::CHILDREN);
206+ let mut out = format!(".{}", class);
207+ for (i, prefix) in chain.iter().enumerate() {
208+ if prefix == crate::vocab::CHILDREN {
209+ if Some(i) == last_children {
210+ out = format!(":where({})>*", out);
211+ } else {
212+ out.push_str(">*");
213+ }
214+ } else if let Some(pseudo) = crate::vocab::pseudo_selector(prefix) {
215+ out.push_str(&pseudo);
216+ }
217+ }
218+ out
219+}
220+
221+/// The rule for the elements an element prefix picks out inside the
222+/// element with the class `class` (see [`Condition::inside`]): the scope's
223+/// root, from the selector prefixes before it (`hover:@td:` is inside a
224+/// hovered element), and the selector for the elements, from those after
225+/// it (`@td:hover:` is a hovered cell). `:scope td` leaves out the root
226+/// itself, so `@ul [@ul:...]` styles the lists inside, not its own. `None`
227+/// when the name has no tag of its own (the parser reports it).
228+fn scoped_selector(class: &str, chain: &[String], at: usize) -> Option<(String, String)> {
229+ let name = crate::vocab::element_prefix(&chain[at])?;
230+ let tag = ElementKind::from_name(name)?.own_tag()?;
231+ let root = selector(class, &chain[..at]);
232+ let mut target = format!(":scope {}", tag);
233+ for prefix in &chain[at + 1..] {
234+ if let Some(pseudo) = crate::vocab::pseudo_selector(prefix) {
235+ target.push_str(&pseudo);
236+ }
237+ }
238+ Some((root, target))
239+}
240+
241+/// The `@media` or `@container` rule an at-rule prefix stands for, as
242+/// written in readable and in compact output.
243+fn at_rule(rank: usize, dev: bool) -> String {
244+ let prefix = crate::vocab::at_rule_prefixes()
245+ .nth(rank)
246+ .unwrap_or_default();
247+ let name = prefix.trim_end_matches(':');
248+ let width = |name: &str| {
249+ BREAKPOINTS
250+ .iter()
251+ .find(|(n, _)| *n == name)
252+ .map_or("0", |(_, w)| *w)
253+ };
254+ let (kind, feature, value) = if let Some(size) = name.strip_prefix("cq-") {
255+ ("@container", "min-width", width(size))
256+ } else {
257+ match name {
258+ "print" => {
259+ return "@media print".to_string();
260+ }
261+ "dark" => ("@media", "prefers-color-scheme", "dark"),
262+ "motion-safe" => ("@media", "prefers-reduced-motion", "no-preference"),
263+ "motion-reduce" => ("@media", "prefers-reduced-motion", "reduce"),
264+ "landscape" | "portrait" => ("@media", "orientation", name),
265+ _ => ("@media", "min-width", width(name)),
33266 }
34− n /= 36;
267+ };
268+ if dev {
269+ format!("{} ({}: {})", kind, feature, value)
270+ } else {
271+ format!("{}({}:{})", kind, feature, value)
35272 }
36− // Reverse so it reads naturally
37− name.chars().rev().collect()
273+}
274+
275+/// A style as text, for its hash: the defaults of its element's kind, its
276+/// rules (the at-rules' ranks, the selector prefixes and the declarations
277+/// of each) and the rules keyed on it, each part ended by a character CSS
278+/// doesn't have.
279+fn style_text(defaults: &str, rules: &[(Condition, String)], distinct: &str) -> String {
280+ let mut text = format!("{defaults}\u{1}");
281+ for (condition, body) in rules {
282+ for rank in &condition.at {
283+ text.push_str(&format!("{rank}\u{2}"));
284+ }
285+ text.push('\u{3}');
286+ for prefix in &condition.selector {
287+ text.push_str(prefix);
288+ text.push('\u{2}');
289+ }
290+ text.push('\u{3}');
291+ text.push_str(body);
292+ text.push('\u{1}');
293+ }
294+ text.push_str(distinct);
295+ text
38296}
39297
40298struct StyleEntry {
41299 class_name: String,
42− base: String,
43− /// (CSS selector suffix, css_rules) — e.g. (":hover", "color:red;")
44− pseudo: Vec<(String, String)>,
45− /// Responsive overrides: (breakpoint_prefix, css)
46− responsive: Vec<(String, String)>,
47− /// Dark mode overrides
48− dark: String,
49− /// Print overrides
50− print: String,
51− motion_safe: String,
52− motion_reduce: String,
53− landscape: String,
54− portrait: String,
55− /// Container query overrides: (breakpoint_prefix, css)
56− container: Vec<(String, String)>,
300+ /// The hash of the style, which names the class
301+ style: u64,
302+ /// The defaults of the element's kind (its layout, a heading's
303+ /// `margin:0`, ...), less what its own attributes set. They are written
304+ /// as `:where(.hl-a)` in the layer `hl-kind`, so an element prefix
305+ /// around it (`@h2:`), its parent's `children:` styles and its own
306+ /// attributes, all in later layers, win over them.
307+ defaults: String,
308+ /// The element's rules: under each condition, its declarations, in the
309+ /// order they are written
310+ rules: Vec<(Condition, String)>,
311+ /// What else tells two elements with the same CSS apart: the rules
312+ /// keyed on this class (see [`StyleCollector::keyed`]). Not written.
313+ distinct: String,
57314}
58315
59316struct StyleCollector {
60317 entries: Vec<StyleEntry>,
61− /// Maps a pre-hashed style signature to an index into `entries`.
62− /// Using u64 as the key keeps lookups allocation-free; on the rare case of
63− /// a hash collision we fall back to a full equality check against the entry.
318+ /// Maps a style's hash to its entries (more than one only when two
319+ /// styles hash alike, which the full comparison tells apart).
64320 index: HashMap<u64, Vec<usize>>,
321+ /// The names given, which a style whose name is taken doesn't get
322+ names: std::collections::HashSet<String>,
323+ /// Rules for an element's children, keyed on its class and written in
324+ /// the block of an at-rule condition: `(at-rules, selector, body)`, e.g.
325+ /// `([md], ":where(.a)>.b", "flex:1;")` (see [`Flow`]).
326+ keyed: Vec<(Vec<usize>, String, String)>,
327+ /// The keyed rules already added
328+ keyed_seen: std::collections::HashSet<(Vec<usize>, String, String)>,
65329}
66330
67331impl StyleCollector {
@@ -69,328 +333,282 @@ impl StyleCollector {
69333 StyleCollector {
70334 entries: Vec::new(),
71335 index: HashMap::new(),
336+ names: std::collections::HashSet::new(),
337+ keyed: Vec::new(),
338+ keyed_seen: std::collections::HashSet::new(),
339+ }
340+ }
341+
342+ /// Add a rule for `selector` under the at-rules `at`, once.
343+ fn add_keyed(&mut self, at: Vec<usize>, selector: String, body: String) {
344+ let rule = (at, selector, body);
345+ if self.keyed_seen.insert(rule.clone()) {
346+ self.keyed.push(rule);
72347 }
73348 }
74349
75350 /// Returns a class name for this style combination, or None if all empty.
76− #[allow(clippy::too_many_arguments)]
77351 fn get_class(
78352 &mut self,
79− base: String,
80− pseudo: Vec<(String, String)>,
81− responsive: Vec<(String, String)>,
82− dark: String,
83− print: String,
84− motion_safe: String,
85− motion_reduce: String,
86− landscape: String,
87− portrait: String,
88− container: Vec<(String, String)>,
353+ defaults: String,
354+ mut rules: Vec<(Condition, String)>,
355+ distinct: String,
89356 ) -> Option<String> {
90− if base.is_empty()
91− && pseudo.is_empty()
92− && responsive.is_empty()
93− && dark.is_empty()
94− && print.is_empty()
95− && motion_safe.is_empty()
96− && motion_reduce.is_empty()
97− && landscape.is_empty()
98− && portrait.is_empty()
99− && container.is_empty()
100− {
357+ rules.retain(|(_, body)| !body.is_empty());
358+ if defaults.is_empty() && rules.is_empty() {
101359 return None;
102360 }
103− use std::collections::hash_map::DefaultHasher;
104− let mut h = DefaultHasher::new();
105− base.hash(&mut h);
106− pseudo.hash(&mut h);
107− responsive.hash(&mut h);
108− dark.hash(&mut h);
109− print.hash(&mut h);
110− motion_safe.hash(&mut h);
111− motion_reduce.hash(&mut h);
112− landscape.hash(&mut h);
113− portrait.hash(&mut h);
114− container.hash(&mut h);
115− let sig = h.finish();
116−
117− if let Some(indices) = self.index.get(&sig) {
361+ rules.sort_by_cached_key(|(condition, _)| condition.order());
362+ let style = stable_hash(&style_text(&defaults, &rules, &distinct));
363+
364+ if let Some(indices) = self.index.get(&style) {
118365 for &idx in indices {
119366 let e = &self.entries[idx];
120− if e.base == base
121− && e.pseudo == pseudo
122− && e.responsive == responsive
123− && e.dark == dark
124− && e.print == print
125− && e.motion_safe == motion_safe
126− && e.motion_reduce == motion_reduce
127− && e.landscape == landscape
128− && e.portrait == portrait
129− && e.container == container
130− {
367+ if e.defaults == defaults && e.rules == rules && e.distinct == distinct {
131368 return Some(e.class_name.clone());
132369 }
133370 }
134371 }
372+ // The name is the hash's first digits, and more of them when
373+ // another style of this file has those; after all 13, the entry's
374+ // number tells the two apart
135375 let idx = self.entries.len();
136− let name = short_class_name(idx);
376+ let name = (CLASS_DIGITS..=13)
377+ .map(|digits| format!("{}{}", CLASS_PREFIX, hash_digits(style, digits)))
378+ .find(|name| !self.names.contains(name))
379+ .unwrap_or_else(|| format!("{}{}{}", CLASS_PREFIX, hash_digits(style, 13), idx));
380+ self.names.insert(name.clone());
137381 self.entries.push(StyleEntry {
138382 class_name: name.clone(),
139− base,
140− pseudo,
141− responsive,
142− dark,
143− print,
144− motion_safe,
145− motion_reduce,
146− landscape,
147− portrait,
148− container,
383+ style,
384+ defaults,
385+ rules,
386+ distinct,
149387 });
150− self.index.entry(sig).or_default().push(idx);
388+ self.index.entry(style).or_default().push(idx);
151389 Some(name)
152390 }
153391
154− fn to_css_formatted(&self, dev: bool, use_layer: bool) -> String {
155− let mut css = String::new();
156− let inner_indent = if use_layer && dev { " " } else { "" };
392+ /// The classes given, in the order they were
393+ fn classes(&self) -> impl Iterator<Item = Class> + '_ {
394+ self.entries.iter().map(|e| Class {
395+ name: e.class_name.clone(),
396+ style: e.style,
397+ })
398+ }
157399
158− // Wrap in @layer for specificity management
159− if use_layer {
400+ /// All generated rules, in three layers (see [`LAYERS`]): the element
401+ /// kinds' defaults in `hl-kind` (`:where(.hl-a)`), the rules for the
402+ /// elements an element prefix picks out inside an element (`@td:`), and
403+ /// a function's scoped `@style`, in `hl-inside`, and the elements' own
404+ /// rules in `htmlang`. They are written in a fixed order, so which rule
405+ /// wins never depends on where in the page an element is: the rules
406+ /// without a prefix, then those under selector prefixes, then each block
407+ /// of at-rules (see [`at_order`]), in which the rules keyed on a parent
408+ /// come first, then the element's own, then its selectors (see
409+ /// [`selector_order`]).
410+ fn to_css_formatted(&self, dev: bool, scoped: &[String]) -> String {
411+ // The defaults: `:where(.hl-a,.hl-b){...}`
412+ let mut kind = String::new();
413+ let mut defaults: Vec<(&str, Vec<&str>)> = Vec::new();
414+ for e in self.entries.iter().filter(|e| !e.defaults.is_empty()) {
415+ match defaults.iter_mut().find(|(body, _)| *body == e.defaults) {
416+ Some((_, classes)) => classes.push(&e.class_name),
417+ None => defaults.push((&e.defaults, vec![&e.class_name])),
418+ }
419+ }
420+ for (body, classes) in defaults {
421+ let classes: Vec<String> = classes.iter().map(|c| format!(".{}", c)).collect();
422+ let selector = format!(":where({})", classes.join(","));
160423 if dev {
161− css.push_str("@layer htmlang {\n");
424+ kind.push_str(&format!(" {} {{{}}}\n", selector, body));
162425 } else {
163− css.push_str("@layer htmlang{");
426+ kind.push_str(&format!("{}{{{}}}", selector, body));
164427 }
165428 }
166429
167− // Non-responsive rules. Base and pseudo rules are merged separately by
168− // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
169− // replaces two identical rules. Pseudo variants are grouped per selector
170− // suffix (`:hover` with `:hover`, etc.) to keep each pseudo's cascade
171− // position independent of others.
172− let mut base_pairs: Vec<(&str, &str)> = Vec::with_capacity(self.entries.len());
173− for e in &self.entries {
174− if !e.base.is_empty() {
175− base_pairs.push((e.class_name.as_str(), e.base.as_str()));
430+ let mut inside = self.blocks(true, dev);
431+ // A function's scoped `@style`, nested under its class
432+ for block in scoped {
433+ if dev {
434+ inside.push_str(block);
435+ inside.push('\n');
436+ } else {
437+ inside.push_str(&compact_css(block));
176438 }
177439 }
178− emit_grouped_rules(&mut css, &base_pairs, "", inner_indent, dev);
179−
180− // Collect pseudo rules grouped by selector suffix, preserving the order
181− // in which suffixes first appear across entries.
182− let mut pseudo_order: Vec<&str> = Vec::new();
183− let mut pseudo_buckets: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
184− for e in &self.entries {
185− for (selector, body) in &e.pseudo {
186− if body.is_empty() {
187− continue;
188− }
189− let key = selector.as_str();
190− if !pseudo_buckets.contains_key(key) {
191− pseudo_order.push(key);
192− }
193− pseudo_buckets
194− .entry(key)
195− .or_default()
196− .push((e.class_name.as_str(), body.as_str()));
440+
441+ let mut css = String::new();
442+ for (name, rules) in [
443+ ("hl-kind", kind),
444+ ("hl-inside", inside),
445+ ("htmlang", self.blocks(false, dev)),
446+ ] {
447+ if rules.is_empty() {
448+ continue;
197449 }
198− }
199− for selector in &pseudo_order {
200− if let Some(pairs) = pseudo_buckets.get(selector) {
201− emit_grouped_rules(&mut css, pairs, selector, inner_indent, dev);
450+ if dev {
451+ css.push_str(&format!("@layer {} {{\n{}}}\n", name, rules));
452+ } else {
453+ css.push_str(&format!("@layer {}{{{}}}", name, rules));
202454 }
203455 }
456+ css
457+ }
204458
205− // Responsive rules grouped by breakpoint
206− for &(bp_name, bp_width) in BREAKPOINTS {
207− let mut bp_pairs: Vec<(&str, &str)> = Vec::new();
208− for e in &self.entries {
209− for (bp, rule_css) in &e.responsive {
210− if bp == bp_name && !rule_css.is_empty() {
211− bp_pairs.push((e.class_name.as_str(), rule_css.as_str()));
459+ /// The rules of every block of at-rules, in order: those for the
460+ /// elements inside (`inside`: rules under an element prefix, each in
461+ /// an `@scope` on its element's class), or the elements' own.
462+ fn blocks(&self, inside: bool, dev: bool) -> String {
463+ let mut css = String::new();
464+ let rules = || {
465+ self.entries.iter().flat_map(move |e| {
466+ e.rules
467+ .iter()
468+ .filter(move |(c, _)| c.inside().is_some() == inside)
469+ .map(move |(c, body)| (e, c, body))
470+ })
471+ };
472+ let mut blocks: Vec<&[usize]> = vec![&[]];
473+ let keyed = self.keyed.iter().filter(|_| !inside);
474+ let every = rules()
475+ .map(|(_, c, _)| c.at.as_slice())
476+ .chain(keyed.map(|(at, _, _)| at.as_slice()));
477+ for at in every {
478+ if !blocks.contains(&at) {
479+ blocks.push(at);
480+ }
481+ }
482+ blocks.sort_by_key(|at| at_order(at));
483+
484+ for at in blocks {
485+ let depth = at.len();
486+ // Readable output indents a rule inside `@layer` and inside
487+ // each at-rule but the first
488+ let indent = if dev {
489+ " ".repeat(depth.max(1))
490+ } else {
491+ String::new()
492+ };
493+ let mut inner = String::new();
494+ // Rules keyed on a parent's class come first: they have the
495+ // specificity of one class, like a class rule, so the child's
496+ // own rules in the block still win
497+ if !inside {
498+ let keyed: Vec<(&str, &str)> = self
499+ .keyed
500+ .iter()
501+ .filter(|(a, _, _)| a.as_slice() == at)
502+ .map(|(_, selector, body)| (selector.as_str(), body.as_str()))
503+ .collect();
504+ emit_selector_rules(&mut inner, &keyed, &indent, dev);
505+ }
506+ // The selector chains of this block, in order
507+ let mut chains: Vec<&[String]> = Vec::new();
508+ for (_, condition, _) in rules() {
509+ if condition.at == at && !chains.contains(&condition.selector.as_slice()) {
510+ chains.push(&condition.selector);
511+ }
512+ }
513+ if inside {
514+ chains.sort_by_cached_key(|chain| inside_order(chain));
515+ // One `@scope` for each root, its rules in the order of
516+ // the chains: rules for one element under two roots (`.a`
517+ // and `.a:hover`) tie on specificity and proximity, so the
518+ // later one wins
519+ let mut scopes: Vec<(String, Vec<(String, &str)>)> = Vec::new();
520+ for chain in chains {
521+ let Some(element) = chain
522+ .iter()
523+ .position(|p| crate::vocab::element_prefix(p).is_some())
524+ else {
525+ continue;
526+ };
527+ for (e, _, body) in
528+ rules().filter(|(_, c, _)| c.at == at && c.selector == chain)
529+ {
530+ let Some((root, target)) = scoped_selector(&e.class_name, chain, element)
531+ else {
532+ continue;
533+ };
534+ match scopes.iter_mut().find(|(r, _)| *r == root) {
535+ Some((_, targets)) => targets.push((target, body)),
536+ None => scopes.push((root, vec![(target, body)])),
537+ }
212538 }
213539 }
214− }
215− emit_media_block(
216− &mut css,
217− &format!("@media (min-width: {})", bp_width),
218− &format!("@media(min-width:{})", bp_width),
219− &bp_pairs,
220− "",
221− dev,
222− );
223− }
224−
225− // Dark mode rules
226− let dark_pairs: Vec<(&str, &str)> = self
227− .entries
228− .iter()
229− .filter(|e| !e.dark.is_empty())
230− .map(|e| (e.class_name.as_str(), e.dark.as_str()))
231− .collect();
232− emit_media_block(
233− &mut css,
234− "@media (prefers-color-scheme: dark)",
235− "@media(prefers-color-scheme:dark)",
236− &dark_pairs,
237− "",
238− dev,
239− );
240−
241− // Print rules
242− let print_pairs: Vec<(&str, &str)> = self
243− .entries
244− .iter()
245− .filter(|e| !e.print.is_empty())
246− .map(|e| (e.class_name.as_str(), e.print.as_str()))
247− .collect();
248− emit_media_block(
249− &mut css,
250− "@media print",
251− "@media print",
252− &print_pairs,
253− "",
254− dev,
255− );
256−
257− // Motion safe rules
258− let motion_safe_pairs: Vec<(&str, &str)> = self
259− .entries
260− .iter()
261− .filter(|e| !e.motion_safe.is_empty())
262− .map(|e| (e.class_name.as_str(), e.motion_safe.as_str()))
263− .collect();
264− emit_media_block(
265− &mut css,
266− "@media (prefers-reduced-motion: no-preference)",
267− "@media(prefers-reduced-motion:no-preference)",
268− &motion_safe_pairs,
269− "",
270− dev,
271− );
272−
273− // Motion reduce rules
274− let motion_reduce_pairs: Vec<(&str, &str)> = self
275− .entries
276− .iter()
277− .filter(|e| !e.motion_reduce.is_empty())
278− .map(|e| (e.class_name.as_str(), e.motion_reduce.as_str()))
279− .collect();
280− emit_media_block(
281− &mut css,
282− "@media (prefers-reduced-motion: reduce)",
283− "@media(prefers-reduced-motion:reduce)",
284− &motion_reduce_pairs,
285− "",
286− dev,
287− );
288−
289− // Landscape rules
290− let landscape_pairs: Vec<(&str, &str)> = self
291− .entries
292− .iter()
293− .filter(|e| !e.landscape.is_empty())
294− .map(|e| (e.class_name.as_str(), e.landscape.as_str()))
295− .collect();
296− emit_media_block(
297− &mut css,
298− "@media (orientation: landscape)",
299− "@media(orientation:landscape)",
300− &landscape_pairs,
301− "",
302− dev,
303− );
304−
305− // Portrait rules
306− let portrait_pairs: Vec<(&str, &str)> = self
307− .entries
308− .iter()
309− .filter(|e| !e.portrait.is_empty())
310− .map(|e| (e.class_name.as_str(), e.portrait.as_str()))
311− .collect();
312− emit_media_block(
313− &mut css,
314− "@media (orientation: portrait)",
315− "@media(orientation:portrait)",
316− &portrait_pairs,
317− "",
318− dev,
319− );
320−
321− // Container query rules grouped by breakpoint
322− for &(bp_name, bp_width) in BREAKPOINTS {
323− let mut cq_pairs: Vec<(&str, &str)> = Vec::new();
324− for e in &self.entries {
325− for (bp, rule_css) in &e.container {
326− if bp == bp_name && !rule_css.is_empty() {
327− cq_pairs.push((e.class_name.as_str(), rule_css.as_str()));
328− }
540+ let inner_indent = if dev {
541+ format!("{} ", indent)
542+ } else {
543+ String::new()
544+ };
545+ for (root, targets) in scopes {
546+ let targets: Vec<(&str, &str)> =
547+ targets.iter().map(|(t, b)| (t.as_str(), *b)).collect();
548+ inner.push_str(&indent);
549+ inner.push_str(&format!("@scope ({})", root));
550+ inner.push_str(if dev { " {\n" } else { "{" });
551+ emit_selector_rules(&mut inner, &targets, &inner_indent, dev);
552+ inner.push_str(&indent);
553+ inner.push_str(if dev { "}\n" } else { "}" });
329554 }
330− }
331− emit_media_block(
332− &mut css,
333− &format!("@container (min-width: {})", bp_width),
334− &format!("@container(min-width:{})", bp_width),
335− &cq_pairs,
336− "",
337− dev,
338− );
339− }
340−
341− // Close @layer
342− if use_layer {
343− if dev {
344− css.push_str("}\n");
345555 } else {
346− css.push('}');
556+ chains.sort_by_cached_key(|chain| selector_order(chain));
557+ for chain in chains {
558+ let pairs: Vec<(String, &str)> = rules()
559+ .filter(|(_, c, _)| c.at == at && c.selector == chain)
560+ .map(|(e, _, body)| (selector(&e.class_name, chain), body.as_str()))
561+ .collect();
562+ emit_grouped_rules(&mut inner, &pairs, &indent, dev);
563+ }
564+ }
565+ if inner.is_empty() {
566+ continue;
567+ }
568+ // Each at-rule wraps the next, outermost first
569+ for (level, &rank) in at.iter().enumerate().rev() {
570+ let pad = if dev {
571+ " ".repeat(level)
572+ } else {
573+ String::new()
574+ };
575+ inner = if dev {
576+ format!("{pad}{} {{\n{inner}{pad}}}\n", at_rule(rank, true))
577+ } else {
578+ format!("{}{{{inner}}}", at_rule(rank, false))
579+ };
347580 }
581+ css.push_str(&inner);
348582 }
349−
350583 css
351584 }
352585}
353586
354−/// Emit CSS rules from `(class_name, body)` pairs, merging identical bodies
587+/// Emit CSS rules from `(selector, body)` pairs, merging identical bodies
355588/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
356589/// of each distinct body determines ordering, so output stays deterministic
357−/// across runs. `selector_suffix` is appended to each class (e.g. `":hover"`,
358−/// or `""` for plain class rules). `indent` is prepended to each rule line.
359−fn emit_grouped_rules(
360− out: &mut String,
361− pairs: &[(&str, &str)],
362− selector_suffix: &str,
363− indent: &str,
364− dev: bool,
365−) {
590+/// across runs. `indent` is prepended to each rule line.
591+fn emit_grouped_rules(out: &mut String, pairs: &[(String, &str)], indent: &str, dev: bool) {
366592 if pairs.is_empty() {
367593 return;
368594 }
369595 // Group in first-occurrence order.
370596 let mut order: Vec<&str> = Vec::new();
371597 let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
372− for &(name, body) in pairs {
598+ for (selector, body) in pairs {
373599 if body.is_empty() {
374600 continue;
375601 }
376602 if !groups.contains_key(body) {
377603 order.push(body);
378604 }
379− groups.entry(body).or_default().push(name);
605+ groups.entry(body).or_default().push(selector);
380606 }
381607 let sp = if dev { " " } else { "" };
382608 let nl = if dev { "\n" } else { "" };
383609 for body in &order {
384− let names = &groups[body];
385610 out.push_str(indent);
386− for (i, n) in names.iter().enumerate() {
387− if i > 0 {
388− out.push(',');
389− }
390− out.push('.');
391− out.push_str(n);
392− out.push_str(selector_suffix);
393− }
611+ out.push_str(&groups[body].join(","));
394612 out.push_str(sp);
395613 out.push('{');
396614 out.push_str(body);
@@ -399,38 +617,44 @@ fn emit_grouped_rules(
399617 }
400618}
401619
402−/// Emit an `@media` / `@container` block containing grouped class rules.
403−/// Skips the block entirely if no non-empty bodies are present.
404−fn emit_media_block(
405− out: &mut String,
406− header_dev: &str,
407− header_min: &str,
408− pairs: &[(&str, &str)],
409− selector_suffix: &str,
410− dev: bool,
411−) {
412− if pairs.iter().all(|(_, body)| body.is_empty()) {
413− return;
414− }
415− let mut inner = String::new();
416− let inner_indent = if dev { " " } else { "" };
417− emit_grouped_rules(&mut inner, pairs, selector_suffix, inner_indent, dev);
418− if inner.is_empty() {
419− return;
420− }
421− if dev {
422− out.push_str(&format!("{} {{\n{}}}\n", header_dev, inner));
423− } else {
424− out.push_str(&format!("{}{{{}}}", header_min, inner));
620+/// Emit `(selector, body)` rules in their order, merging a run of rules
621+/// with the same body into one rule with a selector list (the order
622+/// matters: two rules may apply to one element).
623+fn emit_selector_rules(out: &mut String, rules: &[(&str, &str)], indent: &str, dev: bool) {
624+ let mut i = 0;
625+ while i < rules.len() {
626+ let body = rules[i].1;
627+ let run = rules[i..].iter().take_while(|(_, b)| *b == body).count();
628+ if !body.is_empty() {
629+ let selectors: Vec<&str> = rules[i..i + run].iter().map(|(s, _)| *s).collect();
630+ out.push_str(indent);
631+ out.push_str(&selectors.join(","));
632+ if dev {
633+ out.push(' ');
634+ }
635+ out.push('{');
636+ out.push_str(body);
637+ out.push('}');
638+ if dev {
639+ out.push('\n');
640+ }
641+ }
642+ i += run;
425643 }
426644}
427645
428646struct GenContext {
429647 dev: bool,
430648 depth: usize,
431− image_count: usize,
432649 has_interactive: bool,
433− has_defer: bool,
650+ /// Writing inside a text element, at any depth: htmlang's own `<div>`s
651+ /// are `<span>`s there, which text can hold.
652+ in_text: bool,
653+ /// How the element whose children are being written lays them out
654+ flow: Flow,
655+ /// Writing the children of a `@picture`, where `@source`'s leading
656+ /// argument is its `srcset`
657+ in_picture: bool,
434658}
435659
436660impl GenContext {
@@ -456,23 +680,26 @@ pub struct CodegenOptions {
456680 pub dev: bool,
457681 pub partial: bool,
458682 pub minify: bool,
459− pub compat: bool,
460683}
461684
462685/// Generate HTML from a parsed document using the given options.
463686pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String {
687+ generate_with_classes(doc, opts).0
688+}
689+
690+/// [`generate_with`], and the classes the document's styles were given,
691+/// for a check across files that no name stands for two styles.
692+pub fn generate_with_classes(doc: &Document, opts: &CodegenOptions) -> (String, Vec<Class>) {
693+ let mut classes = Vec::new();
464694 let mut html = if opts.partial {
465− generate_partial_inner(doc, opts.dev)
695+ generate_partial_inner(doc, opts.dev, &mut classes)
466696 } else {
467− generate_full_inner(doc, opts.dev)
697+ generate_full_inner(doc, opts.dev, &mut classes)
468698 };
469699 if opts.minify {
470700 html = minify_html(&html);
471701 }
472− if opts.compat {
473− html = add_vendor_prefixes(&html);
474− }
475− html
702+ (html, classes)
476703}
477704
478705pub fn generate(doc: &Document) -> String {
@@ -520,38 +747,6 @@ pub fn generate_minified(doc: &Document) -> String {
520747 )
521748}
522749
523−pub fn generate_compat(doc: &Document) -> String {
524− generate_with(
525− doc,
526− &CodegenOptions {
527− compat: true,
528− ..Default::default()
529− },
530− )
531−}
532−
533−pub fn generate_dev_compat(doc: &Document) -> String {
534− generate_with(
535− doc,
536− &CodegenOptions {
537− dev: true,
538− compat: true,
539− ..Default::default()
540− },
541− )
542−}
543−
544−pub fn generate_minified_compat(doc: &Document) -> String {
545− generate_with(
546− doc,
547− &CodegenOptions {
548− minify: true,
549− compat: true,
550− ..Default::default()
551− },
552− )
553−}
554−
555750fn minify_html(html: &str) -> String {
556751 let mut result = String::with_capacity(html.len());
557752 let mut in_pre = false;
@@ -566,9 +761,9 @@ fn minify_html(html: &str) -> String {
566761 if i + 4 < chars.len() && chars[i] == '<' {
567762 let rest: String = chars[i..].iter().take(10).collect();
568763 let rest_lower = rest.to_lowercase();
569− if rest_lower.starts_with("<pre") {
764+ if rest_lower.starts_with("<pre") || rest_lower.starts_with("<textarea") {
570765 in_pre = true;
571− } else if rest_lower.starts_with("</pre") {
766+ } else if rest_lower.starts_with("</pre") || rest_lower.starts_with("</textarea") {
572767 in_pre = false;
573768 } else if rest_lower.starts_with("<script") {
574769 in_script = true;
@@ -610,10 +805,11 @@ fn minify_html(html: &str) -> String {
610805 continue;
611806 }
612807
613− // Collapse whitespace between tags
808+ // Collapse whitespace runs to a single space. Spaces are never
809+ // dropped entirely: next to inline elements they are significant
810+ // ("Built with <span>" must keep its space).
614811 if chars[i].is_whitespace() {
615812 if !prev_was_space {
616− // Only emit a space if we're between content (not between tags)
617813 result.push(' ');
618814 prev_was_space = true;
619815 }
@@ -621,14 +817,6 @@ fn minify_html(html: &str) -> String {
621817 continue;
622818 }
623819
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−
632820 prev_was_space = false;
633821 result.push(chars[i]);
634822 i += 1;
@@ -637,177 +825,152 @@ fn minify_html(html: &str) -> String {
637825 result
638826}
639827
640−/// Add vendor prefixes to CSS within <style> tags for broader browser compatibility.
641−fn add_vendor_prefixes(html: &str) -> String {
642− // Find CSS within <style>...</style> and add vendor prefixes
643− let mut result = String::with_capacity(html.len() + 512);
644− let mut rest = html;
645− while let Some(start) = rest.find("<style>") {
646− let after_tag = start + 7;
647− result.push_str(&rest[..after_tag]);
648− rest = &rest[after_tag..];
649− if let Some(end) = rest.find("</style>") {
650− let css = &rest[..end];
651− result.push_str(&prefix_css(css));
652− result.push_str("</style>");
653− rest = &rest[end + 8..];
654− } else {
655− break;
656− }
657− }
658− result.push_str(rest);
659− result
660−}
661−
662−fn prefix_css(css: &str) -> String {
663− let mut out = String::with_capacity(css.len() + 256);
664− let mut i = 0;
665− let bytes = css.as_bytes();
666− while i < bytes.len() {
667− // Find property declarations
668− if let Some(pos) = css[i..].find('{') {
669− let brace = i + pos;
670− out.push_str(&css[i..=brace]);
671− i = brace + 1;
672− // Process declarations within this block
673− if let Some(close) = css[i..].find('}') {
674− let block = &css[i..i + close];
675− out.push_str(&prefix_declarations(block));
676− out.push('}');
677− i = i + close + 1;
678− }
679− } else {
680− out.push_str(&css[i..]);
681− break;
682− }
683− }
684− out
685−}
686−
687−fn prefix_declarations(block: &str) -> String {
688− let mut out = String::with_capacity(block.len() + 128);
689− for decl in block.split(';') {
690− let decl = decl.trim();
691− if decl.is_empty() {
692− continue;
693− }
694− if let Some((prop, val)) = decl.split_once(':') {
695− let prop = prop.trim();
696− let val = val.trim();
697− match prop {
698− "backdrop-filter" => {
699− out.push_str(&format!("-webkit-backdrop-filter:{};", val));
700− out.push_str(&format!("backdrop-filter:{};", val));
701− }
702− "user-select" => {
703− out.push_str(&format!("-webkit-user-select:{};", val));
704− out.push_str(&format!("-moz-user-select:{};", val));
705− out.push_str(&format!("user-select:{};", val));
706− }
707− "appearance" => {
708− out.push_str(&format!("-webkit-appearance:{};", val));
709− out.push_str(&format!("-moz-appearance:{};", val));
710− out.push_str(&format!("appearance:{};", val));
711− }
712− "background-clip" if val.contains("text") => {
713− out.push_str(&format!("-webkit-background-clip:{};", val));
714− out.push_str(&format!("background-clip:{};", val));
715− }
716− "hyphens" => {
717− out.push_str(&format!("-webkit-hyphens:{};", val));
718− out.push_str(&format!("-ms-hyphens:{};", val));
719− out.push_str(&format!("hyphens:{};", val));
720− }
721− "text-size-adjust" => {
722− out.push_str(&format!("-webkit-text-size-adjust:{};", val));
723− out.push_str(&format!("-ms-text-size-adjust:{};", val));
724− out.push_str(&format!("text-size-adjust:{};", val));
725− }
726− "mask-image" | "mask-size" | "mask-repeat" | "mask-position" => {
727− out.push_str(&format!("-webkit-{}:{};", prop, val));
728− out.push_str(&format!("{}:{};", prop, val));
729− }
730− _ => {
731− out.push_str(decl);
732− out.push(';');
733− }
734− }
735− } else {
736− out.push_str(decl);
737− out.push(';');
738− }
739− }
740− out
741−}
742−
743−fn generate_full_inner(doc: &Document, dev: bool) -> String {
828+fn generate_full_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
829+ // Without `@page` the output is a fragment
830+ let Some(page) = &doc.page else {
831+ return generate_partial_inner(doc, dev, classes);
832+ };
744833 let mut styles = StyleCollector::new();
745834 let mut ctx = GenContext {
746835 dev,
747836 depth: 0,
748− image_count: 0,
749837 has_interactive: false,
750− has_defer: false,
838+ in_text: false,
839+ flow: Flow::default(),
840+ in_picture: false,
751841 };
752842
753− // Check if document has @main for skip-to-content link
754− let has_main = has_element_kind(&doc.nodes, &ElementKind::Main);
843+ // The page is the root element: `<body>` is a column (the reset makes
844+ // it one, filling the viewport), `@page`'s styles are its class, and
845+ // the page's top-level elements are its children
846+ let root = Element {
847+ kind: ElementKind::El,
848+ attrs: page.styles.clone(),
849+ argument: None,
850+ children: Vec::new(),
851+ line_num: 0,
852+ function: None,
853+ };
854+ let mut own = Flow::of(&root);
855+ let top = Flow::default();
856+ let site = Site {
857+ kind: &root.kind,
858+ parent: &top,
859+ has_overlay_children: holds_overlays(&doc.nodes),
860+ inline: false,
861+ root: true,
862+ };
863+ let body_class = element_class(&root, &site, &mut styles);
864+ own.class = body_class.clone();
865+ ctx.flow = own;
755866
756867 let mut body = String::new();
868+ generate_children(
869+ &doc.nodes,
870+ Some(Layout::Column),
871+ &mut body,
872+ &mut styles,
873+ &mut ctx,
874+ );
757875
758− // Skip-to-content link for accessibility (only when @main exists)
759− if has_main {
760− if dev {
761− body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>\n");
762− } else {
763− body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>");
764− }
876+ let element_css = build_element_css(doc, &styles, dev);
877+ let nl = if dev { "\n" } else { "" };
878+
879+ let mut meta_html = String::new();
880+ // A `@meta viewport` of the page's own replaces the usual one
881+ if !doc.meta_tags.iter().any(|(name, _)| name == "viewport") {
882+ meta_html.push_str(&format!(
883+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">{}",
884+ nl
885+ ));
886+ }
887+ meta_html.push_str(&format!(
888+ "<title>{}</title>{}",
889+ html_escape(&page.title),
890+ nl
891+ ));
892+ for (name, content) in &doc.meta_tags {
893+ meta_html.push_str(&format!(
894+ "<meta name=\"{}\" content=\"{}\">{}",
895+ html_escape(name),
896+ html_escape(content),
897+ nl
898+ ));
899+ }
900+ for (property, content) in &doc.og_tags {
901+ meta_html.push_str(&format!(
902+ "<meta property=\"og:{}\" content=\"{}\">{}",
903+ html_escape(property),
904+ html_escape(content),
905+ nl
906+ ));
907+ }
908+ if let Some(path) = &page.favicon {
909+ meta_html.push_str(&format!(
910+ "<link rel=\"icon\" href=\"{}\">{}",
911+ html_escape(path),
912+ nl
913+ ));
914+ }
915+ for block in &doc.head_blocks {
916+ meta_html.push_str(block);
917+ meta_html.push_str(nl);
765918 }
766919
767− for node in &doc.nodes {
768− generate_node(node, None, &mut body, &mut styles, &mut ctx);
920+ // `@page`'s HTML attributes go on `<html>`
921+ let mut root_attrs = String::new();
922+ for attr in html_attrs(&page.html_attrs) {
923+ push_html_attr(&mut root_attrs, attr);
769924 }
925+ let mut body_attrs = String::new();
926+ emit_class_attr(&mut body_attrs, body_class.as_deref(), None);
927+
928+ // Focus-visible CSS for interactive elements (accessibility)
929+ let focus_visible_css = if ctx.has_interactive {
930+ let focusable = ":where(a,button,input,select,textarea):focus-visible";
931+ if dev {
932+ format!(
933+ "{}{} {{ outline: 2px solid currentColor; outline-offset: 2px; }}\n",
934+ OWN_ELEMENTS, focusable
935+ )
936+ } else {
937+ format!(
938+ "{}{}{{outline:2px solid currentColor;outline-offset:2px}}",
939+ OWN_ELEMENTS, focusable
940+ )
941+ }
942+ } else {
943+ String::new()
944+ };
945+ let reset_css = reset_css(dev, true, &focus_visible_css);
946+ classes.extend(styles.classes());
770947
771− // Collect external domains for DNS prefetch
772− let dns_prefetch_html = collect_dns_prefetch(&body, dev);
948+ format!(
949+ "<!DOCTYPE html>{nl}<html{root_attrs}>{nl}<head>{nl}<meta charset=\"utf-8\">{nl}\
950+ {meta_html}<style>{nl}{reset_css}{element_css}</style>{nl}</head>{nl}\
951+ <body{body_attrs}>{nl}{body}</body>{nl}</html>{nl}",
952+ )
953+}
773954
955+/// Assemble every CSS block the document needs (font faces, custom
956+/// properties, generated class rules, keyframes, and user CSS). Shared by
957+/// full-page and partial output so both emit the same styles.
958+fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
774959 let mut element_css = String::new();
775960
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.
961+ // Collect the CSS custom properties declared with `@let --name` so they
962+ // can be emitted in a single `:root` block below.
779963 let mut root_vars: Vec<(String, String)> = Vec::new();
780964 for (name, value) in &doc.css_vars {
781965 root_vars.push((name.clone(), value.clone()));
782966 }
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− }
789967
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− };
968+ // Generated rules always go in htmlang's layers, so unlayered user CSS
969+ // (`@style`, `@raw`) overrides them regardless of specificity.
970+ let styles_css = styles.to_css_formatted(dev, &doc.scoped_css);
808971
809− // Emit the (possibly extended) :root block first so the cascade picks up
810− // the custom properties before the class rules consume them.
972+ // Emit the :root block first so the cascade picks up the custom
973+ // properties before the class rules consume them.
811974 if !root_vars.is_empty() {
812975 if dev {
813976 element_css.push_str(":root {\n");
@@ -829,538 +992,121 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
829992
830993 element_css.push_str(&styles_css);
831994
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−
858995 // @style blocks (custom CSS)
859996 for block in &doc.custom_css {
860997 if dev {
861998 element_css.push_str(block);
862999 element_css.push('\n');
8631000 } else {
864− // Minify: collapse whitespace
865− let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
866− element_css.push_str(&minified);
1001+ element_css.push_str(&compact_css(block));
8671002 }
8681003 }
8691004
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− }
1005+ element_css
1006+}
8801007
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− }
899−
900− // Build meta tags string
901− let meta_html = if doc.meta_tags.is_empty() {
902− String::new()
903− } else {
904− let mut m = String::new();
905− for (name, content) in &doc.meta_tags {
906− if dev {
907− m.push_str(&format!(
908− "<meta name=\"{}\" content=\"{}\">\n",
909− html_escape(name),
910− html_escape(content)
911− ));
912− } else {
913− m.push_str(&format!(
914− "<meta name=\"{}\" content=\"{}\">",
915− html_escape(name),
916− html_escape(content)
917− ));
918− }
919− }
920− m
921− };
922−
923− // Build OG meta tags
924− let og_html = if doc.og_tags.is_empty() {
925− String::new()
926− } else {
927− let mut o = String::new();
928− for (property, content) in &doc.og_tags {
929− if dev {
930− o.push_str(&format!(
931− "<meta property=\"og:{}\" content=\"{}\">\n",
932− html_escape(property),
933− html_escape(content)
934− ));
935− } else {
936− o.push_str(&format!(
937− "<meta property=\"og:{}\" content=\"{}\">",
938− html_escape(property),
939− html_escape(content)
940− ));
941− }
942− }
943− o
944− };
945−
946− // Build head blocks string
947− let head_html = if doc.head_blocks.is_empty() {
948− String::new()
949− } else {
950− let mut h = String::new();
951− for block in &doc.head_blocks {
952− h.push_str(block);
953− if dev {
954− h.push('\n');
955− }
956− }
957− h
958− };
959−
960− let lang_attr = match &doc.lang {
961− Some(lang) => format!(" lang=\"{}\"", html_escape(lang)),
962− None => String::new(),
963− };
964−
965− let favicon_html = match &doc.favicon {
966− Some(path) => {
967− // Try to read and inline the favicon
968− if let Ok(data) = std::fs::read(path) {
969− let mime = if path.ends_with(".ico") {
970− "image/x-icon"
971− } else if path.ends_with(".png") {
972− "image/png"
973− } else if path.ends_with(".svg") {
974− "image/svg+xml"
975− } else {
976− "image/x-icon"
977− };
978− let b64 = base64_encode(&data);
979− if dev {
980− format!(
981− "<link rel=\"icon\" href=\"data:{};base64,{}\">\n",
982− mime, b64
983− )
984− } else {
985− format!("<link rel=\"icon\" href=\"data:{};base64,{}\">", mime, b64)
986− }
987− } else {
988− // Fall back to href
989− if dev {
990− format!("<link rel=\"icon\" href=\"{}\">\n", html_escape(path))
991− } else {
992− format!("<link rel=\"icon\" href=\"{}\">", html_escape(path))
993− }
994− }
995− }
996− None => String::new(),
997− };
998−
999− // Canonical URL
1000− let canonical_html = match &doc.canonical {
1001− Some(url) => {
1002− if dev {
1003− format!("<link rel=\"canonical\" href=\"{}\">\n", html_escape(url))
1004− } else {
1005− format!("<link rel=\"canonical\" href=\"{}\">", html_escape(url))
1006− }
1007− }
1008− None => String::new(),
1009− };
1010−
1011− // Base URL
1012− let base_html = match &doc.base_url {
1013− Some(url) => {
1014− if dev {
1015− format!("<base href=\"{}\">\n", html_escape(url))
1016− } else {
1017− format!("<base href=\"{}\">", html_escape(url))
1018− }
1019− }
1020− None => String::new(),
1021− };
1008+/// An `@style` body on one line: each line trimmed, and the lines joined with
1009+/// a space, so a value that goes on over two lines (`margin: 0` then `auto;`)
1010+/// keeps its words apart.
1011+fn compact_css(block: &str) -> String {
1012+ block
1013+ .lines()
1014+ .map(str::trim)
1015+ .filter(|l| !l.is_empty())
1016+ .collect::<Vec<_>>()
1017+ .join(" ")
1018+}
10221019
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− ));
1020+/// htmlang's own elements: those with a generated class, or the class of a
1021+/// function's scoped `@style`. The reset's rules for elements are kept to
1022+/// them, so `@raw` and `@markdown` content, and the page a fragment is put
1023+/// into, keep their own.
1024+const OWN_ELEMENTS: &str = ":where([class^=\"hl-\"],[class*=\" hl-\"])";
1025+
1026+/// htmlang's cascade layers, from the one that loses to the one that wins:
1027+/// the reset; the defaults of each element's kind (a heading's `margin:0`,
1028+/// a column's `display:flex`); the styles an element gets from an
1029+/// element prefix on an element around it (`@td:padding 8`) and from a
1030+/// function's scoped `@style`; and the element's own styles. So a
1031+/// scope's styles win over the defaults, and what an element says about
1032+/// itself wins over both. CSS outside any layer (`@style`, `@raw`) wins
1033+/// over all of them.
1034+const LAYERS: [&str; 4] = ["hl-reset", "hl-kind", "hl-inside", "htmlang"];
1035+
1036+/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1037+/// every layer, so an unlayered rule here would override the generated
1038+/// ones. A page resets `box-sizing` everywhere and makes `<body>` a column
1039+/// that fills the window; a fragment, which goes into a page it doesn't
1040+/// own, touches only htmlang's own elements. In both, an element htmlang
1041+/// lays out stays hidden while it is `hidden`, a closed `<dialog>` or a
1042+/// popover that isn't showing: its generated `display` would otherwise
1043+/// beat the browser's `display: none`.
1044+fn reset_css(dev: bool, page: bool, focus_visible_css: &str) -> String {
1045+ let own = OWN_ELEMENTS;
1046+ let mut rules = Vec::new();
1047+ if page {
1048+ rules.push(if dev {
1049+ "*, *::before, *::after { box-sizing: border-box; }".to_string()
10411050 } 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−
1052− // JSON-LD blocks
1053− let json_ld_html: String = doc
1054− .json_ld_blocks
1055− .iter()
1056− .map(|block| {
1057− if dev {
1058− format!(
1059− "<script type=\"application/ld+json\">\n{}\n</script>\n",
1060− block
1061− )
1062− } else {
1063− format!("<script type=\"application/ld+json\">{}</script>", block)
1064− }
1065− })
1066− .collect();
1067−
1068− // Manifest link
1069− let manifest_html = if let Some(ref manifest) = doc.manifest {
1070− let mut json = String::from("{");
1071− json.push_str(&format!("\"name\":\"{}\",", manifest.name));
1072− if let Some(ref short) = manifest.short_name {
1073− json.push_str(&format!("\"short_name\":\"{}\",", short));
1074− }
1075− json.push_str(&format!("\"start_url\":\"{}\",", manifest.start_url));
1076− json.push_str(&format!("\"display\":\"{}\"", manifest.display));
1077− if let Some(ref bg) = manifest.background_color {
1078− json.push_str(&format!(",\"background_color\":\"{}\"", bg));
1079− }
1080− if let Some(ref tc) = manifest.theme_color {
1081− json.push_str(&format!(",\"theme_color\":\"{}\"", tc));
1082− }
1083− if let Some(ref desc) = manifest.description {
1084− json.push_str(&format!(",\"description\":\"{}\"", desc));
1085− }
1086− if !manifest.icons.is_empty() {
1087− json.push_str(",\"icons\":[");
1088− for (i, (src, sizes)) in manifest.icons.iter().enumerate() {
1089− if i > 0 {
1090− json.push(',');
1091− }
1092− json.push_str(&format!(
1093− "{{\"src\":\"{}\",\"sizes\":\"{}\",\"type\":\"image/png\"}}",
1094− src, sizes
1095− ));
1096− }
1097− json.push(']');
1098− }
1099− json.push('}');
1100−
1101− // Inline the manifest as a data URI
1102− let encoded = base64_encode(json.as_bytes());
1103− if dev {
1104− format!(
1105− "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">\n",
1106− encoded
1107− )
1051+ "*,*::before,*::after{box-sizing:border-box}".to_string()
1052+ });
1053+ rules.push(if dev {
1054+ "body { margin: 0; font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; min-height: 100dvh; }".to_string()
11081055 } else {
1109− format!(
1110− "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">",
1111− encoded
1112− )
1113− }
1056+ "body{margin:0;font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;min-height:100dvh}".to_string()
1057+ });
11141058 } else {
1115− String::new()
1116− };
1117−
1118− // Preload hints (auto-detect fonts from @font-face)
1119− let mut preload_html = String::new();
1120− for (_, url) in &doc.font_faces {
1121− if dev {
1122− preload_html.push_str(&format!(
1123− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
1124− url
1125− ));
1059+ rules.push(if dev {
1060+ format!("{own}, {own}::before, {own}::after {{ box-sizing: border-box; }}")
11261061 } else {
1127− preload_html.push_str(&format!(
1128− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>",
1129− url
1130− ));
1131− }
1132− }
1133− // Explicit preload hints from the document
1134− for hint in &doc.preload_hints {
1135− if dev {
1136− preload_html.push_str(&format!(
1137− "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>\n",
1138− html_escape(&hint.href),
1139− hint.as_type,
1140− if hint.crossorigin { " crossorigin" } else { "" }
1141− ));
1142− } else {
1143− preload_html.push_str(&format!(
1144− "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>",
1145− html_escape(&hint.href),
1146− hint.as_type,
1147− if hint.crossorigin { " crossorigin" } else { "" }
1148− ));
1149− }
1062+ format!("{own},{own}::before,{own}::after{{box-sizing:border-box}}")
1063+ });
11501064 }
1151−
1152− // Auto theme-color meta from @theme primary token
1153− let theme_color_html = doc
1154− .theme_tokens
1155− .iter()
1156− .find(|(name, _)| name == "primary" || name == "theme-color")
1157− .map(|(_, value)| {
1158− if dev {
1159− format!(
1160− "<meta name=\"theme-color\" content=\"{}\">\n",
1161− html_escape(value)
1162− )
1163− } else {
1164− format!(
1165− "<meta name=\"theme-color\" content=\"{}\">",
1166− html_escape(value)
1167− )
1168− }
1169− })
1170− .unwrap_or_default();
1171−
1172− // Focus-visible CSS for interactive elements (accessibility)
1173− let focus_visible_css = if ctx.has_interactive {
1174− if dev {
1175− "a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n"
1176− } else {
1177− "a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}"
1178− }
1179− } else {
1180− ""
1181− };
1182−
1183− // Skip-to-content CSS (visually hidden but accessible)
1184− let skip_link_css = if has_main {
1185− if dev {
1186− ".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; }\n.hl-skip:focus { left: 8px; top: 8px; width: auto; height: auto; overflow: visible; }\n"
1187− } else {
1188− ".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}"
1189− }
1065+ let hidden = "[hidden]:not([hidden=\"until-found\"]),dialog:not([open]):not(:popover-open),\
1066+ [popover]:not(:popover-open):not(dialog[open])";
1067+ rules.push(if dev {
1068+ format!("{own}:where({hidden}) {{ display: none !important; }}")
11901069 } else {
1191− ""
1192− };
1193−
1194− // Noscript fallback: show deferred content if JS is disabled
1195− let noscript_html = if ctx.has_defer {
1196− if dev {
1197− "<noscript><style>.hl-defer-placeholder { display: none; }</style></noscript>\n"
1198− } else {
1199− "<noscript><style>.hl-defer-placeholder{display:none}</style></noscript>"
1200− }
1070+ format!("{own}:where({hidden}){{display:none!important}}")
1071+ });
1072+ let mut rules = rules.join(if dev { "\n" } else { "" });
1073+ if dev {
1074+ rules.push('\n');
1075+ }
1076+ rules.push_str(focus_visible_css);
1077+ if dev {
1078+ format!(
1079+ "@layer {};\n@layer hl-reset {{\n{}}}\n",
1080+ LAYERS.join(", "),
1081+ rules
1082+ )
12011083 } else {
1202− ""
1203− };
1204−
1205− match &doc.page_title {
1206− Some(title) => {
1207− if dev {
1208− format!(
1209− "\
1210−<!DOCTYPE html>
1211−<html{lang_attr}>
1212−<head>
1213−<meta charset=\"utf-8\">
1214−<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
1215−<title>{title}</title>
1216−{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}\
1217−<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}\
1223−</style>
1224−{noscript_html}\
1225−</head>
1226−<body>
1227−{body}\
1228−</body>
1229−</html>
1230−",
1231− title = html_escape(title),
1232− lang_attr = lang_attr,
1233− theme_color_html = theme_color_html,
1234− base_html = base_html,
1235− canonical_html = canonical_html,
1236− manifest_html = manifest_html,
1237− preload_html = preload_html,
1238− dns_prefetch_html = dns_prefetch_html,
1239− meta_html = meta_html,
1240− favicon_html = favicon_html,
1241− json_ld_html = json_ld_html,
1242− head_html = head_html,
1243− og_html = og_html,
1244− focus_visible_css = focus_visible_css,
1245− skip_link_css = skip_link_css,
1246− noscript_html = noscript_html,
1247− element_css = element_css,
1248− body = body,
1249− )
1250− } else {
1251− 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>",
1253− title = html_escape(title),
1254− lang_attr = lang_attr,
1255− theme_color_html = theme_color_html,
1256− base_html = base_html,
1257− canonical_html = canonical_html,
1258− manifest_html = manifest_html,
1259− preload_html = preload_html,
1260− dns_prefetch_html = dns_prefetch_html,
1261− meta_html = meta_html,
1262− og_html = og_html,
1263− favicon_html = favicon_html,
1264− json_ld_html = json_ld_html,
1265− head_html = head_html,
1266− focus_visible_css = focus_visible_css,
1267− skip_link_css = skip_link_css,
1268− noscript_html = noscript_html,
1269− element_css = element_css,
1270− body = body,
1271− )
1272− }
1273− }
1274− None => {
1275− if element_css.is_empty() {
1276− body
1277− } else if dev {
1278− format!("<style>\n{}</style>\n{}", element_css, body)
1279− } else {
1280− format!("<style>{}</style>{}", element_css, body)
1281− }
1282− }
1084+ format!("@layer {};@layer hl-reset{{{}}}", LAYERS.join(","), rules)
12831085 }
12841086}
12851087
12861088/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1287−fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1089+fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
12881090 let mut styles = StyleCollector::new();
12891091 let mut ctx = GenContext {
12901092 dev,
12911093 depth: 0,
1292− image_count: 0,
12931094 has_interactive: false,
1294− has_defer: false,
1095+ in_text: false,
1096+ flow: Flow::default(),
1097+ in_picture: false,
12951098 };
12961099 let mut body = String::new();
12971100
1298− for node in &doc.nodes {
1299− generate_node(node, None, &mut body, &mut styles, &mut ctx);
1300− }
1301−
1302− let has_custom_css = !doc.custom_css.is_empty();
1303− let mut element_css = String::new();
1304−
1305− let mut root_vars: Vec<(String, String)> = Vec::new();
1306− for (name, value) in &doc.css_vars {
1307− root_vars.push((name.clone(), value.clone()));
1308− }
1309− for (name, value) in &doc.theme_tokens {
1310− let css_name = format!("--{}", name);
1311− if !root_vars.iter().any(|(n, _)| *n == css_name) {
1312− root_vars.push((css_name, value.clone()));
1313− }
1314− }
1315−
1316− let styles_css = styles.to_css_formatted(dev, !has_custom_css);
1317− let styles_css = substitute_css_vars(&styles_css, &root_vars);
1318− let styles_css = if dev {
1319− styles_css
1320− } else {
1321− let (new_css, auto_vars) = auto_extract_repeats(&styles_css, &root_vars);
1322− root_vars.extend(auto_vars);
1323− new_css
1324− };
1325−
1326− if !root_vars.is_empty() {
1327− if dev {
1328− element_css.push_str(":root {\n");
1329− for (name, value) in &root_vars {
1330− element_css.push_str(&format!(" {}: {};\n", name, value));
1331− }
1332− element_css.push_str("}\n");
1333− } else {
1334− element_css.push_str(":root{");
1335− for (name, value) in &root_vars {
1336− element_css.push_str(name);
1337− element_css.push(':');
1338− element_css.push_str(value);
1339− element_css.push(';');
1340− }
1341− element_css.push('}');
1342− }
1343− }
1344−
1345− element_css.push_str(&styles_css);
1346−
1347− for (name, kf_body) in &doc.keyframes {
1348− if dev {
1349− element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
1350− } else {
1351− element_css.push_str(&format!("@keyframes {}{{{}}}", name, kf_body));
1352− }
1353− }
1101+ generate_children(&doc.nodes, None, &mut body, &mut styles, &mut ctx);
13541102
1355− for block in &doc.custom_css {
1356− if dev {
1357− element_css.push_str(block);
1358− element_css.push('\n');
1359− } else {
1360− let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1361− element_css.push_str(&minified);
1362− }
1103+ let mut element_css = build_element_css(doc, &styles, dev);
1104+ // htmlang's own elements get the reset's rules for elements (which
1105+ // also puts htmlang's layers in order)
1106+ if !styles.entries.is_empty() || !doc.scoped_css.is_empty() {
1107+ element_css.insert_str(0, &reset_css(dev, false, ""));
13631108 }
1109+ classes.extend(styles.classes());
13641110
13651111 if element_css.is_empty() {
13661112 body
@@ -1375,44 +1121,20 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
13751121// Node generation
13761122// ---------------------------------------------------------------------------
13771123
1124+/// Write `node`, which is inside an element with the layout `parent`
1125+/// (`None` at the top of the page).
13781126fn generate_node(
13791127 node: &Node,
1380− parent_kind: Option<&ElementKind>,
1128+ parent: Option<Layout>,
13811129 out: &mut String,
13821130 styles: &mut StyleCollector,
13831131 ctx: &mut GenContext,
13841132) {
13851133 match node {
1386− Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
1134+ Node::Element(elem) => generate_element(elem, parent, out, styles, ctx),
13871135 Node::Text(segments) => {
1388− let needs_wrap = matches!(
1389− parent_kind,
1390− Some(ElementKind::Row)
1391− | Some(ElementKind::Column)
1392− | Some(ElementKind::El)
1393− | Some(ElementKind::Nav)
1394− | Some(ElementKind::Header)
1395− | Some(ElementKind::Footer)
1396− | Some(ElementKind::Main)
1397− | Some(ElementKind::Section)
1398− | Some(ElementKind::Article)
1399− | Some(ElementKind::Aside)
1400− | Some(ElementKind::ListItem)
1401− | Some(ElementKind::Form)
1402− | Some(ElementKind::Details)
1403− | Some(ElementKind::Figure)
1404− | Some(ElementKind::Blockquote)
1405− | Some(ElementKind::Dialog)
1406− | Some(ElementKind::DefinitionList)
1407− | Some(ElementKind::DefinitionDescription)
1408− | Some(ElementKind::Fieldset)
1409− | Some(ElementKind::Datalist)
1410− | Some(ElementKind::Grid)
1411− | Some(ElementKind::Stack)
1412− | Some(ElementKind::Noscript)
1413− | Some(ElementKind::Address)
1414− | Some(ElementKind::Search)
1415− );
1136+ // In a row, column or grid each line of text is a child
1137+ let needs_wrap = parent.is_some_and(Layout::is_container);
14161138 if needs_wrap {
14171139 out.push_str(&ctx.indent());
14181140 out.push_str("<span>");
@@ -1431,197 +1153,191 @@ fn generate_node(
14311153 }
14321154}
14331155
1434−/// HTML attributes that are passed through to the HTML tag rather than converted to CSS.
1435−const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
1436− "type",
1437− "placeholder",
1438− "name",
1439− "value",
1440− "disabled",
1441− "required",
1442− "checked",
1443− "for",
1444− "action",
1445− "method",
1446− "autocomplete",
1447− "min",
1448− "max",
1449− "step",
1450− "pattern",
1451− "maxlength",
1452− "rows",
1453− "cols",
1454− "multiple",
1455− "alt",
1456− "role",
1457− "tabindex",
1458− "title",
1459− "autofocus",
1460− // Media
1461− "controls",
1462− "autoplay",
1463− "loop",
1464− "muted",
1465− "playsinline",
1466− "poster",
1467− "preload",
1468− // Image optimization
1469− "loading",
1470− "decoding",
1471− // Media src (explicit attribute form)
1472− "src",
1473− // New element attributes
1474− "datetime",
1475− "media",
1476− "sizes",
1477− "srcset",
1478− "list",
1479− // Details
1480− "open",
1481− // Form
1482− "novalidate",
1483− // Progress/Meter
1484− "low",
1485− "high",
1486− "optimum",
1487− // Table
1488− "colspan",
1489− "rowspan",
1490− "scope",
1491− // Popover API
1492− "popover",
1493− "popovertarget",
1494− "popovertargetaction",
1495− // Modern form/input hints
1496− "inputmode",
1497− "enterkeyhint",
1498− // Performance hints
1499− "fetchpriority",
1500− "blocking",
1501− // Global attrs
1502− "translate",
1503− "spellcheck",
1504− // ARIA live regions
1505− "aria-live",
1506− "aria-atomic",
1507− "aria-relevant",
1508−];
1156+/// What goes between two things written one after the other in an element
1157+/// with this layout (`None`: the top of the page): text flows, so its lines
1158+/// and children are joined with a space; in HTML's own layout, and at the
1159+/// top of the page, two lines of text are separated by a line break (a
1160+/// space, except where whitespace is kept, as in `@pre`).
1161+fn separator(layout: Option<Layout>, previous_is_text: bool, next: &Node) -> Option<char> {
1162+ match layout {
1163+ Some(Layout::Text) => Some(' '),
1164+ Some(Layout::Native) | None if previous_is_text && matches!(next, Node::Text(_)) => {
1165+ Some('\n')
1166+ }
1167+ _ => None,
1168+ }
1169+}
15091170
1510−/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
1511−const BOOLEAN_HTML_ATTRS: &[&str] = &[
1512− "disabled",
1513− "required",
1514− "checked",
1515− "multiple",
1516− "controls",
1517− "autoplay",
1518− "loop",
1519− "muted",
1520− "playsinline",
1521− "open",
1522− "novalidate",
1523− "autofocus",
1524− "defer",
1525− "async",
1526− "nomodule",
1527− // Popover
1528− "popover",
1529−];
1171+/// Write the children of an element (or of a `@fragment`, which has no
1172+/// element of its own) laid out as `layout` (`None`: the top of the page).
1173+/// The first line of an element's text is its first child.
1174+fn generate_children(
1175+ children: &[Node],
1176+ layout: Option<Layout>,
1177+ out: &mut String,
1178+ styles: &mut StyleCollector,
1179+ ctx: &mut GenContext,
1180+) {
1181+ generate_run(children, layout, &mut None, out, styles, ctx);
1182+}
15301183
1531−fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
1532− for attr in attrs {
1533− let key = attr.key.as_str();
1534− let is_passthrough = HTML_PASSTHROUGH_ATTRS.contains(&key)
1535− || key.starts_with("aria-")
1536− || key.starts_with("data-");
1537− if !is_passthrough {
1184+/// [`generate_children`], with `previous` saying whether what was written
1185+/// last was a line of text (`None`: nothing yet). A `@fragment`'s children
1186+/// are written as if they stood in its place, so its lines are separated
1187+/// from the lines around it like any other lines.
1188+fn generate_run(
1189+ children: &[Node],
1190+ layout: Option<Layout>,
1191+ previous: &mut Option<bool>,
1192+ out: &mut String,
1193+ styles: &mut StyleCollector,
1194+ ctx: &mut GenContext,
1195+) {
1196+ for child in children {
1197+ if let Node::Element(elem) = child
1198+ && elem.kind == ElementKind::Fragment
1199+ {
1200+ generate_run(&elem.children, layout, previous, out, styles, ctx);
15381201 continue;
15391202 }
1540− if BOOLEAN_HTML_ATTRS.contains(&key) && attr.value.is_none() {
1541− out.push(' ');
1542− out.push_str(key);
1543− } else if let Some(val) = &attr.value {
1544− out.push(' ');
1545− out.push_str(key);
1546− out.push_str("=\"");
1547− out.push_str(&html_escape(strip_string_quotes(val)));
1548− out.push('"');
1203+ let start = out.len();
1204+ if let Some(previous_is_text) = *previous
1205+ && let Some(sep) = separator(layout, previous_is_text, child)
1206+ {
1207+ out.push(sep);
1208+ }
1209+ let body = out.len();
1210+ generate_node(child, layout, out, styles, ctx);
1211+ // A child that writes nothing (an empty `@fragment`) takes no
1212+ // separator either, so text never gets two spaces in a row
1213+ if out.len() == body {
1214+ out.truncate(start);
1215+ continue;
15491216 }
1217+ *previous = Some(matches!(child, Node::Text(_)));
15501218 }
15511219}
15521220
1553−/// If the value is wrapped in a single pair of matching quotes (e.g.
1554−/// `"Avatar"`), return the inner content. Quotes act as source-level
1555−/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute
1556−/// values. Multi-quoted values like `"h h" "s m"` are left unchanged —
1557−/// those are real string tokens (used e.g. by CSS `grid-template-areas`).
1558−fn strip_string_quotes(val: &str) -> &str {
1559− let bytes = val.as_bytes();
1560− if bytes.len() < 2 {
1561− return val;
1562− }
1563− let first = bytes[0];
1564− let last = bytes[bytes.len() - 1];
1565− if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1566− let inner = &val[1..val.len() - 1];
1567− if !inner.as_bytes().contains(&first) {
1568− return inner;
1221+/// Emit the element's leading argument as its attribute (`@iframe URL` →
1222+/// `src="URL"`, `@form /submit` → `action="/submit"`, `@link /a` →
1223+/// `href="/a"`), first, before the class. `@image [inline] photo.png` puts
1224+/// the file itself into the page.
1225+fn emit_argument_attr(out: &mut String, elem: &Element, in_picture: bool) {
1226+ let (Some(attr), Some(value)) = (elem.kind.leading_attribute(in_picture), &elem.argument)
1227+ else {
1228+ return;
1229+ };
1230+ // `@image [inline]`'s file is already a `data:` URI (see the parser)
1231+ let value = html_escape(value);
1232+ out.push(' ');
1233+ out.push_str(attr);
1234+ out.push_str("=\"");
1235+ out.push_str(&value);
1236+ out.push('"');
1237+}
1238+
1239+/// A file's bytes as a `data:` URI, its type from its extension.
1240+pub(crate) fn data_uri(path: &str, data: &[u8]) -> String {
1241+ let mime = match path
1242+ .rsplit('.')
1243+ .next()
1244+ .map(str::to_ascii_lowercase)
1245+ .as_deref()
1246+ {
1247+ Some("png") => "image/png",
1248+ Some("jpg" | "jpeg") => "image/jpeg",
1249+ Some("gif") => "image/gif",
1250+ Some("webp") => "image/webp",
1251+ Some("avif") => "image/avif",
1252+ Some("svg") => "image/svg+xml",
1253+ Some("ico") => "image/x-icon",
1254+ _ => "application/octet-stream",
1255+ };
1256+ format!("data:{};base64,{}", mime, base64_encode(data))
1257+}
1258+
1259+/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1260+/// are emitted with the generated class) and flags like `required`.
1261+fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1262+ for attr in html_attrs(attrs) {
1263+ let key = attr.key.as_str();
1264+ if key != "id" && key != "class" {
1265+ push_html_attr(out, attr);
15691266 }
15701267 }
1571− val
1268+}
1269+
1270+/// The HTML attributes of `attrs`, `key=value` ones and flags, each name
1271+/// once: the later one wins, as a style does. (Of two attributes with one
1272+/// name, the browser keeps the first, so a bundle's attribute or a
1273+/// function's own would win over one written after it.)
1274+fn html_attrs(attrs: &[Attribute]) -> impl Iterator<Item = &Attribute> {
1275+ let is_html =
1276+ |a: &Attribute| a.html || (a.value.is_none() && crate::vocab::is_html_flag(&a.key));
1277+ attrs.iter().enumerate().filter_map(move |(i, attr)| {
1278+ let later = attrs[i + 1..]
1279+ .iter()
1280+ .any(|other| is_html(other) && other.key == attr.key);
1281+ (is_html(attr) && !later).then_some(attr)
1282+ })
1283+}
1284+
1285+/// ` key="value"`, or ` key` for a flag.
1286+fn push_html_attr(out: &mut String, attr: &Attribute) {
1287+ out.push(' ');
1288+ out.push_str(&attr.key);
1289+ if attr.html {
1290+ out.push_str("=\"");
1291+ // Quoted text has already lost its quotes (see parser.rs)
1292+ out.push_str(&html_escape(attr.value.as_deref().unwrap_or("")));
1293+ out.push('"');
1294+ }
1295+}
1296+
1297+// True if any direct child is `@in-front` or `@behind`. Such children render
1298+// as absolutely positioned overlays, so the parent automatically becomes a
1299+// positioning context (position:relative + isolation:isolate).
1300+fn has_overlay_children(elem: &Element) -> bool {
1301+ holds_overlays(&elem.children)
1302+}
1303+
1304+fn holds_overlays(children: &[Node]) -> bool {
1305+ children.iter().any(|child| {
1306+ matches!(
1307+ child,
1308+ Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
1309+ )
1310+ })
15721311}
15731312
15741313fn generate_element(
15751314 elem: &Element,
1576− parent_kind: Option<&ElementKind>,
1315+ parent: Option<Layout>,
15771316 out: &mut String,
15781317 styles: &mut StyleCollector,
15791318 ctx: &mut GenContext,
15801319) {
15811320 // Self-closing elements
1582− if matches!(
1583− elem.kind,
1584− ElementKind::Image
1585− | ElementKind::Input
1586− | ElementKind::HorizontalRule
1587− | ElementKind::Source
1588− | ElementKind::Spacer
1589− ) {
1590− generate_self_closing(elem, parent_kind, out, styles, ctx);
1321+ if elem.kind.layout() == Layout::Void {
1322+ generate_self_closing(elem, parent, out, styles, ctx);
15911323 return;
15921324 }
1593− // @script renders as <script> with raw body content (no HTML escaping)
1594− if elem.kind == ElementKind::Script {
1325+ // A verbatim element (@script) writes its body as it is, with no HTML
1326+ // escaping; it isn't shown, so it has no class (its styles are an error)
1327+ if elem.kind.is_verbatim() {
1328+ let tag = elem.kind.spec().map_or("script", |spec| spec.html);
15951329 out.push_str(&ctx.indent());
1596− out.push_str("<script");
1597− // Pass through src, type, defer, async, etc.
1598− for attr in &elem.attrs {
1599− let key = attr.key.as_str();
1600− if matches!(
1601− key,
1602− "src"
1603− | "type"
1604− | "defer"
1605− | "async"
1606− | "crossorigin"
1607− | "integrity"
1608− | "nomodule"
1609− | "id"
1610− ) {
1611− if let Some(val) = &attr.value {
1612− out.push(' ');
1613− out.push_str(key);
1614− out.push_str("=\"");
1615− out.push_str(&html_escape(val));
1616− out.push('"');
1617− } else {
1618− out.push(' ');
1619− out.push_str(key);
1620− }
1330+ out.push('<');
1331+ out.push_str(tag);
1332+ emit_argument_attr(out, elem, false);
1333+ let (id, class) = extract_id_class(&elem.attrs);
1334+ for (key, value) in [("id", id), ("class", class)] {
1335+ if let Some(value) = value {
1336+ out.push_str(&format!(" {}=\"{}\"", key, html_escape(&value)));
16211337 }
16221338 }
1339+ emit_html_attrs(out, &elem.attrs);
16231340 out.push('>');
1624− // Children are raw JS code, not HTML
16251341 for child in &elem.children {
16261342 match child {
16271343 Node::Text(segments) => {
@@ -1635,46 +1351,10 @@ fn generate_element(
16351351 _ => {}
16361352 }
16371353 }
1638− out.push_str("</script>");
1639− out.push_str(ctx.nl());
1640− return;
1641− }
1642− // @breadcrumb generates semantic <nav aria-label="breadcrumb"><ol>...</ol></nav>
1643− if elem.kind == ElementKind::Breadcrumb {
1644− let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
1645− let (id, user_class) = extract_id_class(&elem.attrs);
1646− out.push_str(&ctx.indent());
1647− out.push_str("<nav aria-label=\"breadcrumb\"");
1648− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1649− if let Some(id) = id {
1650− out.push_str(" id=\"");
1651− out.push_str(&html_escape(&id));
1652− out.push('"');
1653− }
1354+ out.push_str("</");
1355+ out.push_str(tag);
16541356 out.push('>');
16551357 out.push_str(ctx.nl());
1656− ctx.depth += 1;
1657− out.push_str(&ctx.indent());
1658− out.push_str("<ol>");
1659− out.push_str(ctx.nl());
1660− ctx.depth += 1;
1661− for child in &elem.children {
1662− out.push_str(&ctx.indent());
1663− out.push_str("<li>");
1664− let mut buf = String::new();
1665− generate_node(child, Some(&elem.kind), &mut buf, styles, ctx);
1666− out.push_str(buf.trim());
1667− out.push_str("</li>");
1668− out.push_str(ctx.nl());
1669− }
1670− ctx.depth -= 1;
1671− out.push_str(&ctx.indent());
1672− out.push_str("</ol>");
1673− out.push_str(ctx.nl());
1674− ctx.depth -= 1;
1675− out.push_str(&ctx.indent());
1676− out.push_str("</nav>");
1677− out.push_str(ctx.nl());
16781358 return;
16791359 }
16801360 if elem.kind == ElementKind::Children {
@@ -1684,198 +1364,38 @@ fn generate_element(
16841364 return;
16851365 }
16861366 if elem.kind == ElementKind::Fragment {
1687− // Render children without a wrapper element
1688− for child in &elem.children {
1689− generate_node(child, parent_kind, out, styles, ctx);
1690− }
1367+ // Render children without a wrapper element, as if they were
1368+ // written where the fragment is
1369+ generate_children(&elem.children, parent, out, styles, ctx);
16911370 return;
16921371 }
16931372
1373+ let mut own = Flow::of(elem);
16941374 let tag = match &elem.kind {
1695− ElementKind::Row
1696− | ElementKind::Column
1697− | ElementKind::El
1698− | ElementKind::Grid
1699− | ElementKind::Stack => "div",
1375+ ElementKind::Row | ElementKind::El => "div",
17001376 ElementKind::Text => "span",
17011377 ElementKind::Paragraph => "p",
17021378 ElementKind::Link => "a",
1703− ElementKind::Button => "button",
1704− ElementKind::Select => "select",
1705− ElementKind::Textarea => "textarea",
1706− ElementKind::Option => "option",
1707− ElementKind::Label => "label",
1708− // Semantic elements
1709− ElementKind::Nav => "nav",
1710− ElementKind::Header => "header",
1711− ElementKind::Footer => "footer",
1712− ElementKind::Main => "main",
1713− ElementKind::Section => "section",
1714− ElementKind::Article => "article",
1715− ElementKind::Aside => "aside",
1716− // List
1717− ElementKind::List => {
1718− if elem.attrs.iter().any(|a| a.key == "ordered") {
1719− "ol"
1720− } else {
1721− "ul"
1722− }
1723− }
1724− ElementKind::ListItem => "li",
1725− // Table
1726− ElementKind::Table => "table",
1727− ElementKind::TableHead => "thead",
1728− ElementKind::TableBody => "tbody",
1729− ElementKind::TableRow => "tr",
1730− ElementKind::TableCell => "td",
1731− ElementKind::TableHeaderCell => "th",
1732− // Media
1733− ElementKind::Video => "video",
1734− ElementKind::Audio => "audio",
1735− // Additional semantic elements
1736− ElementKind::Form => "form",
1737− ElementKind::Details => "details",
1738− ElementKind::Summary => "summary",
1739− ElementKind::Blockquote => "blockquote",
1740− ElementKind::Cite => "cite",
1741− ElementKind::Code => "code",
1742− ElementKind::Pre => "pre",
1743− ElementKind::Figure => "figure",
1744− ElementKind::FigCaption => "figcaption",
1745− ElementKind::Progress => "progress",
1746− ElementKind::Meter => "meter",
1747− // New elements
1748− ElementKind::Dialog => "dialog",
1749− ElementKind::DefinitionList => "dl",
1750− ElementKind::DefinitionTerm => "dt",
1751− ElementKind::DefinitionDescription => "dd",
1752− ElementKind::Fieldset => "fieldset",
1753− ElementKind::Legend => "legend",
1754− ElementKind::Picture => "picture",
1755− ElementKind::Time => "time",
1756− ElementKind::Mark => "mark",
1757− ElementKind::Kbd => "kbd",
1758− ElementKind::Abbr => "abbr",
1759− ElementKind::Datalist => "datalist",
1760− ElementKind::Iframe => "iframe",
1761− ElementKind::Output => "output",
1762− ElementKind::Canvas => "canvas",
1763− ElementKind::Badge => "span",
1764− ElementKind::Tooltip => "span",
1765− ElementKind::Avatar => "div",
1766− ElementKind::Carousel => "div",
1767− ElementKind::Chip => "span",
1768− ElementKind::Tag => "span",
1769− ElementKind::Noscript => "noscript",
1770− ElementKind::Address => "address",
1771− ElementKind::Search => "search",
1772− // Heading elements
1773− ElementKind::H1 => "h1",
1774− ElementKind::H2 => "h2",
1775− ElementKind::H3 => "h3",
1776− ElementKind::H4 => "h4",
1777− ElementKind::H5 => "h5",
1778− ElementKind::H6 => "h6",
1779− ElementKind::Image
1780− | ElementKind::Input
1781− | ElementKind::HorizontalRule
1782− | ElementKind::Children
1783− | ElementKind::Slot(_)
1784− | ElementKind::Fragment
1785− | ElementKind::Source
1786− | ElementKind::Spacer
1787− | ElementKind::Script
1788− | ElementKind::Breadcrumb => unreachable!(),
1789− };
1790−
1791− let kind_label = match elem.kind {
1792− ElementKind::Row => "row",
1793− ElementKind::Column => "column",
1794− ElementKind::El => "el",
1795− ElementKind::Text => "text",
1796− ElementKind::Paragraph => "paragraph",
1797− ElementKind::Link => "link",
1798− ElementKind::Button => "button",
1799− ElementKind::Select => "select",
1800− ElementKind::Textarea => "textarea",
1801− ElementKind::Option => "option",
1802− ElementKind::Label => "label",
1803− ElementKind::Nav => "nav",
1804− ElementKind::Header => "header",
1805− ElementKind::Footer => "footer",
1806− ElementKind::Main => "main",
1807− ElementKind::Section => "section",
1808− ElementKind::Article => "article",
1809− ElementKind::Aside => "aside",
1810− ElementKind::List => "list",
1811− ElementKind::ListItem => "item",
1812− ElementKind::Table => "table",
1813− ElementKind::TableHead => "thead",
1814− ElementKind::TableBody => "tbody",
1815− ElementKind::TableRow => "tr",
1816− ElementKind::TableCell => "td",
1817− ElementKind::TableHeaderCell => "th",
1818− ElementKind::Video => "video",
1819− ElementKind::Audio => "audio",
1820− ElementKind::Form => "form",
1821− ElementKind::Details => "details",
1822− ElementKind::Summary => "summary",
1823− ElementKind::Blockquote => "blockquote",
1824− ElementKind::Cite => "cite",
1825− ElementKind::Code => "code",
1826− ElementKind::Pre => "pre",
1827− ElementKind::Figure => "figure",
1828− ElementKind::FigCaption => "figcaption",
1829− ElementKind::Fragment => "fragment",
1830− ElementKind::Dialog => "dialog",
1831− ElementKind::DefinitionList => "dl",
1832− ElementKind::DefinitionTerm => "dt",
1833− ElementKind::DefinitionDescription => "dd",
1834− ElementKind::Fieldset => "fieldset",
1835− ElementKind::Legend => "legend",
1836− ElementKind::Picture => "picture",
1837− ElementKind::Time => "time",
1838− ElementKind::Mark => "mark",
1839− ElementKind::Kbd => "kbd",
1840− ElementKind::Abbr => "abbr",
1841− ElementKind::Datalist => "datalist",
1842− ElementKind::Iframe => "iframe",
1843− ElementKind::Output => "output",
1844− ElementKind::Canvas => "canvas",
1845− ElementKind::Grid => "grid",
1846− ElementKind::Stack => "stack",
1847− ElementKind::Badge => "badge",
1848− ElementKind::Tooltip => "tooltip",
1849− ElementKind::Avatar => "avatar",
1850− ElementKind::Carousel => "carousel",
1851− ElementKind::Chip => "chip",
1852− ElementKind::Tag => "tag",
1853− ElementKind::Noscript => "noscript",
1854− ElementKind::Address => "address",
1855− ElementKind::Search => "search",
1856− ElementKind::H1 => "h1",
1857− ElementKind::H2 => "h2",
1858− ElementKind::H3 => "h3",
1859− ElementKind::H4 => "h4",
1860− ElementKind::H5 => "h5",
1861− ElementKind::H6 => "h6",
1379+ ElementKind::Tag(spec) => spec.html,
18621380 _ => "",
18631381 };
1382+ // Inside text, htmlang's own `<div>` is a `<span>`, which text can
1383+ // hold (directly in text, a row, column or grid is also laid out
1384+ // inline: see `Site::inline`)
1385+ let in_text = ctx.in_text || parent == Some(Layout::Text);
1386+ let tag = if in_text && tag == "div" { "span" } else { tag };
1387+ let kind_label = elem.kind.name();
18641388
18651389 // Track interactive elements for focus-visible CSS
1866− if matches!(
1867− elem.kind,
1868− ElementKind::Link
1869− | ElementKind::Button
1870− | ElementKind::Input
1871− | ElementKind::Select
1872− | ElementKind::Textarea
1873− ) {
1390+ if elem.kind == ElementKind::Link
1391+ || matches!(kind_label, "button" | "input" | "select" | "textarea")
1392+ {
18741393 ctx.has_interactive = true;
18751394 }
18761395
18771396 // Compute CSS for each state and get a class name
1878− let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
1397+ let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1398+ own.class = gen_class.clone();
18791399 let (id, user_class) = extract_id_class(&elem.attrs);
18801400
18811401 if ctx.dev && elem.line_num > 0 {
@@ -1889,93 +1409,17 @@ fn generate_element(
18891409 out.push('<');
18901410 out.push_str(tag);
18911411
1892− // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1893− if elem.kind == ElementKind::Main && id.is_none() {
1894− out.push_str(" id=\"hl-main\"");
1895− }
1412+ emit_argument_attr(out, elem, ctx.in_picture);
18961413
1897− if elem.kind == ElementKind::Link
1898− && let Some(url) = &elem.argument
1899− {
1900− out.push_str(" href=\"");
1901− out.push_str(&html_escape(url));
1414+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1415+
1416+ if let Some(id) = id {
1417+ out.push_str(" id=\"");
1418+ out.push_str(&html_escape(&id));
19021419 out.push('"');
1903− // Auto rel="noopener noreferrer" and target="_blank" for external links
1904− let is_external = url.starts_with("http://") || url.starts_with("https://");
1905− if is_external {
1906− let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1907− let has_target = elem.attrs.iter().any(|a| a.key == "target");
1908− if !has_rel {
1909− out.push_str(" rel=\"noopener noreferrer\"");
1910− }
1911− if !has_target {
1912− out.push_str(" target=\"_blank\"");
1913− }
1914− }
19151420 }
19161421
1917− // Video/Audio src
1918− if matches!(elem.kind, ElementKind::Video | ElementKind::Audio)
1919− && let Some(src) = &elem.argument
1920− {
1921− out.push_str(" src=\"");
1922− out.push_str(&html_escape(src));
1923− out.push('"');
1924− }
1925−
1926− // Form action
1927− if elem.kind == ElementKind::Form
1928− && let Some(action) = &elem.argument
1929− {
1930− out.push_str(" action=\"");
1931− out.push_str(&html_escape(action));
1932− out.push('"');
1933− }
1934−
1935− // Iframe src
1936− if elem.kind == ElementKind::Iframe
1937− && let Some(src) = &elem.argument
1938− {
1939− out.push_str(" src=\"");
1940− out.push_str(&html_escape(src));
1941− out.push('"');
1942− }
1943−
1944− // Tooltip title
1945− if elem.kind == ElementKind::Tooltip
1946− && let Some(text) = &elem.argument
1947− {
1948− out.push_str(" title=\"");
1949− out.push_str(&html_escape(text));
1950− out.push('"');
1951− }
1952−
1953− // Critical CSS: inline styles directly instead of using a class
1954− let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
1955− if is_critical {
1956− let inline_css = attrs_to_css(&elem.attrs, "", &elem.kind, parent_kind);
1957− if !inline_css.is_empty() {
1958− out.push_str(" style=\"");
1959− out.push_str(&html_escape(&inline_css));
1960− out.push('"');
1961− }
1962− // Still use class for non-base styles (pseudo, responsive, etc.)
1963− if gen_class.as_deref().is_some_and(|c| !c.is_empty()) {
1964− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1965− } else if let Some(ref uc) = user_class {
1966− emit_class_attr(out, None, Some(uc));
1967− }
1968− } else {
1969− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1970− }
1971−
1972− if let Some(id) = id {
1973− out.push_str(" id=\"");
1974− out.push_str(&html_escape(&id));
1975− out.push('"');
1976− }
1977−
1978− emit_html_passthrough_attrs(out, &elem.attrs);
1422+ emit_html_attrs(out, &elem.attrs);
19791423
19801424 // Source map attributes in dev mode
19811425 if ctx.dev && elem.line_num > 0 {
@@ -1986,66 +1430,27 @@ fn generate_element(
19861430 }
19871431
19881432 out.push('>');
1433+ // In `<pre>` and `<textarea>` every space and line break shows, so
1434+ // readable (dev) output adds none there: a code sample's lines stay
1435+ // exactly as written
1436+ let outer_dev = ctx.dev;
1437+ ctx.dev &= !matches!(tag, "pre" | "textarea");
19891438 out.push_str(ctx.nl());
19901439
1991− // Inline text argument
1992− if matches!(
1993− elem.kind,
1994− ElementKind::Text
1995− | ElementKind::Button
1996− | ElementKind::Label
1997− | ElementKind::Option
1998− | ElementKind::Textarea
1999− | ElementKind::ListItem
2000− | ElementKind::TableCell
2001− | ElementKind::TableHeaderCell
2002− | ElementKind::Summary
2003− | ElementKind::Cite
2004− | ElementKind::Code
2005− | ElementKind::FigCaption
2006− | ElementKind::Legend
2007− | ElementKind::DefinitionTerm
2008− | ElementKind::Mark
2009− | ElementKind::Kbd
2010− | ElementKind::Abbr
2011− | ElementKind::Time
2012− | ElementKind::DefinitionDescription
2013− | ElementKind::Badge
2014− | ElementKind::Tooltip
2015− | ElementKind::Chip
2016− | ElementKind::Tag
2017− | ElementKind::H1
2018− | ElementKind::H2
2019− | ElementKind::H3
2020− | ElementKind::H4
2021− | ElementKind::H5
2022− | ElementKind::H6
2023− ) && let Some(text) = &elem.argument
2024− {
2025− out.push_str(&html_escape(text));
2026− }
2027−
2028− // Children
1440+ let layout = elem.kind.layout();
20291441 ctx.depth += 1;
2030− let is_paragraph = matches!(
2031− elem.kind,
2032− ElementKind::Paragraph
2033− | ElementKind::H1
2034− | ElementKind::H2
2035− | ElementKind::H3
2036− | ElementKind::H4
2037− | ElementKind::H5
2038− | ElementKind::H6
2039− );
2040− for (i, child) in elem.children.iter().enumerate() {
2041− generate_node(child, Some(&elem.kind), out, styles, ctx);
2042− if is_paragraph && i < elem.children.len() - 1 {
2043− out.push(' ');
2044− }
2045− }
1442+ let outer_in_text = ctx.in_text;
1443+ ctx.in_text = in_text || layout == Layout::Text;
1444+ let outer_flow = std::mem::replace(&mut ctx.flow, own);
1445+ let outer_picture = std::mem::replace(&mut ctx.in_picture, elem.kind.is_tag("picture"));
1446+ generate_children(&elem.children, Some(layout), out, styles, ctx);
1447+ ctx.in_picture = outer_picture;
1448+ ctx.flow = outer_flow;
1449+ ctx.in_text = outer_in_text;
20461450 ctx.depth -= 1;
20471451
20481452 out.push_str(&ctx.indent());
1453+ ctx.dev = outer_dev;
20491454 out.push_str("</");
20501455 out.push_str(tag);
20511456 out.push('>');
@@ -2054,21 +1459,18 @@ fn generate_element(
20541459
20551460fn generate_self_closing(
20561461 elem: &Element,
2057− parent_kind: Option<&ElementKind>,
1462+ parent: Option<Layout>,
20581463 out: &mut String,
20591464 styles: &mut StyleCollector,
20601465 ctx: &mut GenContext,
20611466) {
2062− let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
1467+ let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
20631468 let (id, user_class) = extract_id_class(&elem.attrs);
20641469
2065− let (tag, kind_label) = match elem.kind {
1470+ let (tag, kind_label) = match &elem.kind {
20661471 ElementKind::Image => ("img", "image"),
2067− ElementKind::Input => ("input", "input"),
2068− ElementKind::HorizontalRule => ("hr", "hr"),
2069− ElementKind::Source => ("source", "source"),
2070− ElementKind::Spacer => ("div", "spacer"),
2071− _ => unreachable!(),
1472+ ElementKind::Tag(spec) => (spec.html, spec.name),
1473+ _ => unreachable!("not a void element: {:?}", elem.kind),
20721474 };
20731475
20741476 if ctx.dev && elem.line_num > 0 {
@@ -2081,43 +1483,7 @@ fn generate_self_closing(
20811483 out.push_str(&ctx.indent());
20821484 out.push('<');
20831485 out.push_str(tag);
2084−
2085− // Image src (with optional non-SVG base64 inlining)
2086− if elem.kind == ElementKind::Image {
2087− let src = elem.argument.as_deref().unwrap_or("");
2088− let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
2089− if is_inline && !src.is_empty() && !src.ends_with(".svg") {
2090− let mime = if src.ends_with(".png") {
2091− "image/png"
2092− } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
2093− "image/jpeg"
2094− } else if src.ends_with(".gif") {
2095− "image/gif"
2096− } else if src.ends_with(".webp") {
2097− "image/webp"
2098− } else if src.ends_with(".avif") {
2099− "image/avif"
2100− } else {
2101− "application/octet-stream"
2102− };
2103− if let Ok(data) = std::fs::read(src) {
2104− let b64 = base64_encode(&data);
2105− out.push_str(" src=\"data:");
2106− out.push_str(mime);
2107− out.push_str(";base64,");
2108− out.push_str(&b64);
2109− out.push('"');
2110− } else {
2111− out.push_str(" src=\"");
2112− out.push_str(&html_escape(src));
2113− out.push('"');
2114− }
2115− } else {
2116− out.push_str(" src=\"");
2117− out.push_str(&html_escape(src));
2118− out.push('"');
2119− }
2120− }
1486+ emit_argument_attr(out, elem, ctx.in_picture);
21211487
21221488 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
21231489
@@ -2127,7 +1493,7 @@ fn generate_self_closing(
21271493 out.push('"');
21281494 }
21291495
2130− emit_html_passthrough_attrs(out, &elem.attrs);
1496+ emit_html_attrs(out, &elem.attrs);
21311497
21321498 // Source map attributes in dev mode (self-closing)
21331499 if ctx.dev && elem.line_num > 0 {
@@ -2137,93 +1503,6 @@ fn generate_self_closing(
21371503 ));
21381504 }
21391505
2140− // Image optimization: auto-add loading="lazy" and decoding="async"
2141− if elem.kind == ElementKind::Image {
2142− // SVG inlining: @image [inline] logo.svg
2143− if elem.attrs.iter().any(|a| a.key == "inline")
2144− && let Some(src) = &elem.argument
2145− && src.ends_with(".svg")
2146− && let Ok(svg_content) = std::fs::read_to_string(src)
2147− {
2148− // Close the tag we opened, then emit inline SVG instead
2149− out.truncate(out.rfind('<').unwrap_or(0));
2150− out.push_str(&ctx.indent());
2151− out.push_str(svg_content.trim());
2152− out.push_str(ctx.nl());
2153− return;
2154− }
2155− // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
2156− let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
2157− if let Some(resp) = responsive_attr
2158− && let Some(ref sizes_str) = resp.value
2159− {
2160− let widths: Vec<&str> = sizes_str.split_whitespace().collect();
2161− if !widths.is_empty() {
2162− let src = elem.argument.as_deref().unwrap_or("");
2163− if !src.is_empty() {
2164− // Generate srcset with width descriptors
2165− // Convention: file-{width}.ext (e.g., photo-400.jpg)
2166− let dot_pos = src.rfind('.').unwrap_or(src.len());
2167− let base = &src[..dot_pos];
2168− let ext = &src[dot_pos..];
2169− let mut srcset_parts = Vec::new();
2170− for w in &widths {
2171− srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
2172− }
2173− out.push_str(" srcset=\"");
2174− out.push_str(&srcset_parts.join(", "));
2175− out.push('"');
2176− // Generate sizes attribute
2177− let max_width = widths.last().unwrap_or(&"100vw");
2178− out.push_str(&format!(
2179− " sizes=\"(max-width: {}px) 100vw, {}px\"",
2180− max_width, max_width
2181− ));
2182− }
2183− }
2184− }
2185−
2186− // Auto image dimensions: read local image file to inject width/height + aspect-ratio
2187− let has_width = elem.attrs.iter().any(|a| a.key == "width");
2188− let has_height = elem.attrs.iter().any(|a| a.key == "height");
2189− if (!has_width || !has_height)
2190− && let Some(ref src) = elem.argument
2191− && !src.starts_with("http://")
2192− && !src.starts_with("https://")
2193− && !src.starts_with("data:")
2194− && let Some((w, h)) = read_image_dimensions(src)
2195− {
2196− if !has_width {
2197− out.push_str(&format!(" width=\"{}\"", w));
2198− }
2199− if !has_height {
2200− out.push_str(&format!(" height=\"{}\"", h));
2201− }
2202− // Auto aspect-ratio to prevent CLS
2203− if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
2204− out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
2205− }
2206− }
2207−
2208− // Smart image loading: first 3 images get fetchpriority="high" (above the fold),
2209− // subsequent images get loading="lazy" + decoding="async"
2210− ctx.image_count += 1;
2211− if ctx.image_count <= 3 {
2212− // Above-the-fold: eager loading with high priority
2213− if !elem.attrs.iter().any(|a| a.key == "fetchpriority") {
2214− out.push_str(" fetchpriority=\"high\"");
2215− }
2216− } else {
2217− // Below-the-fold: lazy loading
2218− if !elem.attrs.iter().any(|a| a.key == "loading") {
2219− out.push_str(" loading=\"lazy\"");
2220− }
2221− if !elem.attrs.iter().any(|a| a.key == "decoding") {
2222− out.push_str(" decoding=\"async\"");
2223− }
2224− }
2225− }
2226−
22271506 out.push('>');
22281507 out.push_str(ctx.nl());
22291508}
@@ -2239,7 +1518,10 @@ fn generate_text_segments(
22391518 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
22401519 TextSegment::Inline(elem) => {
22411520 let mut buf = String::new();
2242− generate_element(elem, None, &mut buf, styles, ctx);
1521+ // An element inside a line of text is in text
1522+ let outer_flow = std::mem::take(&mut ctx.flow);
1523+ generate_element(elem, Some(Layout::Text), &mut buf, styles, ctx);
1524+ ctx.flow = outer_flow;
22431525 out.push_str(buf.trim_end());
22441526 }
22451527 }
@@ -2252,141 +1534,97 @@ fn generate_text_segments(
22521534
22531535fn compute_class(
22541536 attrs: &[Attribute],
2255− kind: &ElementKind,
2256− parent_kind: Option<&ElementKind>,
1537+ site: &Site,
22571538 styles: &mut StyleCollector,
1539+ distinct: String,
22581540) -> Option<String> {
2259− let base = attrs_to_css(attrs, "", kind, parent_kind);
2260−
2261− // Collect pseudo-state overrides
2262− let mut pseudo = Vec::new();
2263− for &(prefix, selector) in PSEUDO_PREFIXES {
2264− let css = attrs_to_css(attrs, prefix, kind, parent_kind);
2265− if !css.is_empty() {
2266− pseudo.push((selector.to_string(), css));
2267− }
2268− }
2269−
2270− // Auto-add ::-webkit-scrollbar pseudo for no-scrollbar attribute
2271− if attrs.iter().any(|a| a.key == "no-scrollbar") {
2272− pseudo.push((
2273− "::-webkit-scrollbar".to_string(),
2274− "display:none;".to_string(),
2275− ));
2276− }
2277−
2278− // Collect nth:EXPR: dynamic pseudo selectors
2279− let mut nth_prefixes: Vec<String> = Vec::new();
2280− for attr in attrs {
2281− if attr.key.starts_with("nth:") {
2282− let rest = &attr.key[4..];
2283− if let Some(colon_pos) = rest.find(':') {
2284− let prefix = format!("nth:{}:", &rest[..colon_pos]);
2285− if !nth_prefixes.contains(&prefix) {
2286− nth_prefixes.push(prefix);
2287− }
2288− }
2289− }
2290− }
2291− for prefix in &nth_prefixes {
2292− let expr = &prefix[4..prefix.len() - 1];
2293− let selector = format!(":nth-child({})", expr);
2294− let css = attrs_to_css(attrs, prefix, kind, parent_kind);
2295− if !css.is_empty() {
2296− pseudo.push((selector, css));
1541+ // Every condition an attribute is under, and none
1542+ let mut conditions = vec![Condition::default()];
1543+ for attr in attrs.iter().filter(|a| !a.html) {
1544+ let (condition, _) = Condition::of(&attr.key);
1545+ if !conditions.contains(&condition) {
1546+ conditions.push(condition);
22971547 }
22981548 }
1549+ // Dedupe: if a property is declared twice within a single rule, keep only
1550+ // the last occurrence.
1551+ let rules: Vec<(Condition, String)> = conditions
1552+ .into_iter()
1553+ .map(|condition| {
1554+ let css = dedupe_declarations(&attrs_to_css(attrs, &condition, site));
1555+ (condition, css)
1556+ })
1557+ .collect();
1558+ // The kind's defaults, less what the element sets itself (a user
1559+ // `[list-style disc]` wins over the default `list-style:none` anyway,
1560+ // so there is no need to ship both)
1561+ let own = rules
1562+ .iter()
1563+ .find(|(condition, _)| *condition == Condition::default())
1564+ .map_or("", |(_, css)| css.as_str());
1565+ let own: Vec<&str> = declarations(own).map(|(name, _)| name).collect();
1566+ let defaults: String = declarations(&dedupe_declarations(&default_css(site)))
1567+ .filter(|(name, _)| !own.contains(name))
1568+ .map(|(_, declaration)| declaration)
1569+ .collect();
1570+ styles.get_class(defaults, rules, distinct)
1571+}
22991572
2300− // Collect has(...): dynamic pseudo selectors
2301− let mut has_prefixes: Vec<String> = Vec::new();
2302− for attr in attrs {
2303− if attr.key.starts_with("has(")
2304− && let Some(close) = attr.key.find("):")
2305− {
2306− let prefix = format!("{}:", &attr.key[..close + 1]);
2307− if !has_prefixes.contains(&prefix) {
2308− has_prefixes.push(prefix);
2309− }
2310− }
1573+/// The defaults of an element's kind where it is: an overlay's positioning
1574+/// context, its layout, and its kind's own CSS (a heading's `margin:0`).
1575+fn default_css(site: &Site) -> String {
1576+ let mut css = String::new();
1577+ // Elements with @in-front / @behind children become positioning
1578+ // contexts
1579+ if site.has_overlay_children {
1580+ css.push_str("position:relative;isolation:isolate;");
23111581 }
2312− for prefix in &has_prefixes {
2313− let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
2314− let selector = format!(":has({})", inner);
2315− let css = attrs_to_css(attrs, prefix, kind, parent_kind);
2316− if !css.is_empty() {
2317− pseudo.push((selector, css));
2318− }
1582+ if !site.root {
1583+ css.push_str(layout_css(site.kind.layout(), site.inline));
23191584 }
1585+ css.push_str(site.kind.css());
1586+ css
1587+}
23201588
2321− // Collect responsive overrides
2322− let mut responsive = Vec::new();
2323− for &(bp_name, _) in BREAKPOINTS {
2324− let prefix = format!("{}:", bp_name);
2325− let css = attrs_to_css(attrs, &prefix, kind, parent_kind);
2326− if !css.is_empty() {
2327− responsive.push((bp_name.to_string(), css));
1589+/// The declarations of a rule body, as (property, `property:value;`), for
1590+/// bodies htmlang wrote: every declaration ends in `;`, and a `;` inside
1591+/// quotes or parentheses doesn't end one.
1592+fn declarations(css: &str) -> impl Iterator<Item = (&str, &str)> {
1593+ let mut rest = css;
1594+ std::iter::from_fn(move || {
1595+ if rest.is_empty() {
1596+ return None;
23281597 }
2329− }
2330−
2331− // Collect container query overrides
2332− let mut container = Vec::new();
2333− for &(bp_name, _) in BREAKPOINTS {
2334− let prefix = format!("cq-{}:", bp_name);
2335− let css = attrs_to_css(attrs, &prefix, kind, parent_kind);
2336− if !css.is_empty() {
2337− container.push((bp_name.to_string(), css));
1598+ let (mut depth, mut quote, mut escaped) = (0i32, None, false);
1599+ let mut end = rest.len();
1600+ for (i, c) in rest.char_indices() {
1601+ match c {
1602+ _ if escaped => escaped = false,
1603+ '\\' => escaped = true,
1604+ c if Some(c) == quote => quote = None,
1605+ _ if quote.is_some() => {}
1606+ '"' | '\'' => quote = Some(c),
1607+ '(' => depth += 1,
1608+ ')' => depth -= 1,
1609+ ';' if depth == 0 => {
1610+ end = i + 1;
1611+ break;
1612+ }
1613+ _ => {}
1614+ }
23381615 }
2339− }
2340−
2341− let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
2342− let print = attrs_to_css(attrs, "print:", kind, parent_kind);
2343− let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind);
2344− let motion_reduce = attrs_to_css(attrs, "motion-reduce:", kind, parent_kind);
2345− let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind);
2346− let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind);
2347−
2348− // Dedupe: if a property is declared twice within a single rule, keep only
2349− // the last occurrence (element-kind defaults are written before
2350− // user attributes, so a user [list-style disc] correctly overrides the
2351− // default list-style:none, and we don't need to ship both).
2352− let base = dedupe_declarations(&base);
2353− let pseudo: Vec<(String, String)> = pseudo
2354− .into_iter()
2355− .map(|(sel, css)| (sel, dedupe_declarations(&css)))
2356− .collect();
2357− let responsive: Vec<(String, String)> = responsive
2358− .into_iter()
2359− .map(|(bp, css)| (bp, dedupe_declarations(&css)))
2360− .collect();
2361− let dark = dedupe_declarations(&dark);
2362− let print = dedupe_declarations(&print);
2363− let motion_safe = dedupe_declarations(&motion_safe);
2364− let motion_reduce = dedupe_declarations(&motion_reduce);
2365− let landscape = dedupe_declarations(&landscape);
2366− let portrait = dedupe_declarations(&portrait);
2367− let container: Vec<(String, String)> = container
2368− .into_iter()
2369− .map(|(bp, css)| (bp, dedupe_declarations(&css)))
2370− .collect();
2371−
2372− styles.get_class(
2373− base,
2374− pseudo,
2375− responsive,
2376− dark,
2377− print,
2378− motion_safe,
2379− motion_reduce,
2380− landscape,
2381− portrait,
2382− container,
2383− )
1616+ let (declaration, after) = rest.split_at(end);
1617+ rest = after;
1618+ let name = declaration.split(':').next().unwrap_or("").trim();
1619+ Some((name, declaration))
1620+ })
23841621}
23851622
23861623/// Dedupe CSS declarations within a single rule body: for any property
23871624/// declared more than once, keep only the last occurrence. Unparseable
2388−/// segments (no `:`) are preserved as-is. Semicolons inside parentheses are
2389−/// treated as part of a value, not as declaration separators.
1625+/// segments (no `:`) are preserved as-is. Semicolons inside parentheses or
1626+/// quoted strings are treated as part of a value, not as declaration
1627+/// separators.
23901628fn dedupe_declarations(css: &str) -> String {
23911629 if css.is_empty() {
23921630 return String::new();
@@ -2401,8 +1639,26 @@ fn dedupe_declarations(css: &str) -> String {
24011639 let mut decls: Vec<(Option<String>, String)> = Vec::new();
24021640 let mut current = String::new();
24031641 let mut depth: i32 = 0;
1642+ // A quoted string's `;` (`content "a;b"`) is text; a backslash escapes
1643+ // the character after it
1644+ let mut quote: Option<char> = None;
1645+ let mut escaped = false;
24041646 for ch in css.chars() {
2405− if ch == '(' {
1647+ if escaped {
1648+ escaped = false;
1649+ current.push(ch);
1650+ } else if ch == '\\' {
1651+ escaped = true;
1652+ current.push(ch);
1653+ } else if let Some(q) = quote {
1654+ if ch == q {
1655+ quote = None;
1656+ }
1657+ current.push(ch);
1658+ } else if ch == '"' || ch == '\'' {
1659+ quote = Some(ch);
1660+ current.push(ch);
1661+ } else if ch == '(' {
24061662 depth += 1;
24071663 current.push(ch);
24081664 } else if ch == ')' {
@@ -2433,1723 +1689,650 @@ fn dedupe_declarations(css: &str) -> String {
24331689
24341690 if decls.len() < 2 {
24351691 return css.to_string();
2436− }
2437−
2438− // Find index of last occurrence of each property.
2439− use std::collections::HashMap;
2440− let mut last: HashMap<String, usize> = HashMap::new();
2441− for (i, (prop, _)) in decls.iter().enumerate() {
2442− if let Some(p) = prop {
2443− last.insert(p.clone(), i);
2444− }
2445− }
2446−
2447− let mut out = String::with_capacity(css.len());
2448− for (i, (prop, raw)) in decls.iter().enumerate() {
2449− let keep = match prop {
2450− Some(p) => last.get(p) == Some(&i),
2451− None => true,
2452− };
2453− if keep {
2454− out.push_str(raw);
2455− }
2456− }
2457− out
2458−}
2459−
2460−fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
2461− match (gen_class, user_class) {
2462− (Some(g), Some(u)) => {
2463− out.push_str(" class=\"");
2464− out.push_str(g);
2465− out.push(' ');
2466− out.push_str(&html_escape(u));
2467− out.push('"');
2468− }
2469− (Some(g), None) => {
2470− out.push_str(" class=\"");
2471− out.push_str(g);
2472− out.push('"');
2473− }
2474− (None, Some(u)) => {
2475− out.push_str(" class=\"");
2476− out.push_str(&html_escape(u));
2477− out.push('"');
2478− }
2479− (None, None) => {}
2480− }
2481−}
2482−
2483−// ---------------------------------------------------------------------------
2484−// Attribute → CSS mapping
2485−// ---------------------------------------------------------------------------
2486−
2487−/// (htmlang prefix, CSS selector suffix)
2488−const PSEUDO_PREFIXES: &[(&str, &str)] = &[
2489− ("hover:", ":hover"),
2490− ("active:", ":active"),
2491− ("focus:", ":focus"),
2492− ("focus-visible:", ":focus-visible"),
2493− ("focus-within:", ":focus-within"),
2494− ("disabled:", ":disabled"),
2495− ("checked:", ":checked"),
2496− ("placeholder:", "::placeholder"),
2497− ("first:", ":first-child"),
2498− ("last:", ":last-child"),
2499− ("odd:", ":nth-child(odd)"),
2500− ("even:", ":nth-child(even)"),
2501− ("before:", "::before"),
2502− ("after:", "::after"),
2503− ("selection:", "::selection"),
2504− ("visited:", ":visited"),
2505− ("empty:", ":empty"),
2506− ("target:", ":target"),
2507− ("valid:", ":valid"),
2508− ("invalid:", ":invalid"),
2509−];
2510−const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
2511−const MEDIA_PREFIXES: &[&str] = &[
2512− "dark:",
2513− "print:",
2514− "motion-safe:",
2515− "motion-reduce:",
2516− "landscape:",
2517− "portrait:",
2518−];
2519−const CONTAINER_QUERY_PREFIXES: &[&str] = &["cq-sm:", "cq-md:", "cq-lg:", "cq-xl:", "cq-2xl:"];
2520−
2521−fn is_prefixed_attr(key: &str) -> bool {
2522− PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
2523− || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
2524− || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
2525− || CONTAINER_QUERY_PREFIXES.iter().any(|p| key.starts_with(p))
2526− || key.starts_with("nth:")
2527− || key.starts_with("has(")
2528−}
2529−
2530−/// `display:flex;flex-direction:column;` — base layout for `@el` and every
2531−/// semantic wrapper that behaves like a column.
2532−const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
2533−
2534−/// Font stack shared by `@pre`, `@code`, `@kbd`.
2535−const MONOSPACE_STACK: &str = "font-family:ui-monospace,monospace;";
2536−
2537−/// Declarations common to `@badge` and `@tag`. They differ only in
2538−/// `border-radius` (and `@badge` additionally centers content / pins
2539−/// line-height:1), so keeping the shared base here avoids drift.
2540−const PILL_BASE: &str =
2541− "display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;";
2542−
2543−fn attrs_to_css(
2544− attrs: &[Attribute],
2545− state_prefix: &str,
2546− kind: &ElementKind,
2547− parent_kind: Option<&ElementKind>,
2548−) -> String {
2549− let mut css = String::new();
2550−
2551− // Base element styles only for the default (non-state) pass
2552− if state_prefix.is_empty() {
2553− match kind {
2554− ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2555− ElementKind::Column
2556− | ElementKind::El
2557− // Semantic elements get flex column layout like @el
2558− | ElementKind::Nav
2559− | ElementKind::Header
2560− | ElementKind::Footer
2561− | ElementKind::Main
2562− | ElementKind::Section
2563− | ElementKind::Article
2564− | ElementKind::Aside
2565− | ElementKind::ListItem
2566− | ElementKind::Form
2567− | ElementKind::Details
2568− | ElementKind::Dialog => css.push_str(FLEX_COLUMN),
2569− ElementKind::Paragraph
2570− | ElementKind::H1
2571− | ElementKind::H2
2572− | ElementKind::H3
2573− | ElementKind::H4
2574− | ElementKind::H5
2575− | ElementKind::H6 => css.push_str("margin:0;"),
2576− // Lists: reset browser defaults
2577− ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
2578− // Figure / Blockquote: flex column with browser margin reset
2579− ElementKind::Figure | ElementKind::Blockquote => {
2580− css.push_str(FLEX_COLUMN);
2581− css.push_str("margin:0;");
2582− }
2583− // Pre: preserve whitespace
2584− ElementKind::Pre => {
2585− css.push_str("margin:0;white-space:pre;");
2586− css.push_str(MONOSPACE_STACK);
2587− }
2588− // Code / Kbd: monospace font
2589− ElementKind::Code | ElementKind::Kbd => css.push_str(MONOSPACE_STACK),
2590− ElementKind::DefinitionList => css.push_str("margin:0;"),
2591− ElementKind::DefinitionDescription => {
2592− css.push_str("margin:0;");
2593− css.push_str(FLEX_COLUMN);
2594− }
2595− ElementKind::Fieldset => {
2596− css.push_str(FLEX_COLUMN);
2597− // Align padding to the px-based scale used by Badge/Tag/Chip
2598− // rather than the old 0.5em (which depended on font-size).
2599− css.push_str("border:1px solid currentColor;padding:8px;margin:0;");
2600− }
2601− ElementKind::Grid => css.push_str("display:grid;"),
2602− ElementKind::Stack => css.push_str("position:relative;"),
2603− ElementKind::Badge => {
2604− css.push_str(PILL_BASE);
2605− css.push_str("justify-content:center;border-radius:9999px;line-height:1;");
2606− }
2607− ElementKind::Tag => {
2608− css.push_str(PILL_BASE);
2609− css.push_str("border-radius:4px;");
2610− }
2611− ElementKind::Tooltip => css.push_str("position:relative;cursor:help;"),
2612− ElementKind::Spacer => css.push_str("flex:1;"),
2613− ElementKind::Avatar => css.push_str("display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;"),
2614− ElementKind::Carousel => css.push_str("display:flex;flex-direction:row;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;"),
2615− ElementKind::Chip => css.push_str("display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:9999px;font-size:0.875rem;border:1px solid currentColor;"),
2616− _ => {}
2617− }
2618− // Children of @carousel get scroll-snap-align and flex-shrink
2619− if matches!(parent_kind, Some(ElementKind::Carousel)) {
2620− css.push_str("scroll-snap-align:start;flex-shrink:0;");
2621− }
2622− }
2623−
2624− for attr in attrs {
2625− // Determine the effective key for this pass
2626− let effective_key = if state_prefix.is_empty() {
2627− if is_prefixed_attr(&attr.key) {
2628− continue;
2629− }
2630− attr.key.as_str()
2631− } else {
2632− match attr.key.strip_prefix(state_prefix) {
2633− Some(k) => k,
2634− None => continue,
2635− }
2636− };
2637−
2638− let val = attr.value.as_deref();
2639−
2640− match effective_key {
2641− // Layout
2642− "spacing" | "gap" => {
2643− if let Some(v) = val {
2644− push_css(&mut css, "gap", &css_px(v));
2645− }
2646− }
2647− "padding" => {
2648− if let Some(v) = val {
2649− push_css(&mut css, "padding", &css_px_multi(v));
2650− }
2651− }
2652− "padding-x" => {
2653− if let Some(v) = val {
2654− // Logical property covers both inline sides in one
2655− // declaration; for symmetric values this is visually
2656− // identical to padding-left/right in LTR and RTL.
2657− push_css(&mut css, "padding-inline", &css_px(v));
2658− }
2659− }
2660− "padding-y" => {
2661− if let Some(v) = val {
2662− push_css(&mut css, "padding-block", &css_px(v));
2663− }
2664− }
2665− "padding-top" => {
2666− if let Some(v) = val {
2667− push_css(&mut css, "padding-top", &css_px(v));
2668− }
2669− }
2670− "padding-bottom" => {
2671− if let Some(v) = val {
2672− push_css(&mut css, "padding-bottom", &css_px(v));
2673− }
2674− }
2675− "padding-left" => {
2676− if let Some(v) = val {
2677− push_css(&mut css, "padding-left", &css_px(v));
2678− }
2679− }
2680− "padding-right" => {
2681− if let Some(v) = val {
2682− push_css(&mut css, "padding-right", &css_px(v));
2683− }
2684− }
2685−
2686− // Sizing
2687− "width" => {
2688− if let Some(v) = val {
2689− match v {
2690− "fill" => match parent_kind {
2691− Some(ElementKind::Row) => {
2692− push_css(&mut css, "flex", "1");
2693− push_css(&mut css, "min-width", "0");
2694− }
2695− _ => push_css(&mut css, "width", "100%"),
2696− },
2697− "shrink" => push_css(&mut css, "flex-shrink", "0"),
2698− _ => push_css(&mut css, "width", &css_px(v)),
2699− }
2700− }
2701− }
2702− "height" => {
2703− if let Some(v) = val {
2704− match v {
2705− "fill" => match parent_kind {
2706− Some(ElementKind::Column) => {
2707− push_css(&mut css, "flex", "1");
2708− push_css(&mut css, "min-height", "0");
2709− }
2710− _ => push_css(&mut css, "height", "100%"),
2711− },
2712− "shrink" => push_css(&mut css, "flex-shrink", "0"),
2713− _ => push_css(&mut css, "height", &css_px(v)),
2714− }
2715− }
2716− }
2717− "min-width" => {
2718− if let Some(v) = val {
2719− push_css(&mut css, "min-width", &css_px(v));
2720− }
2721− }
2722− "max-width" => {
2723− if let Some(v) = val {
2724− push_css(&mut css, "max-width", &css_px(v));
2725− }
2726− }
2727− "min-height" => {
2728− if let Some(v) = val {
2729− push_css(&mut css, "min-height", &css_px(v));
2730− }
2731− }
2732− "max-height" => {
2733− if let Some(v) = val {
2734− push_css(&mut css, "max-height", &css_px(v));
2735− }
2736− }
2737−
2738− // Alignment
2739− "center-x" => match parent_kind {
2740− Some(ElementKind::Column) | Some(ElementKind::El) => {
2741− push_css(&mut css, "align-self", "center");
2742− }
2743− _ => {
2744− push_css(&mut css, "margin-left", "auto");
2745− push_css(&mut css, "margin-right", "auto");
2746− }
2747− },
2748− "center-y" => match parent_kind {
2749− Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
2750− _ => {
2751− push_css(&mut css, "margin-top", "auto");
2752− push_css(&mut css, "margin-bottom", "auto");
2753− }
2754− },
2755− "align-left" => match parent_kind {
2756− Some(ElementKind::Column) | Some(ElementKind::El) => {
2757− push_css(&mut css, "align-self", "flex-start");
2758− }
2759− _ => push_css(&mut css, "margin-right", "auto"),
2760− },
2761− "align-right" => match parent_kind {
2762− Some(ElementKind::Column) | Some(ElementKind::El) => {
2763− push_css(&mut css, "align-self", "flex-end");
2764− }
2765− _ => push_css(&mut css, "margin-left", "auto"),
2766− },
2767− "align-top" => match parent_kind {
2768− Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
2769− _ => push_css(&mut css, "margin-bottom", "auto"),
2770− },
2771− "align-bottom" => match parent_kind {
2772− Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
2773− _ => push_css(&mut css, "margin-top", "auto"),
2774− },
2775−
2776− // Style
2777− "background" => {
2778− if let Some(v) = val {
2779− push_css(&mut css, "background", v);
2780− }
2781− }
2782− "color" => {
2783− if let Some(v) = val {
2784− push_css(&mut css, "color", v);
2785− }
2786− }
2787− "border" => {
2788− if let Some(v) = val {
2789− let parts: Vec<&str> = v.splitn(2, ' ').collect();
2790− if parts.len() == 2 {
2791− push_css(
2792− &mut css,
2793− "border",
2794− &format!("{} solid {}", css_px(parts[0]), parts[1]),
2795− );
2796− } else {
2797− push_css(
2798− &mut css,
2799− "border",
2800− &format!("{} solid currentColor", css_px(parts[0])),
2801− );
2802− }
2803− }
2804− }
2805− "border-top" | "border-bottom" | "border-left" | "border-right" => {
2806− if let Some(v) = val {
2807− let parts: Vec<&str> = v.splitn(2, ' ').collect();
2808− if parts.len() == 2 {
2809− push_css(
2810− &mut css,
2811− effective_key,
2812− &format!("{} solid {}", css_px(parts[0]), parts[1]),
2813− );
2814− } else {
2815− push_css(
2816− &mut css,
2817− effective_key,
2818− &format!("{} solid currentColor", css_px(parts[0])),
2819− );
2820− }
2821− }
2822− }
2823− "rounded" => {
2824− if let Some(v) = val {
2825− push_css(&mut css, "border-radius", &css_px(v));
2826− }
2827− }
2828− "bold" => push_css(&mut css, "font-weight", "bold"),
2829− "italic" => push_css(&mut css, "font-style", "italic"),
2830− "underline" => push_css(&mut css, "text-decoration", "underline"),
2831− "size" => {
2832− if let Some(v) = val {
2833− push_css(&mut css, "font-size", &css_px(v));
2834− }
2835− }
2836− "font" => {
2837− if let Some(v) = val {
2838− push_css(&mut css, "font-family", v);
2839− }
2840− }
2841− "transition" => {
2842− if let Some(v) = val {
2843− push_css(&mut css, "transition", v);
2844− }
2845− }
2846− "cursor" => {
2847− if let Some(v) = val {
2848− push_css(&mut css, "cursor", v);
2849− }
2850− }
2851− "opacity" => {
2852− if let Some(v) = val {
2853− push_css(&mut css, "opacity", v);
2854− }
2855− }
2856−
2857− // Typography
2858− "text-align" => {
2859− if let Some(v) = val {
2860− push_css(&mut css, "text-align", v);
2861− }
2862− }
2863− "line-height" => {
2864− if let Some(v) = val {
2865− push_css(&mut css, "line-height", &css_line_height(v));
2866− }
2867− }
2868− "letter-spacing" => {
2869− if let Some(v) = val {
2870− push_css(&mut css, "letter-spacing", &css_px(v));
2871− }
2872− }
2873− "text-transform" => {
2874− if let Some(v) = val {
2875− push_css(&mut css, "text-transform", v);
2876− }
2877− }
2878− "white-space" => {
2879− if let Some(v) = val {
2880− push_css(&mut css, "white-space", v);
2881− }
2882− }
2883−
2884− // Overflow & positioning
2885− "overflow" => {
2886− if let Some(v) = val {
2887− push_css(&mut css, "overflow", v);
2888− }
2889− }
2890− "position" => {
2891− if let Some(v) = val {
2892− push_css(&mut css, "position", v);
2893− }
2894− }
2895− "top" => {
2896− if let Some(v) = val {
2897− push_css(&mut css, "top", &css_px(v));
2898− }
2899− }
2900− "right" => {
2901− if let Some(v) = val {
2902− push_css(&mut css, "right", &css_px(v));
2903− }
2904− }
2905− "bottom" => {
2906− if let Some(v) = val {
2907− push_css(&mut css, "bottom", &css_px(v));
2908− }
2909− }
2910− "left" => {
2911− if let Some(v) = val {
2912− push_css(&mut css, "left", &css_px(v));
2913− }
2914− }
2915− "z-index" => {
2916− if let Some(v) = val {
2917− push_css(&mut css, "z-index", v);
2918− }
2919− }
2920−
2921− // Display & visibility
2922− "display" => {
2923− if let Some(v) = val {
2924− push_css(&mut css, "display", v);
2925− }
2926− }
2927− "visibility" => {
2928− if let Some(v) = val {
2929− push_css(&mut css, "visibility", v);
2930− }
2931− }
2932− "hidden" => push_css(&mut css, "display", "none"),
2933−
2934− // Transform & filters
2935− "transform" => {
2936− if let Some(v) = val {
2937− push_css(&mut css, "transform", v);
2938− }
2939− }
2940− "backdrop-filter" => {
2941− if let Some(v) = val {
2942− push_css(&mut css, "backdrop-filter", v);
2943− }
2944− }
2945−
2946− // Effects
2947− "shadow" => {
2948− if let Some(v) = val {
2949− push_css(&mut css, "box-shadow", v);
2950− }
2951− }
2952−
2953− // Flow
2954− "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
2955− "gap-x" => {
2956− if let Some(v) = val {
2957− push_css(&mut css, "column-gap", &css_px(v));
2958− }
2959− }
2960− "gap-y" => {
2961− if let Some(v) = val {
2962− push_css(&mut css, "row-gap", &css_px(v));
2963− }
2964− }
2965−
2966− // Grid
2967− "grid" => {
2968− push_css(&mut css, "display", "grid");
2969− }
2970− "grid-cols" => {
2971− if let Some(v) = val {
2972− if let Ok(n) = v.parse::<u32>() {
2973− push_css(
2974− &mut css,
2975− "grid-template-columns",
2976− &format!("repeat({},1fr)", n),
2977− );
2978− } else {
2979− push_css(&mut css, "grid-template-columns", v);
2980− }
2981− }
2982− }
2983− "grid-rows" => {
2984− if let Some(v) = val {
2985− if let Ok(n) = v.parse::<u32>() {
2986− push_css(
2987− &mut css,
2988− "grid-template-rows",
2989− &format!("repeat({},1fr)", n),
2990− );
2991− } else {
2992− push_css(&mut css, "grid-template-rows", v);
2993− }
2994− }
2995− }
2996− "col-span" => {
2997− if let Some(v) = val {
2998− push_css(&mut css, "grid-column", &format!("span {}", v));
2999− }
3000− }
3001− "row-span" => {
3002− if let Some(v) = val {
3003− push_css(&mut css, "grid-row", &format!("span {}", v));
3004− }
3005− }
3006−
3007− // Animation
3008− "animation" => {
3009− if let Some(v) = val {
3010− push_css(&mut css, "animation", v);
3011− }
3012− }
3013−
3014− // Aspect ratio
3015− "aspect-ratio" => {
3016− if let Some(v) = val {
3017− push_css(&mut css, "aspect-ratio", v);
3018− }
3019− }
3020−
3021− // CSS containment for rendering performance
3022− "contain" => {
3023− if let Some(v) = val {
3024− push_css(&mut css, "contain", v);
3025− } else {
3026− push_css(&mut css, "contain", "layout style paint");
3027− }
3028− }
3029− "content-visibility" => {
3030− if let Some(v) = val {
3031− push_css(&mut css, "content-visibility", v);
3032− } else {
3033− push_css(&mut css, "content-visibility", "auto");
3034− }
3035− }
3036−
3037− // Outline (like border but doesn't affect layout)
3038− "outline" => {
3039− if let Some(v) = val {
3040− let parts: Vec<&str> = v.splitn(2, ' ').collect();
3041− if parts.len() == 2 {
3042− push_css(
3043− &mut css,
3044− "outline",
3045− &format!("{} solid {}", css_px(parts[0]), parts[1]),
3046− );
3047− } else {
3048− push_css(
3049− &mut css,
3050− "outline",
3051− &format!("{} solid currentColor", css_px(parts[0])),
3052− );
3053− }
3054− }
3055− }
3056−
3057− // Logical properties (i18n-aware)
3058− "padding-inline" => {
3059− if let Some(v) = val {
3060− push_css(&mut css, "padding-inline", &css_px_multi(v));
3061− }
3062− }
3063− "padding-block" => {
3064− if let Some(v) = val {
3065− push_css(&mut css, "padding-block", &css_px_multi(v));
3066− }
3067− }
3068− "margin-inline" => {
3069− if let Some(v) = val {
3070− push_css(&mut css, "margin-inline", &css_px_multi(v));
3071− }
3072− }
3073− "margin-block" => {
3074− if let Some(v) = val {
3075− push_css(&mut css, "margin-block", &css_px_multi(v));
3076− }
3077− }
3078−
3079− // Logical property start/end variants
3080− "padding-inline-start"
3081− | "padding-inline-end"
3082− | "padding-block-start"
3083− | "padding-block-end"
3084− | "margin-inline-start"
3085− | "margin-inline-end"
3086− | "margin-block-start"
3087− | "margin-block-end" => {
3088− if let Some(v) = val {
3089− push_css(&mut css, effective_key, &css_px(v));
3090− }
3091− }
3092−
3093− // Logical inset
3094− "inset-inline" | "inset-block" => {
3095− if let Some(v) = val {
3096− push_css(&mut css, effective_key, &css_px_multi(v));
3097− }
3098− }
3099− "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
3100− if let Some(v) = val {
3101− push_css(&mut css, effective_key, &css_px(v));
3102− }
3103− }
3104−
3105− // Logical border
3106− "border-inline"
3107− | "border-block"
3108− | "border-inline-start"
3109− | "border-inline-end"
3110− | "border-block-start"
3111− | "border-block-end" => {
3112− if let Some(v) = val {
3113− push_css(&mut css, effective_key, v);
3114− }
3115− }
3116−
3117− // Logical border-radius
3118− "border-start-start-radius"
3119− | "border-start-end-radius"
3120− | "border-end-start-radius"
3121− | "border-end-end-radius" => {
3122− if let Some(v) = val {
3123− push_css(&mut css, effective_key, &css_px(v));
3124− }
3125− }
3126−
3127− // Logical scroll margins & padding
3128− "scroll-margin-inline"
3129− | "scroll-margin-block"
3130− | "scroll-padding-inline"
3131− | "scroll-padding-block" => {
3132− if let Some(v) = val {
3133− push_css(&mut css, effective_key, &css_px_multi(v));
3134− }
3135− }
3136−
3137− // Logical sizing
3138− "inline-size" | "block-size" | "min-inline-size" | "max-inline-size"
3139− | "min-block-size" | "max-block-size" => {
3140− if let Some(v) = val {
3141− push_css(&mut css, effective_key, &css_px(v));
3142− }
3143− }
3144−
3145− // Scroll snap
3146− "scroll-snap-type" => {
3147− if let Some(v) = val {
3148− push_css(&mut css, "scroll-snap-type", v);
3149− }
3150− }
3151− "scroll-snap-align" => {
3152− if let Some(v) = val {
3153− push_css(&mut css, "scroll-snap-align", v);
3154− }
3155− }
3156−
3157− // Margin
3158− "margin" => {
3159− if let Some(v) = val {
3160− push_css(&mut css, "margin", &css_px_multi(v));
3161− }
3162− }
3163− "margin-x" => {
3164− if let Some(v) = val {
3165− push_css(&mut css, "margin-inline", &css_px(v));
3166− }
3167− }
3168− "margin-y" => {
3169− if let Some(v) = val {
3170− push_css(&mut css, "margin-block", &css_px(v));
3171− }
3172− }
3173−
3174− // Filter
3175− "filter" => {
3176− if let Some(v) = val {
3177− push_css(&mut css, "filter", v);
3178− }
3179− }
3180−
3181− // Object fit/position (for images)
3182− "object-fit" => {
3183− if let Some(v) = val {
3184− push_css(&mut css, "object-fit", v);
3185− }
3186− }
3187− "object-position" => {
3188− if let Some(v) = val {
3189− push_css(&mut css, "object-position", v);
3190− }
3191− }
3192−
3193− // Text shadow
3194− "text-shadow" => {
3195− if let Some(v) = val {
3196− push_css(&mut css, "text-shadow", v);
3197− }
3198− }
3199−
3200− // Text overflow
3201− "text-overflow" => {
3202− if let Some(v) = val {
3203− push_css(&mut css, "text-overflow", v);
3204− }
3205− }
3206−
3207− // Interaction
3208− "pointer-events" => {
3209− if let Some(v) = val {
3210− push_css(&mut css, "pointer-events", v);
3211− }
3212− }
3213− "user-select" => {
3214− if let Some(v) = val {
3215− push_css(&mut css, "user-select", v);
3216− }
3217− }
3218−
3219− // Flexbox/grid alignment
3220− "justify-content" => {
3221− if let Some(v) = val {
3222− push_css(&mut css, "justify-content", v);
3223− }
3224− }
3225− "align-items" => {
3226− if let Some(v) = val {
3227− push_css(&mut css, "align-items", v);
3228− }
3229− }
3230−
3231− // Flex item order
3232− "order" => {
3233− if let Some(v) = val {
3234− push_css(&mut css, "order", v);
3235− }
3236− }
3237−
3238− // Background extras
3239− "background-size" => {
3240− if let Some(v) = val {
3241− push_css(&mut css, "background-size", v);
3242− }
3243− }
3244− "background-position" => {
3245− if let Some(v) = val {
3246− push_css(&mut css, "background-position", v);
3247− }
3248− }
3249− "background-repeat" => {
3250− if let Some(v) = val {
3251− push_css(&mut css, "background-repeat", v);
3252− }
3253− }
3254−
3255− // Text wrapping
3256− "word-break" => {
3257− if let Some(v) = val {
3258− push_css(&mut css, "word-break", v);
3259− }
3260− }
3261− "overflow-wrap" => {
3262− if let Some(v) = val {
3263− push_css(&mut css, "overflow-wrap", v);
3264− }
3265− }
3266−
3267− // Container queries
3268− "container" => {
3269− push_css(&mut css, "container-type", "inline-size");
3270− }
3271− "container-name" => {
3272− if let Some(v) = val {
3273− push_css(&mut css, "container-name", v);
3274− }
3275− }
3276− "container-type" => {
3277− if let Some(v) = val {
3278− push_css(&mut css, "container-type", v);
3279− }
3280− }
3281−
3282− // Overflow axis
3283− "overflow-x" => {
3284− if let Some(v) = val {
3285− push_css(&mut css, "overflow-x", v);
3286− }
3287− }
3288− "overflow-y" => {
3289− if let Some(v) = val {
3290− push_css(&mut css, "overflow-y", v);
3291− }
3292− }
3293−
3294− // Inset (shorthand for top/right/bottom/left)
3295− "inset" => {
3296− if let Some(v) = val {
3297− push_css(&mut css, "inset", &css_px(v));
3298− }
3299− }
3300−
3301− // Accent & caret colors
3302− "accent-color" => {
3303− if let Some(v) = val {
3304− push_css(&mut css, "accent-color", v);
3305− }
3306− }
3307− "caret-color" => {
3308− if let Some(v) = val {
3309− push_css(&mut css, "caret-color", v);
3310− }
3311− }
3312−
3313− // Color scheme & appearance
3314− "color-scheme" => {
3315− if let Some(v) = val {
3316− push_css(&mut css, "color-scheme", v);
3317− }
3318− }
3319− "appearance" => {
3320− if let Some(v) = val {
3321− push_css(&mut css, "appearance", v);
3322− }
3323− }
3324−
3325− // List style
3326− "list-style" => {
3327− if let Some(v) = val {
3328− push_css(&mut css, "list-style", v);
3329− }
3330− }
3331−
3332− // Table
3333− "border-collapse" => {
3334− if let Some(v) = val {
3335− push_css(&mut css, "border-collapse", v);
3336− }
3337− }
3338− "border-spacing" => {
3339− if let Some(v) = val {
3340− push_css(&mut css, "border-spacing", &css_px(v));
3341− }
3342− }
3343−
3344− // Text decoration
3345− "text-decoration" => {
3346− if let Some(v) = val {
3347− push_css(&mut css, "text-decoration", v);
3348− }
3349− }
3350− "text-decoration-color" => {
3351− if let Some(v) = val {
3352− push_css(&mut css, "text-decoration-color", v);
3353− }
3354− }
3355− "text-decoration-thickness" => {
3356− if let Some(v) = val {
3357− push_css(&mut css, "text-decoration-thickness", &css_px(v));
3358− }
3359− }
3360− "text-decoration-style" => {
3361− if let Some(v) = val {
3362− push_css(&mut css, "text-decoration-style", v);
3363− }
3364− }
3365− "text-underline-offset" => {
3366− if let Some(v) = val {
3367− push_css(&mut css, "text-underline-offset", &css_px(v));
3368− }
3369− }
3370−
3371− // Multi-column
3372− "column-width" => {
3373− if let Some(v) = val {
3374− push_css(&mut css, "column-width", &css_px(v));
3375− }
3376− }
3377− "column-rule" => {
3378− if let Some(v) = val {
3379− push_css(&mut css, "column-rule", v);
3380− }
3381− }
3382−
3383− // Place items/self
3384− "place-items" => {
3385− if let Some(v) = val {
3386− push_css(&mut css, "place-items", v);
3387− }
3388− }
3389− "place-self" => {
3390− if let Some(v) = val {
3391− push_css(&mut css, "place-self", v);
3392− }
3393− }
3394−
3395− // Scroll behavior
3396− "scroll-behavior" => {
3397− if let Some(v) = val {
3398− push_css(&mut css, "scroll-behavior", v);
3399− }
3400− }
3401−
3402− // Resize
3403− "resize" => {
3404− if let Some(v) = val {
3405− push_css(&mut css, "resize", v);
3406− }
3407− }
3408−
3409− // New CSS properties
3410− "clip-path" => {
3411− if let Some(v) = val {
3412− push_css(&mut css, "clip-path", v);
3413− }
3414− }
3415− "mix-blend-mode" => {
3416− if let Some(v) = val {
3417− push_css(&mut css, "mix-blend-mode", v);
3418− }
3419− }
3420− "background-blend-mode" => {
3421− if let Some(v) = val {
3422− push_css(&mut css, "background-blend-mode", v);
3423− }
3424− }
3425− "writing-mode" => {
3426− if let Some(v) = val {
3427− push_css(&mut css, "writing-mode", v);
3428− }
3429− }
3430− "column-count" => {
3431− if let Some(v) = val {
3432− push_css(&mut css, "column-count", v);
3433− }
3434− }
3435− "column-gap" => {
3436− if let Some(v) = val {
3437− push_css(&mut css, "column-gap", &css_px(v));
3438− }
3439− }
3440− "text-indent" => {
3441− if let Some(v) = val {
3442− push_css(&mut css, "text-indent", &css_px(v));
3443− }
3444− }
3445− "hyphens" => {
3446− if let Some(v) = val {
3447− push_css(&mut css, "hyphens", v);
3448− }
3449− }
3450− "flex-grow" => {
3451− if let Some(v) = val {
3452− push_css(&mut css, "flex-grow", v);
3453− }
3454− }
3455− "flex-shrink" => {
3456− if let Some(v) = val {
3457− push_css(&mut css, "flex-shrink", v);
3458− }
3459− }
3460− "flex-basis" => {
3461− if let Some(v) = val {
3462− push_css(&mut css, "flex-basis", &css_px(v));
3463− }
3464− }
3465− "isolation" => {
3466− if let Some(v) = val {
3467− push_css(&mut css, "isolation", v);
3468− }
3469− }
3470− "place-content" => {
3471− if let Some(v) = val {
3472− push_css(&mut css, "place-content", v);
3473− }
3474− }
3475− "background-image" => {
3476− if let Some(v) = val {
3477− push_css(&mut css, "background-image", v);
3478− }
3479− }
3480− "font-weight" => {
3481− if let Some(v) = val {
3482− push_css(&mut css, "font-weight", v);
3483− }
3484− }
3485− "font-style" => {
3486− if let Some(v) = val {
3487− push_css(&mut css, "font-style", v);
3488− }
3489− }
3490− "text-wrap" => {
3491− if let Some(v) = val {
3492− push_css(&mut css, "text-wrap", v);
3493− }
3494− }
3495− "will-change" => {
3496− if let Some(v) = val {
3497− push_css(&mut css, "will-change", v);
3498− }
3499− }
3500− "touch-action" => {
3501− if let Some(v) = val {
3502− push_css(&mut css, "touch-action", v);
3503− }
3504− }
3505− "vertical-align" => {
3506− if let Some(v) = val {
3507− push_css(&mut css, "vertical-align", v);
3508− }
3509− }
3510− "scroll-margin" => {
3511− if let Some(v) = val {
3512− push_css(&mut css, "scroll-margin", &css_px(v));
3513− }
3514− }
3515− "scroll-margin-top"
3516− | "scroll-margin-bottom"
3517− | "scroll-margin-left"
3518− | "scroll-margin-right" => {
3519− if let Some(v) = val {
3520− push_css(&mut css, effective_key, &css_px(v));
3521− }
3522− }
3523− "scroll-padding" => {
3524− if let Some(v) = val {
3525− push_css(&mut css, "scroll-padding", &css_px(v));
3526− }
3527− }
3528− "scroll-padding-top"
3529− | "scroll-padding-bottom"
3530− | "scroll-padding-left"
3531− | "scroll-padding-right" => {
3532− if let Some(v) = val {
3533− push_css(&mut css, effective_key, &css_px(v));
3534− }
3535− }
3536− "direction" => {
3537− if let Some(v) = val {
3538− push_css(&mut css, "direction", v);
3539− }
3540− }
3541−
3542− "content" => {
3543− if let Some(v) = val {
3544− // Wrap in quotes if not already quoted and not a CSS keyword
3545− if v.starts_with('"')
3546− || v.starts_with('\'')
3547− || v == "none"
3548− || v == "normal"
3549− || v.starts_with("attr(")
3550− || v.starts_with("counter(")
3551− {
3552− push_css(&mut css, "content", v);
3553− } else {
3554− push_css(&mut css, "content", &format!("\"{}\"", v));
3555− }
3556− }
3557− }
3558−
3559− // --- CSS Shorthands ---
3560− "truncate" => {
3561− push_css(&mut css, "overflow", "hidden");
3562− push_css(&mut css, "text-overflow", "ellipsis");
3563− push_css(&mut css, "white-space", "nowrap");
3564− }
3565− "line-clamp" => {
3566− if let Some(v) = val {
3567− push_css(&mut css, "display", "-webkit-box");
3568− push_css(&mut css, "-webkit-line-clamp", v);
3569− push_css(&mut css, "-webkit-box-orient", "vertical");
3570− push_css(&mut css, "overflow", "hidden");
3571− }
3572− }
3573− "blur" => {
3574− if let Some(v) = val {
3575− push_css(&mut css, "filter", &format!("blur({})", css_px(v)));
3576− }
3577− }
3578− "backdrop-blur" => {
3579− if let Some(v) = val {
3580− push_css(&mut css, "backdrop-filter", &format!("blur({})", css_px(v)));
3581− }
3582− }
3583− "no-scrollbar" => {
3584− push_css(&mut css, "scrollbar-width", "none");
3585− push_css(&mut css, "-ms-overflow-style", "none");
3586− }
3587− "skeleton" => {
3588− push_css(
3589− &mut css,
3590− "background",
3591− "linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%)",
3592− );
3593− push_css(&mut css, "background-size", "200% 100%");
3594− push_css(
3595− &mut css,
3596− "animation",
3597− "hl-skeleton 1.5s ease-in-out infinite",
3598− );
3599− }
3600− "gradient" => {
3601− if let Some(v) = val {
3602− // Parse: "from to [angle]" or "color1 color2 [angle]"
3603− let parts: Vec<&str> = v.split_whitespace().collect();
3604− let bg = if parts.len() >= 3
3605− && (parts[2].ends_with("deg")
3606− || parts[2].ends_with("turn")
3607− || parts[2].ends_with("rad"))
3608− {
3609− format!("linear-gradient({},{},{})", parts[2], parts[0], parts[1])
3610− } else if parts.len() >= 2 {
3611− format!("linear-gradient({},{})", parts[0], parts[1])
3612− } else {
3613− format!("linear-gradient({},transparent)", parts[0])
3614− };
3615− push_css(&mut css, "background", &bg);
3616− }
3617− }
3618−
3619− // Grid areas
3620− "grid-template-areas" => {
3621− if let Some(v) = val {
3622− push_css(&mut css, "grid-template-areas", v);
3623− }
3624− }
3625− "grid-area" => {
3626− if let Some(v) = val {
3627− push_css(&mut css, "grid-area", v);
3628− }
3629− }
3630−
3631− // View transitions
3632− "view-transition-name" => {
3633− if let Some(v) = val {
3634− push_css(&mut css, "view-transition-name", v);
3635− }
3636− }
3637−
3638− // Animate shorthand (alias for animation)
3639− "animate" => {
3640− if let Some(v) = val {
3641− push_css(&mut css, "animation", v);
3642− }
3643− }
3644−
3645− // CSS subgrid
3646− "grid-template-columns" => {
3647− if let Some(v) = val {
3648− push_css(&mut css, "grid-template-columns", v);
3649− }
3650− }
3651− "grid-template-rows" => {
3652− if let Some(v) = val {
3653− push_css(&mut css, "grid-template-rows", v);
3654− }
3655− }
3656−
3657− // Scroll-driven animations
3658− "animation-timeline" => {
3659− if let Some(v) = val {
3660− push_css(&mut css, "animation-timeline", v);
3661− }
3662− }
3663− "animation-range" => {
3664− if let Some(v) = val {
3665− push_css(&mut css, "animation-range", v);
3666− }
3667− }
3668− "view-timeline-name" => {
3669− if let Some(v) = val {
3670− push_css(&mut css, "view-timeline-name", v);
3671− }
3672− }
3673− "view-timeline-axis" => {
3674− if let Some(v) = val {
3675− push_css(&mut css, "view-timeline-axis", v);
3676− }
3677− }
3678− "scroll-timeline-name" => {
3679− if let Some(v) = val {
3680− push_css(&mut css, "scroll-timeline-name", v);
3681− }
3682− }
3683− "scroll-timeline-axis" => {
3684− if let Some(v) = val {
3685− push_css(&mut css, "scroll-timeline-axis", v);
3686− }
3687− }
3688−
3689− // Anchor positioning
3690− "anchor-name" => {
3691− if let Some(v) = val {
3692− push_css(&mut css, "anchor-name", v);
3693− }
3694− }
3695− "position-anchor" => {
3696− if let Some(v) = val {
3697− push_css(&mut css, "position-anchor", v);
3698− }
3699− }
3700− "position-area" => {
3701− if let Some(v) = val {
3702− push_css(&mut css, "position-area", v);
3703− }
3704− }
3705− "inset-area" => {
3706− // Legacy alias for position-area
3707− if let Some(v) = val {
3708− push_css(&mut css, "position-area", v);
3709− }
3710− }
3711−
3712− // initial-letter (drop caps)
3713− "initial-letter" => {
3714− if let Some(v) = val {
3715− push_css(&mut css, "initial-letter", v);
3716− }
3717− }
3718−
3719− // Critical CSS hint — not CSS, handled elsewhere
3720− "critical" => {}
3721−
3722− // Identity and HTML passthrough — not CSS
3723− "id" | "class" => {}
3724− "type" | "placeholder" | "name" | "value" | "disabled" | "required" | "checked"
3725− | "for" | "action" | "method" | "autocomplete" | "min" | "max" | "step" | "pattern"
3726− | "maxlength" | "rows" | "cols" | "multiple" | "alt" | "role" | "tabindex"
3727− | "title" | "controls" | "autoplay" | "loop" | "muted" | "playsinline" | "poster"
3728− | "preload" | "loading" | "decoding" | "ordered" | "src" | "open" | "novalidate"
3729− | "low" | "high" | "optimum" | "colspan" | "rowspan" | "scope" | "inline"
3730− | "responsive" | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list"
3731− | "sandbox" | "allow" | "allowfullscreen" | "referrerpolicy" | "formaction"
3732− | "formmethod" | "formtarget" | "target" | "autofocus" => {}
1692+ }
37331693
3734− _ => {}
1694+ // Find index of last occurrence of each property.
1695+ use std::collections::HashMap;
1696+ let mut last: HashMap<String, usize> = HashMap::new();
1697+ for (i, (prop, _)) in decls.iter().enumerate() {
1698+ if let Some(p) = prop {
1699+ last.insert(p.clone(), i);
37351700 }
37361701 }
37371702
3738− css
1703+ let mut out = String::with_capacity(css.len());
1704+ for (i, (prop, raw)) in decls.iter().enumerate() {
1705+ let keep = match prop {
1706+ Some(p) => last.get(p) == Some(&i),
1707+ None => true,
1708+ };
1709+ if keep {
1710+ out.push_str(raw);
1711+ }
1712+ }
1713+ out
37391714}
37401715
3741−fn push_css(css: &mut String, prop: &str, value: &str) {
3742− css.push_str(prop);
3743− css.push(':');
3744− css.push_str(value);
3745− css.push(';');
1716+fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
1717+ match (gen_class, user_class) {
1718+ (Some(g), Some(u)) => {
1719+ out.push_str(" class=\"");
1720+ out.push_str(g);
1721+ out.push(' ');
1722+ out.push_str(&html_escape(u));
1723+ out.push('"');
1724+ }
1725+ (Some(g), None) => {
1726+ out.push_str(" class=\"");
1727+ out.push_str(g);
1728+ out.push('"');
1729+ }
1730+ (None, Some(u)) => {
1731+ out.push_str(" class=\"");
1732+ out.push_str(&html_escape(u));
1733+ out.push('"');
1734+ }
1735+ (None, None) => {}
1736+ }
37461737}
37471738
3748−/// Known CSS units — if a value ends with one, skip appending `px`.
3749−const CSS_UNITS: &[&str] = &[
3750− "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm", "mm",
3751− "in", "pt", "pc", "fr",
3752−];
1739+// ---------------------------------------------------------------------------
1740+// Attribute → CSS mapping
1741+// ---------------------------------------------------------------------------
37531742
3754−/// Format a numeric value: if it already has a CSS unit, pass through as-is;
3755−/// otherwise append `px`.
3756−fn css_px(value: &str) -> String {
3757− let v = value.trim();
3758− if v == "0" {
3759− return "0".to_string();
3760− }
3761− if CSS_UNITS.iter().any(|u| v.ends_with(u)) {
3762− return v.to_string();
3763− }
3764− if v.starts_with("var(")
3765− || v.starts_with("calc(")
3766− || v.starts_with("clamp(")
3767− || v.starts_with("min(")
3768− || v.starts_with("max(")
3769− {
3770− return v.to_string();
3771− }
3772− format!("{}px", v)
1743+/// Where an element is written, which its CSS depends on.
1744+struct Site<'a> {
1745+ kind: &'a ElementKind,
1746+ /// How its parent lays out its children: its `width fill`, `height
1747+ /// fill` and `shrink` compile against it.
1748+ parent: &'a Flow,
1749+ /// It has `@in-front` / `@behind` children.
1750+ has_overlay_children: bool,
1751+ /// A row, column or grid inside text, laid out inline.
1752+ inline: bool,
1753+ /// The page's `<body>`, which the reset already makes a column.
1754+ root: bool,
37731755}
37741756
3775−/// Rewrite literal values that match a declared CSS custom property (from
3776−/// `@theme` or `@let --name value`) to `var(--name)` references. Matches are
3777−/// anchored to CSS value boundaries so e.g. `#3b82f6` inside a longer hex or
3778−/// inside an identifier is not replaced. The `:root` block is emitted before
3779−/// this call runs, so its declarations are not affected.
3780−fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
3781− if css.is_empty() || vars.is_empty() {
3782− return css.to_string();
3783− }
3784− // Prefer longer values first so that if two vars share a prefix, the
3785− // longer (more specific) match wins.
3786− let mut pairs: Vec<(&str, String)> = vars
3787− .iter()
3788− .filter(|(name, value)| name.starts_with("--") && !value.is_empty())
3789− .map(|(name, value)| (value.as_str(), format!("var({})", name)))
3790− .collect();
3791− pairs.sort_by_key(|(v, _)| std::cmp::Reverse(v.len()));
3792− if pairs.is_empty() {
3793− return css.to_string();
1757+impl Site<'_> {
1758+ /// Where an element in a parent with the layout `layout` and the flow
1759+ /// `parent` is.
1760+ fn new<'a>(elem: &'a Element, layout: Option<Layout>, parent: &'a Flow) -> Site<'a> {
1761+ Site {
1762+ kind: &elem.kind,
1763+ parent,
1764+ has_overlay_children: has_overlay_children(elem),
1765+ inline: layout == Some(Layout::Text) && elem.kind.layout().is_container(),
1766+ root: false,
1767+ }
37941768 }
1769+}
37951770
3796− let is_boundary_before = |b: Option<u8>| match b {
3797− None => true,
3798− Some(c) => matches!(c, b':' | b' ' | b',' | b'(' | b';' | b'{' | b'\n' | b'\t'),
3799− };
3800− let is_boundary_after = |b: Option<u8>| match b {
3801− None => true,
3802− Some(c) => matches!(c, b';' | b'}' | b',' | b' ' | b')' | b'\n' | b'\t'),
3803− };
1771+// ---------------------------------------------------------------------------
1772+// Direction: what a child's `fill` and `shrink` compile against
1773+// ---------------------------------------------------------------------------
38041774
3805− let bytes = css.as_bytes();
3806− let mut out = String::with_capacity(css.len());
3807− let mut i = 0;
3808− let mut prev: Option<u8> = None;
3809− while i < bytes.len() {
3810− if is_boundary_before(prev) {
3811− let mut matched = false;
3812− for (val, repl) in &pairs {
3813− let vb = val.as_bytes();
3814− if i + vb.len() <= bytes.len() && &bytes[i..i + vb.len()] == vb {
3815− let next = bytes.get(i + vb.len()).copied();
3816− if is_boundary_after(next) {
3817− out.push_str(repl);
3818− prev = repl.as_bytes().last().copied();
3819− i += vb.len();
3820− matched = true;
3821− break;
3822− }
3823− }
3824− }
3825− if matched {
3826− continue;
3827− }
3828− }
3829− // Advance by one UTF-8 code point.
3830− let start = i;
3831− i += 1;
3832− while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
3833− i += 1;
3834− }
3835− out.push_str(&css[start..i]);
3836− prev = bytes.get(i - 1).copied();
3837− }
3838− out
1775+/// The way a flex row or column lays out its children.
1776+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1777+enum Axis {
1778+ Row,
1779+ Column,
38391780}
38401781
3841−/// Scan generated CSS for literal property values that repeat often enough
3842−/// that substituting them with an auto-named custom property would reduce
3843−/// total byte count. Returns the rewritten CSS and any new vars that should
3844−/// be appended to `:root`.
3845−///
3846−/// The algorithm only promotes a value if it strictly saves bytes after
3847−/// accounting for the `--xN:value;` declaration overhead plus the
3848−/// `var(--xN)` reference cost at each call site. Values that are already
3849−/// `var(...)` references or contain `var(` calls are skipped so we never
3850−/// nest references. Names avoid collisions with `existing_vars`.
3851−fn auto_extract_repeats(
3852− css: &str,
3853− existing_vars: &[(String, String)],
3854−) -> (String, Vec<(String, String)>) {
3855− if css.is_empty() {
3856− return (css.to_string(), Vec::new());
1782+impl Axis {
1783+ /// The direction a `flex-direction` or `flex-flow` value sets, if it
1784+ /// names one (not `var(--dir)`, which only the browser knows).
1785+ fn of_value(value: &str) -> Option<Axis> {
1786+ value.split_whitespace().find_map(|word| match word {
1787+ "row" | "row-reverse" => Some(Axis::Row),
1788+ "column" | "column-reverse" => Some(Axis::Column),
1789+ _ => None,
1790+ })
38571791 }
3858− // Count occurrences of each distinct property value (the text between
3859− // `:` and the next `;` or `}` at the top level of a declaration block,
3860− // ignoring balanced parentheses so that e.g. `rgba(...)` stays intact).
3861− let bytes = css.as_bytes();
3862− let mut counts: HashMap<String, usize> = HashMap::new();
3863− let mut depth: i32 = 0;
3864− let mut i = 0;
3865− while i < bytes.len() {
3866− match bytes[i] {
3867− b'{' => {
3868− depth += 1;
3869− i += 1;
1792+}
1793+
1794+/// How an element lays out its children, as its CSS sets it: the
1795+/// direction its children's `width`/`height` `fill` and `shrink` compile
1796+/// against. That is the direction of `@row` or of a column, or the one its
1797+/// own `flex-direction` (or `flex-flow`) sets, with or without media or
1798+/// container prefixes. Like elm-ui's `.r > .wf`, the rules for a direction
1799+/// set under a prefix are keyed on the element's class: `@media (...) {
1800+/// :where(.a)>.b {...} }`.
1801+#[derive(Clone, Default)]
1802+struct Flow {
1803+ /// Without a prefix; `None` when the element isn't a flex row or column
1804+ /// (a grid, text, HTML's own layout, the top of the page)
1805+ base: Option<Axis>,
1806+ /// The direction under each chain of at-rule prefixes that sets one, in
1807+ /// the order their blocks are written
1808+ changes: Vec<(Vec<usize>, Axis)>,
1809+ /// The element's generated class, which the rules for the changes are
1810+ /// keyed on
1811+ class: Option<String>,
1812+}
1813+
1814+impl Flow {
1815+ fn of(elem: &Element) -> Flow {
1816+ let mut base = match elem.kind.layout() {
1817+ Layout::Row => Axis::Row,
1818+ Layout::Column => Axis::Column,
1819+ _ => return Flow::default(),
1820+ };
1821+ let mut changes: Vec<(Vec<usize>, Axis)> = Vec::new();
1822+ for attr in elem.attrs.iter().filter(|a| !a.html) {
1823+ let (condition, name) = Condition::of(&attr.key);
1824+ if !matches!(name, "flex-direction" | "flex-flow") || !condition.selector.is_empty() {
38701825 continue;
38711826 }
3872− b'}' => {
3873− if depth > 0 {
3874− depth -= 1;
3875− }
3876− i += 1;
1827+ let Some(axis) = attr.value.as_deref().and_then(Axis::of_value) else {
38771828 continue;
1829+ };
1830+ if condition.at.is_empty() {
1831+ base = axis;
1832+ } else {
1833+ // The later one wins, as in the CSS
1834+ changes.retain(|(at, _)| *at != condition.at);
1835+ changes.push((condition.at, axis));
38781836 }
3879− _ => {}
3880− }
3881− if depth > 0 && bytes[i] == b':' {
3882− // Skip past `:` then walk to the terminator.
3883− let start = i + 1;
3884− let mut end = start;
3885− let mut pdepth = 0i32;
3886− while end < bytes.len() {
3887− match bytes[end] {
3888− b'(' => pdepth += 1,
3889− b')' if pdepth > 0 => pdepth -= 1,
3890− b';' | b'}' if pdepth == 0 => break,
3891− b'{' if pdepth == 0 => break,
3892− _ => {}
3893− }
3894− end += 1;
3895− }
3896− let val = css[start..end].trim();
3897− if !val.is_empty() && !val.starts_with("var(") && !val.contains("var(") {
3898− *counts.entry(val.to_string()).or_default() += 1;
3899− }
3900− i = end;
3901− continue;
39021837 }
3903− i += 1;
3904− }
3905−
3906− // Decide which values are worth extracting. A value of length L appearing
3907− // N times costs N*L bytes inline; promoting it costs (L + 6) for the
3908− // `--hK:V;` declaration plus N * ref_len for the call sites. We estimate
3909− // ref_len optimistically as `var(--h0)` (9 bytes) and fall back to 10 for
3910− // two-digit indices, which only affects very large extraction counts.
3911− let existing: std::collections::HashSet<&str> =
3912− existing_vars.iter().map(|(n, _)| n.as_str()).collect();
3913− let mut candidates: Vec<(String, usize)> =
3914− counts.into_iter().filter(|(_, n)| *n >= 2).collect();
3915− // Stable, deterministic ordering — longest values first, tiebreak by text.
3916− candidates.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
3917−
3918− let mut extracted: Vec<(String, String)> = Vec::new();
3919− let mut next_idx: usize = 0;
3920− for (value, n) in candidates {
3921− let l = value.len();
3922− // Allocate the next unused `--hN` name.
3923− let (name, ref_len) = loop {
3924− let candidate = format!("--h{}", next_idx);
3925− next_idx += 1;
3926− let rl = candidate.len() + 6; // `var(` + name + `)`
3927− if !existing.contains(candidate.as_str())
3928− && !extracted.iter().any(|(n, _)| *n == candidate)
3929− {
3930− break (candidate, rl);
3931− }
3932− };
3933− // Skip if promotion would not strictly reduce byte count.
3934− // Before: n * l bytes. After: (l + name.len() + 2) for the :root
3935− // declaration (`<name>:<value>;`) plus n * ref_len for the sites.
3936− let decl_overhead = l + name.len() + 2;
3937− let before = n * l;
3938− let after = decl_overhead + n * ref_len;
3939− if after < before {
3940− extracted.push((name, value));
3941− } else {
3942− // Rewind the index so the next value can reuse this slot.
3943− next_idx -= 1;
1838+ changes.sort_by_key(|(at, _)| at_order(at));
1839+ Flow {
1840+ base: Some(base),
1841+ changes,
1842+ class: None,
39441843 }
39451844 }
39461845
3947− if extracted.is_empty() {
3948− return (css.to_string(), Vec::new());
1846+ /// The direction under the at-rules `at`: the latest one set among
1847+ /// the conditions that hold there (`md:` holds at `lg:`), else the one
1848+ /// without a prefix.
1849+ fn at(&self, at: &[usize]) -> Option<Axis> {
1850+ let base = self.base?;
1851+ let here = Condition::at_only(at);
1852+ let changed = self
1853+ .changes
1854+ .iter()
1855+ .rev()
1856+ .find(|(chain, _)| Condition::at_only(chain).holds_at(&here))
1857+ .map(|&(_, axis)| axis);
1858+ Some(changed.unwrap_or(base))
39491859 }
3950− let new_css = substitute_css_vars(css, &extracted);
3951− (new_css, extracted)
39521860}
39531861
3954−/// Format a `line-height` value. CSS accepts either a unitless multiplier
3955−/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
3956−/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which
3957−/// CSS interprets as 24× font-size — almost never what anyone wants). Treat
3958−/// integers ≥ 2 as pixel lengths; anything with a decimal, an existing unit,
3959−/// or the value `0`/`1` passes through unchanged.
3960−fn css_line_height(value: &str) -> String {
3961− let v = value.trim();
3962− if v == "0" || v == "1" {
3963− return v.to_string();
3964− }
3965− if v.contains('.') {
3966− return v.to_string();
3967− }
3968− if CSS_UNITS.iter().any(|u| v.ends_with(u)) || v.ends_with("px") {
3969− return v.to_string();
3970− }
3971− if v.starts_with("var(")
3972− || v.starts_with("calc(")
3973− || v.starts_with("clamp(")
3974− || v.starts_with("min(")
3975− || v.starts_with("max(")
3976− {
3977− return v.to_string();
3978− }
3979− // Plain integer — treat as pixel length.
3980− if v.chars().all(|c| c.is_ascii_digit()) {
3981− return format!("{}px", v);
3982− }
3983− v.to_string()
1862+/// `width` or `height`.
1863+#[derive(Clone, Copy, PartialEq, Eq)]
1864+enum Dim {
1865+ Width,
1866+ Height,
39841867}
39851868
3986−/// Format multiple space-separated values, each getting px if needed.
3987−fn css_px_multi(value: &str) -> String {
3988− value
3989− .split_whitespace()
3990− .map(css_px)
3991− .collect::<Vec<_>>()
3992− .join(" ")
1869+/// What a `width` or `height` says.
1870+#[derive(Clone, Copy, PartialEq, Eq)]
1871+enum Size {
1872+ Fill,
1873+ Shrink,
1874+ /// A size of its own (`width 300`)
1875+ Set,
39931876}
39941877
3995−fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
3996− let mut id = None;
3997− let mut class = None;
3998− for attr in attrs {
3999− match attr.key.as_str() {
4000− "id" => id = attr.value.clone(),
4001− "class" => class = attr.value.clone(),
4002− _ => {}
1878+impl Size {
1879+ fn of(value: &str) -> Size {
1880+ match value.trim() {
1881+ "fill" => Size::Fill,
1882+ "shrink" => Size::Shrink,
1883+ _ => Size::Set,
40031884 }
40041885 }
4005− (id, class)
40061886}
40071887
4008−/// Read image dimensions from a local file by parsing the header bytes.
4009−/// Supports PNG, JPEG, GIF, WebP, AVIF, and SVG.
4010−fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
4011− // SVG: parse as text for viewBox/width/height attributes
4012− if path.ends_with(".svg") || path.ends_with(".SVG") {
4013− let text = std::fs::read_to_string(path).ok()?;
4014− return read_svg_dimensions(&text);
4015− }
4016−
4017− let data = std::fs::read(path).ok()?;
4018− if data.len() < 12 {
4019− return None;
1888+/// The declarations `fill` and `shrink` make in a parent laid out along
1889+/// `axis` (`None`: not a flex row or column). Along the parent's direction,
1890+/// `fill` takes the remaining space and `shrink` keeps the content's size;
1891+/// across it (or outside flex), `fill` is the full size and `shrink` fits
1892+/// the content.
1893+fn sizing(dim: Dim, size: Size, axis: Option<Axis>) -> &'static [(&'static str, &'static str)] {
1894+ match (dim, size, axis) {
1895+ (_, Size::Set, _) => &[],
1896+ (Dim::Width, Size::Fill, Some(Axis::Row)) => &[("flex", "1"), ("min-width", "0")],
1897+ (Dim::Width, Size::Fill, _) => &[("width", "100%")],
1898+ (Dim::Width, Size::Shrink, Some(Axis::Row)) => &[("flex-shrink", "0")],
1899+ (Dim::Width, Size::Shrink, _) => &[("width", "fit-content")],
1900+ (Dim::Height, Size::Fill, Some(Axis::Column)) => &[("flex", "1"), ("min-height", "0")],
1901+ (Dim::Height, Size::Fill, _) => &[("height", "100%")],
1902+ (Dim::Height, Size::Shrink, Some(Axis::Column)) => &[("flex-shrink", "0")],
1903+ (Dim::Height, Size::Shrink, _) => &[("height", "fit-content")],
40201904 }
1905+}
40211906
4022− // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
4023− if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") {
4024− let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
4025− let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
4026− return Some((w, h));
1907+/// Every property `fill` and `shrink` of `dim` can set, with its initial
1908+/// value (`flex` covers `flex-shrink`).
1909+fn sizing_properties(dim: Dim) -> [(&'static str, &'static str); 3] {
1910+ match dim {
1911+ Dim::Width => [
1912+ ("flex", "0 1 auto"),
1913+ ("min-width", "auto"),
1914+ ("width", "auto"),
1915+ ],
1916+ Dim::Height => [
1917+ ("flex", "0 1 auto"),
1918+ ("min-height", "auto"),
1919+ ("height", "auto"),
1920+ ],
40271921 }
1922+}
40281923
4029− // GIF: "GIF87a" or "GIF89a", width/height at bytes 6-9 (little-endian)
4030− if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4031− let w = u16::from_le_bytes([data[6], data[7]]) as u32;
4032− let h = u16::from_le_bytes([data[8], data[9]]) as u32;
4033− return Some((w, h));
4034− }
1924+/// What an element's attributes under some prefixes say about its size.
1925+#[derive(Default)]
1926+struct Sizes {
1927+ /// The last `width` and `height`
1928+ width: Option<Size>,
1929+ height: Option<Size>,
1930+ /// A `fill` or `shrink` for the width / height is among them (a later
1931+ /// size may replace it)
1932+ width_word: bool,
1933+ height_word: bool,
1934+ /// It writes `flex`, one of its longhands (`flex-grow`, `flex-shrink`,
1935+ /// `flex-basis`), `min-width` or `min-height` itself
1936+ flex: bool,
1937+ flex_grow: bool,
1938+ flex_shrink: bool,
1939+ flex_basis: bool,
1940+ min_width: bool,
1941+ min_height: bool,
1942+}
40351943
4036− // JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2)
4037− if data.starts_with(b"\xff\xd8") {
4038− let mut i = 2;
4039− while i + 9 < data.len() {
4040− if data[i] != 0xFF {
4041− i += 1;
1944+impl Sizes {
1945+ /// Read the attributes under the conditions that hold wherever `at`
1946+ /// does, a later condition over an earlier one.
1947+ fn of(attrs: &[Attribute], at: &Condition) -> Sizes {
1948+ let mut sizes = Sizes::default();
1949+ let mut read: Vec<(Condition, &str, &str)> = Vec::new();
1950+ for attr in attrs.iter().filter(|a| !a.html) {
1951+ let (condition, name) = Condition::of(&attr.key);
1952+ let Some(value) = attr.value.as_deref().filter(|v| !v.trim().is_empty()) else {
40421953 continue;
1954+ };
1955+ if condition.holds_at(at) {
1956+ read.push((condition, name, value));
1957+ }
1958+ }
1959+ read.sort_by_cached_key(|(condition, _, _)| condition.order());
1960+ for (_, name, value) in read {
1961+ match name {
1962+ "width" => {
1963+ let size = Size::of(value);
1964+ sizes.width = Some(size);
1965+ sizes.width_word |= size != Size::Set;
1966+ }
1967+ "height" => {
1968+ let size = Size::of(value);
1969+ sizes.height = Some(size);
1970+ sizes.height_word |= size != Size::Set;
1971+ }
1972+ "flex" => sizes.flex = true,
1973+ "flex-grow" => sizes.flex_grow = true,
1974+ "flex-shrink" => sizes.flex_shrink = true,
1975+ "flex-basis" => sizes.flex_basis = true,
1976+ "min-width" => sizes.min_width = true,
1977+ "min-height" => sizes.min_height = true,
1978+ _ => {}
40431979 }
4044− let marker = data[i + 1];
4045− if marker == 0xC0 || marker == 0xC2 {
4046− let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
4047− let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
4048− return Some((w, h));
4049− }
4050− if i + 3 < data.len() {
4051− let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
4052− i += 2 + len;
4053− } else {
4054− break;
1980+ }
1981+ sizes
1982+ }
1983+
1984+ /// The element writes `property` itself: a layout word leaves it alone.
1985+ fn writes(&self, property: &str) -> bool {
1986+ match property {
1987+ "flex" => self.flex,
1988+ "flex-grow" => self.flex || self.flex_grow,
1989+ "flex-shrink" => self.flex || self.flex_shrink,
1990+ "flex-basis" => self.flex || self.flex_basis,
1991+ "min-width" => self.min_width,
1992+ "min-height" => self.min_height,
1993+ "width" => self.width == Some(Size::Set),
1994+ "height" => self.height == Some(Size::Set),
1995+ _ => false,
1996+ }
1997+ }
1998+
1999+ /// Write a layout word's `property: value`, unless the element writes
2000+ /// the property itself. When it writes some of `flex`'s longhands, a
2001+ /// `flex` is written as the others (`flex-shrink 0, width fill` still
2002+ /// grows).
2003+ fn push(&self, css: &mut String, property: &str, value: &str) {
2004+ if property == "flex" && !self.flex {
2005+ let longhands = match value {
2006+ "1" => [
2007+ ("flex-grow", "1"),
2008+ ("flex-shrink", "1"),
2009+ ("flex-basis", "0%"),
2010+ ],
2011+ // The reset, `0 1 auto`
2012+ _ => [
2013+ ("flex-grow", "0"),
2014+ ("flex-shrink", "1"),
2015+ ("flex-basis", "auto"),
2016+ ],
2017+ };
2018+ if longhands.iter().any(|&(p, _)| self.writes(p)) {
2019+ for (longhand, value) in longhands {
2020+ if !self.writes(longhand) {
2021+ push_css(css, longhand, value);
2022+ }
2023+ }
2024+ return;
40552025 }
40562026 }
2027+ if !self.writes(property) {
2028+ push_css(css, property, value);
2029+ }
40572030 }
2031+}
40582032
4059− // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
4060− if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4061− if &data[12..16] == b"VP8 " && data.len() >= 30 {
4062− let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
4063− let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
4064− return Some((w, h));
2033+/// The rule that gives an element's `fill` and `shrink`, as its attributes
2034+/// under the at-rules `at` set them, their meaning in a parent laid out
2035+/// along `axis`. Every other property a layout word could have set is put
2036+/// back to its initial value, so the rule is right whichever earlier rule
2037+/// it overrides. Properties the element writes itself are left alone.
2038+fn sizing_rule(attrs: &[Attribute], at: &[usize], axis: Axis) -> String {
2039+ let sizes = Sizes::of(attrs, &Condition::at_only(at));
2040+ let mut sets: Vec<(&str, &str)> = Vec::new();
2041+ let mut resets: Vec<(&str, &str)> = Vec::new();
2042+ for (dim, size, word) in [
2043+ (Dim::Width, sizes.width, sizes.width_word),
2044+ (Dim::Height, sizes.height, sizes.height_word),
2045+ ] {
2046+ if !word {
2047+ continue;
40652048 }
4066− if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
4067− let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
4068− let w = (bits & 0x3FFF) + 1;
4069− let h = ((bits >> 14) & 0x3FFF) + 1;
4070− return Some((w, h));
2049+ if let Some(size) = size {
2050+ sets.extend_from_slice(sizing(dim, size, Some(axis)));
40712051 }
2052+ resets.extend_from_slice(&sizing_properties(dim));
40722053 }
4073−
4074− // AVIF: ISOBMFF container with "ftyp" box containing "avif"/"avis" brand,
4075− // then "ispe" box with width/height
4076− if data.len() >= 12 && &data[4..8] == b"ftyp" {
4077− let brand = &data[8..12];
4078− if brand == b"avif" || brand == b"avis" || brand == b"mif1" {
4079− return read_avif_dimensions(&data);
2054+ let mut css = String::new();
2055+ let mut written: Vec<&str> = Vec::new();
2056+ for &(property, value) in &resets {
2057+ if sets.iter().any(|&(p, _)| p == property) || written.contains(&property) {
2058+ continue;
40802059 }
2060+ written.push(property);
2061+ sizes.push(&mut css, property, value);
40812062 }
2063+ for &(property, value) in &sets {
2064+ sizes.push(&mut css, property, value);
2065+ }
2066+ css
2067+}
40822068
4083− None
2069+/// The rules an element's `fill` and `shrink` need under each chain of
2070+/// at-rules where the direction of `flow` (its parent's) changes: `(at,
2071+/// body)`.
2072+fn sizing_changes(attrs: &[Attribute], flow: &Flow) -> Vec<(Vec<usize>, String)> {
2073+ flow.changes
2074+ .iter()
2075+ .map(|(at, axis)| (at.clone(), sizing_rule(attrs, at, *axis)))
2076+ .filter(|(_, body)| !body.is_empty())
2077+ .collect()
40842078}
40852079
4086−/// Parse SVG viewBox or width/height attributes to get dimensions.
4087−fn read_svg_dimensions(text: &str) -> Option<(u32, u32)> {
4088− // Try viewBox first: viewBox="minX minY width height"
4089− if let Some(vb_start) = text.find("viewBox=\"") {
4090− let rest = &text[vb_start + 9..];
4091− if let Some(end) = rest.find('"') {
4092− let parts: Vec<&str> = rest[..end].split_whitespace().collect();
4093− if parts.len() == 4
4094− && let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
4095− && w > 0.0
4096− && h > 0.0
4097− {
4098− return Some((w.round() as u32, h.round() as u32));
4099− }
2080+/// An element's generated class, with the rules keyed on its parent's
2081+/// class that its `fill` and `shrink` need where the parent's direction
2082+/// changes under a prefix (see [`Flow`]): `:where(.a)>.b`, one class of
2083+/// specificity like any class rule.
2084+fn element_class(elem: &Element, site: &Site, styles: &mut StyleCollector) -> Option<String> {
2085+ let as_child = match site.parent.class {
2086+ Some(_) => sizing_changes(&elem.attrs, site.parent),
2087+ None => Vec::new(),
2088+ };
2089+ // Two elements with the same CSS but different rules keyed on them
2090+ // need two classes
2091+ let mut distinct = String::new();
2092+ for (at, body) in &as_child {
2093+ distinct.push_str(&format!("<{:?}{}", at, body));
2094+ }
2095+ let class = compute_class(&elem.attrs, site, styles, distinct)?;
2096+ if let Some(parent) = &site.parent.class {
2097+ for (at, body) in as_child {
2098+ styles.add_keyed(at, format!(":where(.{})>.{}", parent, class), body);
41002099 }
41012100 }
4102− // Fall back to width/height attributes on <svg>
4103− let svg_tag = text.find("<svg")?;
4104− let tag_end = text[svg_tag..].find('>')? + svg_tag;
4105− let tag = &text[svg_tag..tag_end];
4106− let w = extract_svg_attr(tag, "width")?;
4107− let h = extract_svg_attr(tag, "height")?;
4108− Some((w, h))
2101+ Some(class)
41092102}
41102103
4111−fn extract_svg_attr(tag: &str, attr: &str) -> Option<u32> {
4112− let needle = format!("{}=\"", attr);
4113− let start = tag.find(&needle)? + needle.len();
4114− let rest = &tag[start..];
4115− let end = rest.find('"')?;
4116− let val = rest[..end].trim_end_matches("px");
4117− val.parse::<f64>().ok().map(|v| v.round() as u32)
2104+/// The `display` of a layout (nothing for text, native and void elements,
2105+/// which keep HTML's own).
2106+fn layout_css(layout: Layout, inline: bool) -> &'static str {
2107+ match (layout, inline) {
2108+ (Layout::Column, false) => "display:flex;flex-direction:column;",
2109+ (Layout::Column, true) => "display:inline-flex;flex-direction:column;",
2110+ (Layout::Row, false) => "display:flex;flex-direction:row;",
2111+ (Layout::Row, true) => "display:inline-flex;flex-direction:row;",
2112+ (Layout::Grid, false) => "display:grid;",
2113+ (Layout::Grid, true) => "display:inline-grid;",
2114+ _ => "",
2115+ }
41182116}
41192117
4120−/// Parse AVIF (ISOBMFF) container to find ispe box with image dimensions.
4121−fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
4122− // Walk ISOBMFF boxes looking for "ispe" (image spatial extents)
4123− let mut i = 0;
4124− while i + 8 <= data.len() {
4125− let box_size =
4126− u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
4127− let box_type = &data[i + 4..i + 8];
4128− if box_size < 8 {
4129− break;
2118+/// The declarations of the attributes under `condition` (the defaults of
2119+/// the element's kind are [`default_css`]'s).
2120+fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> String {
2121+ let mut css = String::new();
2122+ // The auto margins of `center-x`, `align-left`, ..., written at the end
2123+ let mut aligned = String::new();
2124+ let kind = site.kind;
2125+ // Under `children:` the styles go on the children, whose parent is this
2126+ // element: htmlang's words that place an element in its parent are an
2127+ // error there (the parser reports them), and left out
2128+ let on_children = condition.on_children();
2129+ let inside = condition.inside().is_some();
2130+ // The direction `fill` and `shrink` compile against
2131+ let axis = site.parent.at(&condition.at);
2132+
2133+ // htmlang's words for laying out children (`spacing`, `wrap`,
2134+ // `grid-cols`) mean nothing on an element that doesn't lay out its
2135+ // children, which the parser reports; they are left out. Under
2136+ // `children:` they go on the children, whose layout isn't known here.
2137+ let lays_out_children = kind.layout().is_container() || on_children;
2138+ fn under<'k>(key: &'k str, condition: &Condition) -> Option<&'k str> {
2139+ let (c, name) = Condition::of(key);
2140+ (c == *condition).then_some(name)
2141+ }
2142+
2143+ for (index, attr) in attrs.iter().enumerate() {
2144+ if attr.html {
2145+ continue;
2146+ }
2147+ let Some(effective_key) = under(&attr.key, condition) else {
2148+ continue;
2149+ };
2150+ if on_children && crate::vocab::places_in_parent(effective_key, attr.value.as_deref()) {
2151+ continue;
41302152 }
4131− let box_end = (i + box_size).min(data.len());
4132− // ispe box: 4 bytes version/flags + 4 bytes width + 4 bytes height
4133− if box_type == b"ispe" && box_end >= i + 20 {
4134− let w = u32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
4135− let h = u32::from_be_bytes([data[i + 16], data[i + 17], data[i + 18], data[i + 19]]);
4136− return Some((w, h));
2153+ // Under an element prefix (`@td:`) the styles go on elements whose
2154+ // layout and parent aren't known here: htmlang's own words are an
2155+ // error there (the parser reports them), and left out
2156+ if inside && crate::vocab::is_htmlang_word(effective_key, attr.value.as_deref()) {
2157+ continue;
2158+ }
2159+
2160+ let val = attr.value.as_deref();
2161+ // A style whose value came out empty (a field a record doesn't
2162+ // have, `${if()}` without its other branch) is left out
2163+ if val.is_some_and(|v| v.trim().is_empty()) {
2164+ continue;
41372165 }
4138− // Recurse into container boxes (meta, iprp, ipco)
4139− if matches!(
4140− box_type,
4141− b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia"
4142− ) {
4143− let header_size = if box_type == b"meta" { 12 } else { 8 };
4144− if i + header_size < box_end
4145− && let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end])
2166+
2167+ match effective_key {
2168+ // htmlang's own words: they lay out children or place the
2169+ // element in its parent, and mean more than one CSS property
2170+ "spacing" if !lays_out_children => {}
2171+ "spacing" => {
2172+ if let Some(v) = val {
2173+ push_css(&mut css, "gap", &with_px("gap", v));
2174+ }
2175+ }
2176+
2177+ // Sizing: `fill` and `shrink` compile against the parent's
2178+ // direction. Only the last `width` (`height`) counts, as for
2179+ // any style written twice.
2180+ "width" | "height" if val.is_some_and(|v| Size::of(v) != Size::Set) => {
2181+ let later = attrs[index + 1..].iter().any(|a| {
2182+ !a.html
2183+ && under(&a.key, condition) == Some(effective_key)
2184+ && a.value.as_deref().is_some_and(|v| !v.trim().is_empty())
2185+ });
2186+ if later {
2187+ continue;
2188+ }
2189+ let dim = if effective_key == "width" {
2190+ Dim::Width
2191+ } else {
2192+ Dim::Height
2193+ };
2194+ let size = Size::of(val.unwrap_or_default());
2195+ // What the element writes itself, which a layout word
2196+ // leaves alone
2197+ let sizes = Sizes::of(attrs, condition);
2198+ for &(property, value) in sizing(dim, size, axis) {
2199+ sizes.push(&mut css, property, value);
2200+ }
2201+ }
2202+
2203+ // A size of its own under a prefix, where a `fill` or
2204+ // `shrink` that sets `flex` (along a flex parent's direction)
2205+ // holds too: the word's `flex` and minimum are put back, so the
2206+ // size is the one the element gets (`width fill, lg:width 200`
2207+ // in a row)
2208+ "width" | "height"
2209+ if *condition != Condition::default()
2210+ && attrs.iter().any(|a| {
2211+ let (c, name) = Condition::of(&a.key);
2212+ let along = match site.parent.at(&c.at) {
2213+ Some(Axis::Row) => name == "width",
2214+ Some(Axis::Column) => name == "height",
2215+ None => false,
2216+ };
2217+ !a.html
2218+ && name == effective_key
2219+ && along
2220+ && c != *condition
2221+ && c.holds_at(condition)
2222+ && a.value
2223+ .as_deref()
2224+ .is_some_and(|v| !v.trim().is_empty() && Size::of(v) != Size::Set)
2225+ }) =>
41462226 {
4147− return Some(dims);
2227+ let dim = if effective_key == "width" {
2228+ Dim::Width
2229+ } else {
2230+ Dim::Height
2231+ };
2232+ let sizes = Sizes::of(attrs, condition);
2233+ for (property, value) in sizing_properties(dim) {
2234+ if property != effective_key {
2235+ sizes.push(&mut css, property, value);
2236+ }
2237+ }
2238+ if let Some(v) = val {
2239+ push_css(&mut css, effective_key, &with_px(effective_key, v));
2240+ }
2241+ }
2242+
2243+ // Alignment: auto margins, which work along either direction.
2244+ // They are written last, so a `margin` on the same element
2245+ // keeps its other sides (`center-x, margin 20`)
2246+ "center-x" => {
2247+ push_css(&mut aligned, "margin-left", "auto");
2248+ push_css(&mut aligned, "margin-right", "auto");
2249+ }
2250+ "center-y" => {
2251+ push_css(&mut aligned, "margin-top", "auto");
2252+ push_css(&mut aligned, "margin-bottom", "auto");
2253+ }
2254+ "align-left" => push_css(&mut aligned, "margin-right", "auto"),
2255+ "align-right" => push_css(&mut aligned, "margin-left", "auto"),
2256+ "align-top" => push_css(&mut aligned, "margin-bottom", "auto"),
2257+ "align-bottom" => push_css(&mut aligned, "margin-top", "auto"),
2258+
2259+ "wrap" if lays_out_children => push_css(&mut css, "flex-wrap", "wrap"),
2260+ "wrap" => {}
2261+
2262+ // Grid
2263+ "grid-cols" | "grid-rows" if !lays_out_children => {}
2264+ "grid-cols" | "grid-rows" => {
2265+ if let Some(v) = val {
2266+ let property = if effective_key == "grid-cols" {
2267+ "grid-template-columns"
2268+ } else {
2269+ "grid-template-rows"
2270+ };
2271+ match v.parse::<u32>() {
2272+ Ok(n) => push_css(&mut css, property, &format!("repeat({},1fr)", n)),
2273+ Err(_) => push_css(&mut css, property, &with_px(property, v)),
2274+ }
2275+ }
2276+ }
2277+ "col-span" | "row-span" => {
2278+ if let Some(v) = val {
2279+ let property = if effective_key == "col-span" {
2280+ "grid-column"
2281+ } else {
2282+ "grid-row"
2283+ };
2284+ push_css(&mut css, property, &format!("span {}", v));
2285+ }
2286+ }
2287+
2288+ // CSS's `line-clamp` has no support yet without its
2289+ // `-webkit-` fallback, which needs a box to clamp
2290+ "line-clamp" => {
2291+ if let Some(v) = val {
2292+ push_css(&mut css, "display", "-webkit-box");
2293+ push_css(&mut css, "-webkit-line-clamp", v);
2294+ push_css(&mut css, "-webkit-box-orient", "vertical");
2295+ push_css(&mut css, "overflow", "hidden");
2296+ }
2297+ }
2298+
2299+ // Every other name is a CSS property (standard, custom,
2300+ // vendor-prefixed, or one htmlang doesn't know, which the
2301+ // parser warned about), written under its own name with the
2302+ // value as written, except for `px` after a bare number where
2303+ // the property takes a length
2304+ key if crate::vocab::is_property_name(key) => {
2305+ if let Some(v) = val {
2306+ push_css(&mut css, key, &with_px(key, v));
2307+ }
41482308 }
2309+
2310+ _ => {}
2311+ }
2312+ }
2313+
2314+ css.push_str(&aligned);
2315+ css
2316+}
2317+
2318+fn push_css(css: &mut String, prop: &str, value: &str) {
2319+ css.push_str(prop);
2320+ css.push(':');
2321+ css.push_str(value);
2322+ css.push(';');
2323+}
2324+
2325+fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2326+ let mut id = None;
2327+ let mut class = None;
2328+ for attr in attrs.iter().filter(|a| a.html) {
2329+ match attr.key.as_str() {
2330+ "id" => id = attr.value.clone(),
2331+ "class" => class = attr.value.clone(),
2332+ _ => {}
41492333 }
4150− i = box_end;
41512334 }
4152− None
2335+ (id, class)
41532336}
41542337
41552338fn html_escape(s: &str) -> String {
@@ -4190,10 +2373,24 @@ fn base64_encode(data: &[u8]) -> String {
41902373/// Generate a standard v3 source map with VLQ-encoded mappings.
41912374/// Compatible with browser devtools and source map tooling.
41922375pub fn generate_source_map(doc: &Document, source_file: &str) -> String {
2376+ source_map_for_html(&generate_dev(doc), source_file)
2377+}
2378+
2379+/// Build a source map for dev-mode HTML by reading the `data-hl-line`
2380+/// markers each element carries, so generated line numbers are exact.
2381+pub fn source_map_for_html(html: &str, source_file: &str) -> String {
41932382 let mut mappings: Vec<(usize, usize)> = Vec::new(); // (html_line, hl_line)
4194− collect_source_lines(&doc.nodes, &mut mappings);
4195− mappings.sort_by_key(|m| m.0);
4196− mappings.dedup_by_key(|m| m.0);
2383+ for (idx, line) in html.lines().enumerate() {
2384+ if let Some(pos) = line.find("data-hl-line=\"") {
2385+ let digits: String = line[pos + "data-hl-line=\"".len()..]
2386+ .chars()
2387+ .take_while(|c| c.is_ascii_digit())
2388+ .collect();
2389+ if let Ok(hl_line) = digits.parse::<usize>() {
2390+ mappings.push((idx + 1, hl_line));
2391+ }
2392+ }
2393+ }
41972394
41982395 // Build VLQ-encoded mappings string.
41992396 // Each generated line is separated by ';'. Each segment within a line is
@@ -4253,60 +2450,30 @@ fn vlq_encode(value: i64, out: &mut String) {
42532450 }
42542451}
42552452
4256−fn collect_source_lines(nodes: &[Node], mappings: &mut Vec<(usize, usize)>) {
4257− for node in nodes {
4258− if let Node::Element(elem) = node {
4259− if elem.line_num > 0 {
4260− mappings.push((mappings.len() + 1, elem.line_num));
4261− }
4262− collect_source_lines(&elem.children, mappings);
4263− }
4264− }
4265−}
4266−
4267−/// Check if a specific element kind exists anywhere in the node tree.
4268−fn has_element_kind(nodes: &[Node], target: &ElementKind) -> bool {
4269− for node in nodes {
4270− if let Node::Element(elem) = node {
4271− if elem.kind == *target {
4272− return true;
4273− }
4274− if has_element_kind(&elem.children, target) {
4275− return true;
4276− }
4277− }
4278− }
4279− false
4280−}
4281−
4282−/// Collect unique external domains from generated HTML for DNS prefetch hints.
4283−fn collect_dns_prefetch(html: &str, dev: bool) -> String {
4284− let mut domains = Vec::new();
4285− let mut rest = html;
4286− while let Some(pos) = rest.find("https://") {
4287− let start = pos + 8; // skip "https://"
4288− rest = &rest[start..];
4289− let end = rest
4290− .find(['/', '"', '\'', ' ', '>', ')'])
4291− .unwrap_or(rest.len());
4292− let domain = &rest[..end];
4293− if !domain.is_empty() && domain.contains('.') && !domains.contains(&domain.to_string()) {
4294− domains.push(domain.to_string());
4295− }
4296− }
4297− let mut out = String::new();
4298− for domain in &domains {
4299− if dev {
4300− out.push_str(&format!(
4301− "<link rel=\"dns-prefetch\" href=\"//{}\">\n",
4302− domain
4303− ));
4304− } else {
4305− out.push_str(&format!(
4306− "<link rel=\"dns-prefetch\" href=\"//{}\">",
4307− domain
4308− ));
4309− }
2453+#[cfg(test)]
2454+mod tests {
2455+ use super::*;
2456+
2457+ #[test]
2458+ fn a_name_is_a_stable_hash_of_the_style() {
2459+ // The same in every build and on every platform
2460+ assert_eq!(stable_hash(""), 0xf52a_15e9_a9b5_e89b);
2461+ assert_eq!(hash_digits(35, 3), "z00");
2462+ assert_eq!(hash_digits(u64::MAX, 13), "fsgs46211e5w3");
2463+ }
2464+
2465+ #[test]
2466+ fn a_name_taken_in_the_file_takes_more_digits() {
2467+ // These two styles' hashes end in the same 7 digits
2468+ let mut styles = StyleCollector::new();
2469+ let first = styles.get_class("width:129555px;".into(), Vec::new(), String::new());
2470+ let second = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
2471+ let (first, second) = (first.unwrap(), second.unwrap());
2472+ assert_eq!(first.len(), 3 + CLASS_DIGITS, "{first}");
2473+ assert_eq!(second.len(), 3 + CLASS_DIGITS + 1, "{second}");
2474+ assert!(second.starts_with(&first), "{first} {second}");
2475+ // The same style gets the name it got before
2476+ let again = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
2477+ assert_eq!(again.as_deref(), Some(second.as_str()));
43102478 }
4311− out
43122479}
crates/htmlang-core/src/codegen_tests.rs+33−8
@@ -1,6 +1,8 @@
11#[cfg(test)]
22mod tests {
3− use crate::codegen::{generate, generate_dev, generate_partial, short_class_name};
3+ use crate::codegen::{
4+ CodegenOptions, generate, generate_dev, generate_partial, generate_with_classes,
5+ };
46 use crate::parser::parse;
57
68 fn compile(src: &str) -> String {
@@ -47,19 +49,42 @@ mod tests {
4749
4850 #[test]
4951 fn dev_mode_is_deterministic_across_runs() {
50− let src = "@row [spacing 10]\n @text [bold] a\n @text [italic] b\n";
52+ let src =
53+ "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
5154 let r1 = parse(src);
5255 let r2 = parse(src);
5356 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
5457 }
5558
59+ /// The classes of `src`, compiled as a fragment
60+ fn classes(src: &str) -> Vec<crate::codegen::Class> {
61+ let options = CodegenOptions {
62+ partial: true,
63+ ..Default::default()
64+ };
65+ generate_with_classes(&parse(src).document, &options).1
66+ }
67+
5668 #[test]
57− fn short_class_name_is_stable() {
58− assert_eq!(short_class_name(0), "a");
59− assert_eq!(short_class_name(25), "z");
60− // 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");
69+ fn a_style_has_one_name_in_every_file() {
70+ // A fragment's classes mean in a page what they mean in the
71+ // fragment, whatever else either holds
72+ let page = classes("@el [padding 10, background red] a\n@el [gap 4] b\n");
73+ let fragment = classes("@row [margin 2] x\n@el [padding 10, background red] y\n");
74+ assert_eq!(page[0], fragment[1]);
75+ assert_ne!(page[1].style, fragment[0].style);
76+ assert_ne!(page[1].name, fragment[0].name);
77+ for class in page.iter().chain(&fragment) {
78+ let digits = class.name.strip_prefix("hl-").expect("hl-");
79+ assert_eq!(digits.len(), crate::codegen::CLASS_DIGITS, "{}", class.name);
80+ assert!(
81+ digits
82+ .bytes()
83+ .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase()),
84+ "{}",
85+ class.name
86+ );
87+ }
6388 }
6489
6590 #[test]
crates/htmlang-core/src/diagnostic.rsnew file+245
@@ -0,0 +1,245 @@
1+//! Diagnostics: what the compiler reports about a source file.
2+//!
3+//! Every diagnostic has a stable `code` (see [`code`]), so tools such as the
4+//! language server key their quick fixes on the code rather than on the
5+//! wording of the message. `subject` and `suggestion` carry the name the
6+//! diagnostic is about and the name to use instead, when there is one.
7+
8+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9+pub enum Severity {
10+ Error,
11+ Warning,
12+ Info,
13+ Help,
14+}
15+
16+#[derive(Debug, Clone)]
17+pub struct Diagnostic {
18+ /// Stable identifier of the kind of problem, e.g. `unknown-element`.
19+ pub code: &'static str,
20+ pub line: usize,
21+ pub column: Option<usize>,
22+ pub message: String,
23+ pub severity: Severity,
24+ pub source_line: Option<Box<str>>,
25+ /// The name the diagnostic is about, as written (`@nosuch` without the
26+ /// `@`, an attribute key, a definition's name).
27+ pub subject: Option<Box<str>>,
28+ /// The name to write instead of `subject` ("did you mean ...?").
29+ pub suggestion: Option<Box<str>>,
30+}
31+
32+impl Diagnostic {
33+ pub fn new(code: &'static str, severity: Severity, line: usize, message: String) -> Self {
34+ Diagnostic {
35+ code,
36+ line,
37+ column: None,
38+ message,
39+ severity,
40+ source_line: None,
41+ subject: None,
42+ suggestion: None,
43+ }
44+ }
45+
46+ pub fn error(code: &'static str, line: usize, message: String) -> Self {
47+ Self::new(code, Severity::Error, line, message)
48+ }
49+
50+ pub fn warning(code: &'static str, line: usize, message: String) -> Self {
51+ Self::new(code, Severity::Warning, line, message)
52+ }
53+
54+ pub fn column(mut self, column: usize) -> Self {
55+ self.column = Some(column);
56+ self
57+ }
58+
59+ pub fn source(mut self, source: impl Into<String>) -> Self {
60+ self.source_line = Some(source.into().into_boxed_str());
61+ self
62+ }
63+
64+ pub fn subject(mut self, subject: impl Into<String>) -> Self {
65+ self.subject = Some(subject.into().into_boxed_str());
66+ self
67+ }
68+
69+ pub fn suggest(mut self, suggestion: Option<impl Into<String>>) -> Self {
70+ self.suggestion = suggestion.map(|s| s.into().into_boxed_str());
71+ self
72+ }
73+}
74+
75+/// The diagnostic codes. A code names a kind of problem and never changes,
76+/// even when a message is reworded.
77+pub mod code {
78+ // Syntax: found while reading the file, before anything runs
79+ pub const UNCLOSED_BRACKET: &str = "unclosed-bracket";
80+ /// A `]` that ends an attribute list inside what reads as a `'...'`
81+ /// string in a value (`hx-on:click=alert(']')`).
82+ pub const UNBALANCED_QUOTE: &str = "unbalanced-quote";
83+ pub const UNEXPECTED_BODY: &str = "unexpected-body";
84+ pub const UNEXPECTED_ARGUMENT: &str = "unexpected-argument";
85+ pub const MISSING_ARGUMENT: &str = "missing-argument";
86+ pub const INVALID_LOOP: &str = "invalid-loop";
87+ /// A `@let` whose name, parameters or body don't fit its kind.
88+ pub const INVALID_DEFINITION: &str = "invalid-definition";
89+ pub const STRAY_ELSE: &str = "stray-else";
90+ /// Indentation with tabs and spaces mixed, which nests lines in ways
91+ /// the eye doesn't see.
92+ pub const MIXED_INDENTATION: &str = "mixed-indentation";
93+
94+ // Names
95+ pub const UNKNOWN_ELEMENT: &str = "unknown-element";
96+ /// An attribute name htmlang doesn't know: a warning when CSS could
97+ /// have the name (it is written to the CSS as it is), an error otherwise.
98+ pub const UNKNOWN_ATTRIBUTE: &str = "unknown-attribute";
99+ /// A misspelled prefix (`hovr:color red`).
100+ pub const UNKNOWN_PREFIX: &str = "unknown-prefix";
101+ /// A prefix the attribute can't take: a second one (`md:hover:`), or
102+ /// one on an HTML attribute (`hover:required`).
103+ pub const INVALID_PREFIX: &str = "invalid-prefix";
104+ /// A style written without its value (`[padding]`).
105+ pub const MISSING_VALUE: &str = "missing-value";
106+ pub const HTML_ATTRIBUTE_FORM: &str = "html-attribute-form";
107+ /// A second `@page`: a page has one root, so one `@page`.
108+ pub const DUPLICATE_PAGE: &str = "duplicate-page";
109+ pub const UNDEFINED_VARIABLE: &str = "undefined-variable";
110+ /// An attribute, or an attribute's name, made from a variable.
111+ pub const ATTRIBUTE_FROM_VARIABLE: &str = "attribute-from-variable";
112+ pub const DUPLICATE_ATTRIBUTE: &str = "duplicate-attribute";
113+
114+ // Values
115+ /// A value that can't be written into the page's CSS (an error), or
116+ /// one that can't mean what it says (a warning).
117+ pub const INVALID_VALUE: &str = "invalid-value";
118+ pub const INVALID_COLOR: &str = "invalid-color";
119+ pub const INVALID_EXPRESSION: &str = "invalid-expression";
120+ pub const INVALID_JSON: &str = "invalid-json";
121+ /// A name htmlang doesn't know right after a `key=value`: the rest of
122+ /// the value, which a comma ended (`hx-trigger=load, every 2s`).
123+ pub const SPLIT_VALUE: &str = "split-value";
124+ /// A leading argument taken as a URL that reads as text (`@link
125+ /// [hx-get=/x] Open panel`, whose href is `Open`).
126+ pub const TEXT_AS_URL: &str = "text-as-url";
127+
128+ // Definitions and functions
129+ pub const UNUSED_VARIABLE: &str = "unused-variable";
130+ pub const UNUSED_BUNDLE: &str = "unused-bundle";
131+ pub const UNUSED_FUNCTION: &str = "unused-function";
132+ pub const RECURSIVE_CALL: &str = "recursive-call";
133+ pub const NO_SINGLE_ROOT: &str = "no-single-root";
134+ /// A call that leaves out a parameter without a default.
135+ pub const MISSING_PARAMETER: &str = "missing-parameter";
136+ /// A parameter passed `name=value`: parameters are written `name value`.
137+ pub const PARAMETER_FORM: &str = "parameter-form";
138+ /// A function named like a built-in element (which it replaces) or a
139+ /// directive (which it can't replace).
140+ pub const SHADOWS_BUILT_IN: &str = "shadows-built-in";
141+ /// A `@slot NAME` block at a call whose function has no such slot.
142+ pub const UNKNOWN_SLOT: &str = "unknown-slot";
143+ /// Content passed to a function whose body has no `@children`.
144+ pub const UNEXPECTED_CONTENT: &str = "unexpected-content";
145+ /// A `@slot` or `@children` where it marks or fills nothing: outside
146+ /// a function's body, inside an element at a call, or inline in text.
147+ pub const MISPLACED_SLOT: &str = "misplaced-slot";
148+ /// A slot name that isn't one name (`@slot my footer`).
149+ pub const INVALID_SLOT_NAME: &str = "invalid-slot-name";
150+
151+ // Files
152+ pub const UNREADABLE_FILE: &str = "unreadable-file";
153+ pub const CIRCULAR_INCLUDE: &str = "circular-include";
154+ /// Two files of one build whose different styles got one class name:
155+ /// a name is a hash of its style (`htmlang build` checks).
156+ pub const CLASS_COLLISION: &str = "class-collision";
157+ pub const UNSET_ENVIRONMENT: &str = "unset-environment";
158+
159+ // Context: an attribute or element where it has no effect
160+ /// An attribute the element ignores (a warning), or a layout word such
161+ /// as `spacing` on an element that doesn't lay out its children (an
162+ /// error: it is left out).
163+ pub const NO_EFFECT: &str = "no-effect";
164+ /// An element whose HTML ends a `<p>`, written inside a `@paragraph`.
165+ pub const BLOCK_IN_PARAGRAPH: &str = "block-in-paragraph";
166+
167+ // Accessibility
168+ pub const MISSING_ALT: &str = "missing-alt";
169+ pub const MISSING_INPUT_TYPE: &str = "missing-input-type";
170+ pub const MISSING_LINK_TEXT: &str = "missing-link-text";
171+ pub const MISSING_LABEL: &str = "missing-label";
172+ pub const MISSING_TITLE: &str = "missing-title";
173+ pub const MISSING_BUTTON_TEXT: &str = "missing-button-text";
174+ pub const MISSING_CAPTIONS: &str = "missing-captions";
175+ pub const LOW_CONTRAST: &str = "low-contrast";
176+ pub const POSITIVE_TABINDEX: &str = "positive-tabindex";
177+
178+ // `htmlang lint`
179+ pub const DEEP_NESTING: &str = "deep-nesting";
180+ pub const EMPTY_CONTAINER: &str = "empty-container";
181+ pub const MISSING_BUTTON_TYPE: &str = "missing-button-type";
182+
183+ /// A bug in the compiler itself.
184+ pub const INTERNAL: &str = "internal";
185+
186+ /// Every code, for tools and tests.
187+ pub const ALL: &[&str] = &[
188+ UNCLOSED_BRACKET,
189+ UNBALANCED_QUOTE,
190+ UNEXPECTED_BODY,
191+ UNEXPECTED_ARGUMENT,
192+ MISSING_ARGUMENT,
193+ INVALID_LOOP,
194+ INVALID_DEFINITION,
195+ STRAY_ELSE,
196+ MIXED_INDENTATION,
197+ UNKNOWN_ELEMENT,
198+ UNKNOWN_ATTRIBUTE,
199+ UNKNOWN_PREFIX,
200+ INVALID_PREFIX,
201+ MISSING_VALUE,
202+ HTML_ATTRIBUTE_FORM,
203+ DUPLICATE_PAGE,
204+ UNDEFINED_VARIABLE,
205+ ATTRIBUTE_FROM_VARIABLE,
206+ DUPLICATE_ATTRIBUTE,
207+ INVALID_VALUE,
208+ INVALID_COLOR,
209+ INVALID_EXPRESSION,
210+ INVALID_JSON,
211+ SPLIT_VALUE,
212+ TEXT_AS_URL,
213+ UNUSED_VARIABLE,
214+ UNUSED_BUNDLE,
215+ UNUSED_FUNCTION,
216+ RECURSIVE_CALL,
217+ NO_SINGLE_ROOT,
218+ MISSING_PARAMETER,
219+ PARAMETER_FORM,
220+ SHADOWS_BUILT_IN,
221+ UNKNOWN_SLOT,
222+ UNEXPECTED_CONTENT,
223+ MISPLACED_SLOT,
224+ INVALID_SLOT_NAME,
225+ UNREADABLE_FILE,
226+ CIRCULAR_INCLUDE,
227+ CLASS_COLLISION,
228+ UNSET_ENVIRONMENT,
229+ NO_EFFECT,
230+ BLOCK_IN_PARAGRAPH,
231+ MISSING_ALT,
232+ MISSING_INPUT_TYPE,
233+ MISSING_LINK_TEXT,
234+ MISSING_LABEL,
235+ MISSING_TITLE,
236+ MISSING_BUTTON_TEXT,
237+ MISSING_CAPTIONS,
238+ LOW_CONTRAST,
239+ POSITIVE_TABINDEX,
240+ DEEP_NESTING,
241+ EMPTY_CONTAINER,
242+ MISSING_BUTTON_TYPE,
243+ INTERNAL,
244+ ];
245+}
crates/htmlang-core/src/expr.rsnew file+801
@@ -0,0 +1,801 @@
1+//! Expressions: conditions (`@if $count > 2 and not $hidden`), computed
2+//! values (`@let gap = $base * 2`) and `${...}` in text and values
3+//! (`${if($featured, 24, 0)}`).
4+//!
5+//! Expressions are tokenized and evaluated as a whole. Variables are looked
6+//! up while evaluating, never pasted into the text first, so a value that
7+//! happens to contain `==` or spaces can't change how an expression parses.
8+//!
9+//! Only what decides the result is evaluated: `if()` evaluates the branch
10+//! it takes, `and` and `or` stop at the first side that decides. The rest
11+//! is still parsed (and its function names and argument counts checked),
12+//! so `$n != 0 and 10 / $n > 1` is false for `$n` = 0, not an error.
13+//!
14+//! ```text
15+//! expr := or
16+//! or := and ("or" and)*
17+//! and := not ("and" not)*
18+//! not := "not" not | compare
19+//! compare := range (("==" | "!=" | "<" | ">" | "<=" | ">=") range)?
20+//! range := sum (".." sum ("step" sum)?)?
21+//! sum := product (("+" | "-") product)*
22+//! product := unary (("*" | "/" | "%") unary)*
23+//! unary := "-" unary | primary
24+//! primary := NUMBER | "STRING" | $VAR | WORD | true | false
25+//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
26+//! ```
27+//!
28+//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is text. Values are
29+//! typed (see `value.rs`, which also defines truthiness and comparison):
30+//! `$name` keeps whatever it holds, a list or a record included, and
31+//! `A..B` (with `step N`) is a list of whole numbers. Functions:
32+//! `if(cond, a, b)` (`b` may be left out: empty), tests (`contains(s, x)`,
33+//! `starts-with(s, x)`, `ends-with(s, x)`), text (`uppercase`, `lowercase`,
34+//! `capitalize`, `trim`, `length`, `reverse`, `truncate(s, n)`,
35+//! `replace(s, old, new)`, `default(s, fallback)`). A colour variant is
36+//! CSS, not a function here: `color-mix(in srgb, var(--brand), black 8%)`
37+//! is an attribute's value, written as it is.
38+//! `length`, `contains` and `reverse` work on the items of a list; the other
39+//! text functions take text, and a list is an error (a text with commas in
40+//! it is written in quotes or with `\,`, so it isn't a list).
41+//!
42+//! A `$name` ends as it does in text (see `interp.rs`), so `$post.title`
43+//! reads a field and `$a..$b` is two names. An undefined name is an error.
44+
45+use std::fmt;
46+
47+use crate::interp::{self, Problem, Reference, Scope};
48+
49+pub use crate::value::Value;
50+
51+/// Why an expression has no value.
52+#[derive(Debug, Clone, PartialEq)]
53+pub enum Error {
54+ /// `$name` has no definition; `offset` is where the `$` is.
55+ Undefined { name: String, offset: usize },
56+ /// Anything else: a syntax error, a type error, division by zero.
57+ Invalid(String),
58+}
59+
60+impl Error {
61+ /// This error as a problem of a text in which the expression starts
62+ /// at `base`.
63+ pub(crate) fn at(self, base: usize) -> Problem {
64+ match self {
65+ Error::Undefined { name, offset } => Problem::Undefined {
66+ name,
67+ offset: base + offset,
68+ },
69+ Error::Invalid(message) => Problem::Invalid {
70+ message,
71+ offset: base,
72+ },
73+ }
74+ }
75+}
76+
77+impl From<String> for Error {
78+ fn from(message: String) -> Self {
79+ Error::Invalid(message)
80+ }
81+}
82+
83+impl fmt::Display for Error {
84+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85+ match self {
86+ Error::Undefined { name, .. } => write!(f, "undefined variable '${}'", name),
87+ Error::Invalid(message) => f.write_str(message),
88+ }
89+ }
90+}
91+
92+/// Evaluate `src` with the variables of `scope`. An undefined variable is
93+/// an error; a field that a record doesn't have is empty.
94+pub fn eval(src: &str, scope: &dyn Scope) -> Result<Value, Error> {
95+ let mut tokens = Vec::new();
96+ tokenize(src, scope, 0, &mut tokens)?;
97+ let mut parser = Parser {
98+ tokens: &tokens,
99+ pos: 0,
100+ scope,
101+ skip: false,
102+ };
103+ let value = parser.expr()?;
104+ match parser.tokens.get(parser.pos) {
105+ None => Ok(value),
106+ Some((tok, _)) => Err(Error::Invalid(format!(
107+ "unexpected {} in `{}`",
108+ tok,
109+ src.trim()
110+ ))),
111+ }
112+}
113+
114+#[derive(Debug, Clone, PartialEq)]
115+enum Token {
116+ Num(f64),
117+ Str(String),
118+ Var(String),
119+ Word(String),
120+ Op(&'static str),
121+ LParen,
122+ RParen,
123+ Comma,
124+}
125+
126+impl fmt::Display for Token {
127+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128+ match self {
129+ Token::Num(n) => write!(f, "`{}`", n),
130+ Token::Str(s) => write!(f, "`\"{}\"`", s),
131+ Token::Var(v) => write!(f, "`${}`", v),
132+ Token::Word(w) => write!(f, "`{}`", w),
133+ Token::Op(op) => write!(f, "`{}`", op),
134+ Token::LParen => f.write_str("`(`"),
135+ Token::RParen => f.write_str("`)`"),
136+ Token::Comma => f.write_str("`,`"),
137+ }
138+ }
139+}
140+
141+const OPERATORS: &[&str] = &[
142+ "..", "==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%",
143+];
144+
145+/// Read `src` into tokens, each with its byte offset (plus `base`).
146+/// Variable names end as in text (see `interp.rs`), and `${...}` is a
147+/// variable (`${name}`) or a parenthesized expression.
148+fn tokenize(
149+ src: &str,
150+ scope: &dyn Scope,
151+ base: usize,
152+ tokens: &mut Vec<(Token, usize)>,
153+) -> Result<(), Error> {
154+ let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
155+ let mut i = 0;
156+ while let Some(c) = src[i..].chars().next() {
157+ let at = base + i;
158+ if c.is_whitespace() {
159+ i += c.len_utf8();
160+ } else if c == '(' {
161+ tokens.push((Token::LParen, at));
162+ i += 1;
163+ } else if c == ')' {
164+ tokens.push((Token::RParen, at));
165+ i += 1;
166+ } else if c == ',' {
167+ tokens.push((Token::Comma, at));
168+ i += 1;
169+ } else if c == '"' {
170+ let start = i + 1;
171+ let end = src[start..]
172+ .find('"')
173+ .map(|p| start + p)
174+ .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
175+ tokens.push((Token::Str(src[start..end].to_string()), at));
176+ i = end + 1;
177+ } else if c == '$' {
178+ match interp::reference(&src[i + 1..], scope) {
179+ Some((Reference::Var(path), len)) => {
180+ tokens.push((Token::Var(path), at));
181+ i += 1 + len;
182+ }
183+ Some((Reference::Expr(inner), len)) => {
184+ tokens.push((Token::LParen, at));
185+ tokenize(inner, scope, at + 2, tokens)?;
186+ tokens.push((Token::RParen, at + len));
187+ i += 1 + len;
188+ }
189+ None => {
190+ let n = interp::custom_property_len(&src[i + 1..]);
191+ return Err(Error::Invalid(match n {
192+ 0 => format!("`$` without a variable name in `{}`", src.trim()),
193+ n => interp::custom_property_message(&src[i + 1..i + 1 + n]),
194+ }));
195+ }
196+ }
197+ } else if let Some(op) = OPERATORS
198+ .iter()
199+ .find(|op| src[i..].starts_with(**op))
200+ // A `-` followed by a letter starts a word (`-webkit-box`);
201+ // inside words (`ease-in`) it is consumed by the word itself.
202+ .filter(|op| **op != "-" || !src[i + 1..].starts_with(char::is_alphabetic))
203+ {
204+ tokens.push((Token::Op(op), at));
205+ i += op.len();
206+ } else if is_word_char(c) {
207+ let start = i;
208+ // A word ends at `..`, so `1..5` is a range
209+ while let Some(c) = src[i..]
210+ .chars()
211+ .next()
212+ .filter(|&c| is_word_char(c) && !src[i..].starts_with(".."))
213+ {
214+ i += c.len_utf8();
215+ }
216+ let word = &src[start..i];
217+ tokens.push((
218+ match word.parse::<f64>() {
219+ Ok(n) if !word.ends_with('.') => Token::Num(n),
220+ _ => Token::Word(word.to_string()),
221+ },
222+ at,
223+ ));
224+ } else {
225+ return Err(Error::Invalid(format!(
226+ "unexpected `{}` in `{}`",
227+ c,
228+ src.trim()
229+ )));
230+ }
231+ }
232+ Ok(())
233+}
234+
235+struct Parser<'a> {
236+ tokens: &'a [(Token, usize)],
237+ pos: usize,
238+ scope: &'a dyn Scope,
239+ /// Parsing a part whose value isn't needed (the branch `if()` doesn't
240+ /// take, the side of `and`/`or` that doesn't decide): it is read and
241+ /// checked for syntax, not evaluated.
242+ skip: bool,
243+}
244+
245+/// The value a skipped part stands for; nothing reads it.
246+const SKIPPED: Value = Value::Bool(false);
247+
248+impl Parser<'_> {
249+ fn peek(&self) -> Option<&Token> {
250+ self.tokens.get(self.pos).map(|(token, _)| token)
251+ }
252+
253+ fn eat_word(&mut self, word: &str) -> bool {
254+ if matches!(self.peek(), Some(Token::Word(w)) if w == word) {
255+ self.pos += 1;
256+ true
257+ } else {
258+ false
259+ }
260+ }
261+
262+ fn eat_op(&mut self, ops: &[&'static str]) -> Option<&'static str> {
263+ match self.peek() {
264+ Some(Token::Op(op)) if ops.contains(op) => {
265+ let op = *op;
266+ self.pos += 1;
267+ Some(op)
268+ }
269+ _ => None,
270+ }
271+ }
272+
273+ fn expect(&mut self, token: Token) -> Result<(), Error> {
274+ match self.peek() {
275+ Some(t) if *t == token => {
276+ self.pos += 1;
277+ Ok(())
278+ }
279+ Some(t) => Err(format!("expected {} but found {}", token, t).into()),
280+ None => Err(format!("expected {} at the end", token).into()),
281+ }
282+ }
283+
284+ /// Parse with `f`, skipping evaluation when `skip` (or when already
285+ /// skipping).
286+ fn skipping(
287+ &mut self,
288+ skip: bool,
289+ f: impl FnOnce(&mut Self) -> Result<Value, Error>,
290+ ) -> Result<Value, Error> {
291+ let outer = self.skip;
292+ self.skip |= skip;
293+ let value = f(self);
294+ self.skip = outer;
295+ value
296+ }
297+
298+ fn expr(&mut self) -> Result<Value, Error> {
299+ let mut left = self.and()?;
300+ while self.eat_word("or") {
301+ let decided = left.truthy();
302+ let right = self.skipping(decided, Self::and)?;
303+ left = Value::Bool(decided || right.truthy());
304+ }
305+ Ok(left)
306+ }
307+
308+ fn and(&mut self) -> Result<Value, Error> {
309+ let mut left = self.not()?;
310+ while self.eat_word("and") {
311+ let decided = !left.truthy();
312+ let right = self.skipping(decided, Self::not)?;
313+ left = Value::Bool(!decided && right.truthy());
314+ }
315+ Ok(left)
316+ }
317+
318+ fn not(&mut self) -> Result<Value, Error> {
319+ if self.eat_word("not") {
320+ return Ok(Value::Bool(!self.not()?.truthy()));
321+ }
322+ self.compare()
323+ }
324+
325+ fn compare(&mut self) -> Result<Value, Error> {
326+ let left = self.range()?;
327+ let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
328+ return Ok(left);
329+ };
330+ let right = self.range()?;
331+ if self.skip {
332+ return Ok(SKIPPED);
333+ }
334+ Ok(Value::Bool(left.compare(op, &right)?))
335+ }
336+
337+ /// `A..B` or `A..B step N`: the whole numbers from A to B.
338+ fn range(&mut self) -> Result<Value, Error> {
339+ let start = self.sum()?;
340+ if self.eat_op(&[".."]).is_none() {
341+ return Ok(start);
342+ }
343+ let end = self.sum()?;
344+ let step = match self.eat_word("step") {
345+ true => Some(self.sum()?),
346+ false => None,
347+ };
348+ if self.skip {
349+ return Ok(SKIPPED);
350+ }
351+ let number = |value: &Value| {
352+ value.as_num().ok_or_else(|| {
353+ Error::Invalid(format!(
354+ "a range goes from one number to another, not from {} (`{}`)",
355+ value.describe(),
356+ value
357+ ))
358+ })
359+ };
360+ let step = match &step {
361+ Some(step) => number(step)?,
362+ None => 1.0,
363+ };
364+ Ok(crate::value::range(number(&start)?, number(&end)?, step)?)
365+ }
366+
367+ fn sum(&mut self) -> Result<Value, Error> {
368+ let mut left = self.product()?;
369+ while let Some(op) = self.eat_op(&["+", "-"]) {
370+ let right = self.product()?;
371+ left = self.arithmetic(op, &left, &right)?;
372+ }
373+ Ok(left)
374+ }
375+
376+ fn product(&mut self) -> Result<Value, Error> {
377+ let mut left = self.unary()?;
378+ while let Some(op) = self.eat_op(&["*", "/", "%"]) {
379+ let right = self.unary()?;
380+ left = self.arithmetic(op, &left, &right)?;
381+ }
382+ Ok(left)
383+ }
384+
385+ fn unary(&mut self) -> Result<Value, Error> {
386+ if self.eat_op(&["-"]).is_some() {
387+ let value = self.unary()?;
388+ if self.skip {
389+ return Ok(SKIPPED);
390+ }
391+ return value
392+ .as_num()
393+ .map(|n| Value::Num(-n))
394+ .ok_or_else(|| Error::Invalid(format!("cannot negate `{}`", value)));
395+ }
396+ self.primary()
397+ }
398+
399+ fn primary(&mut self) -> Result<Value, Error> {
400+ let (token, at) = self
401+ .tokens
402+ .get(self.pos)
403+ .cloned()
404+ .ok_or_else(|| "expected a value at the end".to_string())?;
405+ self.pos += 1;
406+ match token {
407+ Token::Num(n) => Ok(Value::Num(n)),
408+ Token::Str(_) | Token::Var(_) if self.skip => Ok(SKIPPED),
409+ Token::Str(s) => {
410+ let (text, problems) = interp::interpolate(&s, self.scope);
411+ match problems.into_iter().next() {
412+ None => Ok(Value::Str(text)),
413+ Some(Problem::Undefined { name, offset }) => Err(Error::Undefined {
414+ name,
415+ offset: at + 1 + offset,
416+ }),
417+ Some(Problem::Invalid { message, .. } | Problem::Record { message, .. }) => {
418+ Err(Error::Invalid(message))
419+ }
420+ Some(Problem::CustomProperty { name, .. }) => {
421+ Err(Error::Invalid(interp::custom_property_message(&name)))
422+ }
423+ }
424+ }
425+ Token::Var(name) => {
426+ interp::resolve(&name, self.scope).ok_or(Error::Undefined { name, offset: at })
427+ }
428+ Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
429+ Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
430+ Token::Word(name) if name == "if" && self.peek() == Some(&Token::LParen) => {
431+ self.pos += 1;
432+ self.choice()
433+ }
434+ Token::Word(name) if self.peek() == Some(&Token::LParen) => {
435+ self.pos += 1;
436+ let mut args = Vec::new();
437+ if self.peek() != Some(&Token::RParen) {
438+ args.push(self.expr()?);
439+ while self.peek() == Some(&Token::Comma) {
440+ self.pos += 1;
441+ args.push(self.expr()?);
442+ }
443+ }
444+ self.expect(Token::RParen)?;
445+ check_call(&name, args.len())?;
446+ if self.skip {
447+ return Ok(SKIPPED);
448+ }
449+ Ok(call(&name, args)?)
450+ }
451+ Token::Word(w) => Ok(Value::Str(w)),
452+ Token::LParen => {
453+ let value = self.expr()?;
454+ self.expect(Token::RParen)?;
455+ Ok(value)
456+ }
457+ other => Err(format!("unexpected {}", other).into()),
458+ }
459+ }
460+}
461+
462+impl Parser<'_> {
463+ /// `if(cond, a, b)` after its `(`: the branch that `cond` picks is
464+ /// evaluated, the other only read. Without `b`, a false `cond` gives
465+ /// an empty value.
466+ fn choice(&mut self) -> Result<Value, Error> {
467+ let arguments =
468+ || Error::Invalid("if() takes 2 or 3 arguments: if(CONDITION, A, B)".into());
469+ let condition = self.expr()?;
470+ match self.peek() {
471+ Some(Token::RParen) | None => return Err(arguments()),
472+ _ => self.expect(Token::Comma)?,
473+ }
474+ let taken = condition.truthy();
475+ let then = self.skipping(!taken, Self::expr)?;
476+ let otherwise = if self.peek() == Some(&Token::Comma) {
477+ self.pos += 1;
478+ self.skipping(taken, Self::expr)?
479+ } else {
480+ Value::Str(String::new())
481+ };
482+ if self.peek() == Some(&Token::Comma) {
483+ return Err(arguments());
484+ }
485+ self.expect(Token::RParen)?;
486+ Ok(if taken { then } else { otherwise })
487+ }
488+
489+ fn arithmetic(&self, op: &str, left: &Value, right: &Value) -> Result<Value, Error> {
490+ if self.skip {
491+ return Ok(SKIPPED);
492+ }
493+ Ok(arithmetic(op, left, right)?)
494+ }
495+}
496+
497+fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
498+ let (Some(a), Some(b)) = (left.as_num(), right.as_num()) else {
499+ return Err(format!("`{} {} {}` needs numbers", left, op, right));
500+ };
501+ Ok(Value::Num(match op {
502+ "+" => a + b,
503+ "-" => a - b,
504+ "*" => a * b,
505+ "/" if b == 0.0 => return Err("division by zero".to_string()),
506+ "/" => a / b,
507+ "%" if b == 0.0 => return Err("division by zero".to_string()),
508+ "%" => a % b,
509+ _ => unreachable!(),
510+ }))
511+}
512+
513+/// The built-in functions (other than `if`) and how many arguments each
514+/// takes.
515+const FUNCTIONS: &[(&str, usize)] = &[
516+ ("contains", 2),
517+ ("starts-with", 2),
518+ ("ends-with", 2),
519+ ("uppercase", 1),
520+ ("lowercase", 1),
521+ ("capitalize", 1),
522+ ("trim", 1),
523+ ("length", 1),
524+ ("reverse", 1),
525+ ("truncate", 2),
526+ ("replace", 3),
527+ ("default", 2),
528+];
529+
530+/// Check that `name` is a built-in function and gets `count` arguments.
531+fn check_call(name: &str, count: usize) -> Result<(), String> {
532+ let Some(&(_, arity)) = FUNCTIONS.iter().find(|(n, _)| *n == name) else {
533+ return Err(format!("unknown function `{}()`", name));
534+ };
535+ if count == arity {
536+ Ok(())
537+ } else {
538+ Err(format!(
539+ "{}() takes {} argument{}",
540+ name,
541+ arity,
542+ if arity == 1 { "" } else { "s" }
543+ ))
544+ }
545+}
546+
547+/// Call a built-in function with the right number of arguments (see
548+/// [`check_call`]). The text functions take text (a number or `true` and
549+/// `false` count as their text); a list or a record is an error, except
550+/// for `length`, `contains`, `reverse` and `default`, which also work on
551+/// a list's items.
552+fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
553+ let text = |i: usize| -> Result<String, String> {
554+ match &args[i] {
555+ value @ Value::List(_) => Err(format!(
556+ "{}() takes text, but `{}` is {}: to write one text with commas in \
557+ it, quote it or write `\\,`",
558+ name,
559+ value,
560+ value.describe()
561+ )),
562+ Value::Record(_) => Err(format!(
563+ "{}() takes text, but argument {} is a record: use one of its fields",
564+ name,
565+ i + 1
566+ )),
567+ value => Ok(value.to_string()),
568+ }
569+ };
570+ let num = |i: usize| {
571+ args.get(i)
572+ .and_then(Value::as_num)
573+ .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
574+ };
575+ Ok(match name {
576+ "contains" => Value::Bool(match &args[0] {
577+ Value::List(list) => list.items.iter().any(|item| item.equals(&args[1])),
578+ _ => text(0)?.contains(&text(1)?),
579+ }),
580+ "starts-with" => Value::Bool(text(0)?.starts_with(&text(1)?)),
581+ "ends-with" => Value::Bool(text(0)?.ends_with(&text(1)?)),
582+ "uppercase" => Value::Str(text(0)?.to_uppercase()),
583+ "lowercase" => Value::Str(text(0)?.to_lowercase()),
584+ "capitalize" => {
585+ let s = text(0)?;
586+ let mut chars = s.chars();
587+ Value::Str(match chars.next() {
588+ Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
589+ None => String::new(),
590+ })
591+ }
592+ "trim" => Value::Str(text(0)?.trim().to_string()),
593+ "length" => Value::Num(match &args[0] {
594+ Value::List(list) => list.items.len(),
595+ Value::Record(fields) => fields.len(),
596+ _ => text(0)?.chars().count(),
597+ } as f64),
598+ "reverse" => match &args[0] {
599+ Value::List(list) => Value::list(list.items.iter().rev().cloned().collect()),
600+ _ => Value::Str(text(0)?.chars().rev().collect()),
601+ },
602+ "truncate" => {
603+ let (s, n) = (text(0)?, num(1)? as usize);
604+ Value::Str(if s.chars().count() > n {
605+ format!("{}...", s.chars().take(n).collect::<String>())
606+ } else {
607+ s
608+ })
609+ }
610+ "replace" => Value::Str(text(0)?.replace(&text(1)?, &text(2)?)),
611+ "default" => {
612+ let empty = match &args[0] {
613+ Value::List(list) => list.items.is_empty(),
614+ Value::Record(_) => false,
615+ value => value.to_string().is_empty(),
616+ };
617+ let mut args = args;
618+ args.swap_remove(usize::from(empty))
619+ }
620+ _ => return Err(format!("unknown function `{}()`", name)),
621+ })
622+}
623+
624+#[cfg(test)]
625+mod tests {
626+ use super::*;
627+
628+ use crate::interp::tests::{Map, record, text_list};
629+
630+ fn scope() -> Map {
631+ Map::new(&[
632+ ("count", "3"),
633+ ("name", "World"),
634+ ("theme", "dark"),
635+ ("tricky", "a == b"),
636+ ("empty", ""),
637+ ])
638+ .with("tags", text_list(&["rust", "web dev"]))
639+ .with("post", record(&[("title", "Hi")]))
640+ }
641+
642+ fn ev(src: &str) -> Value {
643+ eval(src, &scope()).unwrap()
644+ }
645+
646+ #[test]
647+ fn arithmetic_has_precedence() {
648+ assert_eq!(ev("2 + 3 * 4").to_string(), "14");
649+ assert_eq!(ev("(2 + 3) * 4").to_string(), "20");
650+ assert_eq!(ev("$count * 2 - 1").to_string(), "5");
651+ assert_eq!(ev("7 / 2").to_string(), "3.5");
652+ assert_eq!(ev("-$count").to_string(), "-3");
653+ }
654+
655+ #[test]
656+ fn comparisons_and_logic() {
657+ assert!(ev("$count > 2").truthy());
658+ assert!(ev("$theme == dark").truthy());
659+ assert!(ev("$theme != light and not $empty").truthy());
660+ assert!(!ev("$count < 2 or $empty").truthy());
661+ assert!(ev("contains($name, or)").truthy());
662+ assert!(ev("starts-with($name, Wo) and ends-with($name, ld)").truthy());
663+ assert!(ev("uppercase($name) == WORLD").truthy());
664+ assert!(ev("#3b82f6 != red").truthy());
665+ }
666+
667+ #[test]
668+ fn variable_values_are_never_reparsed() {
669+ // The value contains `==`, but it's just a string.
670+ assert!(ev("$tricky == \"a == b\"").truthy());
671+ assert!(ev("$tricky").truthy());
672+ }
673+
674+ #[test]
675+ fn strings_and_if() {
676+ assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
677+ assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
678+ assert_eq!(ev("${name}").to_string(), "World");
679+ assert_eq!(ev("${$count + 1} * 2").to_string(), "8");
680+ }
681+
682+ #[test]
683+ fn only_the_branch_taken_is_evaluated() {
684+ let zero = Map::new(&[("n", "0"), ("on", "true")]);
685+ let ev0 = |src: &str| eval(src, &zero).map(|v| v.to_string());
686+ assert_eq!(ev0("if($n != 0, 10 / $n, 0)"), Ok("0".into()));
687+ assert_eq!(ev0("if($n == 0, none, 10 / $n)"), Ok("none".into()));
688+ assert_eq!(ev0("if($on, 24)"), Ok("24".into()));
689+ assert_eq!(ev0("if(not $on, 24)"), Ok("".into()));
690+ assert_eq!(
691+ ev0("if($on, \"#10b981\", \"var(--muted)\")"),
692+ Ok("#10b981".into())
693+ );
694+ // and / or stop at the side that decides
695+ assert_eq!(ev0("$n != 0 and 10 / $n > 1"), Ok("false".into()));
696+ assert_eq!(ev0("$n == 0 or 10 / $n > 1"), Ok("true".into()));
697+ assert_eq!(ev0("$on or truncate(abc, x)"), Ok("true".into()));
698+ // The branch not taken is still read: its syntax, function names and
699+ // argument counts are checked; its variables and values are not
700+ assert_eq!(ev0("if($on, 1, $undefined)"), Ok("1".into()));
701+ assert!(eval("if($on, 1, (2)", &zero).is_err());
702+ assert!(eval("if($on, 1, nope(2))", &zero).is_err());
703+ assert!(eval("if($on, 1, uppercase(a, b))", &zero).is_err());
704+ // if() itself takes 2 or 3 arguments
705+ assert!(eval("if($on)", &zero).is_err());
706+ assert!(eval("if($on, 1, 2, 3)", &zero).is_err());
707+ // Something else after the condition is what is reported
708+ let error = eval("if($n.x, 1, 2)", &zero).unwrap_err().to_string();
709+ assert!(error.contains("`.x`"), "{}", error);
710+ // The taken branch still reports its errors
711+ assert!(eval("if($n == 0, 10 / $n, 0)", &zero).is_err());
712+ assert!(eval("$n == 0 and 10 / $n > 1", &zero).is_err());
713+ }
714+
715+ #[test]
716+ fn names_and_fields() {
717+ assert_eq!(ev("$post.title").to_string(), "Hi");
718+ // A field the record doesn't have is empty
719+ assert_eq!(ev("default($post.draft, no)").to_string(), "no");
720+ assert_eq!(
721+ eval("$missing == 1", &scope()),
722+ Err(Error::Undefined {
723+ name: "missing".into(),
724+ offset: 0
725+ })
726+ );
727+ assert_eq!(
728+ eval("\"a $nope\"", &scope()),
729+ Err(Error::Undefined {
730+ name: "nope".into(),
731+ offset: 3
732+ })
733+ );
734+ }
735+
736+ #[test]
737+ fn functions() {
738+ assert_eq!(ev("uppercase($name)").to_string(), "WORLD");
739+ assert_eq!(ev("length($name) + 1").to_string(), "6");
740+ assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
741+ assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
742+ assert_eq!(ev("default($empty, none)").to_string(), "none");
743+ }
744+
745+ #[test]
746+ fn lists() {
747+ assert_eq!(ev("length($tags)").to_string(), "2");
748+ assert!(ev("contains($tags, \"web dev\")").truthy());
749+ assert!(!ev("contains($tags, web)").truthy());
750+ assert_eq!(ev("$tags").to_string(), "rust, web dev");
751+ assert_eq!(ev("reverse($tags)").to_string(), "web dev, rust");
752+ assert_eq!(ev("default($tags, none)").to_string(), "rust, web dev");
753+ // Text functions don't guess what a list means
754+ let error = eval("uppercase($tags)", &scope()).unwrap_err().to_string();
755+ assert!(error.contains("a list of 2 items"), "{}", error);
756+ assert!(eval("truncate($post, 2)", &scope()).is_err());
757+ }
758+
759+ #[test]
760+ fn ranges() {
761+ assert_eq!(ev("1..5").to_string(), "1, 2, 3, 4, 5");
762+ assert_eq!(ev("reverse(1..5)").to_string(), "5, 4, 3, 2, 1");
763+ assert_eq!(ev("length(0..10 step 5)").to_string(), "3");
764+ assert_eq!(ev("$count..1").to_string(), "3, 2, 1");
765+ assert_eq!(ev("1..$count + 1").to_string(), "1, 2, 3, 4");
766+ assert!(ev("contains(1..3, 2)").truthy());
767+ assert!(eval("1..x", &scope()).is_err());
768+ assert!(eval("1..5 step 0", &scope()).is_err());
769+ }
770+
771+ #[test]
772+ fn truthiness_and_comparison_follow_the_types() {
773+ let zero = Map::new(&[("z", "0.0"), ("upper", "#FFF")]);
774+ assert!(!eval("$z", &zero).unwrap().truthy());
775+ assert!(eval("$z == 0", &zero).unwrap().truthy());
776+ assert!(!eval("$upper == #fff", &zero).unwrap().truthy());
777+ assert_eq!(ev("100 / 3").to_string(), "33.3333");
778+ assert!(eval("1..3 < 2", &scope()).is_err());
779+ }
780+
781+ #[test]
782+ fn errors_are_reported() {
783+ let none = Map::new(&[]);
784+ assert!(eval("dark * 2", &none).is_err());
785+ assert!(eval("1 +", &none).is_err());
786+ assert!(eval("(1", &none).is_err());
787+ assert!(eval("1 / 0", &none).is_err());
788+ assert!(eval("nope(1)", &none).is_err());
789+ assert!(eval("uppercase(a, b)", &none).is_err());
790+ // Colour maths is CSS's (`color-mix()`), and a custom property is
791+ // read with `var()`
792+ for gone in ["darken", "lighten", "alpha", "mix"] {
793+ let error = eval(&format!("{}(red, 10)", gone), &none).unwrap_err();
794+ assert!(error.to_string().contains("unknown function"), "{}", error);
795+ }
796+ let error = eval("$--brand == red", &none).unwrap_err().to_string();
797+ assert!(error.contains("`var(--brand)`"), "{}", error);
798+ let error = eval("\"a $--brand\"", &none).unwrap_err().to_string();
799+ assert!(error.contains("`var(--brand)`"), "{}", error);
800+ }
801+}
crates/htmlang-core/src/interp.rsnew file+651
@@ -0,0 +1,651 @@
1+//! Variables in htmlang strings: `$name`, `$record.field` and `${...}`.
2+//!
3+//! The syntax tree finds every slot first: a run of text, an attribute's
4+//! value, an element's argument, a `@let` value, a file path, the title of
5+//! `@page`, the value of `@meta`. Each slot is then interpolated here, once.
6+//! A reference fills its own slot, and what it inserts is never read again
7+//! as htmlang: a value can't become an attribute name, a whole attribute, a
8+//! comma between attributes, markup or another `$name`.
9+//!
10+//! - `$` followed by a letter or `_` starts a name, which goes on with
11+//! letters, digits, `_` and `-` (a `-` only when a name character follows
12+//! it).
13+//! - `$--name` is an error: a custom property is read one way, with
14+//! `var(--name)`, in the browser, so it follows a token redefined under
15+//! `dark:`. See [`custom_property_len`].
16+//! - `.field` continues the name only when the value is a record or a list
17+//! (`$post.title`, `$tags.0`). After `@let lang fr`, `$lang.json` is
18+//! `fr.json`, and `$a..$b` is two names.
19+//! - `${name}` is the same name, delimited (`${size}px`), and `${EXPR}`
20+//! inserts the value of an expression (see `expr.rs`).
21+//! - A `$` before anything else is text: `$5`, `$$`, a `$` at the end.
22+//! - An undefined name is an error. A field that a record doesn't have is
23+//! empty, and so are its fields (`$post.author.name` when `$post` has no
24+//! `author`), so optional fields of `@data` records work.
25+//! - Quoted text (`@let arrow "→ "`, `@card [quote "→ "]`) remembers its
26+//! quotes: a CSS value gets them (`content $arrow` is `content:"→ "`),
27+//! text and HTML attribute values don't. See [`Sink`].
28+
29+use crate::expr;
30+use crate::value::Value;
31+
32+/// The variables in scope, as the evaluator stores them.
33+pub trait Scope {
34+ /// The value of a variable, by its name (without fields).
35+ fn get(&self, name: &str) -> Option<Value>;
36+}
37+
38+/// Where a slot's value goes, which decides what quoted text inserts.
39+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40+pub enum Sink {
41+ /// Text, an HTML attribute value, a path, a parameter: quoted text
42+ /// inserts what it says, without the quotes.
43+ Text,
44+ /// A CSS value: quoted text inserts itself with its quotes, or, inside a
45+ /// quoted string of the value, what it says with `"` escaped as `\"`.
46+ Css,
47+}
48+
49+/// Whether a CSS value is inside a quoted string after `text`, when it
50+/// was (`inside`) before it. A backslash inside a string escapes the next
51+/// character.
52+fn css_string_after(text: &str, mut inside: bool) -> bool {
53+ let mut chars = text.chars();
54+ while let Some(c) = chars.next() {
55+ match c {
56+ '\\' if inside => {
57+ chars.next();
58+ }
59+ '"' => inside = !inside,
60+ _ => {}
61+ }
62+ }
63+ inside
64+}
65+
66+/// `text` as it goes inside a CSS string: `"` escaped as `\"`. A backslash
67+/// and the character after it are a CSS escape and stay as written
68+/// (`\201C`), but a backslash at the end is doubled, so it can't escape
69+/// the rest of the string (`a\` gives `a\\`).
70+pub fn css_string_body(text: &str) -> String {
71+ let mut out = String::with_capacity(text.len());
72+ let mut chars = text.chars();
73+ while let Some(c) = chars.next() {
74+ match c {
75+ '\\' => {
76+ out.push('\\');
77+ out.push(chars.next().unwrap_or('\\'));
78+ }
79+ '"' => out.push_str("\\\""),
80+ _ => out.push(c),
81+ }
82+ }
83+ out
84+}
85+
86+/// A problem found while interpolating, at a byte offset into the text.
87+#[derive(Debug, Clone, PartialEq)]
88+pub enum Problem {
89+ /// A name with no definition.
90+ Undefined { name: String, offset: usize },
91+ /// `$--name`: a custom property, which is read with `var(--name)`.
92+ CustomProperty { name: String, offset: usize },
93+ /// A `${...}` whose expression doesn't evaluate.
94+ Invalid { message: String, offset: usize },
95+ /// A record put where text goes, which it has none of: it would print
96+ /// nothing.
97+ Record { message: String, offset: usize },
98+}
99+
100+/// The problem with putting `value`, written `written`, where text goes:
101+/// a record has no text.
102+fn no_text(written: &str, value: &Value, offset: usize) -> Option<Problem> {
103+ let name = written.strip_prefix('$').map(|name| {
104+ name.strip_prefix('{')
105+ .and_then(|n| n.strip_suffix('}'))
106+ .map_or(name, str::trim)
107+ });
108+ let fields = match value {
109+ Value::Record(fields) => fields,
110+ // A list of records, which would print only what isn't a record
111+ Value::List(list)
112+ if list.written.is_none()
113+ && list
114+ .items
115+ .iter()
116+ .any(|item| matches!(item, Value::Record(_))) =>
117+ {
118+ let example = match (name, list.items.first()) {
119+ (Some(name), Some(Value::Record(fields))) if is_path(name) => {
120+ match fields.first() {
121+ Some((field, _)) => format!(", such as `${}.0.{}`", name, field),
122+ None => String::new(),
123+ }
124+ }
125+ _ => String::new(),
126+ };
127+ return Some(Problem::Record {
128+ message: format!(
129+ "'{}' is a list of records, which has no text of its own: loop over it \
130+ with @each, or write a field of one of its items{}",
131+ written, example
132+ ),
133+ offset,
134+ });
135+ }
136+ _ => return None,
137+ };
138+ let example = match (name, fields.first()) {
139+ (Some(name), Some((field, _))) if is_path(name) => {
140+ format!(", such as `${}.{}`", name, field)
141+ }
142+ _ => String::new(),
143+ };
144+ Some(Problem::Record {
145+ message: format!(
146+ "'{}' is a record, which has no text of its own: write one of its fields{}",
147+ written, example
148+ ),
149+ offset,
150+ })
151+}
152+
153+fn is_name_char(c: char) -> bool {
154+ c.is_alphanumeric() || c == '_'
155+}
156+
157+/// The length of a word at the start of `s` whose first character passes
158+/// `first`: name characters, with `-` between them.
159+fn word_len(s: &str, first: impl Fn(char) -> bool) -> usize {
160+ let Some(c) = s.chars().next().filter(|&c| first(c)) else {
161+ return 0;
162+ };
163+ let mut end = c.len_utf8();
164+ let mut i = end;
165+ while let Some(c) = s[i..].chars().next() {
166+ if is_name_char(c) {
167+ i += c.len_utf8();
168+ end = i;
169+ } else if c == '-' {
170+ let rest = &s[i..];
171+ let dashes = rest.len() - rest.trim_start_matches('-').len();
172+ if !rest[dashes..].starts_with(is_name_char) {
173+ break;
174+ }
175+ i += dashes;
176+ } else {
177+ break;
178+ }
179+ }
180+ end
181+}
182+
183+/// The length of the variable name at the start of `s` (the text after a
184+/// `$`), by syntax alone, or 0 when `s` doesn't start one.
185+pub fn name_len(s: &str) -> usize {
186+ word_len(s, |c| c.is_alphabetic() || c == '_')
187+}
188+
189+/// The length of the custom property's name `--name` at the start of `s`
190+/// (the text after a `$`), or 0. `$--name` is not a variable and not text
191+/// either: it is an error that says to write `var(--name)`.
192+pub fn custom_property_len(s: &str) -> usize {
193+ match s.strip_prefix("--") {
194+ Some(rest) => match word_len(rest, |c| c.is_alphanumeric() || c == '_') {
195+ 0 => 0,
196+ n => n + 2,
197+ },
198+ None => 0,
199+ }
200+}
201+
202+/// What is wrong with `$NAME`, where `name` is a custom property's name
203+/// (`--brand`).
204+pub fn custom_property_message(name: &str) -> String {
205+ format!(
206+ "`${}` is not a variable: a custom property is read with `var({})`",
207+ name, name
208+ )
209+}
210+
211+/// Whether `s` is a whole name path, `name` or `name.field.field`.
212+fn is_path(s: &str) -> bool {
213+ let n = name_len(s);
214+ if n == 0 || matches!(&s[..n], "true" | "false" | "not" | "and" | "or") {
215+ return false;
216+ }
217+ let mut rest = &s[n..];
218+ while let Some(field) = rest.strip_prefix('.') {
219+ let f = word_len(field, is_name_char);
220+ if f == 0 {
221+ return false;
222+ }
223+ rest = &field[f..];
224+ }
225+ rest.is_empty()
226+}
227+
228+/// What a name path leads to so far: a value, or a field that a record
229+/// (or an item that a list) doesn't have, which is empty, and so is every
230+/// field of it.
231+#[derive(Clone)]
232+enum Step {
233+ Value(Value),
234+ Missing,
235+}
236+
237+impl Step {
238+ /// The field `name` of this step, or `None` when it has no fields
239+ /// (text, a number, true or false).
240+ fn field(&self, name: &str) -> Option<Step> {
241+ let Step::Value(value) = self else {
242+ return Some(Step::Missing);
243+ };
244+ match value {
245+ Value::Record(fields) => Some(
246+ fields
247+ .iter()
248+ .find(|(key, _)| key == name)
249+ .map_or(Step::Missing, |(_, value)| Step::Value(value.clone())),
250+ ),
251+ Value::List(list) => Some(
252+ name.parse::<usize>()
253+ .ok()
254+ .and_then(|i| list.items.get(i).cloned())
255+ .map_or(Step::Missing, Step::Value),
256+ ),
257+ _ => None,
258+ }
259+ }
260+
261+ fn value(self) -> Value {
262+ match self {
263+ Step::Value(value) => value,
264+ Step::Missing => Value::empty(),
265+ }
266+ }
267+}
268+
269+/// The name path at the start of `s` (after a `$`) and its length: the
270+/// name, and each `.field` while the value so far is a record or a list,
271+/// or a field one of them doesn't have.
272+fn path_at(s: &str, scope: &dyn Scope) -> Option<(String, usize)> {
273+ let n = name_len(s);
274+ if n == 0 {
275+ return None;
276+ }
277+ let mut path = s[..n].to_string();
278+ let mut len = n;
279+ let mut step = scope.get(&path).map(Step::Value);
280+ while let Some(rest) = s[len..].strip_prefix('.') {
281+ let f = word_len(rest, is_name_char);
282+ let Some(field) = step
283+ .as_ref()
284+ .filter(|_| f > 0)
285+ .and_then(|v| v.field(&rest[..f]))
286+ else {
287+ break;
288+ };
289+ path = format!("{}.{}", path, &rest[..f]);
290+ len += 1 + f;
291+ step = Some(field);
292+ }
293+ Some((path, len))
294+}
295+
296+/// A reference, after its `$`.
297+#[derive(Debug, Clone, PartialEq)]
298+pub enum Reference<'a> {
299+ /// `$name`, `$name.field` or `${name}`: the name path.
300+ Var(String),
301+ /// `${EXPR}`: the expression.
302+ Expr(&'a str),
303+}
304+
305+/// The reference that `s` (the text after a `$`) starts with, and its
306+/// length. `None` when the `$` is text.
307+pub fn reference<'a>(s: &'a str, scope: &dyn Scope) -> Option<(Reference<'a>, usize)> {
308+ if s.starts_with('{') {
309+ let close = matching_brace(s)?;
310+ let inner = &s[1..close];
311+ let reference = if is_path(inner.trim()) {
312+ Reference::Var(inner.trim().to_string())
313+ } else {
314+ Reference::Expr(inner)
315+ };
316+ return Some((reference, close + 1));
317+ }
318+ let (path, len) = path_at(s, scope)?;
319+ Some((Reference::Var(path), len))
320+}
321+
322+/// The value of a name path, or `None` when it is undefined. A field that
323+/// a record doesn't have is empty, and so is any field of it.
324+pub fn resolve(path: &str, scope: &dyn Scope) -> Option<Value> {
325+ let mut parts = path.split('.');
326+ let mut step = Step::Value(scope.get(parts.next()?)?);
327+ for field in parts {
328+ step = step.field(field)?;
329+ }
330+ Some(step.value())
331+}
332+
333+/// Fill in the `$name`s and `${...}`s of one slot's text, for text (see
334+/// [`Sink::Text`]). Each one that can't be filled is left as written and
335+/// reported.
336+pub fn interpolate(text: &str, scope: &dyn Scope) -> (String, Vec<Problem>) {
337+ interpolate_for(text, scope, Sink::Text)
338+}
339+
340+/// [`interpolate`] for a slot whose value goes to `sink`.
341+pub fn interpolate_for(text: &str, scope: &dyn Scope, sink: Sink) -> (String, Vec<Problem>) {
342+ let mut problems = Vec::new();
343+ if !text.contains('$') {
344+ return (text.to_string(), problems);
345+ }
346+ let mut out = String::with_capacity(text.len());
347+ let mut pos = 0;
348+ // Whether the CSS value is inside a quoted string at `pos`
349+ let mut in_string = false;
350+ while let Some(found) = text[pos..].find('$') {
351+ let dollar = pos + found;
352+ out.push_str(&text[pos..dollar]);
353+ if sink == Sink::Css {
354+ in_string = css_string_after(&text[pos..dollar], in_string);
355+ }
356+ let after = &text[dollar + 1..];
357+ let Some((reference, len)) = reference(after, scope) else {
358+ let n = custom_property_len(after);
359+ if n > 0 {
360+ problems.push(Problem::CustomProperty {
361+ name: after[..n].to_string(),
362+ offset: dollar,
363+ });
364+ }
365+ out.push_str(&text[dollar..dollar + 1 + n]);
366+ pos = dollar + 1 + n;
367+ continue;
368+ };
369+ let written = &text[dollar..dollar + 1 + len];
370+ let insert = |value: String, quoted: Option<String>| match (sink, quoted) {
371+ (Sink::Css, Some(css)) if in_string => {
372+ // The string's body, without its quotes
373+ let body = css.strip_prefix('"').unwrap_or(&css);
374+ body.strip_suffix('"').unwrap_or(body).to_string()
375+ }
376+ (Sink::Css, Some(css)) => css,
377+ (Sink::Css, None) if in_string => css_string_body(&value),
378+ _ => value,
379+ };
380+ match reference {
381+ Reference::Var(path) => match resolve(&path, scope) {
382+ Some(value) => match no_text(written, &value, dollar) {
383+ Some(problem) => problems.push(problem),
384+ None => out.push_str(&insert(
385+ value.to_string(),
386+ value.quoted_css().map(str::to_string),
387+ )),
388+ },
389+ None => {
390+ problems.push(Problem::Undefined {
391+ name: path,
392+ offset: dollar,
393+ });
394+ out.push_str(written);
395+ }
396+ },
397+ Reference::Expr(source) => match expr::eval(source, scope) {
398+ Ok(value) => match no_text(written, &value, dollar) {
399+ Some(problem) => problems.push(problem),
400+ None => out.push_str(&insert(value.to_string(), None)),
401+ },
402+ Err(error) => {
403+ problems.push(match error {
404+ // At the `${`
405+ expr::Error::Invalid(message) => Problem::Invalid {
406+ message,
407+ offset: dollar,
408+ },
409+ // At the name, inside the braces
410+ error => error.at(dollar + 2),
411+ });
412+ out.push_str(written);
413+ }
414+ },
415+ }
416+ pos = dollar + 1 + len;
417+ }
418+ out.push_str(&text[pos..]);
419+ (out, problems)
420+}
421+
422+/// The names `text` refers to, by syntax alone: the first name of each
423+/// path, including those inside `${...}`.
424+pub fn names(text: &str) -> Vec<&str> {
425+ name_spans(text)
426+ .into_iter()
427+ .map(|span| &text[span])
428+ .collect()
429+}
430+
431+/// Where the names of [`names`] are in `text`, as byte ranges of the name
432+/// alone: after the `$` of `$name`, inside the braces of `${name}`.
433+pub fn name_spans(text: &str) -> Vec<std::ops::Range<usize>> {
434+ let mut found = Vec::new();
435+ collect_name_spans(text, 0, &mut found);
436+ found
437+}
438+
439+fn collect_name_spans(text: &str, base: usize, found: &mut Vec<std::ops::Range<usize>>) {
440+ let mut pos = 0;
441+ while let Some(i) = text[pos..].find('$') {
442+ let after = pos + i + 1;
443+ let rest = &text[after..];
444+ pos = after;
445+ // `\$` is a dollar sign (and `\\$` a backslash before a name)
446+ let backslashes = text[..after - 1].len() - text[..after - 1].trim_end_matches('\\').len();
447+ if backslashes % 2 == 1 {
448+ continue;
449+ }
450+ if rest.starts_with('{')
451+ && let Some(close) = matching_brace(rest)
452+ {
453+ let inner = &rest[1..close];
454+ let trimmed = inner.trim_start();
455+ if is_path(trimmed.trim_end()) {
456+ let start = base + after + 1 + inner.len() - trimmed.len();
457+ found.push(start..start + name_len(trimmed));
458+ } else {
459+ collect_name_spans(inner, base + after + 1, found);
460+ }
461+ pos = after + close + 1;
462+ continue;
463+ }
464+ let n = name_len(rest);
465+ if n > 0 {
466+ found.push(base + after..base + after + n);
467+ pos = after + n;
468+ }
469+ }
470+}
471+
472+/// Index of the `}` matching the `{` that `s` starts with (skipping
473+/// braces inside string literals).
474+pub(crate) fn matching_brace(s: &str) -> Option<usize> {
475+ let mut depth = 0;
476+ let mut in_string = false;
477+ for (i, c) in s.char_indices() {
478+ match c {
479+ '"' => in_string = !in_string,
480+ _ if in_string => {}
481+ '{' => depth += 1,
482+ '}' => {
483+ depth -= 1;
484+ if depth == 0 {
485+ return Some(i);
486+ }
487+ }
488+ _ => {}
489+ }
490+ }
491+ None
492+}
493+
494+#[cfg(test)]
495+pub(crate) mod tests {
496+ use super::*;
497+ use std::collections::HashMap;
498+
499+ /// Variables by name: text from `new`, any value from `with`.
500+ pub(crate) struct Map(pub HashMap<String, Value>);
501+
502+ impl Map {
503+ pub(crate) fn new(pairs: &[(&str, &str)]) -> Self {
504+ Map(pairs
505+ .iter()
506+ .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
507+ .collect())
508+ }
509+
510+ pub(crate) fn with(mut self, name: &str, value: Value) -> Self {
511+ self.0.insert(name.to_string(), value);
512+ self
513+ }
514+ }
515+
516+ impl Scope for Map {
517+ fn get(&self, name: &str) -> Option<Value> {
518+ self.0.get(name).cloned()
519+ }
520+ }
521+
522+ pub(crate) fn text_list(items: &[&str]) -> Value {
523+ Value::list(items.iter().map(|s| Value::Str(s.to_string())).collect())
524+ }
525+
526+ pub(crate) fn record(fields: &[(&str, &str)]) -> Value {
527+ Value::Record(std::rc::Rc::new(
528+ fields
529+ .iter()
530+ .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
531+ .collect(),
532+ ))
533+ }
534+
535+ fn vars() -> Map {
536+ Map::new(&[("lang", "fr"), ("n", "3"), ("n-1", "two")])
537+ .with("post", record(&[("title", "Hello")]))
538+ .with("tags", text_list(&["a", "b"]))
539+ }
540+
541+ fn fill(text: &str) -> String {
542+ interpolate(text, &vars()).0
543+ }
544+
545+ #[test]
546+ fn names_end_where_they_must() {
547+ assert_eq!(fill("locales/$lang.json"), "locales/fr.json");
548+ assert_eq!(fill("$post.title!"), "Hello!");
549+ assert_eq!(fill("$tags.1 and $tags"), "b and a, b");
550+ assert_eq!(fill("${lang}uage"), "fruage");
551+ assert_eq!(fill("$lang- $lang:"), "fr- fr:");
552+ assert_eq!(fill("$n-1"), "two");
553+ assert_eq!(fill("1..$n"), "1..3");
554+ // A name ends at `..`
555+ assert_eq!(fill("$n..$n $post.title..x"), "3..3 Hello..x");
556+ }
557+
558+ #[test]
559+ fn a_dollar_before_anything_else_is_text() {
560+ assert_eq!(fill("$5 and $$ and $"), "$5 and $$ and $");
561+ assert_eq!(fill("a $-x ${"), "a $-x ${");
562+ }
563+
564+ #[test]
565+ fn a_custom_property_is_not_a_variable() {
566+ let (out, problems) = interpolate("x $--brand-dark $-- $--1", &vars());
567+ assert_eq!(out, "x $--brand-dark $-- $--1");
568+ assert_eq!(
569+ problems,
570+ [
571+ Problem::CustomProperty {
572+ name: "--brand-dark".into(),
573+ offset: 2
574+ },
575+ Problem::CustomProperty {
576+ name: "--1".into(),
577+ offset: 20
578+ }
579+ ]
580+ );
581+ assert!(custom_property_message("--brand").contains("`var(--brand)`"));
582+ assert_eq!(names("$--e $f"), ["f"]);
583+ }
584+
585+ #[test]
586+ fn missing_fields_are_empty_and_undefined_names_are_errors() {
587+ assert_eq!(fill("[$post.summary]"), "[]");
588+ let (out, problems) = interpolate("x $nope y", &vars());
589+ assert_eq!(out, "x $nope y");
590+ assert_eq!(
591+ problems,
592+ [Problem::Undefined {
593+ name: "nope".into(),
594+ offset: 2
595+ }]
596+ );
597+ }
598+
599+ #[test]
600+ fn expressions() {
601+ assert_eq!(fill("${$n * 2}px"), "6px");
602+ let (_, problems) = interpolate("a ${$n + $m}", &vars());
603+ assert_eq!(
604+ problems,
605+ [Problem::Undefined {
606+ name: "m".into(),
607+ offset: 9
608+ }]
609+ );
610+ }
611+
612+ #[test]
613+ fn quoted_text_keeps_its_quotes_only_in_css() {
614+ let quoted = Value::Quoted(crate::ast::Quoted {
615+ text: r#"a"b"#.into(),
616+ css: r#""a\"b""#.into(),
617+ });
618+ let scope = Map::new(&[("n", r#"x"y"#)]).with("q", quoted);
619+ let fill = |text: &str, sink| interpolate_for(text, &scope, sink).0;
620+ assert_eq!(fill("$q", Sink::Text), r#"a"b"#);
621+ assert_eq!(fill("$q", Sink::Css), r#""a\"b""#);
622+ // Inside a CSS string, what it says, escaped as the string needs
623+ assert_eq!(
624+ fill(r#""($q) $n" $q"#, Sink::Css),
625+ r#""(a\"b) x\"y" "a\"b""#
626+ );
627+ assert_eq!(fill(r#""\" $q""#, Sink::Css), r#""\" a\"b""#);
628+ // What an unquoted value holds goes into a string that stays valid
629+ let scope = Map::new(&[("end", r"a\"), ("esc", r"\201C"), ("q", r#"x\"y"#)]);
630+ let fill = |text: &str| interpolate_for(text, &scope, Sink::Css).0;
631+ assert_eq!(fill(r#""$end""#), r#""a\\""#);
632+ assert_eq!(fill(r#""$esc""#), r#""\201C""#);
633+ assert_eq!(fill(r#""$q""#), r#""x\"y""#);
634+ }
635+
636+ #[test]
637+ fn inserted_values_are_never_read_again() {
638+ let scope = Map::new(&[("a", "$b"), ("b", "no")]);
639+ assert_eq!(interpolate("$a", &scope).0, "$b");
640+ }
641+
642+ #[test]
643+ fn names_by_syntax() {
644+ assert_eq!(names("$a.b ${c} ${upper($d)} $5 $--e"), ["a", "c", "d"]);
645+ assert_eq!(names(r"\$a \\$b \\\$c"), ["b"]);
646+ assert_eq!(
647+ name_spans("x ${ size }px ${$n + ${m}} $a- b"),
648+ [5..9, 17..18, 23..24, 28..29]
649+ );
650+ }
651+}
crates/htmlang-core/src/lib.rs+6
@@ -1,6 +1,12 @@
11pub mod ast;
22pub mod codegen;
3+pub mod diagnostic;
4+pub mod expr;
5+pub mod interp;
36pub mod parser;
7+pub mod syntax;
8+pub mod value;
9+pub mod vocab;
410
511#[cfg(test)]
612mod codegen_tests;
crates/htmlang-core/src/parser.rs+6313−5233

This diff is too big to show here.

crates/htmlang-core/src/std.hlnew file+9

This diff is too big to show here.

crates/htmlang-core/src/syntax.rsnew file+2943

This diff is too big to show here.

crates/htmlang-core/src/value.rsnew file+406

This diff is too big to show here.

crates/htmlang-core/src/vocab.rsnew file+891

This diff is too big to show here.

crates/htmlang-wasm/playground/index.htmlnew file+113

This diff is too big to show here.

crates/htmlang-wasm/src/lib.rs+77−16

This diff is too big to show here.

editors/vscode/README.md+86−27

This diff is too big to show here.

editors/vscode/package.json+33−1

This diff is too big to show here.

editors/vscode/snippets/htmlang.json+256−192

This diff is too big to show here.

editors/vscode/src/extension.ts+112−4

This diff is too big to show here.

editors/vscode/syntaxes/htmlang.tmLanguage.json+346−64

This diff is too big to show here.

examples/blog.hl+86−49

This diff is too big to show here.

examples/blog.html.map+1−1

This diff is too big to show here.

examples/demo.hl+276−303

This diff is too big to show here.

examples/demo.html+800−3

This diff is too big to show here.

examples/demo.html.map+1−1

This diff is too big to show here.

examples/docs.hl+132−62

This diff is too big to show here.

examples/docs.html.map+1−1

This diff is too big to show here.

examples/landing.hl+131−66

This diff is too big to show here.

examples/landing.html.map+1−1

This diff is too big to show here.

examples/posts.jsonnew file+27

This diff is too big to show here.

src/bin/htmlang_lsp/analysis.rs+1308−972

This diff is too big to show here.

src/bin/htmlang_lsp/completion.rs+1862−1134

This diff is too big to show here.

src/bin/htmlang_lsp/docs.rsnew file+616

This diff is too big to show here.

src/bin/htmlang_lsp/hover.rs+345−949

This diff is too big to show here.

src/bin/htmlang_lsp/main.rs+482−380

This diff is too big to show here.

src/bin/htmlang_lsp/navigation.rs+585−249

This diff is too big to show here.

src/bin/htmlang_lsp/state.rsnew file+315

This diff is too big to show here.

src/bin/htmlang_lsp/tree.rsnew file+222

This diff is too big to show here.

src/cli.rs+34−276

This diff is too big to show here.

src/convert.rsdeleted−1287

This diff is too big to show here.

src/fmt.rs+451−404

This diff is too big to show here.

src/lib.rs+4−1

This diff is too big to show here.

src/main.rs+570−2930

This diff is too big to show here.

src/playground_default.hldeleted−330

This diff is too big to show here.

src/serve.rs+224−184

This diff is too big to show here.

tests/common/mod.rsnew file+140

This diff is too big to show here.

tests/docs.rsnew file+198

This diff is too big to show here.

tests/editor.rsnew file+157

This diff is too big to show here.

tests/fmt.rsnew file+55

This diff is too big to show here.

tests/regressions.rsnew file+2471

This diff is too big to show here.

tests/snapshots.rs+4624−1187

This diff is too big to show here.

tests/snapshots/accessibility.hl+5−5

This diff is too big to show here.

tests/snapshots/accessibility.htmlnew file+1

This diff is too big to show here.

tests/snapshots/alignment.hl+1−1

This diff is too big to show here.

tests/snapshots/alignment.htmlnew file+1

This diff is too big to show here.

tests/snapshots/anchor_positioning.htmlnew file+1

This diff is too big to show here.

tests/snapshots/animate_shorthand.hl+13−9

This diff is too big to show here.

tests/snapshots/animate_shorthand.htmlnew file+1

This diff is too big to show here.

tests/snapshots/animations.hl+9−5

This diff is too big to show here.

tests/snapshots/animations.htmlnew file+1

This diff is too big to show here.

tests/snapshots/arithmetic.hl+4−4

This diff is too big to show here.

tests/snapshots/arithmetic.htmlnew file+1

This diff is too big to show here.

tests/snapshots/assert_directive.hldeleted−10

This diff is too big to show here.

tests/snapshots/attributes.hl+5−5

This diff is too big to show here.

tests/snapshots/attributes.htmlnew file+1

This diff is too big to show here.

tests/snapshots/basic_elements.hl+2−2

This diff is too big to show here.

tests/snapshots/basic_elements.htmlnew file+1

This diff is too big to show here.

tests/snapshots/breadcrumb.hldeleted−4

This diff is too big to show here.

tests/snapshots/chain_operator.hl+1−1

This diff is too big to show here.

tests/snapshots/chain_operator.htmlnew file+1

This diff is too big to show here.

tests/snapshots/clamp_css.hl+1−1

This diff is too big to show here.

tests/snapshots/clamp_css.htmlnew file+1

This diff is too big to show here.

tests/snapshots/code_samples.hlnew file+28

This diff is too big to show here.

tests/snapshots/code_samples.htmlnew file+5

This diff is too big to show here.

tests/snapshots/color_scheme.hl+1−1

This diff is too big to show here.

tests/snapshots/color_scheme.htmlnew file+1

This diff is too big to show here.

tests/snapshots/colour_variants.hlnew file+12

This diff is too big to show here.

tests/snapshots/colour_variants.htmlnew file+1

This diff is too big to show here.

tests/snapshots/compat_test.hl+1−1

This diff is too big to show here.

tests/snapshots/computed_let.hl+3−3

This diff is too big to show here.

tests/snapshots/computed_let.htmlnew file+1

This diff is too big to show here.

tests/snapshots/conditionals.htmlnew file+1

This diff is too big to show here.

tests/snapshots/container_queries.hl+2−2

This diff is too big to show here.

tests/snapshots/container_queries.htmlnew file+1

This diff is too big to show here.

tests/snapshots/convert_basic.hl+1−1

This diff is too big to show here.

tests/snapshots/css_property.hl+6−4

This diff is too big to show here.

tests/snapshots/css_property.htmlnew file+1

This diff is too big to show here.

tests/snapshots/css_shorthands.hl+7−10

This diff is too big to show here.

tests/snapshots/css_shorthands.htmlnew file+1

This diff is too big to show here.

tests/snapshots/css_units.hl+3−3

This diff is too big to show here.

tests/snapshots/css_units.htmlnew file+1

This diff is too big to show here.

tests/snapshots/css_vars.hl+5−5

This diff is too big to show here.

tests/snapshots/css_vars.htmlnew file+1

This diff is too big to show here.

tests/snapshots/custom_properties.hlnew file+47

This diff is too big to show here.

tests/snapshots/custom_properties.htmlnew file+1

This diff is too big to show here.

tests/snapshots/dark_mode.hl+2−2

This diff is too big to show here.

tests/snapshots/dark_mode.htmlnew file+1

This diff is too big to show here.

tests/snapshots/data_directive.hl+7−6

This diff is too big to show here.

tests/snapshots/data_directive.htmlnew file+1

This diff is too big to show here.

tests/snapshots/direction_attr.htmlnew file+1

This diff is too big to show here.

tests/snapshots/each_destructuring.hl+4−3

This diff is too big to show here.

tests/snapshots/each_destructuring.htmlnew file+1

This diff is too big to show here.

tests/snapshots/each_else.hl+2−2

This diff is too big to show here.

tests/snapshots/each_else.htmlnew file+1

This diff is too big to show here.

tests/snapshots/each_index.htmlnew file+1

This diff is too big to show here.

tests/snapshots/each_range.htmlnew file+1

This diff is too big to show here.

tests/snapshots/each_step.hl+4−4

This diff is too big to show here.

tests/snapshots/each_step.htmlnew file+1

This diff is too big to show here.

tests/snapshots/element_prefixes.hlnew file+42

This diff is too big to show here.

tests/snapshots/element_prefixes.htmlnew file+3

This diff is too big to show here.

tests/snapshots/else_if.htmlnew file+1

This diff is too big to show here.

tests/snapshots/env_directive.hl+1−1

This diff is too big to show here.

tests/snapshots/env_directive.htmlnew file+1

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.hlnew file+19

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.htmlnew file+1

This diff is too big to show here.

tests/snapshots/extra_css.hl+3−3

This diff is too big to show here.

tests/snapshots/extra_css.htmlnew file+1

This diff is too big to show here.

tests/snapshots/fn_defaults.hl+2−2

This diff is too big to show here.

tests/snapshots/fn_defaults.htmlnew file+1

This diff is too big to show here.

tests/snapshots/form_elements.hl+9−9

This diff is too big to show here.

tests/snapshots/form_elements.htmlnew file+1

This diff is too big to show here.

tests/snapshots/fragment.hl+1−1

This diff is too big to show here.

tests/snapshots/fragment.htmlnew file+1

This diff is too big to show here.

tests/snapshots/function_calls.hlnew file+66

This diff is too big to show here.

tests/snapshots/function_calls.htmlnew file+1

This diff is too big to show here.

tests/snapshots/function_definitions.hlnew file+35

This diff is too big to show here.

tests/snapshots/function_definitions.htmlnew file+1

This diff is too big to show here.

tests/snapshots/functions.hl+5−5

This diff is too big to show here.

tests/snapshots/functions.htmlnew file+1

This diff is too big to show here.

tests/snapshots/grid_areas.hl+1−1

This diff is too big to show here.

tests/snapshots/grid_areas.htmlnew file+1

This diff is too big to show here.

tests/snapshots/has_pseudo.hl+2−2

This diff is too big to show here.

tests/snapshots/has_pseudo.htmlnew file+1

This diff is too big to show here.

tests/snapshots/hidden_attr.hl+1−1

This diff is too big to show here.

tests/snapshots/hidden_attr.htmlnew file+1

This diff is too big to show here.

tests/snapshots/html_elements.hlnew file+42

This diff is too big to show here.

tests/snapshots/html_elements.htmlnew file+1

This diff is too big to show here.

tests/snapshots/if_expr.hl+21−3

This diff is too big to show here.

tests/snapshots/if_expr.htmlnew file+1

This diff is too big to show here.

tests/snapshots/implicit_el.hldeleted−7

This diff is too big to show here.

tests/snapshots/initial_letter.htmlnew file+1

This diff is too big to show here.

tests/snapshots/inline_text.hl+2−2

This diff is too big to show here.

tests/snapshots/inline_text.htmlnew file+1

This diff is too big to show here.

tests/snapshots/lang_directive.hl+1−2

This diff is too big to show here.

tests/snapshots/lang_directive.htmlnew file+1

This diff is too big to show here.

tests/snapshots/layer_wrapping.htmlnew file+1

This diff is too big to show here.

tests/snapshots/layout_direction.hlnew file+33

This diff is too big to show here.

tests/snapshots/layout_direction.htmlnew file+1

This diff is too big to show here.

tests/snapshots/layout_modes.hlnew file+44

This diff is too big to show here.

tests/snapshots/layout_modes.htmlnew file+2

This diff is too big to show here.

tests/snapshots/leading_argument.hlnew file+50

This diff is too big to show here.

tests/snapshots/leading_argument.htmlnew file+1

This diff is too big to show here.

tests/snapshots/list_elements.hl+11−11

This diff is too big to show here.

tests/snapshots/list_elements.htmlnew file+1

This diff is too big to show here.

tests/snapshots/loops.htmlnew file+1

This diff is too big to show here.

tests/snapshots/manifest_directive.hl+3−8

This diff is too big to show here.

tests/snapshots/manifest_directive.htmlnew file+1

This diff is too big to show here.

tests/snapshots/markdown_block.hl+1−1

This diff is too big to show here.

tests/snapshots/markdown_block.htmlnew file+10

This diff is too big to show here.

tests/snapshots/match_directive.hl+9−10

This diff is too big to show here.

tests/snapshots/match_directive.htmlnew file+1

This diff is too big to show here.

tests/snapshots/media_elements.hl+1−1

This diff is too big to show here.

tests/snapshots/media_elements.htmlnew file+1

This diff is too big to show here.

tests/snapshots/media_extended.htmlnew file+1

This diff is too big to show here.

tests/snapshots/meta_head.hl+1−1

This diff is too big to show here.

tests/snapshots/meta_head.htmlnew file+1

This diff is too big to show here.

tests/snapshots/mixin_spread.hl+4−4

This diff is too big to show here.

tests/snapshots/mixin_spread.htmlnew file+1

This diff is too big to show here.

tests/snapshots/named_slots.hl+2−2

This diff is too big to show here.

tests/snapshots/named_slots.htmlnew file+1

This diff is too big to show here.

tests/snapshots/names_and_values.hlnew file+18

This diff is too big to show here.

tests/snapshots/names_and_values.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_css.hl+1−1

This diff is too big to show here.

tests/snapshots/new_css.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_css_attrs.hl+1−1

This diff is too big to show here.

tests/snapshots/new_css_attrs.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_css_properties.hl+7−7

This diff is too big to show here.

tests/snapshots/new_css_properties.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_css_properties_2.hl+2−2

This diff is too big to show here.

tests/snapshots/new_css_properties_2.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_css_properties_3.hl+2−2

This diff is too big to show here.

tests/snapshots/new_css_properties_3.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_css_properties_4.hl+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_4.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_directives.hl+18−9

This diff is too big to show here.

tests/snapshots/new_directives.htmlnew file+8

This diff is too big to show here.

tests/snapshots/new_elements.hl+7−7

This diff is too big to show here.

tests/snapshots/new_elements.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_elements_2.hl+10−10

This diff is too big to show here.

tests/snapshots/new_elements_2.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_elements_3.hl+2−2

This diff is too big to show here.

tests/snapshots/new_elements_3.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_elements_4.hl+4−13

This diff is too big to show here.

tests/snapshots/new_elements_4.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_elements_5.hldeleted−19

This diff is too big to show here.

tests/snapshots/new_elements_6.hl+3−3

This diff is too big to show here.

tests/snapshots/new_elements_6.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_html_attrs.hl+5−5

This diff is too big to show here.

tests/snapshots/new_html_attrs.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_pseudos.hl+2−2

This diff is too big to show here.

tests/snapshots/new_pseudos.htmlnew file+1

This diff is too big to show here.

tests/snapshots/no_page.hl+1−1

This diff is too big to show here.

tests/snapshots/no_page.htmlnew file+1

This diff is too big to show here.

tests/snapshots/nth_pseudo.hl+3−3

This diff is too big to show here.

tests/snapshots/nth_pseudo.htmlnew file+1

This diff is too big to show here.

tests/snapshots/og_directive.hl+3−3

This diff is too big to show here.

tests/snapshots/og_directive.htmlnew file+1

This diff is too big to show here.

tests/snapshots/one_css_path.hlnew file+14

This diff is too big to show here.

tests/snapshots/one_css_path.htmlnew file+4

This diff is too big to show here.

tests/snapshots/page_root.hlnew file+15

This diff is too big to show here.

tests/snapshots/page_root.htmlnew file+1

This diff is too big to show here.

tests/snapshots/pagination_each.hldeleted−3

This diff is too big to show here.

tests/snapshots/parameters.hlnew file+31

This diff is too big to show here.

tests/snapshots/parameters.htmlnew file+1

This diff is too big to show here.

tests/snapshots/popover_api.hl+2−2

This diff is too big to show here.

tests/snapshots/popover_api.htmlnew file+1

This diff is too big to show here.

tests/snapshots/positional.htmlnew file+1

This diff is too big to show here.

tests/snapshots/prefixes_compose.hlnew file+17

This diff is too big to show here.

tests/snapshots/prefixes_compose.htmlnew file+1

This diff is too big to show here.

tests/snapshots/pseudo_elements.hl+1−1

This diff is too big to show here.

tests/snapshots/pseudo_elements.htmlnew file+1

This diff is too big to show here.

tests/snapshots/pseudo_states.htmlnew file+1

This diff is too big to show here.

tests/snapshots/pseudo_states_extended.hl+5−5

This diff is too big to show here.

tests/snapshots/pseudo_states_extended.htmlnew file+1

This diff is too big to show here.

tests/snapshots/px_rule.hlnew file+20

This diff is too big to show here.

tests/snapshots/px_rule.htmlnew file+1

This diff is too big to show here.

tests/snapshots/raw_html.hl+2−2

This diff is too big to show here.

tests/snapshots/raw_html.htmlnew file+1

This diff is too big to show here.

tests/snapshots/repeat_directive.hl+2−2

This diff is too big to show here.

tests/snapshots/repeat_directive.htmlnew file+1

This diff is too big to show here.

tests/snapshots/responsive.hl+1−1

This diff is too big to show here.

tests/snapshots/responsive.htmlnew file+1

This diff is too big to show here.

tests/snapshots/responsive_images.hl+2−2

This diff is too big to show here.

tests/snapshots/responsive_images.htmlnew file+1

This diff is too big to show here.

tests/snapshots/scope_css.hl+6−4

This diff is too big to show here.

tests/snapshots/scope_css.htmlnew file+1

This diff is too big to show here.

tests/snapshots/script_element.hl+1−1

This diff is too big to show here.

tests/snapshots/script_element.htmlnew file+4

This diff is too big to show here.

tests/snapshots/scroll_driven_animations.hl+6−4

This diff is too big to show here.

tests/snapshots/scroll_driven_animations.htmlnew file+1

This diff is too big to show here.

tests/snapshots/selection_pseudo.hl+1−1

This diff is too big to show here.

tests/snapshots/selection_pseudo.htmlnew file+1

This diff is too big to show here.

tests/snapshots/semantic_elements.hl+4−4

This diff is too big to show here.

tests/snapshots/semantic_elements.htmlnew file+1

This diff is too big to show here.

tests/snapshots/sizing.hl+1−1

This diff is too big to show here.

tests/snapshots/sizing.htmlnew file+1

This diff is too big to show here.

tests/snapshots/slots_and_children.hlnew file+59

This diff is too big to show here.

tests/snapshots/slots_and_children.htmlnew file+1

This diff is too big to show here.

tests/snapshots/starting_style.hl+5−3

This diff is too big to show here.

tests/snapshots/starting_style.htmlnew file+1

This diff is too big to show here.

tests/snapshots/stress_deeply_nested.hl+132−132

This diff is too big to show here.

tests/snapshots/stress_deeply_nested.htmlnew file+1

This diff is too big to show here.

tests/snapshots/stress_large.hl+194−190

This diff is too big to show here.

tests/snapshots/stress_large.htmlnew file+1

This diff is too big to show here.

tests/snapshots/string_interpolation.htmlnew file+1

This diff is too big to show here.

tests/snapshots/style_block.hl+1−1

This diff is too big to show here.

tests/snapshots/style_block.htmlnew file+1

This diff is too big to show here.

tests/snapshots/subgrid_css.htmlnew file+1

This diff is too big to show here.

tests/snapshots/table_elements.htmlnew file+1

This diff is too big to show here.

tests/snapshots/text_everywhere.hlnew file+41

This diff is too big to show here.

tests/snapshots/text_everywhere.htmlnew file+3

This diff is too big to show here.

tests/snapshots/translations_i18n.hl+3−8

This diff is too big to show here.

tests/snapshots/translations_i18n.htmlnew file+1

This diff is too big to show here.

tests/snapshots/unless_directive.hl+2−2

This diff is too big to show here.

tests/snapshots/unless_directive.htmlnew file+1

This diff is too big to show here.

tests/snapshots/values_and_scope.hlnew file+62

This diff is too big to show here.

tests/snapshots/values_and_scope.htmlnew file+1

This diff is too big to show here.

tests/snapshots/variable_filters.hl+8−8

This diff is too big to show here.

tests/snapshots/variable_filters.htmlnew file+1

This diff is too big to show here.

tests/snapshots/variable_slots.hlnew file+18

This diff is too big to show here.

tests/snapshots/variable_slots.htmlnew file+1

This diff is too big to show here.

tests/snapshots/variables_defines.hl+3−3

This diff is too big to show here.

tests/snapshots/variables_defines.htmlnew file+1

This diff is too big to show here.

tests/snapshots/view_transitions.hl+2−2

This diff is too big to show here.

tests/snapshots/view_transitions.htmlnew file+1

This diff is too big to show here.

tests/snapshots/with_directive.hl+2−2

This diff is too big to show here.

tests/snapshots/with_directive.htmlnew file+1

This diff is too big to show here.