on master help sign in
287 files changed+33000−22499
since splitsnapshots
unifiedsplit
.gitattributes+3 .github/workflows/playground.yml+4−5 .gitignore+1 CONTRIBUTING.md+113−9 Cargo.lock−211 Cargo.toml−2 DESIGN.md+1478−1172 README.md+195−195 crates/htmlang-core/src/ast.rs+722−120 crates/htmlang-core/src/codegen.rs+1598−3441 crates/htmlang-core/src/codegen_tests.rs+33−14 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+6185−5284 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/snippets/htmlang.json+256−192 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+1297−998 src/bin/htmlang_lsp/completion.rs+1477−1250 src/bin/htmlang_lsp/docs.rs+616 src/bin/htmlang_lsp/hover.rs+343−964 src/bin/htmlang_lsp/main.rs+57−132 src/bin/htmlang_lsp/navigation.rs+503−262 src/bin/htmlang_lsp/state.rs+41−3 src/bin/htmlang_lsp/tree.rs+222 src/cli.rs+34−276 src/convert.rs−1434 src/fmt.rs+401−410 src/lib.rs+4−1 src/main.rs+464−3047 src/playground_default.hl−330 src/serve.rs+128−157 tests/common/mod.rs+140 tests/docs.rs+198 tests/editor.rs+157 tests/fmt.rs+55 tests/regressions.rs+2240−55 tests/snapshots.rs+4528−1190 tests/snapshots/accessibility.hl+5−5 tests/snapshots/accessibility.html+1−1 tests/snapshots/alignment.hl+1−1 tests/snapshots/alignment.html+1−1 tests/snapshots/anchor_positioning.html+1−1 tests/snapshots/animate_shorthand.hl+13−9 tests/snapshots/animate_shorthand.html+1−1 tests/snapshots/animations.hl+9−5 tests/snapshots/animations.html+1−1 tests/snapshots/arithmetic.hl+4−4 tests/snapshots/arithmetic.html+1−1 tests/snapshots/assert_directive.hl−10 tests/snapshots/assert_directive.html−1 tests/snapshots/attributes.hl+5−5 tests/snapshots/attributes.html+1−1 tests/snapshots/basic_elements.hl+2−2 tests/snapshots/basic_elements.html+1−1 tests/snapshots/breadcrumb.hl−4 tests/snapshots/breadcrumb.html−1 tests/snapshots/chain_operator.hl+1−1 tests/snapshots/chain_operator.html+1−1 tests/snapshots/clamp_css.hl+1−1 tests/snapshots/clamp_css.html+1−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−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−1 tests/snapshots/conditionals.html+1−1 tests/snapshots/container_queries.hl+2−2 tests/snapshots/container_queries.html+1−1 tests/snapshots/convert_basic.hl+1−1 tests/snapshots/css_property.hl+6−4 tests/snapshots/css_property.html+1−1 tests/snapshots/css_shorthands.hl+7−10 tests/snapshots/css_shorthands.html+1−1 tests/snapshots/css_units.hl+3−3 tests/snapshots/css_units.html+1−1 tests/snapshots/css_vars.hl+5−5 tests/snapshots/css_vars.html+1−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−1 tests/snapshots/data_directive.hl+7−6 tests/snapshots/data_directive.html+1−1 tests/snapshots/direction_attr.html+1−1 tests/snapshots/each_destructuring.hl+4−3 tests/snapshots/each_destructuring.html+1−1 tests/snapshots/each_else.hl+2−2 tests/snapshots/each_else.html+1−1 tests/snapshots/each_index.html+1−1 tests/snapshots/each_range.html+1−1 tests/snapshots/each_step.hl+4−4 tests/snapshots/each_step.html+1−1 tests/snapshots/element_prefixes.hl+42 tests/snapshots/element_prefixes.html+3 tests/snapshots/else_if.html+1−1 tests/snapshots/env_directive.hl+1−1 tests/snapshots/env_directive.html+1−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−1 tests/snapshots/fn_defaults.hl+2−2 tests/snapshots/fn_defaults.html+1−1 tests/snapshots/form_elements.hl+9−9 tests/snapshots/form_elements.html+1−1 tests/snapshots/fragment.hl+1−1 tests/snapshots/fragment.html+1−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−1 tests/snapshots/grid_areas.hl+1−1 tests/snapshots/grid_areas.html+1−1 tests/snapshots/has_pseudo.hl+2−2 tests/snapshots/has_pseudo.html+1−1 tests/snapshots/hidden_attr.hl+1−1 tests/snapshots/hidden_attr.html+1−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−1 tests/snapshots/implicit_el.hl−7 tests/snapshots/implicit_el.html−1 tests/snapshots/initial_letter.html+1−1 tests/snapshots/inline_text.hl+2−2 tests/snapshots/inline_text.html+1−1 tests/snapshots/lang_directive.hl+1−2 tests/snapshots/lang_directive.html+1−1 tests/snapshots/layer_wrapping.html+1−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−1 tests/snapshots/loops.html+1−1 tests/snapshots/manifest_directive.hl+3−8 tests/snapshots/manifest_directive.html+1−1 tests/snapshots/markdown_block.hl+1−1 tests/snapshots/markdown_block.html+1−1 tests/snapshots/match_directive.hl+9−10 tests/snapshots/match_directive.html+1−1 tests/snapshots/media_elements.hl+1−1 tests/snapshots/media_elements.html+1−1 tests/snapshots/media_extended.html+1−1 tests/snapshots/meta_head.hl+1−1 tests/snapshots/meta_head.html+1−1 tests/snapshots/mixin_spread.hl+4−4 tests/snapshots/mixin_spread.html+1−1 tests/snapshots/named_slots.hl+2−2 tests/snapshots/named_slots.html+1−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−1 tests/snapshots/new_css_attrs.hl+1−1 tests/snapshots/new_css_attrs.html+1−1 tests/snapshots/new_css_properties.hl+7−7 tests/snapshots/new_css_properties.html+1−1 tests/snapshots/new_css_properties_2.hl+2−2 tests/snapshots/new_css_properties_2.html+1−1 tests/snapshots/new_css_properties_3.hl+2−2 tests/snapshots/new_css_properties_3.html+1−1 tests/snapshots/new_css_properties_4.hl+1−1 tests/snapshots/new_css_properties_4.html+1−1 tests/snapshots/new_directives.hl+18−9 tests/snapshots/new_directives.html+8−5 tests/snapshots/new_elements.hl+7−7 tests/snapshots/new_elements.html+1−1 tests/snapshots/new_elements_2.hl+10−10 tests/snapshots/new_elements_2.html+1−1 tests/snapshots/new_elements_3.hl+2−2 tests/snapshots/new_elements_3.html+1−1 tests/snapshots/new_elements_4.hl+4−13 tests/snapshots/new_elements_4.html+1−1 tests/snapshots/new_elements_5.hl−19 tests/snapshots/new_elements_5.html−1 tests/snapshots/new_elements_6.hl+3−3 tests/snapshots/new_elements_6.html+1−1 tests/snapshots/new_html_attrs.hl+5−5 tests/snapshots/new_html_attrs.html+1−1 tests/snapshots/new_pseudos.hl+2−2 tests/snapshots/new_pseudos.html+1−1 tests/snapshots/no_page.hl+1−1 tests/snapshots/no_page.html+1−1 tests/snapshots/nth_pseudo.hl+3−3 tests/snapshots/nth_pseudo.html+1−1 tests/snapshots/og_directive.hl+3−3 tests/snapshots/og_directive.html+1−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/pagination_each.html−1 tests/snapshots/parameters.hl+31 tests/snapshots/parameters.html+1 tests/snapshots/popover_api.hl+2−2 tests/snapshots/popover_api.html+1−1 tests/snapshots/positional.html+1−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−1 tests/snapshots/pseudo_states.html+1−1 tests/snapshots/pseudo_states_extended.hl+5−5 tests/snapshots/pseudo_states_extended.html+1−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−1 tests/snapshots/repeat_directive.hl+2−2 tests/snapshots/repeat_directive.html+1−1 tests/snapshots/responsive.hl+1−1 tests/snapshots/responsive.html+1−1 tests/snapshots/responsive_images.hl+2−2 tests/snapshots/responsive_images.html+1−1 tests/snapshots/scope_css.hl+6−4 tests/snapshots/scope_css.html+1−1 tests/snapshots/script_element.hl+1−1 tests/snapshots/script_element.html+1−1 tests/snapshots/scroll_driven_animations.hl+6−4 tests/snapshots/scroll_driven_animations.html+1−1 tests/snapshots/selection_pseudo.hl+1−1 tests/snapshots/selection_pseudo.html+1−1 tests/snapshots/semantic_elements.hl+4−4 tests/snapshots/semantic_elements.html+1−1 tests/snapshots/sizing.hl+1−1 tests/snapshots/sizing.html+1−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−1 tests/snapshots/stress_deeply_nested.hl+132−132 tests/snapshots/stress_deeply_nested.html+1−1 tests/snapshots/stress_large.hl+194−190 tests/snapshots/stress_large.html+1−1 tests/snapshots/string_interpolation.html+1−1 tests/snapshots/style_block.hl+1−1 tests/snapshots/style_block.html+1−1 tests/snapshots/subgrid_css.html+1−1 tests/snapshots/table_elements.html+1−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−1 tests/snapshots/unless_directive.hl+2−2 tests/snapshots/unless_directive.html+1−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−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−1 tests/snapshots/view_transitions.hl+2−2 tests/snapshots/view_transitions.html+1−1 tests/snapshots/with_directive.hl+2−2 tests/snapshots/with_directive.html+1−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+1
@@ -1,3 +1,4 @@
11/target
22*.html
33!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−211
@@ -24,28 +24,6 @@ dependencies = [
2424 "syn",
2525]
2626
27−[[package]]
28−name = "aws-lc-rs"
29−version = "1.16.3"
30−source = "registry+https://github.com/rust-lang/crates.io-index"
31−checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
32−dependencies = [
33− "aws-lc-sys",
34− "zeroize",
35−]
36−
37−[[package]]
38−name = "aws-lc-sys"
39−version = "0.40.0"
40−source = "registry+https://github.com/rust-lang/crates.io-index"
41−checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
42−dependencies = [
43− "cc",
44− "cmake",
45− "dunce",
46− "fs_extra",
47−]
48−
4927[[package]]
5028name = "bitflags"
5129version = "1.3.2"
@@ -70,33 +48,12 @@ version = "1.11.1"
7048source = "registry+https://github.com/rust-lang/crates.io-index"
7149checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
7250
73−[[package]]
74−name = "cc"
75−version = "1.2.60"
76−source = "registry+https://github.com/rust-lang/crates.io-index"
77−checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
78−dependencies = [
79− "find-msvc-tools",
80− "jobserver",
81− "libc",
82− "shlex",
83−]
84−
8551[[package]]
8652name = "cfg-if"
8753version = "1.0.4"
8854source = "registry+https://github.com/rust-lang/crates.io-index"
8955checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
9056
91−[[package]]
92−name = "cmake"
93−version = "0.1.58"
94−source = "registry+https://github.com/rust-lang/crates.io-index"
95−checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
96−dependencies = [
97− "cc",
98−]
99−
10057[[package]]
10158name = "dashmap"
10259version = "5.5.3"
@@ -121,12 +78,6 @@ dependencies = [
12178 "syn",
12279]
12380
124−[[package]]
125−name = "dunce"
126−version = "1.0.5"
127−source = "registry+https://github.com/rust-lang/crates.io-index"
128−checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
129−
13081[[package]]
13182name = "filetime"
13283version = "0.2.27"
@@ -138,12 +89,6 @@ dependencies = [
13889 "libredox",
13990]
14091
141−[[package]]
142−name = "find-msvc-tools"
143−version = "0.1.9"
144−source = "registry+https://github.com/rust-lang/crates.io-index"
145−checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
146−
14792[[package]]
14893name = "form_urlencoded"
14994version = "1.2.2"
@@ -153,12 +98,6 @@ dependencies = [
15398 "percent-encoding",
15499]
155100
156−[[package]]
157−name = "fs_extra"
158−version = "1.3.0"
159−source = "registry+https://github.com/rust-lang/crates.io-index"
160−checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
161−
162101[[package]]
163102name = "fsevent-sys"
164103version = "4.1.0"
@@ -244,29 +183,6 @@ dependencies = [
244183 "slab",
245184]
246185
247−[[package]]
248−name = "getrandom"
249−version = "0.2.17"
250−source = "registry+https://github.com/rust-lang/crates.io-index"
251−checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
252−dependencies = [
253− "cfg-if",
254− "libc",
255− "wasi",
256−]
257−
258−[[package]]
259−name = "getrandom"
260−version = "0.3.4"
261−source = "registry+https://github.com/rust-lang/crates.io-index"
262−checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
263−dependencies = [
264− "cfg-if",
265− "libc",
266− "r-efi",
267− "wasip2",
268−]
269−
270186[[package]]
271187name = "hashbrown"
272188version = "0.14.5"
@@ -279,10 +195,8 @@ version = "0.1.0"
279195dependencies = [
280196 "htmlang-core",
281197 "notify",
282− "rustls-pemfile",
283198 "serde_json",
284199 "tokio",
285− "tokio-rustls",
286200 "tower-lsp",
287201]
288202
@@ -442,16 +356,6 @@ version = "1.0.18"
442356source = "registry+https://github.com/rust-lang/crates.io-index"
443357checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
444358
445−[[package]]
446−name = "jobserver"
447−version = "0.1.34"
448−source = "registry+https://github.com/rust-lang/crates.io-index"
449−checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
450−dependencies = [
451− "getrandom 0.3.4",
452− "libc",
453−]
454−
455359[[package]]
456360name = "kqueue"
457361version = "1.1.1"
@@ -654,12 +558,6 @@ dependencies = [
654558 "proc-macro2",
655559]
656560
657−[[package]]
658−name = "r-efi"
659−version = "5.3.0"
660−source = "registry+https://github.com/rust-lang/crates.io-index"
661−checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
662−
663561[[package]]
664562name = "redox_syscall"
665563version = "0.5.18"
@@ -678,66 +576,6 @@ dependencies = [
678576 "bitflags 2.11.1",
679577]
680578
681−[[package]]
682−name = "ring"
683−version = "0.17.14"
684−source = "registry+https://github.com/rust-lang/crates.io-index"
685−checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
686−dependencies = [
687− "cc",
688− "cfg-if",
689− "getrandom 0.2.17",
690− "libc",
691− "untrusted",
692− "windows-sys 0.52.0",
693−]
694−
695−[[package]]
696−name = "rustls"
697−version = "0.23.38"
698−source = "registry+https://github.com/rust-lang/crates.io-index"
699−checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
700−dependencies = [
701− "aws-lc-rs",
702− "log",
703− "once_cell",
704− "ring",
705− "rustls-pki-types",
706− "rustls-webpki",
707− "subtle",
708− "zeroize",
709−]
710−
711−[[package]]
712−name = "rustls-pemfile"
713−version = "2.2.0"
714−source = "registry+https://github.com/rust-lang/crates.io-index"
715−checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
716−dependencies = [
717− "rustls-pki-types",
718−]
719−
720−[[package]]
721−name = "rustls-pki-types"
722−version = "1.14.0"
723−source = "registry+https://github.com/rust-lang/crates.io-index"
724−checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
725−dependencies = [
726− "zeroize",
727−]
728−
729−[[package]]
730−name = "rustls-webpki"
731−version = "0.103.12"
732−source = "registry+https://github.com/rust-lang/crates.io-index"
733−checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
734−dependencies = [
735− "aws-lc-rs",
736− "ring",
737− "rustls-pki-types",
738− "untrusted",
739−]
740−
741579[[package]]
742580name = "rustversion"
743581version = "1.0.22"
@@ -813,12 +651,6 @@ dependencies = [
813651 "syn",
814652]
815653
816−[[package]]
817−name = "shlex"
818−version = "1.3.0"
819−source = "registry+https://github.com/rust-lang/crates.io-index"
820−checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
821−
822654[[package]]
823655name = "slab"
824656version = "0.4.12"
@@ -847,12 +679,6 @@ version = "1.2.1"
847679source = "registry+https://github.com/rust-lang/crates.io-index"
848680checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
849681
850−[[package]]
851−name = "subtle"
852−version = "2.6.1"
853−source = "registry+https://github.com/rust-lang/crates.io-index"
854−checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
855−
856682[[package]]
857683name = "syn"
858684version = "2.0.117"
@@ -911,16 +737,6 @@ dependencies = [
911737 "syn",
912738]
913739
914−[[package]]
915−name = "tokio-rustls"
916−version = "0.26.4"
917−source = "registry+https://github.com/rust-lang/crates.io-index"
918−checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
919−dependencies = [
920− "rustls",
921− "tokio",
922−]
923−
924740[[package]]
925741name = "tokio-util"
926742version = "0.7.18"
@@ -1031,12 +847,6 @@ version = "1.0.24"
1031847source = "registry+https://github.com/rust-lang/crates.io-index"
1032848checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1033849
1034−[[package]]
1035−name = "untrusted"
1036−version = "0.9.0"
1037−source = "registry+https://github.com/rust-lang/crates.io-index"
1038−checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1039−
1040850[[package]]
1041851name = "url"
1042852version = "2.5.8"
@@ -1072,15 +882,6 @@ version = "0.11.1+wasi-snapshot-preview1"
1072882source = "registry+https://github.com/rust-lang/crates.io-index"
1073883checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1074884
1075−[[package]]
1076−name = "wasip2"
1077−version = "1.0.2+wasi-0.2.9"
1078−source = "registry+https://github.com/rust-lang/crates.io-index"
1079−checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
1080−dependencies = [
1081− "wit-bindgen",
1082−]
1083−
1084885[[package]]
1085886name = "wasm-bindgen"
1086887version = "0.2.114"
@@ -1223,12 +1024,6 @@ version = "0.52.6"
12231024source = "registry+https://github.com/rust-lang/crates.io-index"
12241025checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
12251026
1226−[[package]]
1227−name = "wit-bindgen"
1228−version = "0.51.0"
1229−source = "registry+https://github.com/rust-lang/crates.io-index"
1230−checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
1231−
12321027[[package]]
12331028name = "writeable"
12341029version = "0.6.3"
@@ -1279,12 +1074,6 @@ dependencies = [
12791074 "synstructure",
12801075]
12811076
1282−[[package]]
1283−name = "zeroize"
1284−version = "1.8.2"
1285−source = "registry+https://github.com/rust-lang/crates.io-index"
1286−checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
1287−
12881077[[package]]
12891078name = "zerotrie"
12901079version = "0.2.4"
Cargo.toml−2
@@ -13,10 +13,8 @@ path = "src/bin/htmlang_lsp/main.rs"
1313[dependencies]
1414htmlang-core = { path = "crates/htmlang-core" }
1515notify = "7"
16−rustls-pemfile = "2.2.0"
1716serde_json = "1"
1817tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
19−tokio-rustls = { version = "0.26.4", features = ["ring"] }
2018tower-lsp = "0.20"
2119
2220[profile.release]
DESIGN.md+1478−1172
@@ -1,1313 +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−| `@in-front` | div (absolute) | Overlay layer painted on top of parent |
63−| `@behind` | div (absolute) | Layer painted behind the parent's content |
64−
65−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.
6644
6745## Syntax
6846
69−### Basic structure
47+### Elements
7048
7149```
72−@element [attributes]
50+@element [attributes] argument
7351 children
7452```
7553
76−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:
7758
7859```
7960@el [
8061 padding 20,
8162 background white,
82− rounded 8,
83− 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)
8465]
8566 Content
8667```
8768
88−### Full example
89−
90−```
91−-- My Site
92−@page My Site
93−@let primary #3b82f6
94−@let gap 20
95−@define card [
96− padding 20,
97− background white,
98− rounded 8,
99− border 1 #e5e7eb,
100− shadow 0 2px 4px rgba(0,0,0,0.05)
101−]
102−
103−@include header.hl
104−
105−@column [max-width 800, center-x, padding 40, spacing $gap]
106− @row [spacing $gap]
107− @column [width fill, spacing 10]
108− @text [bold, size 32] Welcome
109− @paragraph [line-height 1.6]
110− This is a page built with {@text [bold, color $primary] htmlang}.
111− Read the {@link https://docs.example.com docs} to learn more.
112− @image [width 80, height 80, rounded 40] avatar.png
113−
114− -- cards
115− @row [wrap, spacing 10]
116− [$card]
117− First card
118− [$card, background #f9fafb]
119− Second card
120−
121− -- footer
122− @row [spacing 10]
123− @el [padding 16, background $primary, rounded 8] > @link https://example.com
124− @text [color white] Get Started
125− @el [width fill]
126− @text [color #888, text-align right] © 2026
127−
128− @raw """
129− <canvas id="chart"></canvas>
130− """
131−```
132−
133−## Directives
69+An attribute can take one of three forms:
13470
135−### `@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`).
13679
137−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:
13882
13983```
140−@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]
14186```
14287
143−### `@let`
144−
145−Defines a variable. Referenced with `$name`.
146−
147−```
148−@let primary #3b82f6
149−@let gap 20
150−
151−@el [background $primary, spacing $gap]
152−```
153−
154−Quoted values support string interpolation:
155−
156−```
157−@let name World
158−@let greeting "Hello $name" -- "Hello World"
159−@let base /api
160−@let url "$base/users" -- "/api/users"
161−```
162−
163−### `@include`
164−
165−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.
166−
167−```
168−@include header.hl
169−@include components/card.hl
170−```
171−
172−Variables can be used in the filename:
173−
174−```
175−@let component card
176−@include $component.hl
177−```
178−
179−Circular includes are detected and reported as errors. Nested includes are supported.
180−
181−### `@import`
182−
183−Like `@include`, but only imports definitions (`@let`, `@define`, `@fn`) without emitting DOM nodes. Use this for shared theme/component libraries.
184−
185−```
186−@import theme.hl -- imports variables, defines, functions only
187−@include header.hl -- inlines everything including DOM nodes
188−```
189−
190−### `@data`
191−
192−Loads a JSON file and makes its values available as template variables. Useful for data-driven static sites.
193−
194−```
195−@data site.json -- top-level object keys become variables
196−@data $posts data/posts.json -- load with a prefix
197−```
198−
199−For a JSON file like:
200−```json
201−{
202− "title": "My Site",
203− "tags": ["rust", "html", "css"],
204− "links": [
205− {"label": "Home", "url": "/"},
206− {"label": "About", "url": "/about"}
207− ]
208−}
209−```
210−
211−Without prefix (`@data site.json`):
212−- `$title` → `"My Site"`
213−- `$tags` → iterable with `@each`
214−- `$links` → destructurable: `@each $label, $url in $links`
215−
216−With prefix (`@data $site site.json`):
217−- `$site.title` → `"My Site"`
218−- `$site.tags` → iterable
219−- `$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 `\,`.
22091
221−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.
22295
223−### `@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).
224100
225−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:
226103
227104```
228−@meta description A portfolio site
229−@meta og:image https://example.com/preview.png
105+@text Use [ to open a list
106+@paragraph Arrays look like [1, 2, 3].
230107```
231108
232−### `@head`
109+### Text
233110
234−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:
235115
236116```
237−@head
238− <link rel="icon" href="favicon.ico">
239− <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
240−```
241−
242−### `@define`
243−
244−Creates a named attribute bundle. Referenced with `$name` inside attribute lists.
245−
246−```
247−@define card [padding 20, background white, rounded 8, border 1 #e5e7eb]
248−
249−@el [$card]
250− Content
251−@el [$card, background #f9fafb]
252− Overridden background
253−```
254−
255−Attributes listed after `$name` override those in the definition.
256−
257−### `@mixin`
258−
259−Creates a composable style group. Like `@define`, but intended for reusable style patterns. Referenced with `...$name` (spread syntax) or `$name` inside attribute lists.
260−
261−```
262−@mixin card-style [padding 20, rounded 8, background white]
263−@mixin interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
264−
265−@el [...$card-style, ...$interactive, border 1 #ccc]
266− Composed styles from two mixins
267−```
268−
269−### `@assert`
270−
271−Compile-time assertion. If the condition is false, a compile error is emitted. Useful for enforcing design system constraints.
272−
273−```
274−@let spacing 16
275−@let primary #3b82f6
276−
277−@assert $spacing == 16
278−@assert $primary != red
279−```
280−
281−### `@fn`
282−
283−Defines a pure function (reusable component). Parameters are prefixed with `$`. Default values can be specified with `=`.
284−
285−```
286−@fn card $title $variant=primary
287− @el [padding 20, background white, rounded 8, border 1 #e5e7eb]
288− @text [bold, size 18] $title
289− @children
290−```
291−
292−Call it like any element, passing parameters in `[...]`:
293−
294−```
295−@card [title Hello World]
296− This is the card body.
297− @text [italic] With styled text.
298−
299−@card [title Warning, variant danger]
300− Overridden variant.
301−```
302−
303−Parameters with defaults can be omitted in calls — the default value is used.
304−
305−### `@children`
306−
307−A slot inside a function body that expands to the caller's indented children.
308−
309−```
310−@fn layout $title
311− @column [max-width 800, center-x, padding 40]
312− @text [bold, size 32] $title
313− @children
314−
315−@layout [title My Page]
316− @row [spacing 10]
317− Content goes here
318−```
319−
320−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.
321−
322−## Text
323−
324−### Bare text
325−
326−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.
327141
328142```
329−@column
330− Hello world
331− 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
332151```
333152
334−### 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`).
335156
336−Use `@text` when you need attributes.
157+### Escapes
337158
338−```
339−@text [bold, size 24, color #333] Hello world
340−```
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`.
341162
342−### 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 |
343172
344−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`, `$$`.
345177
346178```
347−@paragraph
348− 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]
349183```
350184
351−## Comments
185+### Comments
352186
353−`--` 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).
354193
355194```
356195-- this is a comment
357196@row [spacing 10]
358− -- todo: add nav items
359−```
360−
361−## Implicit `@el`
362−
363−A line starting with `[` (attributes) that has indented children is an anonymous container (implicit `@el`).
364−
365−```
366−[padding 20, background white]
367− Hello
368−
369−-- equivalent to:
370−@el [padding 20, background white]
371− 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
372207```
373208
374−## Single-child chaining
209+### Chains
375210
376−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).
377218
378219```
379−@el [padding 16, background blue, rounded 8] > @link https://example.com
220+@el [padding 16, background blue, border-radius 8] > @link https://example.com
380221 @text [color white] Get Started
381−
382−-- equivalent to:
383−@el [padding 16, background blue, rounded 8]
384− @link https://example.com
385− @text [color white] Get Started
386−```
387−
388−The last element in the chain takes the indented children.
389−
390−## `@raw`
391−
392−Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or JS.
393−
394−```
395−@raw """
396−<style>
397− @keyframes spin { to { transform: rotate(360deg); } }
398−</style>
399−<div class="custom-widget"></div>
400−"""
401−```
402−
403−## Units
404−
405−All numeric values default to pixels. You can use CSS units explicitly:
406−
407−```
408−@el [width 50%, height 100vh, padding 2rem, max-width 80ch, size 1.2em]
409−```
410−
411−Supported units: `%`, `rem`, `em`, `vh`, `vw`, `vmin`, `vmax`, `dvh`, `svh`, `ch`, `ex`, `cm`, `mm`, `in`, `pt`, `pc`, `fr`.
412−
413−## New elements
414−
415−### `@form`
416−
417−Form container. Argument is the `action` URL.
418−
419−```
420−@form [method post] /submit
421− @label [for email] Email
422− @input [type email, name email, required]
423− @button [type submit] Send
424−```
425−
426−### `@details` / `@summary`
427−
428−Native disclosure widget. Use `[open]` to expand by default.
429−
430−```
431−@details [open]
432− @summary FAQ Question
433− @text The answer is here.
434−```
435−
436−### `@blockquote` / `@cite`
437−
438−Semantic quotation with optional citation.
439−
440−```
441−@blockquote [padding 20, border-left 4 #ccc]
442− @text To be or not to be
443− @cite Shakespeare
444−```
445−
446−### `@code` / `@pre`
447−
448−Code and preformatted text. `@code` renders as `<code>` with monospace font. `@pre` preserves whitespace.
449−
450−```
451−@pre
452− @code console.log("hello")
453−```
454−
455−### `@hr`
456−
457−Horizontal rule / divider (self-closing). Alias: `@divider`.
458−
459−```
460−@hr [border-top 1 #e5e7eb]
461222```
462223
463−### `@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:
464343
465−Figure with optional caption.
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>`.
466427
467428```
468−@figure
469− @image [alt Sunset, width fill] sunset.jpg
470− @figcaption A beautiful sunset
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
471434```
472435
473−### `@progress` / `@meter`
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>`.
474440
475−Progress bar and meter elements. Use `value`, `max`, `min` attributes.
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:
476444
477445```
478−@progress [value 70, max 100]
479−@meter [value 6, min 0, max 10, low 3, high 8]
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.
480449```
481450
482−### `@in-front` / `@behind`
451+### Overlays: `@in-front` and `@behind`
483452
484−Overlay layers, inspired by elm-ui's `inFront` and `behind`. Children of these
485−directives are absolutely positioned to fill the parent's bounds; the parent
486−automatically becomes a positioning context (`position: relative` and
487−`isolation: isolate`).
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.
488457
489458```
490459@el [width 200, height 200, background blue]
491460 @text [color white] Main content
492461 @in-front
493− @text [color yellow] Painted on top
462+ @text [color yellow, align-right, align-bottom] Painted on top
494463 @behind
495− @el [background red, width fill, height fill]
496−```
497−
498−`@in-front` paints on top of the parent's content (DOM order ensures it
499−overlays static siblings). `@behind` paints behind (`z-index: -1` keeps it
500−under the siblings, while `isolation: isolate` on the parent prevents it from
501−slipping behind the parent's own background).
502−
503−If you set `position` explicitly on the parent (e.g., `position absolute` for a
504−popup), that wins over the auto-applied `position: relative`.
505−
506−## `@each` destructuring
507−
508−When `@each` has more than one variable and items contain spaces, values are destructured:
509−
510−```
511−@each $name, $url in Home /, About /about, Contact /contact
512− @link $url $name
513−```
514−
515−`var()` and `calc()` expressions are also passed through as-is.
516−
517−## `@each` with `@else`
518−
519−When the list is empty, the `@else` block is rendered as a fallback:
520−
521−```
522−@each $item in $items
523− @text $item
524−@else
525− @text [color #888] No items found.
526−```
527−
528−## `@iframe`
529−
530−Embedded external page. Argument is the `src` URL.
531−
532−```
533−@iframe [width fill, height 400, sandbox] https://example.com
534−```
535−
536−Attributes: `sandbox`, `allow`, `allowfullscreen`, `referrerpolicy`.
537−
538−## `@canvas`
539−
540−Drawing surface for JavaScript. Use with `@raw` for scripts.
541−
542−```
543−@canvas [width 400, height 300, id chart]
544−```
545−
546−## `@output`
547−
548−Form output element for displaying calculation results.
549−
550−```
551−@output [for a b] Result
552−```
553−
554−## Pseudo-elements
555−
556−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.
557−
558−```
559−@el [before:content "→ ", before:color red, before:font-weight bold]
560− Item with arrow prefix
561−
562−@el [after:content " ✓", after:color green]
563− Completed item
564−```
565−
566−Content values are automatically quoted unless they are CSS keywords (`none`, `normal`) or CSS functions (`attr()`, `counter()`).
567−
568−## Conditional attribute values
569−
570−Use `if(condition, true_value, false_value)` inside attribute values for conditional styling:
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`.
571570
572571```
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]
573745@let active true
574−@let theme dark
575−
576−@el [background if($active, blue, gray)]
577− Conditionally styled
578−
579−@el [color if($theme == dark, white, black)]
580− Theme-aware text
581−```
582−
583−Supports equality (`==`), inequality (`!=`), and truthy checks.
584−
585−## Attributes reference
586−
587−### Layout (set on parent)
588−
589−| Attribute | Effect |
590−|------------------------|----------------------------------|
591−| `spacing N` | Gap between children |
592−| `gap N` | Alias for `spacing` |
593−| `gap-x N` | Horizontal gap (column-gap) |
594−| `gap-y N` | Vertical gap (row-gap) |
595−| `padding N` | Uniform padding |
596−| `padding Y X` | Vertical + horizontal padding |
597−| `padding T H B` | Top + horizontal + bottom |
598−| `padding T R B L` | Per-side padding |
599−| `padding-x N` | Horizontal padding |
600−| `padding-y N` | Vertical padding |
601−
602−### Sizing (set on child)
603−
604−| Attribute | Effect |
605−|------------------------|----------------------------------|
606−| `width fill` | Take remaining space (flex: 1) |
607−| `width N` | Exact pixels |
608−| `width shrink` | Fit to content (default) |
609−| `height fill` | Take remaining space |
610−| `height N` | Exact pixels |
611−| `height shrink` | Fit to content (default) |
612−| `min-width N` | Minimum width |
613−| `max-width N` | Maximum width |
614−| `min-height N` | Minimum height |
615−| `max-height N` | Maximum height |
616−
617−### Alignment (set on child)
618−
619−| Attribute | Effect |
620−|------------------------|----------------------------------|
621−| `center-x` | Center horizontally |
622−| `center-y` | Center vertically |
623−| `align-left` | Align to left |
624−| `align-right` | Align to right |
625−| `align-top` | Align to top |
626−| `align-bottom` | Align to bottom |
627−
628−### Style
629−
630−| Attribute | Effect |
631−|------------------------|----------------------------------|
632−| `background COLOR` | Background color |
633−| `color COLOR` | Text color |
634−| `border N COLOR` | Border width and color |
635−| `border-top N COLOR` | Top border |
636−| `border-bottom N COLOR`| Bottom border |
637−| `border-left N COLOR` | Left border |
638−| `border-right N COLOR` | Right border |
639−| `rounded N` | Border radius |
640−| `shadow VALUE` | Box shadow (CSS value) |
641−| `bold` | Bold text |
642−| `italic` | Italic text |
643−| `underline` | Underlined text |
644−| `size N` | Font size in px |
645−| `font NAME` | Font family |
646−| `text-align VALUE` | Text alignment (left/center/right/justify) |
647−| `line-height VALUE` | Line height (unitless or px) |
648−| `letter-spacing N` | Letter spacing |
649−| `text-transform VALUE` | Transform (uppercase/lowercase/capitalize) |
650−| `white-space VALUE` | White-space (nowrap/pre/normal) |
651−| `transition VALUE` | CSS transition |
652−| `cursor VALUE` | Cursor style |
653−| `opacity VALUE` | Opacity (0–1) |
654−| `overflow VALUE` | Overflow (hidden/scroll/auto/visible) |
655−| `position VALUE` | Position (relative/absolute/fixed/sticky) |
656−| `top N` | Top offset (positioned elements) |
657−| `right N` | Right offset |
658−| `bottom N` | Bottom offset |
659−| `left N` | Left offset |
660−| `z-index N` | Stack order |
661−| `display VALUE` | Display mode (none/block/flex/grid) |
662−| `visibility VALUE` | Visibility (visible/hidden) |
663−| `transform VALUE` | CSS transform (e.g., rotate(45deg)) |
664−| `backdrop-filter VALUE`| Backdrop filter (e.g., blur(10px)) |
665−
666−### Margin
667−
668−| Attribute | Effect |
669−|------------------------|----------------------------------|
670−| `margin N` | Uniform margin |
671−| `margin Y X` | Vertical + horizontal margin |
672−| `margin T R B L` | Per-side margin |
673−| `margin-x N` | Horizontal margin |
674−| `margin-y N` | Vertical margin |
675−
676−### Additional Style
677−
678−| Attribute | Effect |
679−|------------------------|----------------------------------|
680−| `filter VALUE` | CSS filter (blur, brightness) |
681−| `object-fit VALUE` | Object fit (cover/contain/fill) |
682−| `object-position VALUE`| Object position within container|
683−| `text-shadow VALUE` | Text shadow |
684−| `text-overflow VALUE` | Text overflow (ellipsis/clip) |
685−| `pointer-events VALUE` | Pointer events (none/auto) |
686−| `user-select VALUE` | User selection (none/text/all) |
687−| `justify-content VALUE`| Main axis alignment |
688−| `align-items VALUE` | Cross axis alignment |
689−| `order N` | Flex/grid item order |
690−| `background-size VALUE`| Background size |
691−| `background-position VALUE`| Background position |
692−| `background-repeat VALUE`| Background repeat |
693−| `word-break VALUE` | Word break (break-all/keep-all) |
694−| `overflow-wrap VALUE` | Overflow wrap (break-word) |
695−| `font-weight VALUE` | Font weight (100-900/bold/lighter)|
696−| `font-style VALUE` | Font style (normal/italic/oblique)|
697−| `text-wrap VALUE` | Text wrapping (balance/pretty) |
698−| `will-change VALUE` | Performance hint (transform) |
699−| `touch-action VALUE` | Touch behavior (none/pan-x) |
700−| `vertical-align VALUE` | Vertical alignment (middle/top) |
701−| `contain VALUE` | CSS containment (layout/paint) |
702−| `content-visibility VALUE` | Lazy rendering (auto) |
703−| `scroll-margin N` | Scroll margin (for anchors) |
704−| `scroll-padding N` | Scroll padding (scroll-snap) |
705−| `content VALUE` | Pseudo-element content (with before:/after:)|
706−
707−### Pseudo-states
708−
709−Prefix any style attribute with `hover:`, `active:`, or `focus:` to apply it on that state.
710−
711−```
712−@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]]
713747 @text [color white] Click me
714−```
715−
716−All style attributes support state prefixes: `hover:color`, `active:rounded`, `focus:border`, etc.
717−
718−### 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:
719777
720−Prefix any style attribute with `dark:` to apply it when the user's system is in dark mode.
721−
722−```
723−@el [background white, dark:background #1a1a2e, color #333, dark:color #eee]
724− @text Theme-aware content
725−```
726−
727−This generates a `@media (prefers-color-scheme: dark)` rule.
728−
729−### Print styles
730−
731−Prefix any style attribute with `print:` to apply it when printing.
732−
733−```
734−@nav [print:display none]
735− @text Navigation (hidden in print)
736−```
737−
738−This generates a `@media print` rule.
739−
740−### Flow & Grid
741−
742−| Attribute | Effect |
743−|------------------------|----------------------------------|
744−| `wrap` | Enable flex-wrap (on `@row`) |
745−| `grid` | Enable CSS grid layout |
746−| `grid-cols N` | Grid template columns (N equal) |
747−| `grid-rows N` | Grid template rows (N equal) |
748−| `col-span N` | Span N columns in grid |
749−| `row-span N` | Span N rows in grid |
750−
751−### Identity
752−
753−| Attribute | Effect |
754−|------------------------|----------------------------------|
755−| `id NAME` | HTML id attribute |
756−| `class NAME` | HTML class attribute |
757−
758−### Modern CSS
759−
760−| Attribute | Effect |
761−|------------------------|----------------------------------|
762−| `color-scheme VALUE` | Color scheme (`light`, `dark`, `light dark`) |
763−| `appearance VALUE` | Form element appearance (`none`) |
764−
765−### Popover API
766−
767−Use HTML Popover API attributes for declarative popovers without JavaScript.
768−
769−```
770−@button [popovertarget info-panel] Show Info
771−@el [popover, id info-panel, padding 20, background white, rounded 8]
772− @text This is a popover
773778```
774−
775−| Attribute | Effect |
776−|-------------------------------|---------------------------------|
777−| `popover` | Make element a popover |
778−| `popovertarget ID` | Open popover with given ID |
779−| `popovertargetaction VALUE` | Action (toggle/show/hide) |
780−
781−### Input hints
782−
783−| Attribute | Effect |
784−|------------------------|----------------------------------|
785−| `inputmode VALUE` | Virtual keyboard type (numeric/email/search/tel/url) |
786−| `enterkeyhint VALUE` | Enter key label (enter/done/go/next/previous/search/send) |
787−| `fetchpriority VALUE` | Resource priority (high/low/auto) |
788−| `translate VALUE` | Whether to translate (yes/no) |
789−| `spellcheck VALUE` | Spell check (true/false) |
790−
791−## Asset inlining
792−
793−SVG images can be inlined directly into the HTML output using the `[inline]` attribute:
794−
795−```
796−@image [inline, width 24, height 24] icon.svg
797−```
798−
799−This reads the SVG file and embeds its content directly, keeping the output self-contained.
800−
801−## 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:
802836
803837```
804−htmlang init # scaffold a new project
805−htmlang init my-site # scaffold in a new directory
806−htmlang new about-us # create about-us.hl from template
807−htmlang page.hl # compile page.hl → page.html
808−htmlang site/ # compile all .hl files in directory
809−htmlang --watch page.hl # compile and watch for changes
810−htmlang -w page.hl # short form
811−htmlang --serve site/ # serve a multi-page site with hot reload
812−htmlang -s --open site/ # serve and open browser
813−htmlang check page.hl # check for errors without writing output
814−htmlang check src/ # check all .hl files in a directory
815−htmlang convert page.html # convert HTML to .hl format (stdout)
816−htmlang build src/ -o dist/ # parallel compile + copy static assets
817−htmlang --compat page.hl # compile with vendor prefixes
818−htmlang --format json page.hl # output diagnostics as JSON
819−htmlang lint --format json src/ # lint with JSON output
820−htmlang dead-code --format json src/ # dead code report as JSON
821−htmlang lint page.hl # stricter lint checks (accessibility, nesting)
822−htmlang lint src/ # lint all .hl files in a directory
823−htmlang stats page.hl # show file statistics (elements, CSS, colors)
824−htmlang repl # interactive REPL (type .hl, get HTML)
825−htmlang feed src/ # generate RSS feed from @page metadata
826−htmlang feed src/ -b https://mysite.com # with custom base URL
827−htmlang components src/ # list all @fn definitions across project
828−htmlang deps src/ # show dependency graph (@include/@import)
829−htmlang dead-code src/ # find unused @fn, @define, @let across project
830−htmlang deploy src/ # build and deploy to GitHub Pages
831−htmlang playground # generate self-contained HTML playground
832−htmlang clean src/ # remove generated .html files
833−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"
834954```
835955
836−Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
837−
838−The `build` command automatically extracts shared CSS rules across pages into a `shared.css` file when outputting to a directory.
839−
840−## Compilation target
841−
842−Each `.hl` file compiles to a single self-contained `.html` file:
843−
844−- Elements map to `<div>`, `<span>`, `<p>`, `<a>`, `<img>` as appropriate
845−- Layout uses flexbox (`display: flex`, `flex-direction`, `gap`, etc.)
846−- Styles are scoped via generated class names in an embedded `<style>` block
847−- No external CSS, no JavaScript (unless injected via `@raw`)
848−- `@page` generates the HTML boilerplate; without it, output is an HTML fragment
956+### HTML attributes
849957
850−## Template inheritance (`@extends`)
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:
851968
852−A page can inherit a base layout and fill named `@slot` blocks:
853−
854−**layout.hl:**
855−```
856−@page My Site
857−@column [max-width 800, center-x, padding 40]
858− @slot header
859− @text [bold, size 32] Default Header
860− @slot content
861− @slot footer
862− @text [color #888] Default Footer
863−```
864−
865−**about.hl:**
866969```
867−@extends layout.hl
868−@slot header
869− @text [bold, size 32] About Us
870−@slot content
871− @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
872975```
873976
874−Slots not filled by the extending page use the default children from the layout.
977+## Definitions
875978
876−## Design tokens (`@theme`)
979+`@let` defines everything reusable. What it defines depends on its form:
877980
878−Centralized design token definitions. Each token becomes both a `$variable` and a `--css-custom-property`:
879−
880−```
881−@theme
882− primary #3b82f6
883− secondary #10b981
884− spacing-sm 8
885− spacing-md 16
886− font-body system-ui, sans-serif
887−```
888−
889−Equivalent to:
890981```
982+-- A value, used as $primary
891983@let primary #3b82f6
892−@let --primary #3b82f6
893−@let secondary #10b981
894−@let --secondary #10b981
895−...
896−```
897−
898−## Deprecation (`@deprecated`)
899−
900−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
901995
902−```
903−@deprecated Use @card-v2 instead
904−@fn old-card $title
905− @el [padding 20]
906− @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
9071187 @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
9081199```
9091200
910−When `@old-card` is called, the compiler emits: `warning: @old-card is deprecated: Use @card-v2 instead`
911−
912−## Color functions
913−
914−Color manipulation via variable filters:
1201+`@children` takes a fallback the same way:
9151202
9161203```
917−@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
9181265
919−@el [background $primary|lighten:20] -- 20% lighter
920−@el [background $primary|darken:10] -- 10% darker
921−@el [background $primary|alpha:0.5] -- 50% transparent (#3b82f67f)
922−@el [background $primary|mix:#ffffff:50] -- 50% mixed with white
1266+@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
9231267```
9241268
925−Filters: `lighten:N` (0-100), `darken:N` (0-100), `alpha:N` (0-1), `mix:COLOR:N` (0-100).
926−
927−## Enhanced `@keyframes`
928−
929−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:
9301272
9311273```
932−@keyframes fade-in
933− from [opacity 0]
934− 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
9351280
936−@keyframes slide
937− 0% [transform translateX(-100%)]
938− 50% [transform translateX(0), opacity 1]
939− 100% [transform translateX(100%), opacity 0]
1281+@paragraph
1282+ @intro
1283+@paragraph
1284+ In short: {@intro} Press {@key Ctrl+K} to search.
9401285```
9411286
942−Raw CSS syntax is also supported for backwards compatibility.
943−
944−## Additional attributes
945−
946−| Attribute | Effect |
947−|-----------|--------|
948−| `autofocus` | Auto-focus element on page load (boolean) |
949−
950−## Named grid areas
951−
952−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.
9531293
9541294```
955−@el [grid, grid-template-areas "header header" "sidebar main" "footer footer", gap 20]
956− @el [grid-area header, padding 10, background #3b82f6]
957− Header
958− @el [grid-area sidebar, padding 10]
959− Sidebar
960− @el [grid-area main, padding 10]
961− Main content
962− @el [grid-area footer, padding 10]
963− 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
9641415```
9651416
966−## Animate shorthand
967−
968−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.
9691426
9701427```
971−@keyframes fade-in
972− from [opacity 0]
973− 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.
9741433
975−@el [animate fade-in 0.3s ease]
976− Fades in on load
1434+@row [spacing 8]
1435+ @each $n in 10..0 step 5
1436+ @text $n
9771437```
9781438
979−## View transitions
1439+## Data and files
9801440
981−The `view-transition-name` attribute enables the View Transitions API for smooth page transitions:
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 |
9821450
983−```
984−@el [view-transition-name hero]
985− Hero content that transitions between pages
986−```
987−
988−## `:has()` pseudo-selector
989−
990−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:
9911453
9921454```
993−@el [padding 20, has(.active):background #dbeafe, has(img):padding 0]
994− 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
9951465```
9961466
997−Generates CSS `:has()` selectors: `.class:has(.active) { background:#dbeafe; }`
998−
999−## Computed `@let`
1467+### Page layouts
10001468
1001−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.
10021473
10031474```
1004−@let base 16
1005−@let large = $base * 2 -- 32
1006−@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
10071484```
10081485
1009−Supported operators: `+`, `-`, `*`, `/`.
1010−
1011−## Named slots in `@fn`
1012−
1013−Functions support named `@slot` blocks for multi-region components:
1014−
10151486```
1016−@fn layout $title
1017− @column [max-width 800, center-x, padding 40]
1018− @slot header
1019− @text [bold, size 32] $title
1020− @slot content
1021− @slot footer
1022− @text [color #888] Default Footer
1023−
1024−@layout [title My Page]
1487+-- page.hl
1488+@include layout.hl
1489+@layout
10251490 @slot header
1026− @text [bold, size 32] Custom Header
1027− @slot content
1028− @paragraph Main content here
1029−```
1030−
1031−Slots not filled by the caller use the default children from the function definition.
1032−
1033−## New elements
1034−
1035−### `@script`
1036−
1037−Embeds JavaScript. Supports inline code and external files via `src`. Children are raw JS (not HTML-escaped).
1038−
1039−```
1040−@script [src app.js, defer]
1041−
1042−@script
1043− console.log("hello world");
1044− document.addEventListener("DOMContentLoaded", () => {
1045− init();
1046− });
1047−```
1048−
1049−Attributes: `src`, `type`, `defer`, `async`, `crossorigin`, `integrity`, `nomodule`.
1050−
1051−### `@noscript`
1052−
1053−Fallback content displayed when JavaScript is disabled.
1054−
1055−```
1056−@noscript
1057− @text This page requires JavaScript.
1058−```
1059−
1060−### `@address`
1061−
1062−Contact information, rendered as `<address>`.
1063−
1064−```
1065−@address
1066− @text [bold] John Doe
1067− @link mailto:john@example.com john@example.com
1068−```
1069−
1070−### `@search`
1071−
1072−HTML5 `<search>` landmark for search functionality.
1073−
1074−```
1075−@search
1076− @input [type search, placeholder Search...]
1077− @button [type submit] Search
1078−```
1079−
1080−### `@breadcrumb`
1081−
1082−Semantic breadcrumb navigation. Generates `<nav aria-label="breadcrumb">` with an `<ol>` list. Each child becomes an `<li>`.
1083−
1084−```
1085−@breadcrumb [spacing 10]
1086− @link / Home
1087− @link /docs Documentation
1088− @text Current Page
1089−```
1090−
1091−## New directives
1092−
1093−### `@canonical`
1094−
1095−Sets the canonical URL. Generates `<link rel="canonical">` in the document head.
1096−
1097−```
1098−@canonical https://example.com/my-page
1099−```
1100−
1101−### `@base`
1102−
1103−Sets the base URL for relative links. Generates `<base href="...">`.
1104−
1105−```
1106−@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
11071544```
11081545
1109−### `@font-face`
1546+Translations are a JSON file per locale: `@data $t locales/$lang.json`
1547+loads `locales/fr.json` when `$lang` is `fr`.
11101548
1111−Declares a custom font. Generates a CSS `@font-face` rule with `font-display: swap`. Format is auto-detected from the file extension.
1112−
1113−```
1114−@font-face Inter fonts/inter.woff2
1115−@font-face Mono fonts/jetbrains-mono.ttf
1116−```
1549+## CSS
11171550
1118−### `@json-ld`
1119−
1120−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:
11211554
11221555```
1123−@json-ld
1124− {
1125− "@context": "https://schema.org",
1126− "@type": "WebPage",
1127− "name": "My Site"
1556+@style
1557+ @keyframes fade-in {
1558+ from { opacity: 0; }
1559+ to { opacity: 1; }
11281560 }
1129−```
1130−
1131−## Additional pseudo-states
1132−
1133−| Prefix | CSS Selector | Purpose |
1134−|--------|--------------|---------|
1135−| `visited:` | `:visited` | Visited links |
1136−| `empty:` | `:empty` | Empty elements |
1137−| `target:` | `:target` | URL fragment target |
1138−| `valid:` | `:valid` | Valid form inputs |
1139−| `invalid:` | `:invalid` | Invalid form inputs |
1140−
1141−```
1142−@link [visited:color purple] https://example.com
1143− Already visited
1144−
1145−@input [type email, valid:border 2 green]
1146−@el [target:background yellow, id section-1]
1147− Highlighted when navigated to
1148−```
1149−
1150−## Additional CSS properties
1151−
1152−| Attribute | Effect |
1153−|-----------|--------|
1154−| `text-underline-offset N` | Offset of text underline from text |
1155−| `column-width N` | Ideal column width for multi-column layout |
1156−| `column-rule VALUE` | Rule between columns (e.g., `1px solid #ccc`) |
1157−
1158−```
1159−@text [underline, text-underline-offset 4, text-decoration-thickness 2]
1160− Styled underline
1161−
1162−@el [column-count 3, column-width 200, column-rule 1px solid #ccc]
1163− Multi-column content
1164−```
1165−
1166−## CSS `@layer` wrapping
1167−
1168−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.
1169−
1170−## CLI commands
1171−
1172−### `htmlang deps`
1173−
1174−Show the file dependency graph (`@include`, `@import`, `@extends` relationships):
1175−
1176−```
1177−htmlang deps src/
1178−```
1179−
1180−### `htmlang dead-code`
1181−
1182−Find unused `@fn`, `@define`, and `@let` definitions across an entire project:
1183−
1184−```
1185−htmlang dead-code src/
1186−```
1187−
1188−### `htmlang deploy`
1189−
1190−Build and deploy to GitHub Pages:
1191−
1192−```
1193−htmlang deploy src/
1194−```
1195−
1196−Compiles all `.hl` files, copies static assets, and pushes to a `gh-pages` branch.
1197−
1198−### `htmlang clean`
1199−
1200−Remove generated `.html` files from a directory (matching `.hl` source files). Also removes `sitemap.xml` if present.
1201−
1202−```
1203−htmlang clean src/
1204−htmlang clean # clean current directory
1205−```
1206−
1207−### `htmlang outline`
1208−
1209−Show the document structure tree — a quick overview of elements and nesting without compiling.
1210−
1211−```
1212−htmlang outline page.hl
1213−```
1214−
1215−### `htmlang playground`
1216−
1217−Generate a self-contained HTML playground for experimenting with htmlang:
1218−
1219−```
1220−htmlang playground # writes playground.html
1221−htmlang playground my-play.html # custom output path
1222−htmlang clean src/ # remove generated .html files
1223−htmlang outline page.hl # show document structure tree
1224−```
1225−
1226−## Editor support
1227−
1228−A VS Code extension is available in `editors/vscode/` with:
1229−
1230−- Syntax highlighting via TextMate grammar
1231−- LSP integration via `htmlang-lsp` for:
1232− - Real-time diagnostics and error checking
1233− - Completions for elements, attributes, variables, and functions
1234− - Hover documentation for all elements and attributes
1235− - Go to definition for variables, defines, and functions
1236− - Find all references for variables and functions
1237− - Rename refactoring for variables and functions
1238− - Signature help for `@fn` parameters
1239− - Document formatting (format on save)
1240− - Code actions (quick-fixes for typos, unused variables, missing attributes)
1241− - Auto-import suggestions (scans project for `@fn` definitions)
1242− - Extract selection to `@fn` refactoring
1243− - Color picker for hex colors
1244− - Code folding and document symbols
1245− - Semantic tokens for syntax highlighting
1246− - VS Code snippets for common patterns (`@fn`, `@each`, `@grid`, `@form`, etc.)
1247−
1248−## Additional directives reference
1249−
1250−The compiler recognizes a number of directives that are documented only briefly
1251−above. A concise reference:
1252−
1253−### Content generation
1254−
1255−- `@markdown` — indented body is parsed as Markdown and emitted as HTML.
1256−- `@raw """..."""` — embed verbatim HTML / CSS / JS.
1257−- `@svg path/to/icon.svg` — inline an SVG file's contents.
1258−- `@fetch url -> $name` — fetch a JSON payload at build time and bind it.
1259−- `@data name path.json` — load a JSON file for iteration with `@each`.
1260−
1261−### Metadata & head
1262−
1263−- `@lang en` — set `<html lang>`.
1264−- `@favicon /favicon.png` — inject a favicon link.
1265−- `@meta name value` — add a `<meta>` tag.
1266−- `@og key value` — add an Open Graph `<meta property="og:key">` tag.
1267−- `@canonical https://…` — set `<link rel="canonical">`.
1268−- `@base /base/path/` — set `<base href>`.
1269−- `@manifest [Name]` — emit a PWA `manifest.json` reference and body.
1270−- `@breakpoint name width` — register a custom responsive prefix.
1271−
1272−### Styling
1273−
1274−- `@style` — indented body is raw CSS injected into `<style>`.
1275−- `@scope (selector)` — wrap body in CSS `@scope` for scoped styles.
1276−- `@starting-style` — CSS entry-animation starting values.
1277−- `@font-face name url` — register a web font.
1278−- `@theme` — design tokens emitted as CSS custom properties.
1279−- `@keyframes name` — define keyframe animations.
1280−- `@css-property name syntax value` — register a typed custom property.
1281−
1282−### Control flow
1283−
1284−- `@if cond` / `@unless cond` / `@else` / `@else if`.
1285−- `@match $value` / `@switch $value` — exhaustive branching.
1286−- `@each item in list` / `@each item, $_index in list` — iterate.
1287−- `@for $i in 0..10 [step 2]` — numeric ranges.
1288−- `@repeat N` — repeat a block N times.
1289−- `@defer` — defer block rendering below the fold.
1290−
1291−### Module system
1292−
1293−- `@include file.hl` — paste file contents here.
1294−- `@import file.hl` — import @fn / @let / @define (with optional `as` alias).
1295−- `@use "file.hl" fn1, fn2` — import a named subset.
1296−- `@extends template.hl` — template inheritance; override with `@slot`.
1297−- `@layout path.hl` — wrap output in a layout template.
1298−- `@collection path/ -> $items` — iterate over a directory.
1299−
1300−### Functions & composition
1301−
1302−- `@fn name $p1 $p2` — define a function; body uses `@children` / `@slot`.
1303−- `@component name $p` — like `@fn` but emits a scoped wrapper class.
1304−- `@mixin name [...attrs]` — attribute bundle you can merge elsewhere.
1305−- `@define name [...attrs]` — named attribute preset, used as `[$name]`.
1306−- `@with $source as $alias` — temporarily rebind a variable.
1307−
1308−### Debugging
1309−
1310−- `@assert cond [message]` — emit an error if a boolean check fails.
1311−- `@warn message` / `@debug expr` / `@log message` — compile-time output.
1312−- `@deprecated name [reason]` — mark a function as deprecated.
1313−- `@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+195−195
@@ -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−@let 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,208 +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 function (`@let` with body) definitions |
63−| `deps file.hl` | Print the include / import graph |
64−| `dead-code [dir]` | Report unused functions, variables and bundles |
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.
85−
86−## Language overview
87−
88−### Elements
89−
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 |
112−| `@in-front` | Overlay layer on top of parent |
113−| `@behind` | Layer behind parent content |
114−
115−### Layout attributes
116−
117−```
118−-- gap between children
119−@row [spacing 20]
120−-- separate horizontal/vertical gaps
121−@row [gap-x 10, gap-y 20]
122−-- uniform padding (also padding-x, padding-y)
123−@el [padding 20]
124−-- take remaining space (also width 200, width shrink)
125−@el [width fill]
126−-- center horizontally (also align-left, align-right)
127−@el [center-x]
128−-- overflow behavior (hidden, scroll, auto)
129−@el [overflow hidden]
134+htmlang build src -o dist # compile a whole site
130135```
131136
132−Comments start with `--` and run to the end of the line; they must be on a
133−line of their own.
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 |
134146
135−### Style attributes
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`.
136151
137−```
138−@el [background #3b82f6, color white, rounded 8, border 1 #e5e7eb]
139−@text [bold, italic, underline, size 18, font Inter]
140−@el [opacity 0.5, cursor pointer, transition all 0.15s ease]
141−@el [shadow 0 2px 4px rgba(0,0,0,0.1)]
142−@paragraph [text-align center, line-height 1.5]
143−```
144−
145−### Positioning
146−
147−```
148−@el [position relative]
149− @el [position absolute, z-index 10]
150− Overlay content
151−```
152+## A short tour
152153
153−For overlays, prefer `@in-front` and `@behind` — they make the parent a
154−positioning context and stretch to its bounds automatically:
154+Layout attributes place an element. Everything else is CSS, and `key=value`
155+is an HTML attribute:
155156
156157```
157−@el [width 200, height 200, background blue]
158− @text [color white] Main content
159− @in-front
160− @text [color yellow] Top layer
161− @behind
162− @el [background red, width fill, height fill]
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
163163```
164164
165−### Pseudo-states and media prefixes
166−
167−Prefix any style attribute with `hover:`, `active:`, or `focus:`:
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:
168168
169169```
170−@el [background #3b82f6, hover:background #2563eb, active:background #1d4ed8]
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
171172```
172173
173−Use `dark:` for dark mode and `print:` for print styles:
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:
174176
175177```
176−@el [background white, dark:background #1a1a2e, print:display none]
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
177183```
178184
179−### Variables and attribute bundles
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.
180192
181193```
182−-- simple variable, used as $primary
183−@let primary #3b82f6
184−-- quoted string interpolation
185−@let greeting "Hello $name"
186−-- attribute bundle, used as [$card]
187−@let card [padding 20, rounded 8]
188−```
194+@let --primary #3b82f6
195+@let gap = 8 * 2
196+@let rounded [border-radius 8, overflow hidden]
189197
190−### Functions
191−
192−A `@let` with an indented body defines a function. `@children` is where the
193−caller's children go:
194−
195−```
196−@let button $label
197− @el [padding 12, background $primary, rounded 8]
198− @text [color white, bold] $label
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]
199203 @children
200204
201−@button [label Click me]
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.
202210```
203211
204−### File includes
212+Data, loops and conditions run at compile time:
205213
206214```
207−-- inline another .hl file
208−@include header.hl
215+@data $links [
216+ {"label": "Home", "url": "/"},
217+ {"label": "Blog", "url": "/blog"},
218+ {"label": "About us", "url": "/about"}
219+]
220+@let current /blog
221+
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
209226```
210227
211−### 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.
212231
213−Attribute lists can span multiple lines:
232+## Editor support
214233
215−```
216−@el [
217− padding 20,
218− background white,
219− rounded 8,
220− shadow 0 2px 4px rgba(0,0,0,0.1)
221−]
222− Content here
223−```
224−
225−### Other features
226−
227−- `--` comments
228−- `{@text [bold] inline}` elements in text
229−- `@el [attrs] > @link url` single-child chaining
230−- `[padding 20]` bare attributes as implicit `@el`
231−- `@raw """..."""` for embedding arbitrary HTML/CSS/JS
232−- `@image [inline] logo.svg` inlines SVG content
233−- `@each $name, $url in Home /, About /about` destructuring
234−- `@let large = $base * 2` computed variables
235−- `[grid-template-areas "header main", grid-area header]` named grid areas
236−- `[animate fade-in 0.3s ease]` animation shorthand
237−- `[view-transition-name hero]` View Transitions API
238−- `[has(.active):background blue]` `:has()` pseudo-selector
239−- `@slot header` / `@slot content` named slots in functions
240−- `@theme` design tokens as runtime CSS custom properties
241−- VS Code snippets for common patterns
242−
243−## Documentation
244−
245−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−120
@@ -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,111 +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− // Overlay positioning (elm-ui inspired)
159− InFront,
160− Behind,
161− // HTML5 elements
162− Script,
163− Noscript,
164− Address,
165− Search,
166− 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,
167316}
168317
169−#[derive(Debug, Clone)]
170−pub struct Attribute {
171− pub key: String,
172− 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+ }
173775}
crates/htmlang-core/src/codegen.rs+1598−3441
@@ -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,56 +17,315 @@ const BREAKPOINTS: &[(&str, &str)] = &[
1617 ("2xl", "1536px"),
1718];
1819
19−/// Generate short CSS class names: a..z, then aa..a9, ba..b9, ..., z9, then
20−/// aaa, ... The first character is always a letter; later ones are drawn from
21−/// [a-z0-9]. The mapping is a bijection, so distinct indices never collide.
22−pub(crate) fn short_class_name(idx: usize) -> String {
23− const REST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
24− // Find the name length: 26 names of length 1, 26*36 of length 2, ...
25− let mut n = idx;
26− let mut len = 1;
27− let mut count = 26usize;
28− while n >= count {
29− n -= count;
30− len += 1;
31− count = count.saturating_mul(36);
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)
92+ }
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+ }
32217 }
33− let mut tail = Vec::with_capacity(len - 1);
34− for _ in 1..len {
35− tail.push(REST[n % 36]);
36− n /= 36;
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)),
266+ }
267+ };
268+ if dev {
269+ format!("{} ({}: {})", kind, feature, value)
270+ } else {
271+ format!("{}({}:{})", kind, feature, value)
37272 }
38− let mut name = String::with_capacity(len);
39− name.push((b'a' + n as u8) as char);
40− name.extend(tail.iter().rev().map(|&b| b as char));
41− name
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
42296}
43297
44298struct StyleEntry {
45299 class_name: String,
46− base: String,
47− /// (CSS selector suffix, css_rules) — e.g. (":hover", "color:red;")
48− pseudo: Vec<(String, String)>,
49− /// Responsive overrides: (breakpoint_prefix, css)
50− responsive: Vec<(String, String)>,
51− /// Dark mode overrides
52− dark: String,
53− /// Print overrides
54− print: String,
55− motion_safe: String,
56− motion_reduce: String,
57− landscape: String,
58− portrait: String,
59− /// Container query overrides: (breakpoint_prefix, css)
60− 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,
61314}
62315
63316struct StyleCollector {
64317 entries: Vec<StyleEntry>,
65− /// Maps a pre-hashed style signature to an index into `entries`.
66− /// Using u64 as the key keeps lookups allocation-free; on the rare case of
67− /// 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).
68320 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)>,
69329}
70330
71331impl StyleCollector {
@@ -73,328 +333,282 @@ impl StyleCollector {
73333 StyleCollector {
74334 entries: Vec::new(),
75335 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);
76347 }
77348 }
78349
79350 /// Returns a class name for this style combination, or None if all empty.
80− #[allow(clippy::too_many_arguments)]
81351 fn get_class(
82352 &mut self,
83− base: String,
84− pseudo: Vec<(String, String)>,
85− responsive: Vec<(String, String)>,
86− dark: String,
87− print: String,
88− motion_safe: String,
89− motion_reduce: String,
90− landscape: String,
91− portrait: String,
92− container: Vec<(String, String)>,
353+ defaults: String,
354+ mut rules: Vec<(Condition, String)>,
355+ distinct: String,
93356 ) -> Option<String> {
94− if base.is_empty()
95− && pseudo.is_empty()
96− && responsive.is_empty()
97− && dark.is_empty()
98− && print.is_empty()
99− && motion_safe.is_empty()
100− && motion_reduce.is_empty()
101− && landscape.is_empty()
102− && portrait.is_empty()
103− && container.is_empty()
104− {
357+ rules.retain(|(_, body)| !body.is_empty());
358+ if defaults.is_empty() && rules.is_empty() {
105359 return None;
106360 }
107− use std::collections::hash_map::DefaultHasher;
108− let mut h = DefaultHasher::new();
109− base.hash(&mut h);
110− pseudo.hash(&mut h);
111− responsive.hash(&mut h);
112− dark.hash(&mut h);
113− print.hash(&mut h);
114− motion_safe.hash(&mut h);
115− motion_reduce.hash(&mut h);
116− landscape.hash(&mut h);
117− portrait.hash(&mut h);
118− container.hash(&mut h);
119− let sig = h.finish();
120−
121− 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) {
122365 for &idx in indices {
123366 let e = &self.entries[idx];
124− if e.base == base
125− && e.pseudo == pseudo
126− && e.responsive == responsive
127− && e.dark == dark
128− && e.print == print
129− && e.motion_safe == motion_safe
130− && e.motion_reduce == motion_reduce
131− && e.landscape == landscape
132− && e.portrait == portrait
133− && e.container == container
134− {
367+ if e.defaults == defaults && e.rules == rules && e.distinct == distinct {
135368 return Some(e.class_name.clone());
136369 }
137370 }
138371 }
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
139375 let idx = self.entries.len();
140− 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());
141381 self.entries.push(StyleEntry {
142382 class_name: name.clone(),
143− base,
144− pseudo,
145− responsive,
146− dark,
147− print,
148− motion_safe,
149− motion_reduce,
150− landscape,
151− portrait,
152− container,
383+ style,
384+ defaults,
385+ rules,
386+ distinct,
153387 });
154− self.index.entry(sig).or_default().push(idx);
388+ self.index.entry(style).or_default().push(idx);
155389 Some(name)
156390 }
157391
158− fn to_css_formatted(&self, dev: bool, use_layer: bool) -> String {
159− let mut css = String::new();
160− 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+ }
161399
162− // Wrap in @layer for specificity management
163− 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(","));
164423 if dev {
165− css.push_str("@layer htmlang {\n");
424+ kind.push_str(&format!(" {} {{{}}}\n", selector, body));
166425 } else {
167− css.push_str("@layer htmlang{");
426+ kind.push_str(&format!("{}{{{}}}", selector, body));
168427 }
169428 }
170429
171− // Non-responsive rules. Base and pseudo rules are merged separately by
172− // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
173− // replaces two identical rules. Pseudo variants are grouped per selector
174− // suffix (`:hover` with `:hover`, etc.) to keep each pseudo's cascade
175− // position independent of others.
176− let mut base_pairs: Vec<(&str, &str)> = Vec::with_capacity(self.entries.len());
177− for e in &self.entries {
178− if !e.base.is_empty() {
179− 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));
180438 }
181439 }
182− emit_grouped_rules(&mut css, &base_pairs, "", inner_indent, dev);
183−
184− // Collect pseudo rules grouped by selector suffix, preserving the order
185− // in which suffixes first appear across entries.
186− let mut pseudo_order: Vec<&str> = Vec::new();
187− let mut pseudo_buckets: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
188− for e in &self.entries {
189− for (selector, body) in &e.pseudo {
190− if body.is_empty() {
191− continue;
192− }
193− let key = selector.as_str();
194− if !pseudo_buckets.contains_key(key) {
195− pseudo_order.push(key);
196− }
197− pseudo_buckets
198− .entry(key)
199− .or_default()
200− .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;
201449 }
202− }
203− for selector in &pseudo_order {
204− if let Some(pairs) = pseudo_buckets.get(selector) {
205− 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));
206454 }
207455 }
456+ css
457+ }
208458
209− // Responsive rules grouped by breakpoint
210− for &(bp_name, bp_width) in BREAKPOINTS {
211− let mut bp_pairs: Vec<(&str, &str)> = Vec::new();
212− for e in &self.entries {
213− for (bp, rule_css) in &e.responsive {
214− if bp == bp_name && !rule_css.is_empty() {
215− 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+ }
216538 }
217539 }
218− }
219− emit_media_block(
220− &mut css,
221− &format!("@media (min-width: {})", bp_width),
222− &format!("@media(min-width:{})", bp_width),
223− &bp_pairs,
224− "",
225− dev,
226− );
227− }
228−
229− // Dark mode rules
230− let dark_pairs: Vec<(&str, &str)> = self
231− .entries
232− .iter()
233− .filter(|e| !e.dark.is_empty())
234− .map(|e| (e.class_name.as_str(), e.dark.as_str()))
235− .collect();
236− emit_media_block(
237− &mut css,
238− "@media (prefers-color-scheme: dark)",
239− "@media(prefers-color-scheme:dark)",
240− &dark_pairs,
241− "",
242− dev,
243− );
244−
245− // Print rules
246− let print_pairs: Vec<(&str, &str)> = self
247− .entries
248− .iter()
249− .filter(|e| !e.print.is_empty())
250− .map(|e| (e.class_name.as_str(), e.print.as_str()))
251− .collect();
252− emit_media_block(
253− &mut css,
254− "@media print",
255− "@media print",
256− &print_pairs,
257− "",
258− dev,
259− );
260−
261− // Motion safe rules
262− let motion_safe_pairs: Vec<(&str, &str)> = self
263− .entries
264− .iter()
265− .filter(|e| !e.motion_safe.is_empty())
266− .map(|e| (e.class_name.as_str(), e.motion_safe.as_str()))
267− .collect();
268− emit_media_block(
269− &mut css,
270− "@media (prefers-reduced-motion: no-preference)",
271− "@media(prefers-reduced-motion:no-preference)",
272− &motion_safe_pairs,
273− "",
274− dev,
275− );
276−
277− // Motion reduce rules
278− let motion_reduce_pairs: Vec<(&str, &str)> = self
279− .entries
280− .iter()
281− .filter(|e| !e.motion_reduce.is_empty())
282− .map(|e| (e.class_name.as_str(), e.motion_reduce.as_str()))
283− .collect();
284− emit_media_block(
285− &mut css,
286− "@media (prefers-reduced-motion: reduce)",
287− "@media(prefers-reduced-motion:reduce)",
288− &motion_reduce_pairs,
289− "",
290− dev,
291− );
292−
293− // Landscape rules
294− let landscape_pairs: Vec<(&str, &str)> = self
295− .entries
296− .iter()
297− .filter(|e| !e.landscape.is_empty())
298− .map(|e| (e.class_name.as_str(), e.landscape.as_str()))
299− .collect();
300− emit_media_block(
301− &mut css,
302− "@media (orientation: landscape)",
303− "@media(orientation:landscape)",
304− &landscape_pairs,
305− "",
306− dev,
307− );
308−
309− // Portrait rules
310− let portrait_pairs: Vec<(&str, &str)> = self
311− .entries
312− .iter()
313− .filter(|e| !e.portrait.is_empty())
314− .map(|e| (e.class_name.as_str(), e.portrait.as_str()))
315− .collect();
316− emit_media_block(
317− &mut css,
318− "@media (orientation: portrait)",
319− "@media(orientation:portrait)",
320− &portrait_pairs,
321− "",
322− dev,
323− );
324−
325− // Container query rules grouped by breakpoint
326− for &(bp_name, bp_width) in BREAKPOINTS {
327− let mut cq_pairs: Vec<(&str, &str)> = Vec::new();
328− for e in &self.entries {
329− for (bp, rule_css) in &e.container {
330− if bp == bp_name && !rule_css.is_empty() {
331− cq_pairs.push((e.class_name.as_str(), rule_css.as_str()));
332− }
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 { "}" });
333554 }
334− }
335− emit_media_block(
336− &mut css,
337− &format!("@container (min-width: {})", bp_width),
338− &format!("@container(min-width:{})", bp_width),
339− &cq_pairs,
340− "",
341− dev,
342− );
343− }
344−
345− // Close @layer
346− if use_layer {
347− if dev {
348− css.push_str("}\n");
349555 } else {
350− 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;
351567 }
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+ };
580+ }
581+ css.push_str(&inner);
352582 }
353−
354583 css
355584 }
356585}
357586
358−/// Emit CSS rules from `(class_name, body)` pairs, merging identical bodies
587+/// Emit CSS rules from `(selector, body)` pairs, merging identical bodies
359588/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
360589/// of each distinct body determines ordering, so output stays deterministic
361−/// across runs. `selector_suffix` is appended to each class (e.g. `":hover"`,
362−/// or `""` for plain class rules). `indent` is prepended to each rule line.
363−fn emit_grouped_rules(
364− out: &mut String,
365− pairs: &[(&str, &str)],
366− selector_suffix: &str,
367− indent: &str,
368− dev: bool,
369−) {
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) {
370592 if pairs.is_empty() {
371593 return;
372594 }
373595 // Group in first-occurrence order.
374596 let mut order: Vec<&str> = Vec::new();
375597 let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
376− for &(name, body) in pairs {
598+ for (selector, body) in pairs {
377599 if body.is_empty() {
378600 continue;
379601 }
380602 if !groups.contains_key(body) {
381603 order.push(body);
382604 }
383− groups.entry(body).or_default().push(name);
605+ groups.entry(body).or_default().push(selector);
384606 }
385607 let sp = if dev { " " } else { "" };
386608 let nl = if dev { "\n" } else { "" };
387609 for body in &order {
388− let names = &groups[body];
389610 out.push_str(indent);
390− for (i, n) in names.iter().enumerate() {
391− if i > 0 {
392− out.push(',');
393− }
394− out.push('.');
395− out.push_str(n);
396− out.push_str(selector_suffix);
397− }
611+ out.push_str(&groups[body].join(","));
398612 out.push_str(sp);
399613 out.push('{');
400614 out.push_str(body);
@@ -403,38 +617,44 @@ fn emit_grouped_rules(
403617 }
404618}
405619
406−/// Emit an `@media` / `@container` block containing grouped class rules.
407−/// Skips the block entirely if no non-empty bodies are present.
408−fn emit_media_block(
409− out: &mut String,
410− header_dev: &str,
411− header_min: &str,
412− pairs: &[(&str, &str)],
413− selector_suffix: &str,
414− dev: bool,
415−) {
416− if pairs.iter().all(|(_, body)| body.is_empty()) {
417− return;
418− }
419− let mut inner = String::new();
420− let inner_indent = if dev { " " } else { "" };
421− emit_grouped_rules(&mut inner, pairs, selector_suffix, inner_indent, dev);
422− if inner.is_empty() {
423− return;
424− }
425− if dev {
426− out.push_str(&format!("{} {{\n{}}}\n", header_dev, inner));
427− } else {
428− 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;
429643 }
430644}
431645
432646struct GenContext {
433647 dev: bool,
434648 depth: usize,
435− image_count: usize,
436649 has_interactive: bool,
437− 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,
438658}
439659
440660impl GenContext {
@@ -460,23 +680,26 @@ pub struct CodegenOptions {
460680 pub dev: bool,
461681 pub partial: bool,
462682 pub minify: bool,
463− pub compat: bool,
464683}
465684
466685/// Generate HTML from a parsed document using the given options.
467686pub 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();
468694 let mut html = if opts.partial {
469− generate_partial_inner(doc, opts.dev)
695+ generate_partial_inner(doc, opts.dev, &mut classes)
470696 } else {
471− generate_full_inner(doc, opts.dev)
697+ generate_full_inner(doc, opts.dev, &mut classes)
472698 };
473699 if opts.minify {
474700 html = minify_html(&html);
475701 }
476− if opts.compat {
477− html = add_vendor_prefixes(&html);
478− }
479− html
702+ (html, classes)
480703}
481704
482705pub fn generate(doc: &Document) -> String {
@@ -524,38 +747,6 @@ pub fn generate_minified(doc: &Document) -> String {
524747 )
525748}
526749
527−pub fn generate_compat(doc: &Document) -> String {
528− generate_with(
529− doc,
530− &CodegenOptions {
531− compat: true,
532− ..Default::default()
533− },
534− )
535−}
536−
537−pub fn generate_dev_compat(doc: &Document) -> String {
538− generate_with(
539− doc,
540− &CodegenOptions {
541− dev: true,
542− compat: true,
543− ..Default::default()
544− },
545− )
546−}
547−
548−pub fn generate_minified_compat(doc: &Document) -> String {
549− generate_with(
550− doc,
551− &CodegenOptions {
552− minify: true,
553− compat: true,
554− ..Default::default()
555− },
556− )
557−}
558−
559750fn minify_html(html: &str) -> String {
560751 let mut result = String::with_capacity(html.len());
561752 let mut in_pre = false;
@@ -634,514 +825,132 @@ fn minify_html(html: &str) -> String {
634825 result
635826}
636827
637−/// Add vendor prefixes to CSS within <style> tags for broader browser compatibility.
638−fn add_vendor_prefixes(html: &str) -> String {
639− // Find CSS within <style>...</style> and add vendor prefixes
640− let mut result = String::with_capacity(html.len() + 512);
641− let mut rest = html;
642− while let Some(start) = rest.find("<style>") {
643− let after_tag = start + 7;
644− result.push_str(&rest[..after_tag]);
645− rest = &rest[after_tag..];
646− if let Some(end) = rest.find("</style>") {
647− let css = &rest[..end];
648− result.push_str(&prefix_css(css));
649− result.push_str("</style>");
650− rest = &rest[end + 8..];
651− } else {
652− break;
653− }
654− }
655− result.push_str(rest);
656− result
657−}
658−
659−fn prefix_css(css: &str) -> String {
660− let mut out = String::with_capacity(css.len() + 256);
661− let mut i = 0;
662− let bytes = css.as_bytes();
663− while i < bytes.len() {
664− // Find property declarations
665− if let Some(pos) = css[i..].find('{') {
666− let brace = i + pos;
667− out.push_str(&css[i..=brace]);
668− i = brace + 1;
669− // Process declarations within this block
670− if let Some(close) = css[i..].find('}') {
671− let block = &css[i..i + close];
672− out.push_str(&prefix_declarations(block));
673− out.push('}');
674− i = i + close + 1;
675− }
676− } else {
677− out.push_str(&css[i..]);
678− break;
679− }
680− }
681− out
682−}
683−
684−fn prefix_declarations(block: &str) -> String {
685− let mut out = String::with_capacity(block.len() + 128);
686− for decl in block.split(';') {
687− let decl = decl.trim();
688− if decl.is_empty() {
689− continue;
690− }
691− if let Some((prop, val)) = decl.split_once(':') {
692− let prop = prop.trim();
693− let val = val.trim();
694− match prop {
695− "backdrop-filter" => {
696− out.push_str(&format!("-webkit-backdrop-filter:{};", val));
697− out.push_str(&format!("backdrop-filter:{};", val));
698− }
699− "user-select" => {
700− out.push_str(&format!("-webkit-user-select:{};", val));
701− out.push_str(&format!("-moz-user-select:{};", val));
702− out.push_str(&format!("user-select:{};", val));
703− }
704− "appearance" => {
705− out.push_str(&format!("-webkit-appearance:{};", val));
706− out.push_str(&format!("-moz-appearance:{};", val));
707− out.push_str(&format!("appearance:{};", val));
708− }
709− "background-clip" if val.contains("text") => {
710− out.push_str(&format!("-webkit-background-clip:{};", val));
711− out.push_str(&format!("background-clip:{};", val));
712− }
713− "hyphens" => {
714− out.push_str(&format!("-webkit-hyphens:{};", val));
715− out.push_str(&format!("-ms-hyphens:{};", val));
716− out.push_str(&format!("hyphens:{};", val));
717− }
718− "text-size-adjust" => {
719− out.push_str(&format!("-webkit-text-size-adjust:{};", val));
720− out.push_str(&format!("-ms-text-size-adjust:{};", val));
721− out.push_str(&format!("text-size-adjust:{};", val));
722− }
723− "mask-image" | "mask-size" | "mask-repeat" | "mask-position" => {
724− out.push_str(&format!("-webkit-{}:{};", prop, val));
725− out.push_str(&format!("{}:{};", prop, val));
726− }
727− _ => {
728− out.push_str(decl);
729− out.push(';');
730− }
731− }
732− } else {
733− out.push_str(decl);
734− out.push(';');
735− }
736− }
737− out
738−}
739−
740−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+ };
741833 let mut styles = StyleCollector::new();
742834 let mut ctx = GenContext {
743835 dev,
744836 depth: 0,
745− image_count: 0,
746837 has_interactive: false,
747− has_defer: false,
838+ in_text: false,
839+ flow: Flow::default(),
840+ in_picture: false,
748841 };
749842
750− // Check if document has @main for skip-to-content link
751− 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;
752866
753867 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+ );
754875
755− // Skip-to-content link for accessibility (only when @main exists)
756− if has_main {
757− if dev {
758− body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>\n");
759− } else {
760− body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>");
761− }
762− }
876+ let element_css = build_element_css(doc, &styles, dev);
877+ let nl = if dev { "\n" } else { "" };
763878
764− for node in &doc.nodes {
765− generate_node(node, None, &mut body, &mut styles, &mut ctx);
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);
766918 }
767919
768− // Collect external domains for DNS prefetch
769− let dns_prefetch_html = collect_dns_prefetch(&body, dev);
770−
771− let element_css = build_element_css(doc, &styles, dev);
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);
924+ }
925+ let mut body_attrs = String::new();
926+ emit_class_attr(&mut body_attrs, body_class.as_deref(), None);
772927
773− // Build meta tags string
774− let meta_html = if doc.meta_tags.is_empty() {
775− String::new()
776− } else {
777− let mut m = String::new();
778− for (name, content) in &doc.meta_tags {
779− if dev {
780− m.push_str(&format!(
781− "<meta name=\"{}\" content=\"{}\">\n",
782− html_escape(name),
783− html_escape(content)
784− ));
785− } else {
786− m.push_str(&format!(
787− "<meta name=\"{}\" content=\"{}\">",
788− html_escape(name),
789− html_escape(content)
790− ));
791− }
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+ )
792941 }
793− m
794− };
795−
796− // Build OG meta tags
797− let og_html = if doc.og_tags.is_empty() {
798− String::new()
799942 } else {
800− let mut o = String::new();
801− for (property, content) in &doc.og_tags {
802− if dev {
803− o.push_str(&format!(
804− "<meta property=\"og:{}\" content=\"{}\">\n",
805− html_escape(property),
806− html_escape(content)
807− ));
808− } else {
809− o.push_str(&format!(
810− "<meta property=\"og:{}\" content=\"{}\">",
811− html_escape(property),
812− html_escape(content)
813− ));
814− }
815− }
816− o
817− };
818−
819− // Build head blocks string
820− let head_html = if doc.head_blocks.is_empty() {
821943 String::new()
822− } else {
823− let mut h = String::new();
824− for block in &doc.head_blocks {
825− h.push_str(block);
826− if dev {
827− h.push('\n');
828− }
829− }
830− h
831− };
832−
833− let lang_attr = match &doc.lang {
834− Some(lang) => format!(" lang=\"{}\"", html_escape(lang)),
835− None => String::new(),
836944 };
945+ let reset_css = reset_css(dev, true, &focus_visible_css);
946+ classes.extend(styles.classes());
837947
838− let favicon_html = match &doc.favicon {
839− Some(path) => {
840− // Try to read and inline the favicon
841− if let Ok(data) = std::fs::read(path) {
842− let mime = if path.ends_with(".ico") {
843− "image/x-icon"
844− } else if path.ends_with(".png") {
845− "image/png"
846− } else if path.ends_with(".svg") {
847− "image/svg+xml"
848− } else {
849− "image/x-icon"
850− };
851− let b64 = base64_encode(&data);
852− if dev {
853− format!(
854− "<link rel=\"icon\" href=\"data:{};base64,{}\">\n",
855− mime, b64
856− )
857− } else {
858− format!("<link rel=\"icon\" href=\"data:{};base64,{}\">", mime, b64)
859− }
860− } else {
861− // Fall back to href
862− if dev {
863− format!("<link rel=\"icon\" href=\"{}\">\n", html_escape(path))
864− } else {
865− format!("<link rel=\"icon\" href=\"{}\">", html_escape(path))
866− }
867− }
868− }
869− None => String::new(),
870− };
871−
872− // Canonical URL
873− let canonical_html = match &doc.canonical {
874− Some(url) => {
875− if dev {
876− format!("<link rel=\"canonical\" href=\"{}\">\n", html_escape(url))
877− } else {
878− format!("<link rel=\"canonical\" href=\"{}\">", html_escape(url))
879− }
880− }
881− None => String::new(),
882− };
883−
884− // Base URL
885− let base_html = match &doc.base_url {
886− Some(url) => {
887− if dev {
888− format!("<base href=\"{}\">\n", html_escape(url))
889− } else {
890− format!("<base href=\"{}\">", html_escape(url))
891− }
892− }
893− None => String::new(),
894− };
895−
896− // JSON-LD blocks
897− let json_ld_html: String = doc
898− .json_ld_blocks
899− .iter()
900− .map(|block| {
901− if dev {
902− format!(
903− "<script type=\"application/ld+json\">\n{}\n</script>\n",
904− block
905− )
906− } else {
907− format!("<script type=\"application/ld+json\">{}</script>", block)
908− }
909− })
910− .collect();
911−
912− // Manifest link
913− let manifest_html = if let Some(ref manifest) = doc.manifest {
914− let mut json = String::from("{");
915− json.push_str(&format!("\"name\":{},", json_str(&manifest.name)));
916− if let Some(ref short) = manifest.short_name {
917− json.push_str(&format!("\"short_name\":{},", json_str(short)));
918− }
919− json.push_str(&format!("\"start_url\":{},", json_str(&manifest.start_url)));
920− json.push_str(&format!("\"display\":{}", json_str(&manifest.display)));
921− if let Some(ref bg) = manifest.background_color {
922− json.push_str(&format!(",\"background_color\":{}", json_str(bg)));
923− }
924− if let Some(ref tc) = manifest.theme_color {
925− json.push_str(&format!(",\"theme_color\":{}", json_str(tc)));
926− }
927− if let Some(ref desc) = manifest.description {
928− json.push_str(&format!(",\"description\":{}", json_str(desc)));
929− }
930− if !manifest.icons.is_empty() {
931− json.push_str(",\"icons\":[");
932− for (i, (src, sizes)) in manifest.icons.iter().enumerate() {
933− if i > 0 {
934− json.push(',');
935− }
936− json.push_str(&format!(
937− "{{\"src\":{},\"sizes\":{},\"type\":\"image/png\"}}",
938− json_str(src),
939− json_str(sizes)
940− ));
941− }
942− json.push(']');
943− }
944− json.push('}');
945−
946− // Inline the manifest as a data URI
947− let encoded = base64_encode(json.as_bytes());
948− if dev {
949− format!(
950− "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">\n",
951− encoded
952− )
953− } else {
954− format!(
955− "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">",
956− encoded
957− )
958− }
959− } else {
960− String::new()
961− };
962−
963− // Preload hints (auto-detect fonts from @font-face)
964− let mut preload_html = String::new();
965− for (_, url) in &doc.font_faces {
966− if dev {
967− preload_html.push_str(&format!(
968− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
969− url
970− ));
971− } else {
972− preload_html.push_str(&format!(
973− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>",
974− url
975− ));
976− }
977− }
978− // Explicit preload hints from the document
979− for hint in &doc.preload_hints {
980− if dev {
981− preload_html.push_str(&format!(
982− "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>\n",
983− html_escape(&hint.href),
984− hint.as_type,
985− if hint.crossorigin { " crossorigin" } else { "" }
986− ));
987− } else {
988− preload_html.push_str(&format!(
989− "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>",
990− html_escape(&hint.href),
991− hint.as_type,
992− if hint.crossorigin { " crossorigin" } else { "" }
993− ));
994− }
995− }
996−
997− // Auto theme-color meta from @theme primary token
998− let theme_color_html = doc
999− .theme_tokens
1000− .iter()
1001− .find(|(name, _)| name == "primary" || name == "theme-color")
1002− .map(|(_, value)| {
1003− if dev {
1004− format!(
1005− "<meta name=\"theme-color\" content=\"{}\">\n",
1006− html_escape(value)
1007− )
1008− } else {
1009− format!(
1010− "<meta name=\"theme-color\" content=\"{}\">",
1011− html_escape(value)
1012− )
1013− }
1014− })
1015− .unwrap_or_default();
1016−
1017− // Focus-visible CSS for interactive elements (accessibility)
1018− let focus_visible_css = if ctx.has_interactive {
1019− if dev {
1020− "a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n"
1021− } else {
1022− "a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}"
1023− }
1024− } else {
1025− ""
1026− };
1027−
1028− // Skip-to-content CSS (visually hidden but accessible)
1029− let skip_link_css = if has_main {
1030− if dev {
1031− ".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"
1032− } else {
1033− ".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}"
1034− }
1035− } else {
1036− ""
1037− };
1038−
1039− // Noscript fallback: show deferred content if JS is disabled
1040− let noscript_html = if ctx.has_defer {
1041− if dev {
1042− "<noscript><style>.hl-defer-placeholder { display: none; }</style></noscript>\n"
1043− } else {
1044− "<noscript><style>.hl-defer-placeholder{display:none}</style></noscript>"
1045− }
1046− } else {
1047− ""
1048− };
1049−
1050− let reset_css = reset_css(doc, dev, focus_visible_css, skip_link_css);
1051−
1052− match &doc.page_title {
1053− Some(title) => {
1054− if dev {
1055− format!(
1056− "\
1057−<!DOCTYPE html>
1058−<html{lang_attr}>
1059−<head>
1060−<meta charset=\"utf-8\">
1061−<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
1062−<title>{title}</title>
1063−{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}\
1064−<style>
1065−{reset_css}{element_css}\
1066−</style>
1067−{noscript_html}\
1068−</head>
1069−<body>
1070−{body}\
1071−</body>
1072−</html>
1073−",
1074− title = html_escape(title),
1075− lang_attr = lang_attr,
1076− theme_color_html = theme_color_html,
1077− base_html = base_html,
1078− canonical_html = canonical_html,
1079− manifest_html = manifest_html,
1080− preload_html = preload_html,
1081− dns_prefetch_html = dns_prefetch_html,
1082− meta_html = meta_html,
1083− favicon_html = favicon_html,
1084− json_ld_html = json_ld_html,
1085− head_html = head_html,
1086− og_html = og_html,
1087− reset_css = reset_css,
1088− noscript_html = noscript_html,
1089− element_css = element_css,
1090− body = body,
1091− )
1092− } else {
1093− format!(
1094− "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}<style>{reset_css}{element_css}</style>{noscript_html}</head><body>{body}</body></html>",
1095− title = html_escape(title),
1096− lang_attr = lang_attr,
1097− theme_color_html = theme_color_html,
1098− base_html = base_html,
1099− canonical_html = canonical_html,
1100− manifest_html = manifest_html,
1101− preload_html = preload_html,
1102− dns_prefetch_html = dns_prefetch_html,
1103− meta_html = meta_html,
1104− og_html = og_html,
1105− favicon_html = favicon_html,
1106− json_ld_html = json_ld_html,
1107− head_html = head_html,
1108− reset_css = reset_css,
1109− noscript_html = noscript_html,
1110− element_css = element_css,
1111− body = body,
1112− )
1113− }
1114− }
1115− None => {
1116− if element_css.is_empty() {
1117− body
1118− } else if dev {
1119− format!("<style>\n{}</style>\n{}", element_css, body)
1120− } else {
1121− format!("<style>{}</style>{}", element_css, body)
1122− }
1123− }
1124− }
1125−}
1126−
1127−/// Encode `s` as a JSON string literal (with surrounding quotes).
1128−pub(crate) fn json_str(s: &str) -> String {
1129− let mut out = String::with_capacity(s.len() + 2);
1130− out.push('"');
1131− for c in s.chars() {
1132− match c {
1133− '"' => out.push_str("\\\""),
1134− '\\' => out.push_str("\\\\"),
1135− '\n' => out.push_str("\\n"),
1136− '\r' => out.push_str("\\r"),
1137− '\t' => out.push_str("\\t"),
1138− c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1139− c => out.push(c),
1140− }
1141− }
1142− out.push('"');
1143− out
1144−}
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+}
1145954
1146955/// Assemble every CSS block the document needs (font faces, custom
1147956/// properties, generated class rules, keyframes, and user CSS). Shared by
@@ -1149,61 +958,16 @@ pub(crate) fn json_str(s: &str) -> String {
1149958fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
1150959 let mut element_css = String::new();
1151960
1152− // @font-face rules, in declaration order
1153− for (name, url) in &doc.font_faces {
1154− let format_hint = if url.ends_with(".woff2") {
1155− " format('woff2')"
1156− } else if url.ends_with(".woff") {
1157− " format('woff')"
1158− } else if url.ends_with(".ttf") {
1159− " format('truetype')"
1160− } else if url.ends_with(".otf") {
1161− " format('opentype')"
1162− } else {
1163− ""
1164− };
1165− if dev {
1166− element_css.push_str(&format!(
1167− "@font-face {{\n font-family: '{}';\n src: url('{}'){};\n font-display: swap;\n}}\n",
1168− name, url, format_hint
1169− ));
1170− } else {
1171− element_css.push_str(&format!(
1172− "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
1173− name, url, format_hint
1174− ));
1175− }
1176− }
1177−
1178− // Collect all CSS custom properties (explicit `@let --name` / `@theme`
1179− // tokens, plus any auto-extracted repeats) so they can be emitted in a
1180− // single `:root` block below.
961+ // Collect the CSS custom properties declared with `@let --name` so they
962+ // can be emitted in a single `:root` block below.
1181963 let mut root_vars: Vec<(String, String)> = Vec::new();
1182964 for (name, value) in &doc.css_vars {
1183965 root_vars.push((name.clone(), value.clone()));
1184966 }
1185− for (name, value) in &doc.theme_tokens {
1186− let css_name = format!("--{}", name);
1187− if !root_vars.iter().any(|(n, _)| *n == css_name) {
1188− root_vars.push((css_name, value.clone()));
1189− }
1190− }
1191967
1192− let styles_css = styles.to_css_formatted(dev, uses_layer(doc));
1193− // Fold literal values declared via `@theme` / `@let --name` back into
1194− // `var(--name)` references so the generated CSS actually uses the
1195− // custom properties emitted in `:root`.
1196− let styles_css = substitute_css_vars(&styles_css, &root_vars);
1197− // Further compress the CSS by auto-extracting any remaining literal
1198− // values that appear often enough for `var(--hN)` references to come out
1199− // shorter overall. Disabled in dev mode to keep the CSS readable.
1200− let styles_css = if dev {
1201− styles_css
1202− } else {
1203− let (new_css, auto_vars) = auto_extract_repeats(&styles_css, &root_vars);
1204− root_vars.extend(auto_vars);
1205− new_css
1206− };
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);
1207971
1208972 // Emit the :root block first so the cascade picks up the custom
1209973 // properties before the class rules consume them.
@@ -1228,93 +992,121 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
1228992
1229993 element_css.push_str(&styles_css);
1230994
1231− // @keyframes
1232− for (name, kf_body) in &doc.keyframes {
1233− if dev {
1234− element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
1235− } else {
1236− element_css.push_str(&format!("@keyframes {}{{{}}}", name, kf_body));
1237− }
1238− }
1239−
1240− // Auto-inject skeleton keyframe if skeleton attribute is used
1241− if element_css.contains("hl-skeleton") {
1242− if dev {
1243− element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
1244− } else {
1245− element_css.push_str("@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}");
1246− }
1247− }
1248−
1249− let push_block = |css: &mut String, block: &str| {
995+ // @style blocks (custom CSS)
996+ for block in &doc.custom_css {
1250997 if dev {
1251− css.push_str(block);
1252− css.push('\n');
998+ element_css.push_str(block);
999+ element_css.push('\n');
12531000 } else {
1254− let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1255− css.push_str(&minified);
1256− }
1257− };
1258−
1259− // @style and @scope blocks (custom CSS)
1260− for block in doc.custom_css.iter().chain(&doc.scope_blocks) {
1261− push_block(&mut element_css, block);
1262− }
1263−
1264− // @starting-style blocks
1265− if !doc.starting_style_blocks.is_empty() {
1266− element_css.push_str(if dev { "@starting-style {\n" } else { "@starting-style{" });
1267− for block in &doc.starting_style_blocks {
1268− push_block(&mut element_css, block);
1001+ element_css.push_str(&compact_css(block));
12691002 }
1270− element_css.push_str(if dev { "}\n" } else { "}" });
12711003 }
12721004
12731005 element_css
12741006}
12751007
1276−/// Generated class rules go in `@layer htmlang` unless the page has custom
1277−/// `@style` CSS (which is unlayered and would then always win).
1278−fn uses_layer(doc: &Document) -> bool {
1279− doc.custom_css.is_empty()
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(" ")
12801018}
12811019
1282−/// Built-in reset rules. When generated rules are layered, the reset must be
1283−/// layered too (in an earlier layer): unlayered CSS beats every layer, so an
1284−/// unlayered `a{color:inherit}` would override `@link [color red]`.
1285−fn reset_css(doc: &Document, dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
1286− let base = if dev {
1287− "*, *::before, *::after { box-sizing: border-box; }\nbody { margin: 0; font-family: system-ui, -apple-system, sans-serif; }\nimg { display: block; }\na { text-decoration: none; color: inherit; }\n"
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()
1050+ } else {
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()
1055+ } else {
1056+ "body{margin:0;font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;min-height:100dvh}".to_string()
1057+ });
12881058 } else {
1289− "*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}"
1290− };
1291− let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
1292− if !uses_layer(doc) {
1293− rules
1294− } else if dev {
1295− format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
1059+ rules.push(if dev {
1060+ format!("{own}, {own}::before, {own}::after {{ box-sizing: border-box; }}")
1061+ } else {
1062+ format!("{own},{own}::before,{own}::after{{box-sizing:border-box}}")
1063+ });
1064+ }
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; }}")
1069+ } else {
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+ )
12961083 } else {
1297− format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
1084+ format!("@layer {};@layer hl-reset{{{}}}", LAYERS.join(","), rules)
12981085 }
12991086}
13001087
13011088/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1302−fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1089+fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
13031090 let mut styles = StyleCollector::new();
13041091 let mut ctx = GenContext {
13051092 dev,
13061093 depth: 0,
1307− image_count: 0,
13081094 has_interactive: false,
1309− has_defer: false,
1095+ in_text: false,
1096+ flow: Flow::default(),
1097+ in_picture: false,
13101098 };
13111099 let mut body = String::new();
13121100
1313− for node in &doc.nodes {
1314− generate_node(node, None, &mut body, &mut styles, &mut ctx);
1315− }
1101+ generate_children(&doc.nodes, None, &mut body, &mut styles, &mut ctx);
13161102
1317− let element_css = build_element_css(doc, &styles, dev);
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, ""));
1108+ }
1109+ classes.extend(styles.classes());
13181110
13191111 if element_css.is_empty() {
13201112 body
@@ -1329,44 +1121,20 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
13291121// Node generation
13301122// ---------------------------------------------------------------------------
13311123
1124+/// Write `node`, which is inside an element with the layout `parent`
1125+/// (`None` at the top of the page).
13321126fn generate_node(
13331127 node: &Node,
1334− parent_kind: Option<&ElementKind>,
1128+ parent: Option<Layout>,
13351129 out: &mut String,
13361130 styles: &mut StyleCollector,
13371131 ctx: &mut GenContext,
13381132) {
13391133 match node {
1340− Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
1134+ Node::Element(elem) => generate_element(elem, parent, out, styles, ctx),
13411135 Node::Text(segments) => {
1342− let needs_wrap = matches!(
1343− parent_kind,
1344− Some(ElementKind::Row)
1345− | Some(ElementKind::Column)
1346− | Some(ElementKind::El)
1347− | Some(ElementKind::Nav)
1348− | Some(ElementKind::Header)
1349− | Some(ElementKind::Footer)
1350− | Some(ElementKind::Main)
1351− | Some(ElementKind::Section)
1352− | Some(ElementKind::Article)
1353− | Some(ElementKind::Aside)
1354− | Some(ElementKind::ListItem)
1355− | Some(ElementKind::Form)
1356− | Some(ElementKind::Details)
1357− | Some(ElementKind::Figure)
1358− | Some(ElementKind::Blockquote)
1359− | Some(ElementKind::Dialog)
1360− | Some(ElementKind::DefinitionList)
1361− | Some(ElementKind::DefinitionDescription)
1362− | Some(ElementKind::Fieldset)
1363− | Some(ElementKind::Datalist)
1364− | Some(ElementKind::Grid)
1365− | Some(ElementKind::Stack)
1366− | Some(ElementKind::Noscript)
1367− | Some(ElementKind::Address)
1368− | Some(ElementKind::Search)
1369− );
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);
13701138 if needs_wrap {
13711139 out.push_str(&ctx.indent());
13721140 out.push_str("<span>");
@@ -1385,209 +1153,191 @@ fn generate_node(
13851153 }
13861154}
13871155
1388−/// HTML attributes that are passed through to the HTML tag rather than converted to CSS.
1389−const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
1390− "type",
1391− "placeholder",
1392− "name",
1393− "value",
1394− "disabled",
1395− "required",
1396− "checked",
1397− "for",
1398− "action",
1399− "method",
1400− "autocomplete",
1401− "min",
1402− "max",
1403− "step",
1404− "pattern",
1405− "maxlength",
1406− "rows",
1407− "cols",
1408− "multiple",
1409− "alt",
1410− "role",
1411− "tabindex",
1412− "title",
1413− "autofocus",
1414− // Media
1415− "controls",
1416− "autoplay",
1417− "loop",
1418− "muted",
1419− "playsinline",
1420− "poster",
1421− "preload",
1422− // Image optimization
1423− "loading",
1424− "decoding",
1425− // Media src (explicit attribute form)
1426− "src",
1427− // New element attributes
1428− "datetime",
1429− "media",
1430− "sizes",
1431− "srcset",
1432− "list",
1433− // Details
1434− "open",
1435− // Form
1436− "novalidate",
1437− // Progress/Meter
1438− "low",
1439− "high",
1440− "optimum",
1441− // Table
1442− "colspan",
1443− "rowspan",
1444− "scope",
1445− // Popover API
1446− "popover",
1447− "popovertarget",
1448− "popovertargetaction",
1449− // Modern form/input hints
1450− "inputmode",
1451− "enterkeyhint",
1452− // Performance hints
1453− "fetchpriority",
1454− "blocking",
1455− // Global attrs
1456− "translate",
1457− "spellcheck",
1458− // ARIA live regions
1459− "aria-live",
1460− "aria-atomic",
1461− "aria-relevant",
1462−];
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+}
14631170
1464−/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
1465−const BOOLEAN_HTML_ATTRS: &[&str] = &[
1466− "disabled",
1467− "required",
1468− "checked",
1469− "multiple",
1470− "controls",
1471− "autoplay",
1472− "loop",
1473− "muted",
1474− "playsinline",
1475− "open",
1476− "novalidate",
1477− "autofocus",
1478− "defer",
1479− "async",
1480− "nomodule",
1481− // Popover
1482− "popover",
1483−];
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+}
14841183
1485−fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
1486− for attr in attrs {
1487− let key = attr.key.as_str();
1488− let is_passthrough = HTML_PASSTHROUGH_ATTRS.contains(&key)
1489− || key.starts_with("aria-")
1490− || key.starts_with("data-");
1491− 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);
14921201 continue;
14931202 }
1494− if BOOLEAN_HTML_ATTRS.contains(&key) && attr.value.is_none() {
1495− out.push(' ');
1496− out.push_str(key);
1497− } else if let Some(val) = &attr.value {
1498− out.push(' ');
1499− out.push_str(key);
1500− out.push_str("=\"");
1501− out.push_str(&html_escape(strip_string_quotes(val)));
1502− 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;
15031216 }
1217+ *previous = Some(matches!(child, Node::Text(_)));
15041218 }
15051219}
15061220
1507−/// If the value is wrapped in a single pair of matching quotes (e.g.
1508−/// `"Avatar"`), return the inner content. Quotes act as source-level
1509−/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute
1510−/// values. Multi-quoted values like `"h h" "s m"` are left unchanged —
1511−/// those are real string tokens (used e.g. by CSS `grid-template-areas`).
1512−fn strip_string_quotes(val: &str) -> &str {
1513− let bytes = val.as_bytes();
1514− if bytes.len() < 2 {
1515− return val;
1516− }
1517− let first = bytes[0];
1518− let last = bytes[bytes.len() - 1];
1519− if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1520− let inner = &val[1..val.len() - 1];
1521− if !inner.as_bytes().contains(&first) {
1522− 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);
15231266 }
15241267 }
1525− 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+ }
15261295}
15271296
15281297// True if any direct child is `@in-front` or `@behind`. Such children render
15291298// as absolutely positioned overlays, so the parent automatically becomes a
15301299// positioning context (position:relative + isolation:isolate).
15311300fn has_overlay_children(elem: &Element) -> bool {
1532− elem.children.iter().any(|child| {
1301+ holds_overlays(&elem.children)
1302+}
1303+
1304+fn holds_overlays(children: &[Node]) -> bool {
1305+ children.iter().any(|child| {
15331306 matches!(
15341307 child,
1535− Node::Element(e) if matches!(e.kind, ElementKind::InFront | ElementKind::Behind)
1308+ Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
15361309 )
15371310 })
15381311}
15391312
15401313fn generate_element(
15411314 elem: &Element,
1542− parent_kind: Option<&ElementKind>,
1315+ parent: Option<Layout>,
15431316 out: &mut String,
15441317 styles: &mut StyleCollector,
15451318 ctx: &mut GenContext,
15461319) {
15471320 // Self-closing elements
1548− if matches!(
1549− elem.kind,
1550− ElementKind::Image
1551− | ElementKind::Input
1552− | ElementKind::HorizontalRule
1553− | ElementKind::Source
1554− | ElementKind::Spacer
1555− ) {
1556− 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);
15571323 return;
15581324 }
1559− // @script renders as <script> with raw body content (no HTML escaping)
1560− 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);
15611329 out.push_str(&ctx.indent());
1562− out.push_str("<script");
1563− // Pass through src, type, defer, async, etc.
1564− for attr in &elem.attrs {
1565− let key = attr.key.as_str();
1566− if matches!(
1567− key,
1568− "src"
1569− | "type"
1570− | "defer"
1571− | "async"
1572− | "crossorigin"
1573− | "integrity"
1574− | "nomodule"
1575− | "id"
1576− ) {
1577− if let Some(val) = &attr.value {
1578− out.push(' ');
1579− out.push_str(key);
1580− out.push_str("=\"");
1581− out.push_str(&html_escape(val));
1582− out.push('"');
1583− } else {
1584− out.push(' ');
1585− out.push_str(key);
1586− }
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)));
15871337 }
15881338 }
1339+ emit_html_attrs(out, &elem.attrs);
15891340 out.push('>');
1590− // Children are raw JS code, not HTML
15911341 for child in &elem.children {
15921342 match child {
15931343 Node::Text(segments) => {
@@ -1601,46 +1351,10 @@ fn generate_element(
16011351 _ => {}
16021352 }
16031353 }
1604− out.push_str("</script>");
1605− out.push_str(ctx.nl());
1606− return;
1607− }
1608− // @breadcrumb generates semantic <nav aria-label="breadcrumb"><ol>...</ol></nav>
1609− if elem.kind == ElementKind::Breadcrumb {
1610− let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1611− let (id, user_class) = extract_id_class(&elem.attrs);
1612− out.push_str(&ctx.indent());
1613− out.push_str("<nav aria-label=\"breadcrumb\"");
1614− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1615− if let Some(id) = id {
1616− out.push_str(" id=\"");
1617− out.push_str(&html_escape(&id));
1618− out.push('"');
1619− }
1354+ out.push_str("</");
1355+ out.push_str(tag);
16201356 out.push('>');
16211357 out.push_str(ctx.nl());
1622− ctx.depth += 1;
1623− out.push_str(&ctx.indent());
1624− out.push_str("<ol>");
1625− out.push_str(ctx.nl());
1626− ctx.depth += 1;
1627− for child in &elem.children {
1628− out.push_str(&ctx.indent());
1629− out.push_str("<li>");
1630− let mut buf = String::new();
1631− generate_node(child, Some(&elem.kind), &mut buf, styles, ctx);
1632− out.push_str(buf.trim());
1633− out.push_str("</li>");
1634− out.push_str(ctx.nl());
1635− }
1636− ctx.depth -= 1;
1637− out.push_str(&ctx.indent());
1638− out.push_str("</ol>");
1639− out.push_str(ctx.nl());
1640− ctx.depth -= 1;
1641− out.push_str(&ctx.indent());
1642− out.push_str("</nav>");
1643− out.push_str(ctx.nl());
16441358 return;
16451359 }
16461360 if elem.kind == ElementKind::Children {
@@ -1650,209 +1364,38 @@ fn generate_element(
16501364 return;
16511365 }
16521366 if elem.kind == ElementKind::Fragment {
1653− // Render children without a wrapper element
1654− for child in &elem.children {
1655− generate_node(child, parent_kind, out, styles, ctx);
1656− }
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);
16571370 return;
16581371 }
16591372
1373+ let mut own = Flow::of(elem);
16601374 let tag = match &elem.kind {
1661− ElementKind::Row
1662− | ElementKind::Column
1663− | ElementKind::El
1664− | ElementKind::Grid
1665− | ElementKind::Stack
1666− | ElementKind::InFront
1667− | ElementKind::Behind => "div",
1375+ ElementKind::Row | ElementKind::El => "div",
16681376 ElementKind::Text => "span",
16691377 ElementKind::Paragraph => "p",
16701378 ElementKind::Link => "a",
1671− ElementKind::Button => "button",
1672− ElementKind::Select => "select",
1673− ElementKind::Textarea => "textarea",
1674− ElementKind::Option => "option",
1675− ElementKind::Label => "label",
1676− // Semantic elements
1677− ElementKind::Nav => "nav",
1678− ElementKind::Header => "header",
1679− ElementKind::Footer => "footer",
1680− ElementKind::Main => "main",
1681− ElementKind::Section => "section",
1682− ElementKind::Article => "article",
1683− ElementKind::Aside => "aside",
1684− // List
1685− ElementKind::List => {
1686− if elem.attrs.iter().any(|a| a.key == "ordered") {
1687− "ol"
1688− } else {
1689− "ul"
1690− }
1691− }
1692− ElementKind::ListItem => "li",
1693− // Table
1694− ElementKind::Table => "table",
1695− ElementKind::TableHead => "thead",
1696− ElementKind::TableBody => "tbody",
1697− ElementKind::TableRow => "tr",
1698− ElementKind::TableCell => "td",
1699− ElementKind::TableHeaderCell => "th",
1700− // Media
1701− ElementKind::Video => "video",
1702− ElementKind::Audio => "audio",
1703− // Additional semantic elements
1704− ElementKind::Form => "form",
1705− ElementKind::Details => "details",
1706− ElementKind::Summary => "summary",
1707− ElementKind::Blockquote => "blockquote",
1708− ElementKind::Cite => "cite",
1709− ElementKind::Code => "code",
1710− ElementKind::Pre => "pre",
1711− ElementKind::Figure => "figure",
1712− ElementKind::FigCaption => "figcaption",
1713− ElementKind::Progress => "progress",
1714− ElementKind::Meter => "meter",
1715− // New elements
1716− ElementKind::Dialog => "dialog",
1717− ElementKind::DefinitionList => "dl",
1718− ElementKind::DefinitionTerm => "dt",
1719− ElementKind::DefinitionDescription => "dd",
1720− ElementKind::Fieldset => "fieldset",
1721− ElementKind::Legend => "legend",
1722− ElementKind::Picture => "picture",
1723− ElementKind::Time => "time",
1724− ElementKind::Mark => "mark",
1725− ElementKind::Kbd => "kbd",
1726− ElementKind::Abbr => "abbr",
1727− ElementKind::Datalist => "datalist",
1728− ElementKind::Iframe => "iframe",
1729− ElementKind::Output => "output",
1730− ElementKind::Canvas => "canvas",
1731− ElementKind::Badge => "span",
1732− ElementKind::Tooltip => "span",
1733− ElementKind::Avatar => "div",
1734− ElementKind::Carousel => "div",
1735− ElementKind::Chip => "span",
1736− ElementKind::Tag => "span",
1737− ElementKind::Noscript => "noscript",
1738− ElementKind::Address => "address",
1739− ElementKind::Search => "search",
1740− // Heading elements
1741− ElementKind::H1 => "h1",
1742− ElementKind::H2 => "h2",
1743− ElementKind::H3 => "h3",
1744− ElementKind::H4 => "h4",
1745− ElementKind::H5 => "h5",
1746− ElementKind::H6 => "h6",
1747− ElementKind::Image
1748− | ElementKind::Input
1749− | ElementKind::HorizontalRule
1750− | ElementKind::Children
1751− | ElementKind::Slot(_)
1752− | ElementKind::Fragment
1753− | ElementKind::Source
1754− | ElementKind::Spacer
1755− | ElementKind::Script
1756− | ElementKind::Breadcrumb => unreachable!(),
1757− };
1758−
1759− let kind_label = match elem.kind {
1760− ElementKind::Row => "row",
1761− ElementKind::Column => "column",
1762− ElementKind::El => "el",
1763− ElementKind::Text => "text",
1764− ElementKind::Paragraph => "paragraph",
1765− ElementKind::Link => "link",
1766− ElementKind::Button => "button",
1767− ElementKind::Select => "select",
1768− ElementKind::Textarea => "textarea",
1769− ElementKind::Option => "option",
1770− ElementKind::Label => "label",
1771− ElementKind::Nav => "nav",
1772− ElementKind::Header => "header",
1773− ElementKind::Footer => "footer",
1774− ElementKind::Main => "main",
1775− ElementKind::Section => "section",
1776− ElementKind::Article => "article",
1777− ElementKind::Aside => "aside",
1778− ElementKind::List => "list",
1779− ElementKind::ListItem => "item",
1780− ElementKind::Table => "table",
1781− ElementKind::TableHead => "thead",
1782− ElementKind::TableBody => "tbody",
1783− ElementKind::TableRow => "tr",
1784− ElementKind::TableCell => "td",
1785− ElementKind::TableHeaderCell => "th",
1786− ElementKind::Video => "video",
1787− ElementKind::Audio => "audio",
1788− ElementKind::Form => "form",
1789− ElementKind::Details => "details",
1790− ElementKind::Summary => "summary",
1791− ElementKind::Blockquote => "blockquote",
1792− ElementKind::Cite => "cite",
1793− ElementKind::Code => "code",
1794− ElementKind::Pre => "pre",
1795− ElementKind::Figure => "figure",
1796− ElementKind::FigCaption => "figcaption",
1797− ElementKind::Fragment => "fragment",
1798− ElementKind::Dialog => "dialog",
1799− ElementKind::DefinitionList => "dl",
1800− ElementKind::DefinitionTerm => "dt",
1801− ElementKind::DefinitionDescription => "dd",
1802− ElementKind::Fieldset => "fieldset",
1803− ElementKind::Legend => "legend",
1804− ElementKind::Picture => "picture",
1805− ElementKind::Time => "time",
1806− ElementKind::Mark => "mark",
1807− ElementKind::Kbd => "kbd",
1808− ElementKind::Abbr => "abbr",
1809− ElementKind::Datalist => "datalist",
1810− ElementKind::Iframe => "iframe",
1811− ElementKind::Output => "output",
1812− ElementKind::Canvas => "canvas",
1813− ElementKind::Grid => "grid",
1814− ElementKind::Stack => "stack",
1815− ElementKind::Badge => "badge",
1816− ElementKind::Tooltip => "tooltip",
1817− ElementKind::Avatar => "avatar",
1818− ElementKind::Carousel => "carousel",
1819− ElementKind::Chip => "chip",
1820− ElementKind::Tag => "tag",
1821− ElementKind::Noscript => "noscript",
1822− ElementKind::Address => "address",
1823− ElementKind::Search => "search",
1824− ElementKind::InFront => "in-front",
1825− ElementKind::Behind => "behind",
1826− ElementKind::H1 => "h1",
1827− ElementKind::H2 => "h2",
1828− ElementKind::H3 => "h3",
1829− ElementKind::H4 => "h4",
1830− ElementKind::H5 => "h5",
1831− ElementKind::H6 => "h6",
1379+ ElementKind::Tag(spec) => spec.html,
18321380 _ => "",
18331381 };
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();
18341388
18351389 // Track interactive elements for focus-visible CSS
1836− if matches!(
1837− elem.kind,
1838− ElementKind::Link
1839− | ElementKind::Button
1840− | ElementKind::Input
1841− | ElementKind::Select
1842− | ElementKind::Textarea
1843− ) {
1390+ if elem.kind == ElementKind::Link
1391+ || matches!(kind_label, "button" | "input" | "select" | "textarea")
1392+ {
18441393 ctx.has_interactive = true;
18451394 }
18461395
18471396 // Compute CSS for each state and get a class name
1848− let overlay_children = has_overlay_children(elem);
1849− let gen_class = compute_class(
1850− &elem.attrs,
1851− &elem.kind,
1852− parent_kind,
1853− styles,
1854− overlay_children,
1855− );
1397+ let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1398+ own.class = gen_class.clone();
18561399 let (id, user_class) = extract_id_class(&elem.attrs);
18571400
18581401 if ctx.dev && elem.line_num > 0 {
@@ -1866,85 +1409,9 @@ fn generate_element(
18661409 out.push('<');
18671410 out.push_str(tag);
18681411
1869− // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1870− if elem.kind == ElementKind::Main && id.is_none() {
1871− out.push_str(" id=\"hl-main\"");
1872− }
1873−
1874− if elem.kind == ElementKind::Link
1875− && let Some(url) = &elem.argument
1876− {
1877− out.push_str(" href=\"");
1878− out.push_str(&html_escape(url));
1879− out.push('"');
1880− // Auto rel="noopener noreferrer" and target="_blank" for external links
1881− let is_external = url.starts_with("http://") || url.starts_with("https://");
1882− if is_external {
1883− let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1884− let has_target = elem.attrs.iter().any(|a| a.key == "target");
1885− if !has_rel {
1886− out.push_str(" rel=\"noopener noreferrer\"");
1887− }
1888− if !has_target {
1889− out.push_str(" target=\"_blank\"");
1890− }
1891− }
1892− }
1893−
1894− // Video/Audio src
1895− if matches!(elem.kind, ElementKind::Video | ElementKind::Audio)
1896− && let Some(src) = &elem.argument
1897− {
1898− out.push_str(" src=\"");
1899− out.push_str(&html_escape(src));
1900− out.push('"');
1901− }
1902−
1903− // Form action
1904− if elem.kind == ElementKind::Form
1905− && let Some(action) = &elem.argument
1906− {
1907− out.push_str(" action=\"");
1908− out.push_str(&html_escape(action));
1909− out.push('"');
1910− }
1911−
1912− // Iframe src
1913− if elem.kind == ElementKind::Iframe
1914− && let Some(src) = &elem.argument
1915− {
1916− out.push_str(" src=\"");
1917− out.push_str(&html_escape(src));
1918− out.push('"');
1919− }
1920−
1921− // Tooltip title
1922− if elem.kind == ElementKind::Tooltip
1923− && let Some(text) = &elem.argument
1924− {
1925− out.push_str(" title=\"");
1926− out.push_str(&html_escape(text));
1927− out.push('"');
1928− }
1412+ emit_argument_attr(out, elem, ctx.in_picture);
19291413
1930− // Critical CSS: inline styles directly instead of using a class
1931− let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
1932− if is_critical {
1933− let inline_css = attrs_to_css(&elem.attrs, "", &elem.kind, parent_kind, overlay_children);
1934− if !inline_css.is_empty() {
1935− out.push_str(" style=\"");
1936− out.push_str(&html_escape(&inline_css));
1937− out.push('"');
1938− }
1939− // Still use class for non-base styles (pseudo, responsive, etc.)
1940− if gen_class.as_deref().is_some_and(|c| !c.is_empty()) {
1941− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1942− } else if let Some(ref uc) = user_class {
1943− emit_class_attr(out, None, Some(uc));
1944− }
1945− } else {
1946− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1947− }
1414+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
19481415
19491416 if let Some(id) = id {
19501417 out.push_str(" id=\"");
@@ -1952,7 +1419,7 @@ fn generate_element(
19521419 out.push('"');
19531420 }
19541421
1955− emit_html_passthrough_attrs(out, &elem.attrs);
1422+ emit_html_attrs(out, &elem.attrs);
19561423
19571424 // Source map attributes in dev mode
19581425 if ctx.dev && elem.line_num > 0 {
@@ -1963,66 +1430,27 @@ fn generate_element(
19631430 }
19641431
19651432 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");
19661438 out.push_str(ctx.nl());
19671439
1968− // Inline text argument
1969− if matches!(
1970− elem.kind,
1971− ElementKind::Text
1972− | ElementKind::Button
1973− | ElementKind::Label
1974− | ElementKind::Option
1975− | ElementKind::Textarea
1976− | ElementKind::ListItem
1977− | ElementKind::TableCell
1978− | ElementKind::TableHeaderCell
1979− | ElementKind::Summary
1980− | ElementKind::Cite
1981− | ElementKind::Code
1982− | ElementKind::FigCaption
1983− | ElementKind::Legend
1984− | ElementKind::DefinitionTerm
1985− | ElementKind::Mark
1986− | ElementKind::Kbd
1987− | ElementKind::Abbr
1988− | ElementKind::Time
1989− | ElementKind::DefinitionDescription
1990− | ElementKind::Badge
1991− | ElementKind::Tooltip
1992− | ElementKind::Chip
1993− | ElementKind::Tag
1994− | ElementKind::H1
1995− | ElementKind::H2
1996− | ElementKind::H3
1997− | ElementKind::H4
1998− | ElementKind::H5
1999− | ElementKind::H6
2000− ) && let Some(text) = &elem.argument
2001− {
2002− out.push_str(&html_escape(text));
2003− }
2004−
2005− // Children
1440+ let layout = elem.kind.layout();
20061441 ctx.depth += 1;
2007− let is_paragraph = matches!(
2008− elem.kind,
2009− ElementKind::Paragraph
2010− | ElementKind::H1
2011− | ElementKind::H2
2012− | ElementKind::H3
2013− | ElementKind::H4
2014− | ElementKind::H5
2015− | ElementKind::H6
2016− );
2017− for (i, child) in elem.children.iter().enumerate() {
2018− generate_node(child, Some(&elem.kind), out, styles, ctx);
2019− if is_paragraph && i < elem.children.len() - 1 {
2020− out.push(' ');
2021− }
2022− }
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;
20231450 ctx.depth -= 1;
20241451
20251452 out.push_str(&ctx.indent());
1453+ ctx.dev = outer_dev;
20261454 out.push_str("</");
20271455 out.push_str(tag);
20281456 out.push('>');
@@ -2031,21 +1459,18 @@ fn generate_element(
20311459
20321460fn generate_self_closing(
20331461 elem: &Element,
2034− parent_kind: Option<&ElementKind>,
1462+ parent: Option<Layout>,
20351463 out: &mut String,
20361464 styles: &mut StyleCollector,
20371465 ctx: &mut GenContext,
20381466) {
2039− let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1467+ let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
20401468 let (id, user_class) = extract_id_class(&elem.attrs);
20411469
2042− let (tag, kind_label) = match elem.kind {
1470+ let (tag, kind_label) = match &elem.kind {
20431471 ElementKind::Image => ("img", "image"),
2044− ElementKind::Input => ("input", "input"),
2045− ElementKind::HorizontalRule => ("hr", "hr"),
2046− ElementKind::Source => ("source", "source"),
2047− ElementKind::Spacer => ("div", "spacer"),
2048− _ => unreachable!(),
1472+ ElementKind::Tag(spec) => (spec.html, spec.name),
1473+ _ => unreachable!("not a void element: {:?}", elem.kind),
20491474 };
20501475
20511476 if ctx.dev && elem.line_num > 0 {
@@ -2058,43 +1483,7 @@ fn generate_self_closing(
20581483 out.push_str(&ctx.indent());
20591484 out.push('<');
20601485 out.push_str(tag);
2061−
2062− // Image src (with optional non-SVG base64 inlining)
2063− if elem.kind == ElementKind::Image {
2064− let src = elem.argument.as_deref().unwrap_or("");
2065− let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
2066− if is_inline && !src.is_empty() && !src.ends_with(".svg") {
2067− let mime = if src.ends_with(".png") {
2068− "image/png"
2069− } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
2070− "image/jpeg"
2071− } else if src.ends_with(".gif") {
2072− "image/gif"
2073− } else if src.ends_with(".webp") {
2074− "image/webp"
2075− } else if src.ends_with(".avif") {
2076− "image/avif"
2077− } else {
2078− "application/octet-stream"
2079− };
2080− if let Ok(data) = std::fs::read(src) {
2081− let b64 = base64_encode(&data);
2082− out.push_str(" src=\"data:");
2083− out.push_str(mime);
2084− out.push_str(";base64,");
2085− out.push_str(&b64);
2086− out.push('"');
2087− } else {
2088− out.push_str(" src=\"");
2089− out.push_str(&html_escape(src));
2090− out.push('"');
2091− }
2092− } else {
2093− out.push_str(" src=\"");
2094− out.push_str(&html_escape(src));
2095− out.push('"');
2096− }
2097− }
1486+ emit_argument_attr(out, elem, ctx.in_picture);
20981487
20991488 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
21001489
@@ -2104,7 +1493,7 @@ fn generate_self_closing(
21041493 out.push('"');
21051494 }
21061495
2107− emit_html_passthrough_attrs(out, &elem.attrs);
1496+ emit_html_attrs(out, &elem.attrs);
21081497
21091498 // Source map attributes in dev mode (self-closing)
21101499 if ctx.dev && elem.line_num > 0 {
@@ -2114,93 +1503,6 @@ fn generate_self_closing(
21141503 ));
21151504 }
21161505
2117− // Image optimization: auto-add loading="lazy" and decoding="async"
2118− if elem.kind == ElementKind::Image {
2119− // SVG inlining: @image [inline] logo.svg
2120− if elem.attrs.iter().any(|a| a.key == "inline")
2121− && let Some(src) = &elem.argument
2122− && src.ends_with(".svg")
2123− && let Ok(svg_content) = std::fs::read_to_string(src)
2124− {
2125− // Close the tag we opened, then emit inline SVG instead
2126− out.truncate(out.rfind('<').unwrap_or(0));
2127− out.push_str(&ctx.indent());
2128− out.push_str(svg_content.trim());
2129− out.push_str(ctx.nl());
2130− return;
2131− }
2132− // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
2133− let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
2134− if let Some(resp) = responsive_attr
2135− && let Some(ref sizes_str) = resp.value
2136− {
2137− let widths: Vec<&str> = sizes_str.split_whitespace().collect();
2138− if !widths.is_empty() {
2139− let src = elem.argument.as_deref().unwrap_or("");
2140− if !src.is_empty() {
2141− // Generate srcset with width descriptors
2142− // Convention: file-{width}.ext (e.g., photo-400.jpg)
2143− let dot_pos = src.rfind('.').unwrap_or(src.len());
2144− let base = &src[..dot_pos];
2145− let ext = &src[dot_pos..];
2146− let mut srcset_parts = Vec::new();
2147− for w in &widths {
2148− srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
2149− }
2150− out.push_str(" srcset=\"");
2151− out.push_str(&srcset_parts.join(", "));
2152− out.push('"');
2153− // Generate sizes attribute
2154− let max_width = widths.last().unwrap_or(&"100vw");
2155− out.push_str(&format!(
2156− " sizes=\"(max-width: {}px) 100vw, {}px\"",
2157− max_width, max_width
2158− ));
2159− }
2160− }
2161− }
2162−
2163− // Auto image dimensions: read local image file to inject width/height + aspect-ratio
2164− let has_width = elem.attrs.iter().any(|a| a.key == "width");
2165− let has_height = elem.attrs.iter().any(|a| a.key == "height");
2166− if (!has_width || !has_height)
2167− && let Some(ref src) = elem.argument
2168− && !src.starts_with("http://")
2169− && !src.starts_with("https://")
2170− && !src.starts_with("data:")
2171− && let Some((w, h)) = read_image_dimensions(src)
2172− {
2173− // Intrinsic size attributes only when neither dimension is set in
2174− // CSS; with one CSS dimension, a leftover intrinsic attribute for
2175− // the other would distort the image (200 wide but 1000 tall).
2176− if !has_width && !has_height {
2177− out.push_str(&format!(" width=\"{}\" height=\"{}\"", w, h));
2178− }
2179− // Auto aspect-ratio to prevent CLS
2180− if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
2181− out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
2182− }
2183− }
2184−
2185− // Smart image loading: first 3 images get fetchpriority="high" (above the fold),
2186− // subsequent images get loading="lazy" + decoding="async"
2187− ctx.image_count += 1;
2188− if ctx.image_count <= 3 {
2189− // Above-the-fold: eager loading with high priority
2190− if !elem.attrs.iter().any(|a| a.key == "fetchpriority") {
2191− out.push_str(" fetchpriority=\"high\"");
2192− }
2193− } else {
2194− // Below-the-fold: lazy loading
2195− if !elem.attrs.iter().any(|a| a.key == "loading") {
2196− out.push_str(" loading=\"lazy\"");
2197− }
2198− if !elem.attrs.iter().any(|a| a.key == "decoding") {
2199− out.push_str(" decoding=\"async\"");
2200− }
2201− }
2202− }
2203−
22041506 out.push('>');
22051507 out.push_str(ctx.nl());
22061508}
@@ -2216,7 +1518,10 @@ fn generate_text_segments(
22161518 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
22171519 TextSegment::Inline(elem) => {
22181520 let mut buf = String::new();
2219− 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;
22201525 out.push_str(buf.trim_end());
22211526 }
22221527 }
@@ -2229,143 +1534,97 @@ fn generate_text_segments(
22291534
22301535fn compute_class(
22311536 attrs: &[Attribute],
2232− kind: &ElementKind,
2233− parent_kind: Option<&ElementKind>,
1537+ site: &Site,
22341538 styles: &mut StyleCollector,
2235− has_overlay_children: bool,
1539+ distinct: String,
22361540) -> Option<String> {
2237− let base = attrs_to_css(attrs, "", kind, parent_kind, has_overlay_children);
2238−
2239− // Collect pseudo-state overrides
2240− let mut pseudo = Vec::new();
2241− for &(prefix, selector) in PSEUDO_PREFIXES {
2242− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
2243− if !css.is_empty() {
2244− pseudo.push((selector.to_string(), css));
2245− }
2246− }
2247−
2248− // Auto-add ::-webkit-scrollbar pseudo for no-scrollbar attribute
2249− if attrs.iter().any(|a| a.key == "no-scrollbar") {
2250− pseudo.push((
2251− "::-webkit-scrollbar".to_string(),
2252− "display:none;".to_string(),
2253− ));
2254− }
2255−
2256− // Collect nth:EXPR: dynamic pseudo selectors
2257− let mut nth_prefixes: Vec<String> = Vec::new();
2258− for attr in attrs {
2259− if attr.key.starts_with("nth:") {
2260− let rest = &attr.key[4..];
2261− if let Some(colon_pos) = rest.find(':') {
2262− let prefix = format!("nth:{}:", &rest[..colon_pos]);
2263− if !nth_prefixes.contains(&prefix) {
2264− nth_prefixes.push(prefix);
2265− }
2266− }
2267− }
2268− }
2269− for prefix in &nth_prefixes {
2270− let expr = &prefix[4..prefix.len() - 1];
2271− let selector = format!(":nth-child({})", expr);
2272− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
2273− if !css.is_empty() {
2274− 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);
22751547 }
22761548 }
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+}
22771572
2278− // Collect has(...): dynamic pseudo selectors
2279− let mut has_prefixes: Vec<String> = Vec::new();
2280− for attr in attrs {
2281− if attr.key.starts_with("has(")
2282− && let Some(close) = attr.key.find("):")
2283− {
2284− let prefix = format!("{}:", &attr.key[..close + 1]);
2285− if !has_prefixes.contains(&prefix) {
2286− has_prefixes.push(prefix);
2287− }
2288− }
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;");
22891581 }
2290− for prefix in &has_prefixes {
2291− let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
2292− let selector = format!(":has({})", inner);
2293− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
2294− if !css.is_empty() {
2295− pseudo.push((selector, css));
2296− }
1582+ if !site.root {
1583+ css.push_str(layout_css(site.kind.layout(), site.inline));
22971584 }
1585+ css.push_str(site.kind.css());
1586+ css
1587+}
22981588
2299− // Collect responsive overrides
2300− let mut responsive = Vec::new();
2301− for &(bp_name, _) in BREAKPOINTS {
2302− let prefix = format!("{}:", bp_name);
2303− let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
2304− if !css.is_empty() {
2305− 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;
23061597 }
2307− }
2308−
2309− // Collect container query overrides
2310− let mut container = Vec::new();
2311− for &(bp_name, _) in BREAKPOINTS {
2312− let prefix = format!("cq-{}:", bp_name);
2313− let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
2314− if !css.is_empty() {
2315− 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+ }
23161615 }
2317− }
2318−
2319− let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
2320− let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
2321− let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind, has_overlay_children);
2322− let motion_reduce =
2323− attrs_to_css(attrs, "motion-reduce:", kind, parent_kind, has_overlay_children);
2324− let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
2325− let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
2326−
2327− // Dedupe: if a property is declared twice within a single rule, keep only
2328− // the last occurrence (element-kind defaults are written before
2329− // user attributes, so a user [list-style disc] correctly overrides the
2330− // default list-style:none, and we don't need to ship both).
2331− let base = dedupe_declarations(&base);
2332− let pseudo: Vec<(String, String)> = pseudo
2333− .into_iter()
2334− .map(|(sel, css)| (sel, dedupe_declarations(&css)))
2335− .collect();
2336− let responsive: Vec<(String, String)> = responsive
2337− .into_iter()
2338− .map(|(bp, css)| (bp, dedupe_declarations(&css)))
2339− .collect();
2340− let dark = dedupe_declarations(&dark);
2341− let print = dedupe_declarations(&print);
2342− let motion_safe = dedupe_declarations(&motion_safe);
2343− let motion_reduce = dedupe_declarations(&motion_reduce);
2344− let landscape = dedupe_declarations(&landscape);
2345− let portrait = dedupe_declarations(&portrait);
2346− let container: Vec<(String, String)> = container
2347− .into_iter()
2348− .map(|(bp, css)| (bp, dedupe_declarations(&css)))
2349− .collect();
2350−
2351− styles.get_class(
2352− base,
2353− pseudo,
2354− responsive,
2355− dark,
2356− print,
2357− motion_safe,
2358− motion_reduce,
2359− landscape,
2360− portrait,
2361− container,
2362− )
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+ })
23631621}
23641622
23651623/// Dedupe CSS declarations within a single rule body: for any property
23661624/// declared more than once, keep only the last occurrence. Unparseable
2367−/// segments (no `:`) are preserved as-is. Semicolons inside parentheses are
2368−/// 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.
23691628fn dedupe_declarations(css: &str) -> String {
23701629 if css.is_empty() {
23711630 return String::new();
@@ -2380,8 +1639,26 @@ fn dedupe_declarations(css: &str) -> String {
23801639 let mut decls: Vec<(Option<String>, String)> = Vec::new();
23811640 let mut current = String::new();
23821641 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;
23831646 for ch in css.chars() {
2384− 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 == '(' {
23851662 depth += 1;
23861663 current.push(ch);
23871664 } else if ch == ')' {
@@ -2463,1280 +1740,578 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
24631740// Attribute → CSS mapping
24641741// ---------------------------------------------------------------------------
24651742
2466−/// (htmlang prefix, CSS selector suffix)
2467−const PSEUDO_PREFIXES: &[(&str, &str)] = &[
2468− ("hover:", ":hover"),
2469− ("active:", ":active"),
2470− ("focus:", ":focus"),
2471− ("focus-visible:", ":focus-visible"),
2472− ("focus-within:", ":focus-within"),
2473− ("disabled:", ":disabled"),
2474− ("checked:", ":checked"),
2475− ("placeholder:", "::placeholder"),
2476− ("first:", ":first-child"),
2477− ("last:", ":last-child"),
2478− ("odd:", ":nth-child(odd)"),
2479− ("even:", ":nth-child(even)"),
2480− ("before:", "::before"),
2481− ("after:", "::after"),
2482− ("selection:", "::selection"),
2483− ("visited:", ":visited"),
2484− ("empty:", ":empty"),
2485− ("target:", ":target"),
2486− ("valid:", ":valid"),
2487− ("invalid:", ":invalid"),
2488−];
2489−const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
2490−const MEDIA_PREFIXES: &[&str] = &[
2491− "dark:",
2492− "print:",
2493− "motion-safe:",
2494− "motion-reduce:",
2495− "landscape:",
2496− "portrait:",
2497−];
2498−const CONTAINER_QUERY_PREFIXES: &[&str] = &["cq-sm:", "cq-md:", "cq-lg:", "cq-xl:", "cq-2xl:"];
2499−
2500−fn is_prefixed_attr(key: &str) -> bool {
2501− PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
2502− || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
2503− || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
2504− || CONTAINER_QUERY_PREFIXES.iter().any(|p| key.starts_with(p))
2505− || key.starts_with("nth:")
2506− || key.starts_with("has(")
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,
25071755}
25081756
2509−/// `display:flex;flex-direction:column;` — base layout for `@el` and every
2510−/// semantic wrapper that behaves like a column.
2511−const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
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+ }
1768+ }
1769+}
25121770
2513−/// Font stack shared by `@pre`, `@code`, `@kbd`.
2514−const MONOSPACE_STACK: &str = "font-family:ui-monospace,monospace;";
1771+// ---------------------------------------------------------------------------
1772+// Direction: what a child's `fill` and `shrink` compile against
1773+// ---------------------------------------------------------------------------
25151774
2516−/// Declarations common to `@badge` and `@tag`. They differ only in
2517−/// `border-radius` (and `@badge` additionally centers content / pins
2518−/// line-height:1), so keeping the shared base here avoids drift.
2519−const PILL_BASE: &str =
2520− "display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;";
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,
1780+}
25211781
2522−fn attrs_to_css(
2523− attrs: &[Attribute],
2524− state_prefix: &str,
2525− kind: &ElementKind,
2526− parent_kind: Option<&ElementKind>,
2527− has_overlay_children: bool,
2528−) -> String {
2529− let mut css = String::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+ })
1791+ }
1792+}
25301793
2531− // Base element styles only for the default (non-state) pass
2532− if state_prefix.is_empty() {
2533− // Elements with @in-front / @behind children become positioning
2534− // contexts. Pushed before user attrs so an explicit `position` wins
2535− // via dedupe (only the last declaration of a property is kept).
2536− if has_overlay_children {
2537− css.push_str("position:relative;isolation:isolate;");
2538− }
2539− match kind {
2540− ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2541− ElementKind::Column
2542− | ElementKind::El
2543− // Semantic elements get flex column layout like @el
2544− | ElementKind::Nav
2545− | ElementKind::Header
2546− | ElementKind::Footer
2547− | ElementKind::Main
2548− | ElementKind::Section
2549− | ElementKind::Article
2550− | ElementKind::Aside
2551− | ElementKind::ListItem
2552− | ElementKind::Form
2553− | ElementKind::Details
2554− | ElementKind::Dialog => css.push_str(FLEX_COLUMN),
2555− ElementKind::Paragraph
2556− | ElementKind::H1
2557− | ElementKind::H2
2558− | ElementKind::H3
2559− | ElementKind::H4
2560− | ElementKind::H5
2561− | ElementKind::H6 => css.push_str("margin:0;"),
2562− // Lists: reset browser defaults
2563− ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
2564− // Figure / Blockquote: flex column with browser margin reset
2565− ElementKind::Figure | ElementKind::Blockquote => {
2566− css.push_str(FLEX_COLUMN);
2567− css.push_str("margin:0;");
2568− }
2569− // Pre: preserve whitespace
2570− ElementKind::Pre => {
2571− css.push_str("margin:0;white-space:pre;");
2572− css.push_str(MONOSPACE_STACK);
2573− }
2574− // Code / Kbd: monospace font
2575− ElementKind::Code | ElementKind::Kbd => css.push_str(MONOSPACE_STACK),
2576− ElementKind::DefinitionList => css.push_str("margin:0;"),
2577− ElementKind::DefinitionDescription => {
2578− css.push_str("margin:0;");
2579− css.push_str(FLEX_COLUMN);
2580− }
2581− ElementKind::Fieldset => {
2582− css.push_str(FLEX_COLUMN);
2583− // Align padding to the px-based scale used by Badge/Tag/Chip
2584− // rather than the old 0.5em (which depended on font-size).
2585− css.push_str("border:1px solid currentColor;padding:8px;margin:0;");
2586− }
2587− ElementKind::Grid => css.push_str("display:grid;"),
2588− ElementKind::Stack => css.push_str("position:relative;"),
2589− ElementKind::Badge => {
2590− css.push_str(PILL_BASE);
2591− css.push_str("justify-content:center;border-radius:9999px;line-height:1;");
2592− }
2593− ElementKind::Tag => {
2594− css.push_str(PILL_BASE);
2595− css.push_str("border-radius:4px;");
2596− }
2597− ElementKind::Tooltip => css.push_str("position:relative;cursor:help;"),
2598− ElementKind::Spacer => css.push_str("flex:1;"),
2599− ElementKind::Avatar => css.push_str("display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;"),
2600− 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;"),
2601− 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;"),
2602− // Overlay layers fill the parent. Parent gets position:relative
2603− // applied automatically (see has_overlay_children check below).
2604− ElementKind::InFront => {
2605− css.push_str(FLEX_COLUMN);
2606− css.push_str("position:absolute;inset:0;");
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() {
1825+ continue;
26071826 }
2608− ElementKind::Behind => {
2609− css.push_str(FLEX_COLUMN);
2610− css.push_str("position:absolute;inset:0;z-index:-1;");
1827+ let Some(axis) = attr.value.as_deref().and_then(Axis::of_value) else {
1828+ 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));
26111836 }
2612− _ => {}
26131837 }
2614− // Children of @carousel get scroll-snap-align and flex-shrink
2615− if matches!(parent_kind, Some(ElementKind::Carousel)) {
2616− css.push_str("scroll-snap-align:start;flex-shrink:0;");
1838+ changes.sort_by_key(|(at, _)| at_order(at));
1839+ Flow {
1840+ base: Some(base),
1841+ changes,
1842+ class: None,
1843+ }
1844+ }
1845+
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))
1859+ }
1860+}
1861+
1862+/// `width` or `height`.
1863+#[derive(Clone, Copy, PartialEq, Eq)]
1864+enum Dim {
1865+ Width,
1866+ Height,
1867+}
1868+
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,
1876+}
1877+
1878+impl Size {
1879+ fn of(value: &str) -> Size {
1880+ match value.trim() {
1881+ "fill" => Size::Fill,
1882+ "shrink" => Size::Shrink,
1883+ _ => Size::Set,
26171884 }
26181885 }
1886+}
1887+
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")],
1904+ }
1905+}
1906+
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+ ],
1921+ }
1922+}
26191923
2620− for attr in attrs {
2621− // Determine the effective key for this pass
2622− let effective_key = if state_prefix.is_empty() {
2623− if is_prefixed_attr(&attr.key) {
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+}
1943+
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 {
26241953 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+ _ => {}
26251979 }
2626− attr.key.as_str()
2627− } else {
2628− match attr.key.strip_prefix(state_prefix) {
2629− Some(k) => k,
2630− None => continue,
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;
26312025 }
2026+ }
2027+ if !self.writes(property) {
2028+ push_css(css, property, value);
2029+ }
2030+ }
2031+}
2032+
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;
2048+ }
2049+ if let Some(size) = size {
2050+ sets.extend_from_slice(sizing(dim, size, Some(axis)));
2051+ }
2052+ resets.extend_from_slice(&sizing_properties(dim));
2053+ }
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;
2059+ }
2060+ written.push(property);
2061+ sizes.push(&mut css, property, value);
2062+ }
2063+ for &(property, value) in &sets {
2064+ sizes.push(&mut css, property, value);
2065+ }
2066+ css
2067+}
2068+
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()
2078+}
2079+
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);
2099+ }
2100+ }
2101+ Some(class)
2102+}
2103+
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+ }
2116+}
2117+
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;
26322149 };
2150+ if on_children && crate::vocab::places_in_parent(effective_key, attr.value.as_deref()) {
2151+ continue;
2152+ }
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+ }
26332159
26342160 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;
2165+ }
26352166
26362167 match effective_key {
2637− // Layout
2638− "spacing" | "gap" => {
2639− if let Some(v) = val {
2640− push_css(&mut css, "gap", &css_px(v));
2641− }
2642− }
2643− "padding" => {
2644− if let Some(v) = val {
2645− push_css(&mut css, "padding", &css_px_multi(v));
2646− }
2647− }
2648− "padding-x" => {
2649− if let Some(v) = val {
2650− // Logical property covers both inline sides in one
2651− // declaration; for symmetric values this is visually
2652− // identical to padding-left/right in LTR and RTL.
2653− push_css(&mut css, "padding-inline", &css_px(v));
2654− }
2655− }
2656− "padding-y" => {
2657− if let Some(v) = val {
2658− push_css(&mut css, "padding-block", &css_px(v));
2659− }
2660− }
2661− "padding-top" => {
2662− if let Some(v) = val {
2663− push_css(&mut css, "padding-top", &css_px(v));
2664− }
2665− }
2666− "padding-bottom" => {
2667− if let Some(v) = val {
2668− push_css(&mut css, "padding-bottom", &css_px(v));
2669− }
2670− }
2671− "padding-left" => {
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" => {
26722172 if let Some(v) = val {
2673− push_css(&mut css, "padding-left", &css_px(v));
2674− }
2675− }
2676− "padding-right" => {
2677− if let Some(v) = val {
2678− push_css(&mut css, "padding-right", &css_px(v));
2173+ push_css(&mut css, "gap", &with_px("gap", v));
26792174 }
26802175 }
26812176
2682− // Sizing
2683− "width" => {
2684− if let Some(v) = val {
2685− match v {
2686− "fill" => match parent_kind {
2687− Some(ElementKind::Row) => {
2688− push_css(&mut css, "flex", "1");
2689− push_css(&mut css, "min-width", "0");
2690− }
2691− _ => push_css(&mut css, "width", "100%"),
2692− },
2693− "shrink" => push_css(&mut css, "flex-shrink", "0"),
2694− _ => push_css(&mut css, "width", &css_px(v)),
2695− }
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;
26962188 }
2697− }
2698− "height" => {
2699− if let Some(v) = val {
2700− match v {
2701− "fill" => match parent_kind {
2702− Some(ElementKind::Column) => {
2703− push_css(&mut css, "flex", "1");
2704− push_css(&mut css, "min-height", "0");
2705− }
2706− _ => push_css(&mut css, "height", "100%"),
2707− },
2708− "shrink" => push_css(&mut css, "flex-shrink", "0"),
2709− _ => push_css(&mut css, "height", &css_px(v)),
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+ }) =>
2226+ {
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);
27102236 }
27112237 }
2712− }
2713− "min-width" => {
2714− if let Some(v) = val {
2715− push_css(&mut css, "min-width", &css_px(v));
2716− }
2717− }
2718− "max-width" => {
2719− if let Some(v) = val {
2720− push_css(&mut css, "max-width", &css_px(v));
2721− }
2722− }
2723− "min-height" => {
27242238 if let Some(v) = val {
2725− push_css(&mut css, "min-height", &css_px(v));
2726− }
2727− }
2728− "max-height" => {
2729− if let Some(v) = val {
2730− push_css(&mut css, "max-height", &css_px(v));
2239+ push_css(&mut css, effective_key, &with_px(effective_key, v));
27312240 }
27322241 }
27332242
2734− // Alignment
2735− "center-x" => match parent_kind {
2736− Some(ElementKind::Column) | Some(ElementKind::El) => {
2737− push_css(&mut css, "align-self", "center");
2738− }
2739− _ => {
2740− push_css(&mut css, "margin-left", "auto");
2741− push_css(&mut css, "margin-right", "auto");
2742− }
2743− },
2744− "center-y" => match parent_kind {
2745− Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
2746− _ => {
2747− push_css(&mut css, "margin-top", "auto");
2748− push_css(&mut css, "margin-bottom", "auto");
2749− }
2750− },
2751− "align-left" => match parent_kind {
2752− Some(ElementKind::Column) | Some(ElementKind::El) => {
2753− push_css(&mut css, "align-self", "flex-start");
2754− }
2755− _ => push_css(&mut css, "margin-right", "auto"),
2756− },
2757− "align-right" => match parent_kind {
2758− Some(ElementKind::Column) | Some(ElementKind::El) => {
2759− push_css(&mut css, "align-self", "flex-end");
2760− }
2761− _ => push_css(&mut css, "margin-left", "auto"),
2762− },
2763− "align-top" => match parent_kind {
2764− Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
2765− _ => push_css(&mut css, "margin-bottom", "auto"),
2766− },
2767− "align-bottom" => match parent_kind {
2768− Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
2769− _ => push_css(&mut css, "margin-top", "auto"),
2770− },
2771−
2772− // Style
2773− "background" => {
2774− if let Some(v) = val {
2775− push_css(&mut css, "background", v);
2776− }
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");
27772249 }
2778− "color" => {
2779− if let Some(v) = val {
2780− push_css(&mut css, "color", v);
2781− }
2250+ "center-y" => {
2251+ push_css(&mut aligned, "margin-top", "auto");
2252+ push_css(&mut aligned, "margin-bottom", "auto");
27822253 }
2783− "border" => {
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" => {
27842265 if let Some(v) = val {
2785− let parts: Vec<&str> = v.splitn(2, ' ').collect();
2786− if parts.len() == 2 {
2787− push_css(
2788− &mut css,
2789− "border",
2790− &format!("{} solid {}", css_px(parts[0]), parts[1]),
2791− );
2266+ let property = if effective_key == "grid-cols" {
2267+ "grid-template-columns"
27922268 } else {
2793− push_css(
2794− &mut css,
2795− "border",
2796− &format!("{} solid currentColor", css_px(parts[0])),
2797− );
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)),
27982274 }
27992275 }
28002276 }
2801− "border-top" | "border-bottom" | "border-left" | "border-right" => {
2277+ "col-span" | "row-span" => {
28022278 if let Some(v) = val {
2803− let parts: Vec<&str> = v.splitn(2, ' ').collect();
2804− if parts.len() == 2 {
2805− push_css(
2806− &mut css,
2807− effective_key,
2808− &format!("{} solid {}", css_px(parts[0]), parts[1]),
2809− );
2279+ let property = if effective_key == "col-span" {
2280+ "grid-column"
28102281 } else {
2811− push_css(
2812− &mut css,
2813− effective_key,
2814− &format!("{} solid currentColor", css_px(parts[0])),
2815− );
2816− }
2817− }
2818− }
2819− "rounded" => {
2820− if let Some(v) = val {
2821− push_css(&mut css, "border-radius", &css_px(v));
2822− }
2823− }
2824− "bold" => push_css(&mut css, "font-weight", "bold"),
2825− "italic" => push_css(&mut css, "font-style", "italic"),
2826− "underline" => push_css(&mut css, "text-decoration", "underline"),
2827− "size" => {
2828− if let Some(v) = val {
2829− push_css(&mut css, "font-size", &css_px(v));
2830− }
2831− }
2832− "font" => {
2833− if let Some(v) = val {
2834− // `font "Inter, sans-serif"` quotes a whole font stack so
2835− // its commas don't split the attribute list.
2836− let v = match v.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
2837− Some(stack) if stack.contains(',') => stack,
2838− _ => v,
2282+ "grid-row"
28392283 };
2840− push_css(&mut css, "font-family", v);
2841− }
2842− }
2843− "transition" => {
2844− if let Some(v) = val {
2845− push_css(&mut css, "transition", v);
2846− }
2847− }
2848− "cursor" => {
2849− if let Some(v) = val {
2850− push_css(&mut css, "cursor", v);
2851− }
2852− }
2853− "opacity" => {
2854− if let Some(v) = val {
2855− push_css(&mut css, "opacity", v);
2284+ push_css(&mut css, property, &format!("span {}", v));
28562285 }
28572286 }
28582287
2859− // Typography
2860− "text-align" => {
2861− if let Some(v) = val {
2862− push_css(&mut css, "text-align", v);
2863− }
2864− }
2865− "line-height" => {
2866− if let Some(v) = val {
2867− push_css(&mut css, "line-height", &css_line_height(v));
2868− }
2869− }
2870− "letter-spacing" => {
2871− if let Some(v) = val {
2872− push_css(&mut css, "letter-spacing", &css_px(v));
2873− }
2874− }
2875− "text-transform" => {
2876− if let Some(v) = val {
2877− push_css(&mut css, "text-transform", v);
2878− }
2879− }
2880− "white-space" => {
2288+ // CSS's `line-clamp` has no support yet without its
2289+ // `-webkit-` fallback, which needs a box to clamp
2290+ "line-clamp" => {
28812291 if let Some(v) = val {
2882− push_css(&mut css, "white-space", v);
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");
28832296 }
28842297 }
28852298
2886− // Overflow & positioning
2887− "overflow" => {
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) => {
28882305 if let Some(v) = val {
2889− push_css(&mut css, "overflow", v);
2306+ push_css(&mut css, key, &with_px(key, v));
28902307 }
28912308 }
2892− "position" => {
2893− if let Some(v) = val {
2894− push_css(&mut css, "position", v);
2895− }
2896− }
2897− "top" => {
2898− if let Some(v) = val {
2899− push_css(&mut css, "top", &css_px(v));
2900− }
2901− }
2902− "right" => {
2903− if let Some(v) = val {
2904− push_css(&mut css, "right", &css_px(v));
2905− }
2906− }
2907− "bottom" => {
2908− if let Some(v) = val {
2909− push_css(&mut css, "bottom", &css_px(v));
2910− }
2911− }
2912− "left" => {
2913− if let Some(v) = val {
2914− push_css(&mut css, "left", &css_px(v));
2915− }
2916− }
2917− "z-index" => {
2918− if let Some(v) = val {
2919− push_css(&mut css, "z-index", v);
2920− }
2921− }
2922−
2923− // Display & visibility
2924− "display" => {
2925− if let Some(v) = val {
2926− push_css(&mut css, "display", v);
2927− }
2928− }
2929− "visibility" => {
2930− if let Some(v) = val {
2931− push_css(&mut css, "visibility", v);
2932− }
2933− }
2934− "hidden" => push_css(&mut css, "display", "none"),
2935−
2936− // Transform & filters
2937− "transform" => {
2938− if let Some(v) = val {
2939− push_css(&mut css, "transform", v);
2940− }
2941− }
2942− "backdrop-filter" => {
2943− if let Some(v) = val {
2944− push_css(&mut css, "backdrop-filter", v);
2945− }
2946− }
2947−
2948− // Effects
2949− "shadow" => {
2950− if let Some(v) = val {
2951− push_css(&mut css, "box-shadow", v);
2952− }
2953− }
2954−
2955− // Flow
2956− "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
2957− "gap-x" => {
2958− if let Some(v) = val {
2959− push_css(&mut css, "column-gap", &css_px(v));
2960− }
2961− }
2962− "gap-y" => {
2963− if let Some(v) = val {
2964− push_css(&mut css, "row-gap", &css_px(v));
2965− }
2966− }
2967−
2968− // Grid
2969− "grid" => {
2970− push_css(&mut css, "display", "grid");
2971− }
2972− "grid-cols" => {
2973− if let Some(v) = val {
2974− if let Ok(n) = v.parse::<u32>() {
2975− push_css(
2976− &mut css,
2977− "grid-template-columns",
2978− &format!("repeat({},1fr)", n),
2979− );
2980− } else {
2981− push_css(&mut css, "grid-template-columns", v);
2982− }
2983− }
2984− }
2985− "grid-rows" => {
2986− if let Some(v) = val {
2987− if let Ok(n) = v.parse::<u32>() {
2988− push_css(
2989− &mut css,
2990− "grid-template-rows",
2991− &format!("repeat({},1fr)", n),
2992− );
2993− } else {
2994− push_css(&mut css, "grid-template-rows", v);
2995− }
2996− }
2997− }
2998− "col-span" => {
2999− if let Some(v) = val {
3000− push_css(&mut css, "grid-column", &format!("span {}", v));
3001− }
3002− }
3003− "row-span" => {
3004− if let Some(v) = val {
3005− push_css(&mut css, "grid-row", &format!("span {}", v));
3006− }
3007− }
3008−
3009− // Animation
3010− "animation" => {
3011− if let Some(v) = val {
3012− push_css(&mut css, "animation", v);
3013− }
3014− }
3015−
3016− // Aspect ratio
3017− "aspect-ratio" => {
3018− if let Some(v) = val {
3019− push_css(&mut css, "aspect-ratio", v);
3020− }
3021− }
3022−
3023− // CSS containment for rendering performance
3024− "contain" => {
3025− if let Some(v) = val {
3026− push_css(&mut css, "contain", v);
3027− } else {
3028− push_css(&mut css, "contain", "layout style paint");
3029− }
3030− }
3031− "content-visibility" => {
3032− if let Some(v) = val {
3033− push_css(&mut css, "content-visibility", v);
3034− } else {
3035− push_css(&mut css, "content-visibility", "auto");
3036− }
3037− }
3038−
3039− // Outline (like border but doesn't affect layout)
3040− "outline" => {
3041− if let Some(v) = val {
3042− let parts: Vec<&str> = v.splitn(2, ' ').collect();
3043− if parts.len() == 2 {
3044− push_css(
3045− &mut css,
3046− "outline",
3047− &format!("{} solid {}", css_px(parts[0]), parts[1]),
3048− );
3049− } else {
3050− push_css(
3051− &mut css,
3052− "outline",
3053− &format!("{} solid currentColor", css_px(parts[0])),
3054− );
3055− }
3056− }
3057− }
3058−
3059− // Logical properties (i18n-aware)
3060− "padding-inline" => {
3061− if let Some(v) = val {
3062− push_css(&mut css, "padding-inline", &css_px_multi(v));
3063− }
3064− }
3065− "padding-block" => {
3066− if let Some(v) = val {
3067− push_css(&mut css, "padding-block", &css_px_multi(v));
3068− }
3069− }
3070− "margin-inline" => {
3071− if let Some(v) = val {
3072− push_css(&mut css, "margin-inline", &css_px_multi(v));
3073− }
3074− }
3075− "margin-block" => {
3076− if let Some(v) = val {
3077− push_css(&mut css, "margin-block", &css_px_multi(v));
3078− }
3079− }
3080−
3081− // Logical property start/end variants
3082− "padding-inline-start"
3083− | "padding-inline-end"
3084− | "padding-block-start"
3085− | "padding-block-end"
3086− | "margin-inline-start"
3087− | "margin-inline-end"
3088− | "margin-block-start"
3089− | "margin-block-end" => {
3090− if let Some(v) = val {
3091− push_css(&mut css, effective_key, &css_px(v));
3092− }
3093− }
3094−
3095− // Logical inset
3096− "inset-inline" | "inset-block" => {
3097− if let Some(v) = val {
3098− push_css(&mut css, effective_key, &css_px_multi(v));
3099− }
3100− }
3101− "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
3102− if let Some(v) = val {
3103− push_css(&mut css, effective_key, &css_px(v));
3104− }
3105− }
3106−
3107− // Logical border
3108− "border-inline"
3109− | "border-block"
3110− | "border-inline-start"
3111− | "border-inline-end"
3112− | "border-block-start"
3113− | "border-block-end" => {
3114− if let Some(v) = val {
3115− push_css(&mut css, effective_key, v);
3116− }
3117− }
3118−
3119− // Logical border-radius
3120− "border-start-start-radius"
3121− | "border-start-end-radius"
3122− | "border-end-start-radius"
3123− | "border-end-end-radius" => {
3124− if let Some(v) = val {
3125− push_css(&mut css, effective_key, &css_px(v));
3126− }
3127− }
3128−
3129− // Logical scroll margins & padding
3130− "scroll-margin-inline"
3131− | "scroll-margin-block"
3132− | "scroll-padding-inline"
3133− | "scroll-padding-block" => {
3134− if let Some(v) = val {
3135− push_css(&mut css, effective_key, &css_px_multi(v));
3136− }
3137− }
3138−
3139− // Logical sizing
3140− "inline-size" | "block-size" | "min-inline-size" | "max-inline-size"
3141− | "min-block-size" | "max-block-size" => {
3142− if let Some(v) = val {
3143− push_css(&mut css, effective_key, &css_px(v));
3144− }
3145− }
3146−
3147− // Scroll snap
3148− "scroll-snap-type" => {
3149− if let Some(v) = val {
3150− push_css(&mut css, "scroll-snap-type", v);
3151− }
3152− }
3153− "scroll-snap-align" => {
3154− if let Some(v) = val {
3155− push_css(&mut css, "scroll-snap-align", v);
3156− }
3157− }
3158−
3159− // Margin
3160− "margin" => {
3161− if let Some(v) = val {
3162− push_css(&mut css, "margin", &css_px_multi(v));
3163− }
3164− }
3165− "margin-x" => {
3166− if let Some(v) = val {
3167− push_css(&mut css, "margin-inline", &css_px(v));
3168− }
3169− }
3170− "margin-y" => {
3171− if let Some(v) = val {
3172− push_css(&mut css, "margin-block", &css_px(v));
3173− }
3174− }
3175−
3176− // Filter
3177− "filter" => {
3178− if let Some(v) = val {
3179− push_css(&mut css, "filter", v);
3180− }
3181− }
3182−
3183− // Object fit/position (for images)
3184− "object-fit" => {
3185− if let Some(v) = val {
3186− push_css(&mut css, "object-fit", v);
3187− }
3188− }
3189− "object-position" => {
3190− if let Some(v) = val {
3191− push_css(&mut css, "object-position", v);
3192− }
3193− }
3194−
3195− // Text shadow
3196− "text-shadow" => {
3197− if let Some(v) = val {
3198− push_css(&mut css, "text-shadow", v);
3199− }
3200− }
3201−
3202− // Text overflow
3203− "text-overflow" => {
3204− if let Some(v) = val {
3205− push_css(&mut css, "text-overflow", v);
3206− }
3207− }
3208−
3209− // Interaction
3210− "pointer-events" => {
3211− if let Some(v) = val {
3212− push_css(&mut css, "pointer-events", v);
3213− }
3214− }
3215− "user-select" => {
3216− if let Some(v) = val {
3217− push_css(&mut css, "user-select", v);
3218− }
3219− }
3220−
3221− // Flexbox/grid alignment
3222− "justify-content" => {
3223− if let Some(v) = val {
3224− push_css(&mut css, "justify-content", v);
3225− }
3226− }
3227− "align-items" => {
3228− if let Some(v) = val {
3229− push_css(&mut css, "align-items", v);
3230− }
3231− }
3232−
3233− // Flex item order
3234− "order" => {
3235− if let Some(v) = val {
3236− push_css(&mut css, "order", v);
3237− }
3238− }
3239−
3240− // Background extras
3241− "background-size" => {
3242− if let Some(v) = val {
3243− push_css(&mut css, "background-size", v);
3244− }
3245− }
3246− "background-position" => {
3247− if let Some(v) = val {
3248− push_css(&mut css, "background-position", v);
3249− }
3250− }
3251− "background-repeat" => {
3252− if let Some(v) = val {
3253− push_css(&mut css, "background-repeat", v);
3254− }
3255− }
3256−
3257− // Text wrapping
3258− "word-break" => {
3259− if let Some(v) = val {
3260− push_css(&mut css, "word-break", v);
3261− }
3262− }
3263− "overflow-wrap" => {
3264− if let Some(v) = val {
3265− push_css(&mut css, "overflow-wrap", v);
3266− }
3267− }
3268−
3269− // Container queries
3270− "container" => {
3271− push_css(&mut css, "container-type", "inline-size");
3272− }
3273− "container-name" => {
3274− if let Some(v) = val {
3275− push_css(&mut css, "container-name", v);
3276− }
3277− }
3278− "container-type" => {
3279− if let Some(v) = val {
3280− push_css(&mut css, "container-type", v);
3281− }
3282− }
3283−
3284− // Overflow axis
3285− "overflow-x" => {
3286− if let Some(v) = val {
3287− push_css(&mut css, "overflow-x", v);
3288− }
3289− }
3290− "overflow-y" => {
3291− if let Some(v) = val {
3292− push_css(&mut css, "overflow-y", v);
3293− }
3294− }
3295−
3296− // Inset (shorthand for top/right/bottom/left)
3297− "inset" => {
3298− if let Some(v) = val {
3299− push_css(&mut css, "inset", &css_px(v));
3300− }
3301− }
3302−
3303− // Accent & caret colors
3304− "accent-color" => {
3305− if let Some(v) = val {
3306− push_css(&mut css, "accent-color", v);
3307− }
3308− }
3309− "caret-color" => {
3310− if let Some(v) = val {
3311− push_css(&mut css, "caret-color", v);
3312− }
3313− }
3314−
3315− // Color scheme & appearance
3316− "color-scheme" => {
3317− if let Some(v) = val {
3318− push_css(&mut css, "color-scheme", v);
3319− }
3320− }
3321− "appearance" => {
3322− if let Some(v) = val {
3323− push_css(&mut css, "appearance", v);
3324− }
3325− }
3326−
3327− // List style
3328− "list-style" => {
3329− if let Some(v) = val {
3330− push_css(&mut css, "list-style", v);
3331− }
3332− }
3333−
3334− // Table
3335− "border-collapse" => {
3336− if let Some(v) = val {
3337− push_css(&mut css, "border-collapse", v);
3338− }
3339− }
3340− "border-spacing" => {
3341− if let Some(v) = val {
3342− push_css(&mut css, "border-spacing", &css_px(v));
3343− }
3344− }
3345−
3346− // Text decoration
3347− "text-decoration" => {
3348− if let Some(v) = val {
3349− push_css(&mut css, "text-decoration", v);
3350− }
3351− }
3352− "text-decoration-color" => {
3353− if let Some(v) = val {
3354− push_css(&mut css, "text-decoration-color", v);
3355− }
3356− }
3357− "text-decoration-thickness" => {
3358− if let Some(v) = val {
3359− push_css(&mut css, "text-decoration-thickness", &css_px(v));
3360− }
3361− }
3362− "text-decoration-style" => {
3363− if let Some(v) = val {
3364− push_css(&mut css, "text-decoration-style", v);
3365− }
3366− }
3367− "text-underline-offset" => {
3368− if let Some(v) = val {
3369− push_css(&mut css, "text-underline-offset", &css_px(v));
3370− }
3371− }
3372−
3373− // Multi-column
3374− "column-width" => {
3375− if let Some(v) = val {
3376− push_css(&mut css, "column-width", &css_px(v));
3377− }
3378− }
3379− "column-rule" => {
3380− if let Some(v) = val {
3381− push_css(&mut css, "column-rule", v);
3382− }
3383− }
3384−
3385− // Place items/self
3386− "place-items" => {
3387− if let Some(v) = val {
3388− push_css(&mut css, "place-items", v);
3389− }
3390− }
3391− "place-self" => {
3392− if let Some(v) = val {
3393− push_css(&mut css, "place-self", v);
3394− }
3395− }
3396−
3397− // Scroll behavior
3398− "scroll-behavior" => {
3399− if let Some(v) = val {
3400− push_css(&mut css, "scroll-behavior", v);
3401− }
3402− }
3403−
3404− // Resize
3405− "resize" => {
3406− if let Some(v) = val {
3407− push_css(&mut css, "resize", v);
3408− }
3409− }
3410−
3411− // New CSS properties
3412− "clip-path" => {
3413− if let Some(v) = val {
3414− push_css(&mut css, "clip-path", v);
3415− }
3416− }
3417− "mix-blend-mode" => {
3418− if let Some(v) = val {
3419− push_css(&mut css, "mix-blend-mode", v);
3420− }
3421− }
3422− "background-blend-mode" => {
3423− if let Some(v) = val {
3424− push_css(&mut css, "background-blend-mode", v);
3425− }
3426− }
3427− "writing-mode" => {
3428− if let Some(v) = val {
3429− push_css(&mut css, "writing-mode", v);
3430− }
3431− }
3432− "column-count" => {
3433− if let Some(v) = val {
3434− push_css(&mut css, "column-count", v);
3435− }
3436− }
3437− "column-gap" => {
3438− if let Some(v) = val {
3439− push_css(&mut css, "column-gap", &css_px(v));
3440− }
3441− }
3442− "text-indent" => {
3443− if let Some(v) = val {
3444− push_css(&mut css, "text-indent", &css_px(v));
3445− }
3446− }
3447− "hyphens" => {
3448− if let Some(v) = val {
3449− push_css(&mut css, "hyphens", v);
3450− }
3451− }
3452− "flex-grow" => {
3453− if let Some(v) = val {
3454− push_css(&mut css, "flex-grow", v);
3455− }
3456− }
3457− "flex-shrink" => {
3458− if let Some(v) = val {
3459− push_css(&mut css, "flex-shrink", v);
3460− }
3461− }
3462− "flex-basis" => {
3463− if let Some(v) = val {
3464− push_css(&mut css, "flex-basis", &css_px(v));
3465− }
3466− }
3467− "isolation" => {
3468− if let Some(v) = val {
3469− push_css(&mut css, "isolation", v);
3470− }
3471− }
3472− "place-content" => {
3473− if let Some(v) = val {
3474− push_css(&mut css, "place-content", v);
3475− }
3476− }
3477− "background-image" => {
3478− if let Some(v) = val {
3479− push_css(&mut css, "background-image", v);
3480− }
3481− }
3482− "font-weight" => {
3483− if let Some(v) = val {
3484− push_css(&mut css, "font-weight", v);
3485− }
3486− }
3487− "font-style" => {
3488− if let Some(v) = val {
3489− push_css(&mut css, "font-style", v);
3490− }
3491− }
3492− "text-wrap" => {
3493− if let Some(v) = val {
3494− push_css(&mut css, "text-wrap", v);
3495− }
3496− }
3497− "will-change" => {
3498− if let Some(v) = val {
3499− push_css(&mut css, "will-change", v);
3500− }
3501− }
3502− "touch-action" => {
3503− if let Some(v) = val {
3504− push_css(&mut css, "touch-action", v);
3505− }
3506− }
3507− "vertical-align" => {
3508− if let Some(v) = val {
3509− push_css(&mut css, "vertical-align", v);
3510− }
3511− }
3512− "scroll-margin" => {
3513− if let Some(v) = val {
3514− push_css(&mut css, "scroll-margin", &css_px(v));
3515− }
3516− }
3517− "scroll-margin-top"
3518− | "scroll-margin-bottom"
3519− | "scroll-margin-left"
3520− | "scroll-margin-right" => {
3521− if let Some(v) = val {
3522− push_css(&mut css, effective_key, &css_px(v));
3523− }
3524− }
3525− "scroll-padding" => {
3526− if let Some(v) = val {
3527− push_css(&mut css, "scroll-padding", &css_px(v));
3528− }
3529− }
3530− "scroll-padding-top"
3531− | "scroll-padding-bottom"
3532− | "scroll-padding-left"
3533− | "scroll-padding-right" => {
3534− if let Some(v) = val {
3535− push_css(&mut css, effective_key, &css_px(v));
3536− }
3537− }
3538− "direction" => {
3539− if let Some(v) = val {
3540− push_css(&mut css, "direction", v);
3541− }
3542− }
3543−
3544− "content" => {
3545− if let Some(v) = val {
3546− // Wrap in quotes if not already quoted and not a CSS keyword
3547− if v.starts_with('"')
3548− || v.starts_with('\'')
3549− || v == "none"
3550− || v == "normal"
3551− || v.starts_with("attr(")
3552− || v.starts_with("counter(")
3553− {
3554− push_css(&mut css, "content", v);
3555− } else {
3556− push_css(&mut css, "content", &format!("\"{}\"", v));
3557− }
3558− }
3559− }
3560−
3561− // --- CSS Shorthands ---
3562− "truncate" => {
3563− push_css(&mut css, "overflow", "hidden");
3564− push_css(&mut css, "text-overflow", "ellipsis");
3565− push_css(&mut css, "white-space", "nowrap");
3566− }
3567− "line-clamp" => {
3568− if let Some(v) = val {
3569− push_css(&mut css, "display", "-webkit-box");
3570− push_css(&mut css, "-webkit-line-clamp", v);
3571− push_css(&mut css, "-webkit-box-orient", "vertical");
3572− push_css(&mut css, "overflow", "hidden");
3573− }
3574− }
3575− "blur" => {
3576− if let Some(v) = val {
3577− push_css(&mut css, "filter", &format!("blur({})", css_px(v)));
3578− }
3579− }
3580− "backdrop-blur" => {
3581− if let Some(v) = val {
3582− push_css(&mut css, "backdrop-filter", &format!("blur({})", css_px(v)));
3583− }
3584− }
3585− "no-scrollbar" => {
3586− push_css(&mut css, "scrollbar-width", "none");
3587− push_css(&mut css, "-ms-overflow-style", "none");
3588− }
3589− "skeleton" => {
3590− push_css(
3591− &mut css,
3592− "background",
3593− "linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%)",
3594− );
3595− push_css(&mut css, "background-size", "200% 100%");
3596− push_css(
3597− &mut css,
3598− "animation",
3599− "hl-skeleton 1.5s ease-in-out infinite",
3600− );
3601− }
3602− "gradient" => {
3603− if let Some(v) = val {
3604− // Parse: "from to [angle]" or "color1 color2 [angle]"
3605− let parts: Vec<&str> = v.split_whitespace().collect();
3606− let bg = if parts.len() >= 3
3607− && (parts[2].ends_with("deg")
3608− || parts[2].ends_with("turn")
3609− || parts[2].ends_with("rad"))
3610− {
3611− format!("linear-gradient({},{},{})", parts[2], parts[0], parts[1])
3612− } else if parts.len() >= 2 {
3613− format!("linear-gradient({},{})", parts[0], parts[1])
3614− } else {
3615− format!("linear-gradient({},transparent)", parts[0])
3616− };
3617− push_css(&mut css, "background", &bg);
3618− }
3619− }
3620−
3621− // Grid areas
3622− "grid-template-areas" => {
3623− if let Some(v) = val {
3624− push_css(&mut css, "grid-template-areas", v);
3625− }
3626− }
3627− "grid-area" => {
3628− if let Some(v) = val {
3629− push_css(&mut css, "grid-area", v);
3630− }
3631− }
3632−
3633− // View transitions
3634− "view-transition-name" => {
3635− if let Some(v) = val {
3636− push_css(&mut css, "view-transition-name", v);
3637− }
3638− }
3639−
3640− // Animate shorthand (alias for animation)
3641− "animate" => {
3642− if let Some(v) = val {
3643− push_css(&mut css, "animation", v);
3644− }
3645− }
3646−
3647− // CSS subgrid
3648− "grid-template-columns" => {
3649− if let Some(v) = val {
3650− push_css(&mut css, "grid-template-columns", v);
3651− }
3652− }
3653− "grid-template-rows" => {
3654− if let Some(v) = val {
3655− push_css(&mut css, "grid-template-rows", v);
3656− }
3657− }
3658−
3659− // Scroll-driven animations
3660− "animation-timeline" => {
3661− if let Some(v) = val {
3662− push_css(&mut css, "animation-timeline", v);
3663− }
3664− }
3665− "animation-range" => {
3666− if let Some(v) = val {
3667− push_css(&mut css, "animation-range", v);
3668− }
3669− }
3670− "view-timeline-name" => {
3671− if let Some(v) = val {
3672− push_css(&mut css, "view-timeline-name", v);
3673− }
3674− }
3675− "view-timeline-axis" => {
3676− if let Some(v) = val {
3677− push_css(&mut css, "view-timeline-axis", v);
3678− }
3679− }
3680− "scroll-timeline-name" => {
3681− if let Some(v) = val {
3682− push_css(&mut css, "scroll-timeline-name", v);
3683− }
3684− }
3685− "scroll-timeline-axis" => {
3686− if let Some(v) = val {
3687− push_css(&mut css, "scroll-timeline-axis", v);
3688− }
3689− }
3690−
3691− // Anchor positioning
3692− "anchor-name" => {
3693− if let Some(v) = val {
3694− push_css(&mut css, "anchor-name", v);
3695− }
3696− }
3697− "position-anchor" => {
3698− if let Some(v) = val {
3699− push_css(&mut css, "position-anchor", v);
3700− }
3701− }
3702− "position-area" => {
3703− if let Some(v) = val {
3704− push_css(&mut css, "position-area", v);
3705− }
3706− }
3707− "inset-area" => {
3708− // Legacy alias for position-area
3709− if let Some(v) = val {
3710− push_css(&mut css, "position-area", v);
3711− }
3712− }
3713−
3714− // initial-letter (drop caps)
3715− "initial-letter" => {
3716− if let Some(v) = val {
3717− push_css(&mut css, "initial-letter", v);
3718− }
3719− }
3720−
3721− // Critical CSS hint — not CSS, handled elsewhere
3722− "critical" => {}
3723−
3724− // Identity and HTML passthrough — not CSS
3725− "id" | "class" => {}
3726− "type" | "placeholder" | "name" | "value" | "disabled" | "required" | "checked"
3727− | "for" | "action" | "method" | "autocomplete" | "min" | "max" | "step" | "pattern"
3728− | "maxlength" | "rows" | "cols" | "multiple" | "alt" | "role" | "tabindex"
3729− | "title" | "controls" | "autoplay" | "loop" | "muted" | "playsinline" | "poster"
3730− | "preload" | "loading" | "decoding" | "ordered" | "src" | "open" | "novalidate"
3731− | "low" | "high" | "optimum" | "colspan" | "rowspan" | "scope" | "inline"
3732− | "responsive" | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list"
3733− | "sandbox" | "allow" | "allowfullscreen" | "referrerpolicy" | "formaction"
3734− | "formmethod" | "formtarget" | "target" | "autofocus" => {}
37352309
37362310 _ => {}
37372311 }
37382312 }
37392313
2314+ css.push_str(&aligned);
37402315 css
37412316}
37422317
@@ -3747,262 +2322,10 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
37472322 css.push(';');
37482323}
37492324
3750−/// Known CSS units — if a value ends with one, skip appending `px`.
3751−/// Format a length value: a bare number gets `px` appended; anything else
3752−/// (values with units, keywords like `auto`, functions like `calc(...)`) is
3753−/// passed through unchanged.
3754−fn css_px(value: &str) -> String {
3755− let v = value.trim();
3756− if v == "0" {
3757− return "0".to_string();
3758− }
3759− let is_bare_number = !v.is_empty()
3760− && v.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
3761− && v.parse::<f64>().is_ok();
3762− if is_bare_number {
3763− format!("{}px", v)
3764− } else {
3765− v.to_string()
3766− }
3767−}
3768−
3769−/// Rewrite literal values that match a declared CSS custom property (from
3770−/// `@theme` or `@let --name value`) to `var(--name)` references. Matches are
3771−/// anchored to CSS value boundaries so e.g. `#3b82f6` inside a longer hex or
3772−/// inside an identifier is not replaced. The `:root` block is emitted before
3773−/// this call runs, so its declarations are not affected.
3774−fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
3775− if css.is_empty() || vars.is_empty() {
3776− return css.to_string();
3777− }
3778− // Prefer longer values first so that if two vars share a prefix, the
3779− // longer (more specific) match wins.
3780− let mut pairs: Vec<(&str, String)> = vars
3781− .iter()
3782− .filter(|(name, value)| name.starts_with("--") && !value.is_empty())
3783− .map(|(name, value)| (value.as_str(), format!("var({})", name)))
3784− .collect();
3785− pairs.sort_by_key(|(v, _)| std::cmp::Reverse(v.len()));
3786− if pairs.is_empty() {
3787− return css.to_string();
3788− }
3789−
3790− let is_boundary_before = |b: Option<u8>| match b {
3791− None => true,
3792− Some(c) => matches!(c, b':' | b' ' | b',' | b'(' | b';' | b'{' | b'\n' | b'\t'),
3793− };
3794− let is_boundary_after = |b: Option<u8>| match b {
3795− None => true,
3796− Some(c) => matches!(c, b';' | b'}' | b',' | b' ' | b')' | b'\n' | b'\t'),
3797− };
3798−
3799− let bytes = css.as_bytes();
3800− let mut out = String::with_capacity(css.len());
3801− let mut i = 0;
3802− let mut prev: Option<u8> = None;
3803− // Only declaration values are rewritten — never selectors or at-rule
3804− // preludes such as `@media (min-width:768px)`. `blocks` records, for
3805− // each open `{`, whether it holds declarations (a style rule) or nested
3806− // rules (an at-rule like `@media` / `@layer`).
3807− let mut blocks: Vec<bool> = Vec::new();
3808− let mut prelude_start = 0;
3809− let mut in_value = false;
3810− while i < bytes.len() {
3811− match bytes[i] {
3812− b'{' => {
3813− let prelude = css[prelude_start..i].trim_start();
3814− blocks.push(!prelude.starts_with('@'));
3815− in_value = false;
3816− prelude_start = i + 1;
3817− }
3818− b'}' => {
3819− blocks.pop();
3820− in_value = false;
3821− prelude_start = i + 1;
3822− }
3823− b';' => {
3824− in_value = false;
3825− prelude_start = i + 1;
3826− }
3827− b':' if blocks.last() == Some(&true) => in_value = true,
3828− _ => {}
3829− }
3830− if in_value && is_boundary_before(prev) {
3831− let mut matched = false;
3832− for (val, repl) in &pairs {
3833− let vb = val.as_bytes();
3834− if i + vb.len() <= bytes.len() && &bytes[i..i + vb.len()] == vb {
3835− let next = bytes.get(i + vb.len()).copied();
3836− if is_boundary_after(next) {
3837− out.push_str(repl);
3838− prev = repl.as_bytes().last().copied();
3839− i += vb.len();
3840− matched = true;
3841− break;
3842− }
3843− }
3844− }
3845− if matched {
3846− continue;
3847− }
3848− }
3849− // Advance by one UTF-8 code point.
3850− let start = i;
3851− i += 1;
3852− while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
3853− i += 1;
3854− }
3855− out.push_str(&css[start..i]);
3856− prev = bytes.get(i - 1).copied();
3857− }
3858− out
3859−}
3860−
3861−/// Scan generated CSS for literal property values that repeat often enough
3862−/// that substituting them with an auto-named custom property would reduce
3863−/// total byte count. Returns the rewritten CSS and any new vars that should
3864−/// be appended to `:root`.
3865−///
3866−/// The algorithm only promotes a value if it strictly saves bytes after
3867−/// accounting for the `--xN:value;` declaration overhead plus the
3868−/// `var(--xN)` reference cost at each call site. Values that are already
3869−/// `var(...)` references or contain `var(` calls are skipped so we never
3870−/// nest references. Names avoid collisions with `existing_vars`.
3871−fn auto_extract_repeats(
3872− css: &str,
3873− existing_vars: &[(String, String)],
3874−) -> (String, Vec<(String, String)>) {
3875− if css.is_empty() {
3876− return (css.to_string(), Vec::new());
3877− }
3878− // Count occurrences of each distinct property value (the text between
3879− // `:` and the next `;` or `}` at the top level of a declaration block,
3880− // ignoring balanced parentheses so that e.g. `rgba(...)` stays intact).
3881− let bytes = css.as_bytes();
3882− let mut counts: HashMap<String, usize> = HashMap::new();
3883− let mut depth: i32 = 0;
3884− let mut i = 0;
3885− while i < bytes.len() {
3886− match bytes[i] {
3887− b'{' => {
3888− depth += 1;
3889− i += 1;
3890− continue;
3891− }
3892− b'}' => {
3893− if depth > 0 {
3894− depth -= 1;
3895− }
3896− i += 1;
3897− continue;
3898− }
3899− _ => {}
3900− }
3901− if depth > 0 && bytes[i] == b':' {
3902− // Skip past `:` then walk to the terminator.
3903− let start = i + 1;
3904− let mut end = start;
3905− let mut pdepth = 0i32;
3906− while end < bytes.len() {
3907− match bytes[end] {
3908− b'(' => pdepth += 1,
3909− b')' if pdepth > 0 => pdepth -= 1,
3910− b';' | b'}' if pdepth == 0 => break,
3911− b'{' if pdepth == 0 => break,
3912− _ => {}
3913− }
3914− end += 1;
3915− }
3916− let val = css[start..end].trim();
3917− if !val.is_empty() && !val.starts_with("var(") && !val.contains("var(") {
3918− *counts.entry(val.to_string()).or_default() += 1;
3919− }
3920− i = end;
3921− continue;
3922− }
3923− i += 1;
3924− }
3925−
3926− // Decide which values are worth extracting. A value of length L appearing
3927− // N times costs N*L bytes inline; promoting it costs (L + 6) for the
3928− // `--hK:V;` declaration plus N * ref_len for the call sites. We estimate
3929− // ref_len optimistically as `var(--h0)` (9 bytes) and fall back to 10 for
3930− // two-digit indices, which only affects very large extraction counts.
3931− let existing: std::collections::HashSet<&str> =
3932− existing_vars.iter().map(|(n, _)| n.as_str()).collect();
3933− let mut candidates: Vec<(String, usize)> =
3934− counts.into_iter().filter(|(_, n)| *n >= 2).collect();
3935− // Stable, deterministic ordering — longest values first, tiebreak by text.
3936− candidates.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
3937−
3938− let mut extracted: Vec<(String, String)> = Vec::new();
3939− let mut next_idx: usize = 0;
3940− for (value, n) in candidates {
3941− let l = value.len();
3942− // Allocate the next unused `--hN` name.
3943− let (name, ref_len) = loop {
3944− let candidate = format!("--h{}", next_idx);
3945− next_idx += 1;
3946− let rl = candidate.len() + 6; // `var(` + name + `)`
3947− if !existing.contains(candidate.as_str())
3948− && !extracted.iter().any(|(n, _)| *n == candidate)
3949− {
3950− break (candidate, rl);
3951− }
3952− };
3953− // Skip if promotion would not strictly reduce byte count.
3954− // Before: n * l bytes. After: (l + name.len() + 2) for the :root
3955− // declaration (`<name>:<value>;`) plus n * ref_len for the sites.
3956− let decl_overhead = l + name.len() + 2;
3957− let before = n * l;
3958− let after = decl_overhead + n * ref_len;
3959− if after < before {
3960− extracted.push((name, value));
3961− } else {
3962− // Rewind the index so the next value can reuse this slot.
3963− next_idx -= 1;
3964− }
3965− }
3966−
3967− if extracted.is_empty() {
3968− return (css.to_string(), Vec::new());
3969− }
3970− let new_css = substitute_css_vars(css, &extracted);
3971− (new_css, extracted)
3972−}
3973−
3974−/// Format a `line-height` value. CSS accepts either a unitless multiplier
3975−/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
3976−/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which
3977−/// CSS interprets as 24× font-size — almost never what anyone wants). Treat
3978−/// integers ≥ 2 as pixel lengths; anything with a decimal, an existing unit,
3979−/// or the value `0`/`1` passes through unchanged.
3980−fn css_line_height(value: &str) -> String {
3981− let v = value.trim();
3982− if v == "0" || v == "1" {
3983− return v.to_string();
3984− }
3985− // Plain integer — treat as pixel length. Decimals, units, keywords and
3986− // functions pass through.
3987− if !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()) {
3988− return format!("{}px", v);
3989− }
3990− v.to_string()
3991−}
3992−
3993−/// Format multiple space-separated values, each getting px if needed.
3994−fn css_px_multi(value: &str) -> String {
3995− value
3996− .split_whitespace()
3997− .map(css_px)
3998− .collect::<Vec<_>>()
3999− .join(" ")
4000−}
4001−
40022325fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
40032326 let mut id = None;
40042327 let mut class = None;
4005− for attr in attrs {
2328+ for attr in attrs.iter().filter(|a| a.html) {
40062329 match attr.key.as_str() {
40072330 "id" => id = attr.value.clone(),
40082331 "class" => class = attr.value.clone(),
@@ -4012,153 +2335,6 @@ fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
40122335 (id, class)
40132336}
40142337
4015−/// Read image dimensions from a local file by parsing the header bytes.
4016−/// Supports PNG, JPEG, GIF, WebP, AVIF, and SVG.
4017−fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
4018− // SVG: parse as text for viewBox/width/height attributes
4019− if path.ends_with(".svg") || path.ends_with(".SVG") {
4020− let text = std::fs::read_to_string(path).ok()?;
4021− return read_svg_dimensions(&text);
4022− }
4023−
4024− let data = std::fs::read(path).ok()?;
4025− if data.len() < 12 {
4026− return None;
4027− }
4028−
4029− // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
4030− if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") {
4031− let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
4032− let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
4033− return Some((w, h));
4034− }
4035−
4036− // GIF: "GIF87a" or "GIF89a", width/height at bytes 6-9 (little-endian)
4037− if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4038− let w = u16::from_le_bytes([data[6], data[7]]) as u32;
4039− let h = u16::from_le_bytes([data[8], data[9]]) as u32;
4040− return Some((w, h));
4041− }
4042−
4043− // JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2)
4044− if data.starts_with(b"\xff\xd8") {
4045− let mut i = 2;
4046− while i + 9 < data.len() {
4047− if data[i] != 0xFF {
4048− i += 1;
4049− continue;
4050− }
4051− let marker = data[i + 1];
4052− if marker == 0xC0 || marker == 0xC2 {
4053− let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
4054− let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
4055− return Some((w, h));
4056− }
4057− if i + 3 < data.len() {
4058− let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
4059− i += 2 + len;
4060− } else {
4061− break;
4062− }
4063− }
4064− }
4065−
4066− // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
4067− if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4068− if &data[12..16] == b"VP8 " && data.len() >= 30 {
4069− let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
4070− let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
4071− return Some((w, h));
4072− }
4073− if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
4074− let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
4075− let w = (bits & 0x3FFF) + 1;
4076− let h = ((bits >> 14) & 0x3FFF) + 1;
4077− return Some((w, h));
4078− }
4079− }
4080−
4081− // AVIF: ISOBMFF container with "ftyp" box containing "avif"/"avis" brand,
4082− // then "ispe" box with width/height
4083− if data.len() >= 12 && &data[4..8] == b"ftyp" {
4084− let brand = &data[8..12];
4085− if brand == b"avif" || brand == b"avis" || brand == b"mif1" {
4086− return read_avif_dimensions(&data);
4087− }
4088− }
4089−
4090− None
4091−}
4092−
4093−/// Parse SVG viewBox or width/height attributes to get dimensions.
4094−fn read_svg_dimensions(text: &str) -> Option<(u32, u32)> {
4095− // Try viewBox first: viewBox="minX minY width height"
4096− if let Some(vb_start) = text.find("viewBox=\"") {
4097− let rest = &text[vb_start + 9..];
4098− if let Some(end) = rest.find('"') {
4099− let parts: Vec<&str> = rest[..end].split_whitespace().collect();
4100− if parts.len() == 4
4101− && let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
4102− && w > 0.0
4103− && h > 0.0
4104− {
4105− return Some((w.round() as u32, h.round() as u32));
4106− }
4107− }
4108− }
4109− // Fall back to width/height attributes on <svg>
4110− let svg_tag = text.find("<svg")?;
4111− let tag_end = text[svg_tag..].find('>')? + svg_tag;
4112− let tag = &text[svg_tag..tag_end];
4113− let w = extract_svg_attr(tag, "width")?;
4114− let h = extract_svg_attr(tag, "height")?;
4115− Some((w, h))
4116−}
4117−
4118−fn extract_svg_attr(tag: &str, attr: &str) -> Option<u32> {
4119− let needle = format!("{}=\"", attr);
4120− let start = tag.find(&needle)? + needle.len();
4121− let rest = &tag[start..];
4122− let end = rest.find('"')?;
4123− let val = rest[..end].trim_end_matches("px");
4124− val.parse::<f64>().ok().map(|v| v.round() as u32)
4125−}
4126−
4127−/// Parse AVIF (ISOBMFF) container to find ispe box with image dimensions.
4128−fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
4129− // Walk ISOBMFF boxes looking for "ispe" (image spatial extents)
4130− let mut i = 0;
4131− while i + 8 <= data.len() {
4132− let box_size =
4133− u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
4134− let box_type = &data[i + 4..i + 8];
4135− if box_size < 8 {
4136− break;
4137− }
4138− let box_end = (i + box_size).min(data.len());
4139− // ispe box: 4 bytes version/flags + 4 bytes width + 4 bytes height
4140− if box_type == b"ispe" && box_end >= i + 20 {
4141− let w = u32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
4142− let h = u32::from_be_bytes([data[i + 16], data[i + 17], data[i + 18], data[i + 19]]);
4143− return Some((w, h));
4144− }
4145− // Recurse into container boxes (meta, iprp, ipco)
4146− if matches!(
4147− box_type,
4148− b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia"
4149− ) {
4150− let header_size = if box_type == b"meta" { 12 } else { 8 };
4151− if i + header_size < box_end
4152− && let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end])
4153− {
4154− return Some(dims);
4155− }
4156− }
4157− i = box_end;
4158− }
4159− None
4160−}
4161−
41622338fn html_escape(s: &str) -> String {
41632339 s.replace('&', "&amp;")
41642340 .replace('<', "&lt;")
@@ -4274,49 +2450,30 @@ fn vlq_encode(value: i64, out: &mut String) {
42742450 }
42752451}
42762452
4277−/// Check if a specific element kind exists anywhere in the node tree.
4278−fn has_element_kind(nodes: &[Node], target: &ElementKind) -> bool {
4279− for node in nodes {
4280− if let Node::Element(elem) = node {
4281− if elem.kind == *target {
4282− return true;
4283− }
4284− if has_element_kind(&elem.children, target) {
4285− return true;
4286− }
4287− }
4288− }
4289− false
4290−}
4291−
4292−/// Collect unique external domains from generated HTML for DNS prefetch hints.
4293−fn collect_dns_prefetch(html: &str, dev: bool) -> String {
4294− let mut domains = Vec::new();
4295− let mut rest = html;
4296− while let Some(pos) = rest.find("https://") {
4297− let start = pos + 8; // skip "https://"
4298− rest = &rest[start..];
4299− let end = rest
4300− .find(['/', '"', '\'', ' ', '>', ')'])
4301− .unwrap_or(rest.len());
4302− let domain = &rest[..end];
4303− if !domain.is_empty() && domain.contains('.') && !domains.contains(&domain.to_string()) {
4304− domains.push(domain.to_string());
4305− }
4306− }
4307− let mut out = String::new();
4308− for domain in &domains {
4309− if dev {
4310− out.push_str(&format!(
4311− "<link rel=\"dns-prefetch\" href=\"//{}\">\n",
4312− domain
4313− ));
4314− } else {
4315− out.push_str(&format!(
4316− "<link rel=\"dns-prefetch\" href=\"//{}\">",
4317− domain
4318− ));
4319− }
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()));
43202478 }
4321− out
43222479}
crates/htmlang-core/src/codegen_tests.rs+33−14
@@ -1,6 +1,8 @@
11#[cfg(test)]
22mod tests {
3− use crate::codegen::{generate, generate_dev, generate_partial, json_str, 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..50_000).map(short_class_name).collect();
62− assert_eq!(names.len(), 50_000, "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]
@@ -93,10 +118,4 @@ mod tests {
93118 "duplicate CSS rule emitted ({count} times) in:\n{out}"
94119 );
95120 }
96−
97− #[test]
98− fn json_str_escapes_quotes_and_controls() {
99− assert_eq!(json_str(r#"My "App""#), r#""My \"App\"""#);
100− assert_eq!(json_str("a\\b\n\t\u{1}"), r#""a\\b\n\t\u0001""#);
101− }
102121}
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+6185−5284

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/snippets/htmlang.json+256−192

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+1297−998

This diff is too big to show here.

src/bin/htmlang_lsp/completion.rs+1477−1250

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+343−964

This diff is too big to show here.

src/bin/htmlang_lsp/main.rs+57−132

This diff is too big to show here.

src/bin/htmlang_lsp/navigation.rs+503−262

This diff is too big to show here.

src/bin/htmlang_lsp/state.rs+41−3

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−1434

This diff is too big to show here.

src/fmt.rs+401−410

This diff is too big to show here.

src/lib.rs+4−1

This diff is too big to show here.

src/main.rs+464−3047

This diff is too big to show here.

src/playground_default.hldeleted−330

This diff is too big to show here.

src/serve.rs+128−157

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.rs+2240−55

This diff is too big to show here.

tests/snapshots.rs+4528−1190

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.html+1−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.html+1−1

This diff is too big to show here.

tests/snapshots/anchor_positioning.html+1−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.html+1−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.html+1−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.html+1−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/assert_directive.htmldeleted−1

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.html+1−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.html+1−1

This diff is too big to show here.

tests/snapshots/breadcrumb.hldeleted−4

This diff is too big to show here.

tests/snapshots/breadcrumb.htmldeleted−1

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.html+1−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.html+1−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.html+1−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.html+1−1

This diff is too big to show here.

tests/snapshots/conditionals.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−1

This diff is too big to show here.

tests/snapshots/direction_attr.html+1−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.html+1−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.html+1−1

This diff is too big to show here.

tests/snapshots/each_index.html+1−1

This diff is too big to show here.

tests/snapshots/each_range.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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/implicit_el.htmldeleted−1

This diff is too big to show here.

tests/snapshots/initial_letter.html+1−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.html+1−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.html+1−1

This diff is too big to show here.

tests/snapshots/layer_wrapping.html+1−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.html+1−1

This diff is too big to show here.

tests/snapshots/loops.html+1−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.html+1−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.html+1−1

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.html+1−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.html+1−1

This diff is too big to show here.

tests/snapshots/media_extended.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+8−5

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.html+1−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.html+1−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.html+1−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.html+1−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_5.htmldeleted−1

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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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/pagination_each.htmldeleted−1

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.html+1−1

This diff is too big to show here.

tests/snapshots/positional.html+1−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.html+1−1

This diff is too big to show here.

tests/snapshots/pseudo_states.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−1

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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−1

This diff is too big to show here.

tests/snapshots/string_interpolation.html+1−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.html+1−1

This diff is too big to show here.

tests/snapshots/subgrid_css.html+1−1

This diff is too big to show here.

tests/snapshots/table_elements.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−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.html+1−1

This diff is too big to show here.