on master help sign in
275 files changed+31731−16933
since splitsnapshots
unifiedsplit
.gitattributes+3 .github/workflows/playground.yml+4−5 .gitignore+1 CONTRIBUTING.md+107−14 Cargo.lock−211 Cargo.toml−2 DESIGN.md+1423−305 README.md+190−70 crates/htmlang-core/src/ast.rs+624−123 crates/htmlang-core/src/codegen.rs+1576−2327 crates/htmlang-core/src/codegen_tests.rs+33−14 crates/htmlang-core/src/diagnostic.rs+245 crates/htmlang-core/src/expr.rs+532−182 crates/htmlang-core/src/interp.rs+651 crates/htmlang-core/src/lib.rs+4 crates/htmlang-core/src/parser.rs+6188−3881 crates/htmlang-core/src/std.hl+4−45 crates/htmlang-core/src/syntax.rs+2943 crates/htmlang-core/src/value.rs+406 crates/htmlang-core/src/vocab.rs+777−157 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+254−184 editors/vscode/syntaxes/htmlang.tmLanguage.json+253−30 examples/blog.hl+86−48 examples/blog.html.map+1−1 examples/demo.hl+276−302 examples/demo.html+800−3 examples/demo.html.map+1−1 examples/docs.hl+132−61 examples/docs.html.map+1−1 examples/landing.hl+131−65 examples/landing.html.map+1−1 examples/posts.json+27 src/bin/htmlang_lsp/analysis.rs+1297−966 src/bin/htmlang_lsp/completion.rs+1484−1103 src/bin/htmlang_lsp/docs.rs+616 src/bin/htmlang_lsp/hover.rs+343−927 src/bin/htmlang_lsp/main.rs+56−115 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+6−10 src/convert.rs−1425 src/fmt.rs+401−408 src/lib.rs+3−2 src/main.rs+228−549 src/serve.rs+128−157 src/upgrade.rs−1001 tests/common/mod.rs+140 tests/docs.rs+116−19 tests/editor.rs+157 tests/fmt.rs+55 tests/regressions.rs+2154−56 tests/snapshots.rs+4433−1179 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+11−7 tests/snapshots/animate_shorthand.html+1−1 tests/snapshots/animations.hl+9−5 tests/snapshots/animations.html+1−1 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.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+1−1 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−1 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.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+3−3 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+4−4 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+19−8 tests/snapshots/new_directives.html+8−5 tests/snapshots/new_elements.html+1−1 tests/snapshots/new_elements_2.hl+1−1 tests/snapshots/new_elements_2.html+1−1 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+1−1 tests/snapshots/new_elements_6.html+1−1 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.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+1−1 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+3−3 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+1−1 tests/snapshots/responsive_images.html+1−1 tests/snapshots/scope_css.hl+1−1 tests/snapshots/scope_css.html+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+1−1 tests/snapshots/starting_style.html+1−1 tests/snapshots/stress_deeply_nested.hl+129−129 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.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.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.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+107−14
@@ -19,20 +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− - `ast.rs` — the element kinds; every plain HTML element is one row in `TAGS`.
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.
2375 - `vocab.rs` — the attribute vocabulary (htmlang attributes, CSS properties,
24− HTML attributes) and the state/media prefixes.
25− - `expr.rs` — the expression language for conditions and computed values.
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.
2696 - `std.hl` — the standard library, written in htmlang and loaded before
2797 every file.
2898- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
29−- `src/` — CLI, dev server, formatter, and HTML-to-hl converter.
30−- `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`.
31104- `editors/vscode/` — VS Code extension.
32105- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
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.
33109- `tests/regressions.rs` — one test per fixed bug.
34−- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`.
35−- `examples/` — sample `.hl` files used as smoke tests and documentation.
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`).
36116
37117## Adding a feature
38118
@@ -41,21 +121,34 @@ Linux, macOS, and Windows. Your change should pass all three.
41121 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
42122 tests alongside the code.
431232. Prefer the smallest mechanism: a component belongs in `std.hl`, an HTML
44− element is a row in `TAGS`, and a CSS property needs no code at all. Only
45− thread a feature through the parser, codegen and LSP when it needs to be.
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`.
461293. Document it in `DESIGN.md` (the examples there are compiled by the tests).
47− If it's user-facing, also update `README.md`.
48−4. If it removes or renames syntax, add a rewrite to `src/upgrade.rs` and a
49− hint to `REMOVED_SYNTAX` in the parser.
50−5. If it changes the CLI surface, update the `--help` output.
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.
51133
52134## Style
53135
54136- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
55137- Keep `@` prefixes and bracket-attribute syntax consistent with existing
56− 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`.
57145- Diagnostics should include `line` and, when practical, `column` and a
58146 `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.
59152- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
60153 diagnostic so the compiler keeps going.
61154
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+1423−305
@@ -1,381 +1,1410 @@
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:
45
5−## Principles
6−
7−- `@` means structure, bare lines mean content.
8−- Layout is explicit and compositional: every element declares its own layout
9− role, and there is no CSS cascade to reason about.
10−- Output is one self-contained HTML file with embedded CSS (flexbox), and no
11− JavaScript unless you write some.
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+```
1213
1314Every example block in this file is compiled by the test suite
1415(`tests/docs.rs`), so the examples stay correct.
1516
17+## Principles
18+
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.
44+
1645## Syntax
1746
18−### Structure
47+### Elements
1948
2049```
2150@element [attributes] argument
2251 children
2352```
2453
25−Children are indented under their parent. Attributes are comma-separated inside
26−`[...]` and may span several 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:
2758
2859```
2960@el [
3061 padding 20,
3162 background white,
32− rounded 8,
33− 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)
3465]
3566 Content
3667```
3768
38−An attribute is either a **style**, written `key value` (`padding 20`,
39−`color red`), or an **HTML attribute**, written `key=value` (`id=main`,
40−`type=email`, `aria-label=Close`). Boolean HTML attributes are written bare
41−(`required`, `disabled`, `open`). A comma inside `(...)` or `"..."` does not
42−split attributes, so a font stack is written `font "Inter, sans-serif"`.
69+An attribute can take one of three forms:
70+
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`).
79+
80+A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else,
81+`\,` keeps a comma in the value:
82+
83+```
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]
86+```
87+
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 `\,`.
91+
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.
95+
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).
100+
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:
103+
104+```
105+@text Use [ to open a list
106+@paragraph Arrays look like [1, 2, 3].
107+```
43108
44109### Text
45110
46−Any line that doesn't start with `@` or `[` is text. Text after an element's
47−attributes is its content, and `{...}` puts elements inside a line of text:
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:
48115
49116```
50117@paragraph
51− This is {@text [bold] important} and this is a {@link https://example.com link}.
52−@text [bold, size 24, color #333] Hello world
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}
53123@section [padding 8] Text after the attributes is content too.
54124```
55125
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.
141+
142+```
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
151+```
152+
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`).
156+
157+### Escapes
158+
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`.
162+
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 |
172+
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`, `$$`.
177+
178+```
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]
183+```
184+
56185### Comments
57186
58−`--` at the start of a line begins a comment. Comments must be on their own
59−line: `--` later in a line is ordinary text.
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).
60193
61194```
62195-- this is a comment
63196@row [spacing 10]
64− -- todo: add nav items
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
65207```
66208
67−### Shorthands
209+### Chains
68210
69−A line starting with `[` is an anonymous `@el`. `>` chains single-child
70−elements on one line; the last element in the chain gets the indented children.
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).
71218
72219```
73−[padding 20, background white]
74− Hello
75−
76−@el [padding 16, background blue, rounded 8] > @link https://example.com
220+@el [padding 16, background blue, border-radius 8] > @link https://example.com
77221 @text [color white] Get Started
78222```
79223
80−### Raw content
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`).
81233
82−`@raw """ ... """` is pasted into the output verbatim.
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.
83240
84241```
85−@raw """
86−<div class="custom-widget"></div>
87−"""
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; }
88248```
89249
90−## Elements
250+### Directives
91251
92−Layout elements set up flexbox; the rest map to the HTML element of the same
93−name.
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)).
94256
95−| Element | Output | Purpose |
257+| Directive | Argument | Body |
96258|---|---|---|
97−| `@row` | div, flex row | Horizontal layout |
98−| `@column` | div, flex column | Vertical layout |
99−| `@el` | div, flex column | Generic container |
100−| `@grid` | div, grid | Grid container (use `grid-cols`) |
101−| `@stack` | div, position relative | Children layered on top of each other |
102−| `@in-front` / `@behind` | div, absolute | Overlay layers filling the parent (see below) |
103−| `@text` | span | Styled inline text |
104−| `@paragraph` | p | Flowing text with inline elements |
105−| `@link url` | a | Link; text after the URL becomes its content |
106−| `@image src` | img | Image |
107−| `@h1` … `@h6` | h1 … h6 | Headings |
108−
109−Semantic containers, all laid out as columns: `@nav`, `@header`, `@footer`,
110−`@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@form`,
111−`@details` / `@summary`, `@dialog`, `@figure` / `@figcaption`, `@blockquote` /
112−`@cite`, `@fieldset` / `@legend`, `@noscript`.
113−
114−Content: `@list` / `@item` (`@list [ordered]` for `<ol>`), `@dl` / `@dt` /
115−`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
116−`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
117−`@canvas`, `@iframe src`, `@video src`, `@audio src`, `@picture` / `@source`,
118−`@script`, `@fragment` (children without a wrapper).
119−
120−Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
121−`@label`, `@datalist`. `@form URL` sets the form's `action`.
122−
123−```
124−@list [ordered]
125− @item First
126− @item Second
127−@form [method=post] /subscribe
128− @label [for=email] Email
129− @input [type=email, name=email, id=email, required]
130− @button [type=submit] Send
131−@details [open]
132− @summary Question
133− @text The answer.
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:
343+
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
134410```
135411
136−### `@in-front` / `@behind`
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
137421
138−Overlay layers, like elm-ui's `inFront` and `behind`. Their children fill the
139−parent's bounds; the parent automatically becomes a positioning context
140−(`position: relative; isolation: isolate`). An explicit `position` on the parent
141−wins.
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>`.
427+
428+```
429+@paragraph
430+ Price: {@el [padding 2 6, background #fef3c7, border-radius 4] $9} a month.
431+@h2
432+ Plans
433+ @row [spacing 4, font-size 14] > @text New
434+```
435+
436+Any other element keeps its HTML element. One whose HTML ends a paragraph,
437+such as `@section`, `@ul` or `@h2`, is a warning inside `@paragraph`
438+(`block-in-paragraph`): the browser would move it, and what follows it, out
439+of the `<p>`.
440+
441+`@link` is text too, so a card written inside a link is an inline `<span>`
442+that shrinks to its content. A clickable card is the link itself, laid out
443+as a column with CSS:
444+
445+```
446+@link [href=/post, display flex, flex-direction column, gap 8, padding 16, border 1 solid #e5e7eb]
447+ @strong A post
448+ @text Its summary.
449+```
450+
451+### Overlays: `@in-front` and `@behind`
452+
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.
142457
143458```
144459@el [width 200, height 200, background blue]
145460 @text [color white] Main content
146461 @in-front
147− @text [color yellow] Painted on top
462+ @text [color yellow, align-right, align-bottom] Painted on top
148463 @behind
149− @el [background red, width fill, height fill]
464+ @el [background red, height fill]
150465```
151466
152467### Standard library
153468
154−These components are written in htmlang (`std.hl`) and available in every file.
155−Use them like elements; your own `@let` with the same name takes precedence.
156−
157−| Component | Purpose |
158−|---|---|
159−| `@badge` | Small pill for counts and statuses |
160−| `@tag` | Label with slightly rounded corners |
161−| `@chip` | Outlined, fully rounded label |
162−| `@avatar` | Circular frame for an image or initials |
163−| `@spacer` | Takes up the remaining space in a row or column |
164−| `@tooltip [tip TEXT]` | Text that shows `TEXT` when hovered |
165−| `@carousel` | Horizontally scrolling row that snaps to each child |
166−| `@breadcrumb` | Breadcrumb trail of `@item`s |
167−| `$skeleton`, `$no-scrollbar` | Attribute bundles: loading placeholder, hidden scrollbars |
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`.
168474
169475```
170476@row [spacing 8, align-items center]
171− @badge [background #ef4444, color white] 3
172− @chip Rust
477+ @text Logo
173478 @spacer
174− @tooltip [tip Opens in a new tab] Help
175−@breadcrumb
176− @item > @link / Home
177− @item Docs
178−@el [$skeleton, height 20, width fill]
479+ @text [$truncate, max-width 200] A long title that gets cut off
179480```
180481
181−## Attributes
182−
183−### Layout and sizing
184−
185−| Attribute | Effect |
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 |
186513|---|---|
187−| `spacing N` | Gap between children (also `gap-x N`, `gap-y N`) |
188−| `padding N` / `padding Y X` / `padding T R B L` | Padding (also `padding-x`, `padding-y`) |
189−| `margin ...` | Margin, same forms as padding (also `margin-x`, `margin-y`) |
190−| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
191−| `height fill` / `height N` / `height shrink` | Same for height |
192−| `center-x`, `center-y` | Center within the parent |
193−| `align-left`, `align-right`, `align-top`, `align-bottom` | Align within the parent |
194−| `wrap` | Let a row wrap |
195−| `grid-cols N`, `grid-rows N`, `col-span N`, `row-span N` | Grid layout |
196−| `hidden` | `display: none` |
197−
198−### Style
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.
199543
200−| Attribute | Effect |
201−|---|---|
202−| `border N COLOR` (also `border-top` etc.) | Solid border |
203−| `rounded N` | Border radius |
204−| `shadow VALUE` | Box shadow |
205−| `bold`, `italic`, `underline` | Text style |
206−| `size N` | Font size |
207−| `font NAME` | Font family |
208−| `truncate`, `line-clamp N` | Cut text off with an ellipsis |
209−| `blur N`, `backdrop-blur N` | Blur filters |
210−
211−**Any standard CSS property** can also be used as an attribute, with the same
212−name and value: `background red`, `opacity 0.5`, `cursor pointer`,
213−`z-index 10`, `margin-top 16`, `grid-template-areas "a b"`, and so on.
214−
215−**Units.** For lengths, a bare number is pixels (`padding 20` is `20px`).
216−Values with a unit, keywords and CSS functions are passed through: `width 50%`,
217−`margin 0 auto`, `max-width min(100%, 800px)`.
544+```
545+@el [-webkit-tap-highlight-color transparent, -webkit-text-stroke 1px black]
546+ Passed through as written
547+```
218548
219−### HTML attributes
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`.
220570
221−Write HTML attributes as `key=value`: `id=main`, `class=note`, `href=/about`,
222−`type=email`, `alt=Logo`, `target=_blank`, `aria-label=Close menu`,
223−`data-id=42`. Any name works. Booleans are written bare: `required`,
224−`disabled`, `checked`, `open`, `sandbox`. A style and an HTML attribute may
225−share a name without clashing:
571+```
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:
226589
227590```
228−@image [width=800, width 200, alt=A photo] photo.jpg
229−@select [size=4, size 18]
230− @option One
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.
231598```
232599
233−### State and media prefixes
600+### Prefixes
234601
235−Prefix a style attribute to apply it conditionally:
602+A prefix applies a style only in some condition:
236603
237604| Prefix | Applies |
238605|---|---|
239−| `hover:`, `active:`, `focus:`, `focus-visible:`, `focus-within:`, `disabled:`, `checked:`, `visited:`, `target:`, `valid:`, `invalid:`, `empty:`, `placeholder:`, `selection:` | In that state |
240−| `first:`, `last:`, `odd:`, `even:`, `nth:EXPR:` | By position among siblings |
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`, ...) |
241609| `children:` | To each direct child |
242−| `before:`, `after:` | On the `::before` / `::after` pseudo-element (with `content`) |
243−| `has(SELECTOR):` | When the element contains a match |
244−| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640–1536px) |
245−| `cq-sm:` … `cq-2xl:` | Container queries |
246−| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Media conditions |
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+```
247692
248−`@breakpoint name WIDTH` defines a custom responsive prefix.
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.
249742
250743```
251−@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
744+@let card [padding 16, border 1 solid #e5e7eb, border-radius 8]
745+@let active true
746+@el [padding 16, background #3b82f6, hover:background #2563eb, md:[padding 32, font-size 20], dark:[background #1e3a8a, hover:background #1e40af]]
252747 @text [color white] Click me
253−@row [spacing 4, children:flex 1, nth:2n:background #f3f4f6]
254− @el A
255− @el B
256−@el [before:content "→ ", before:color red]
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]
257753 Item with an arrow
754+@section [spacing 16, md:$card, hover:if($active, [background #eef2ff, color #3730a3])]
755+ A card from md up
258756```
259757
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+
260770### Conditional attributes
261771
262−`key if CONDITION` includes an attribute only when the condition holds;
263−`if(CONDITION, A, B)` picks a value.
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:
264777
265778```
266779@let active true
267−@el [background if($active, blue, gray), bold if $active]
780+@el [if($active, background blue, background gray), if($active, font-weight bold)]
268781 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:
836+
269837```
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`).
270877
271−## Variables and functions
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"
954+```
955+
956+### HTML attributes
272957
273−`@let` defines everything reusable. What it defines depends on its shape:
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:
968+
969+```
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
975+```
976+
977+## Definitions
978+
979+`@let` defines everything reusable. What it defines depends on its form:
274980
275981```
276982-- A value, used as $primary
277983@let primary #3b82f6
278−-- A computed value: `=` makes it an expression
984+-- A computed value: `=` makes the rest an expression
279985@let gap = 8 * 2
280−-- A quoted string, with $variables interpolated
281−@let greeting "Hello $primary"
986+-- Quoted text, with $variables filled in (see Variables for its quotes)
987+@let greeting "Hello from $primary"
282988-- An attribute bundle, used as [$card]
283−@let card [padding 20, background white, rounded 8]
284−-- A function: a @let with an indented body
285−@let panel $title $tone=neutral
286− @el [$card]
287− @text [bold] $title
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
288994 @children
289995
290996@panel [title Welcome]
291− Body text goes into @children.
997+ $greeting
292998@el [$card, background #f9fafb]
293999 Attributes after a bundle override it.
2941000```
2951001
296−A function is called like an element:
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
1187+ @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
1199+```
1200+
1201+`@children` takes a fallback the same way:
2971202
298−- Its parameters are passed as attributes; parameters with `=default` may be
299− omitted.
300−- Other attributes style its root element, so `@panel [title Hi, padding 40]`
301− works like styling a built-in element.
302−- Text after the attributes and indented children replace `@children`, and a
303− caller's `@slot name` block replaces `@slot name` (the slot's own children are
304− the default).
305−- An `@style` block at the top of the body is scoped to the function: its rules
306− only apply inside the function's output.
1203+```
1204+@let @notice
1205+ @el [padding 12, background #fef3c7]
1206+ @children
1207+ @text Nothing to report.
3071208
1209+@notice
1210+@notice Deploys are paused today.
3081211```
309−@let note $kind=info
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]
3101259 @style
1260+ & { border-left: 4px solid #3b82f6; }
3111261 .title { font-weight: bold; }
312− @el [padding 12, rounded 6, background #eff6ff]
1262+ @el [padding 12, border-radius 6, background #eff6ff]
3131263 @text [class=title] $kind
3141264 @children
3151265
3161266@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
3171267```
3181268
319−A multi-line string uses triple quotes; its indented lines are the value:
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:
3201272
3211273```
322−@let intro """
323− First line
324− Second line
325− """
326−@text $intro
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
1280+
1281+@paragraph
1282+ @intro
1283+@paragraph
1284+ In short: {@intro} Press {@key Ctrl+K} to search.
3271285```
3281286
329−`@let --name value` also emits a CSS custom property. `@theme` declares a group
330−of design tokens at once; each becomes both `$name` and `--name`:
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.
3311293
3321294```
333−@theme
334− brand #3b82f6
335− radius 8
336−@el [background $brand, rounded $radius] Themed
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]
3371308```
3381309
339−### Filters
340−
341−`$name|filter` transforms a value: `uppercase`, `lowercase`, `capitalize`,
342−`trim`, `length`, `reverse`, `truncate:N`, `replace:OLD:NEW`, `default:VALUE`,
343−and for colors `lighten:N`, `darken:N`, `alpha:N`, `mix:COLOR:N`.
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%)` |
3441348
3451349```
346−@let name htmlang
347−@let base #3b82f6
348−@text [color $base|darken:10] $name|uppercase
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%
3491357```
3501358
351−### Checks
352−
353−`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
354−warning. `@deprecated MESSAGE` before a function warns every caller.
355−
3561359## Expressions
3571360
3581361Conditions and computed values (`@let x = ...`) are expressions:
3591362
3601363| | |
3611364|---|---|
362−| Values | numbers, `"strings"` (with `$var` interpolation), `$variables` (with filters), `true`, `false`, and bare words (`dark`, `#fff`) as strings |
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 |
3631366| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
364−| Text | `~` joins two values: `$first ~ " " ~ $last` |
365−| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains`, `starts-with`, `ends-with` |
366−| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
367−| Choice | `if(CONDITION, A, B)` |
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)):
3681394
369−Variables are looked up while evaluating, so a value containing `==` or spaces
370−is still one value. An invalid expression is a compile error.
1395+```
1396+@let name htmlang
1397+@let size 12
1398+@text [font-size ${$size + 4}] ${uppercase($name)} has ${length($name)} letters
1399+```
3711400
3721401## Control flow
3731402
3741403All control flow runs at compile time.
3751404
3761405```
377−@let items apple, banana, cherry
3781406@let count 3
1407+@let hidden false
3791408
3801409@if $count > 2 and not $hidden
3811410 @text Many
@@ -383,119 +1412,208 @@ All control flow runs at compile time.
3831412 @text None
3841413@else
3851414 @text Few
1415+```
3861416
387−@each $item in $items
388− @text $_index: $item
389−@else
390− @text The list is empty.
391−
392−@each $i in 1..10 step 3
393− @text $i
394−
395−@each $label, $url in Home /, About /about
396− @link $url $label
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.
3971426
398−@match $count
399− @case 3
400− @text Three
401− @default
402− @text Other
4031427```
1428+@let fruits apple, banana, cherry
1429+@each $fruit, $i in $fruits
1430+ @text ${$i + 1}. $fruit
1431+@else
1432+ @text No fruit.
4041433
405−A range counts down when its start is greater than its end.
1434+@row [spacing 8]
1435+ @each $n in 10..0 step 5
1436+ @text $n
1437+```
4061438
407−## Files and data
1439+## Data and files
4081440
4091441| Directive | Effect |
4101442|---|---|
411−| `@include file.hl` | Insert another file here, content and definitions |
412−| `@import file.hl` | Take only its definitions (`@let`, bundles, functions) |
413−| `@extends layout.hl` | Render this page inside a layout (below) |
414−| `@data file.json` / `@data $prefix file.json` | Load JSON values as variables |
415−| `@fetch $prefix http://...` | Like `@data`, fetched at build time (http only) |
416−| `@collection $name "glob"` | List files matching a pattern |
417−| `@translations` | Per-locale strings, used as `$t.key` |
418−| `@env NAME default` | Read an environment variable |
419−| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
420−| `@svg file.svg` | Inline an SVG file |
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 |
1450+
1451+Records keep their values whole, even when a value contains spaces or
1452+commas:
1453+
1454+```
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
1465+```
1466+
1467+### Page layouts
4211468
422−With `@extends`, the layout marks where content goes with `@slot name` (named
423−blocks) and `@children` (everything in the page outside `@slot` blocks):
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.
4241473
4251474```
4261475-- layout.hl
427−@page My Site
428−@column [max-width 800, center-x]
429− @slot header
430− @text Default header
431− @children
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
4321484```
4331485
4341486```
4351487-- page.hl
436−@extends layout.hl
437−@slot header
438− @text About us
439−@paragraph This fills @children.
1488+@include layout.hl
1489+@layout
1490+ @slot header
1491+ @h1 About us
1492+ @paragraph This fills @children.
4401493```
4411494
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+
4421506## Page and head
4431507
444−`@page TITLE` produces a full HTML document; without it the output is a fragment.
445−Its attributes set `lang`, `favicon`, `canonical` and `base`. `@meta NAME VALUE`
446−adds a meta tag (`og:` names become Open Graph `property` tags), and
447−`@font-face NAME URL`, `@manifest NAME` (with indented settings), `@json-ld`
448−(indented JSON) and `@head` (indented raw HTML) add the rest.
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.
4491532
4501533```
451−@page [lang en, favicon /favicon.png] My Site
1534+@page [lang=en, favicon favicon.png, color #1f2937, dark:background #0b1220, dark:color #e5e7eb] My Site
4521535@meta description A small site
4531536@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
4541544```
4551545
1546+Translations are a JSON file per locale: `@data $t locales/$lang.json`
1547+loads `locales/fr.json` when `$lang` is `fr`.
1548+
4561549## CSS
4571550
458−- `@style` holds raw CSS, including at-rules such as `@scope`, `@property` and
459− `@starting-style`.
460−- `@keyframes name` takes htmlang attribute syntax (`from [opacity 0]`) or raw
461− CSS.
462−- Generated rules live in `@layer htmlang` (after a `hl-reset` layer), so any
463− CSS in `@style` or `@raw` overrides them.
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:
4641554
4651555```
466−@keyframes fade-in
467− from [opacity 0]
468− to [opacity 1]
4691556@style
1557+ @keyframes fade-in {
1558+ from { opacity: 0; }
1559+ to { opacity: 1; }
1560+ }
4701561 .note { color: gray; }
1562+@style .quiet { opacity: 0.6; }
4711563@el [animation fade-in 0.3s ease, class=note] Fades in
4721564```
4731565
474−## Upgrading older files
475−
476−`htmlang upgrade [dir|file]` rewrites removed syntax; the compiler reports each
477−removed form with its replacement.
478−
479−| Removed | Use instead |
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 |
4801598|---|---|
481−| `@fn`, `@define`, `@mixin`, `@component` | `@let` (function, bundle; `@style` in a function body is scoped) |
482−| `@unless COND` | `@if not COND` |
483−| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
484−| `@switch` | `@match` |
485−| `@use "file" names` | `@import file` |
486−| `@with $x as y` | `@let y $x` |
487−| `@layout file` | `@extends file` |
488−| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
489−| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
490−| `@og KEY VALUE` | `@meta og:KEY VALUE` |
491−| `@debug`, `@log` | `@warn` |
492−| `@defer` | Its content, directly |
493−| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@column`, `@paragraph`, `@image`, `@item`, `@button`, `@list`, `@hr`, `@option` |
494−| `type email`, `id main` (HTML attributes written as styles) | `type=email`, `id=main` |
495−| `skeleton`, `no-scrollbar`, `gradient A B` | `$skeleton`, `$no-scrollbar`, `background linear-gradient(A, B)` |
496−| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
497−| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
498−| `COND ? A : B` | `if(COND, A, B)` |
499−| `...$bundle` | `$bundle` |
500−| `animate`, `inset-area` | `animation`, `position-area` |
501−| `\|upper`, `\|lower`, `\|cap`, `\|len` | `\|uppercase`, `\|lowercase`, `\|capitalize`, `\|length` |
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+190−70
@@ -1,35 +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
24+ Layout without writing a stylesheet.
25+ @card [title Static]
26+ One HTML file, with its CSS inside.
2927```
3028
31−This compiles to one self-contained `.html` file with flexbox layout and
32−generated CSS classes. No JavaScript, no external dependencies.
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.
33121
34122## Install
35123
@@ -40,86 +128,118 @@ cargo install --path .
40128## Usage
41129
42130```
43−htmlang page.hl # compile page.hl -> page.html
131+htmlang page.hl # compile page.hl to page.html
44132htmlang page.hl -o out.html # choose the output file
45−htmlang -w page.hl # recompile on change
46133htmlang serve . # dev server with live reload
134+htmlang build src -o dist # compile a whole site
47135```
48136
49137| Command | Purpose |
50138|---|---|
51−| `build <dir> [-o out] [--minify] [--compat] [--strict]` | Compile every `.hl` file under a directory |
52−| `serve [dir\|file] [-p PORT] [--open] [--https --cert C --key K]` | Dev server with live reload |
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 |
53141| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
54142| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
55143| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
56−| `fmt <file.hl>` | Format a file in place |
57−| `convert <file.html>` | Convert HTML to htmlang (prints to stdout) |
58−| `upgrade [dir\|file]` | Rewrite syntax from older versions of the language |
144+| `fmt <file.hl>...` | Format files in place |
59145| `lsp` | Run the language server over stdio |
60146
61−Compiling directly also takes `--dev`, `--compat`, `--strict`, `--partial`,
62−`--format json`, `-s` / `--serve`, `-p` / `--port` and `--open`.
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`.
63151
64−## Editor support
65−
66−A VS Code extension with syntax highlighting and LSP integration is in
67−[`editors/vscode`](editors/vscode). The language server (`htmlang-lsp`) provides
68−diagnostics, completions, hover documentation, go to definition, rename and
69−formatting.
152+## A short tour
70153
71−## Language tour
154+Layout attributes place an element. Everything else is CSS, and `key=value`
155+is an HTML attribute:
72156
73−Elements are laid out with `@row`, `@column` and `@el`; `@text`, `@paragraph`,
74−`@link` and `@image` hold content, and every other HTML element is available by
75−name (`@nav`, `@section`, `@table`, `@input`, ...).
157+```
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
163+```
76164
77−Attributes are styles, written `key value`, or HTML attributes, written
78−`key=value`:
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:
79168
80169```
81−-- gap between children, padding, and an HTML id
82−@row [spacing 20, padding 16, id=toolbar]
83− -- take the remaining space; center the text
84− @el [width fill, text-align center] Search
85− @input [type=search, placeholder=Find..., width 240]
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
86172```
87173
88−Any standard CSS property works as a style (`opacity 0.5`, `margin-top 16`).
89−Prefixes apply a style conditionally:
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:
90176
91177```
92−@el [background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
93− @text [color white] Click me
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
94183```
95184
96−`@let` defines values, computed values, attribute bundles and functions:
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.
97192
98193```
99−@let primary #3b82f6
194+@let --primary #3b82f6
100195@let gap = 8 * 2
101−@let card [padding 20, rounded 8]
102−@let button $label
103− @el [$card, background $primary]
104− @text [color white, bold] $label
196+@let rounded [border-radius 8, overflow hidden]
197+
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]
105203 @children
106204
107−@button [label Click me, padding 12]
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.
108210```
109211
110−Functions are used like elements: extra attributes (here `padding 12`) style
111−their root element. A small standard library (`@badge`, `@chip`, `@tooltip`,
112−`@carousel`, `@breadcrumb`, ...) is written the same way.
113−
114−Control flow runs at compile time:
212+Data, loops and conditions run at compile time:
115213
116214```
117−@let items Home, About, Contact
118−@row [spacing 8]
119− @each $item in $items
120− @if $item != About
121− @link /$item|lowercase $item
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
122226```
123227
124−See [DESIGN.md](DESIGN.md) for the full language: files and data (`@include`,
125−`@import`, `@extends`, `@data`), page metadata, filters, expressions and CSS.
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.
231+
232+## Editor support
233+
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+624−123
@@ -3,45 +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 manifest: Option<ManifestConfig>,
24− pub preload_hints: Vec<PreloadHint>,
2518 pub nodes: Vec<Node>,
2619}
2720
28−#[derive(Debug, Clone)]
29−pub struct ManifestConfig {
30− pub name: String,
31− pub short_name: Option<String>,
32− pub start_url: String,
33− pub display: String,
34− pub background_color: Option<String>,
35− pub theme_color: Option<String>,
36− pub description: Option<String>,
37− pub icons: Vec<(String, String)>, // (src, sizes)
38−}
39−
40−#[derive(Debug, Clone)]
41−pub struct PreloadHint {
42− pub href: String,
43− pub as_type: String, // "font", "image", "style", "script"
44− 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>,
4533}
4634
4735#[derive(Debug, Clone)]
@@ -61,32 +49,52 @@ pub enum TextSegment {
6149pub struct Element {
6250 pub kind: ElementKind,
6351 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.
6456 pub argument: Option<String>,
6557 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.
6660 pub line_num: usize,
61+ /// The function whose body wrote it, when it comes from a call.
62+ pub function: Option<String>,
6763}
6864
69−
7065#[derive(Debug, Clone)]
7166pub struct Attribute {
7267 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.
7370 pub value: Option<String>,
7471 /// Written `key=value`: an HTML attribute rather than a style.
7572 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,
7686}
7787
7888#[derive(Debug, Clone, PartialEq)]
7989pub enum ElementKind {
8090 // Layout and text: the core of the language
8191 Row,
82− Column,
8392 El,
8493 Text,
8594 Paragraph,
8695 Link,
8796 Image,
8897 // Elements generated in a special way
89− Script,
9098 Fragment,
9199 // Function bodies: placeholders for the caller's content
92100 Children,
@@ -100,13 +108,11 @@ impl ElementKind {
100108 pub fn name(&self) -> &str {
101109 match self {
102110 ElementKind::Row => "row",
103− ElementKind::Column => "column",
104111 ElementKind::El => "el",
105112 ElementKind::Text => "text",
106113 ElementKind::Paragraph => "paragraph",
107114 ElementKind::Link => "link",
108115 ElementKind::Image => "image",
109− ElementKind::Script => "script",
110116 ElementKind::Fragment => "fragment",
111117 ElementKind::Children => "children",
112118 ElementKind::Slot(_) => "slot",
@@ -121,6 +127,86 @@ impl ElementKind {
121127 }
122128 }
123129
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+
124210 /// Is this the table element named `name` (e.g. `"main"`)?
125211 pub fn is_tag(&self, name: &str) -> bool {
126212 self.spec().is_some_and(|spec| spec.name == name)
@@ -130,13 +216,11 @@ impl ElementKind {
130216 pub fn from_name(name: &str) -> Option<ElementKind> {
131217 Some(match name {
132218 "row" => ElementKind::Row,
133− "column" => ElementKind::Column,
134219 "el" => ElementKind::El,
135220 "text" => ElementKind::Text,
136221 "paragraph" => ElementKind::Paragraph,
137222 "link" => ElementKind::Link,
138223 "image" => ElementKind::Image,
139− "script" => ElementKind::Script,
140224 "fragment" => ElementKind::Fragment,
141225 "children" => ElementKind::Children,
142226 "slot" => ElementKind::Slot(String::new()),
@@ -147,8 +231,15 @@ impl ElementKind {
147231 /// Every element name, for suggestions and completions.
148232 pub fn all_names() -> impl Iterator<Item = &'static str> {
149233 [
150− "row", "column", "el", "text", "paragraph", "link", "image", "script", "fragment",
151− "children", "slot",
234+ "row",
235+ "el",
236+ "text",
237+ "paragraph",
238+ "link",
239+ "image",
240+ "fragment",
241+ "children",
242+ "slot",
152243 ]
153244 .into_iter()
154245 .chain(TAGS.iter().map(|spec| spec.name))
@@ -158,12 +249,90 @@ impl ElementKind {
158249/// What an element does with the text after its name and attributes.
159250#[derive(Debug, Clone, Copy, PartialEq)]
160251pub enum TagArg {
161− /// Leading text content (`@section Hello`).
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.
162255 Child,
163− /// Printed as the element's text (`@item First`).
164− Text,
165− /// Emitted as this HTML attribute (`@iframe URL` sets `src`).
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 `${...}`.
166260 Attr(&'static str),
261+ /// `@source`'s leading attribute, which depends on where it is:
262+ /// `srcset` directly inside `@picture`, `src` elsewhere (`@video`,
263+ /// `@audio`).
264+ Source,
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.
303+ Grid,
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,
316+}
317+
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+ }
167336}
168337
169338/// How an element compiles: the data behind [`ElementKind::Tag`].
@@ -173,17 +342,19 @@ pub struct TagSpec {
173342 pub name: &'static str,
174343 /// HTML element emitted.
175344 pub html: &'static str,
176− /// Default CSS (flex column, margin reset, ...).
345+ /// Default CSS besides the layout's `display` (margin reset, ...).
177346 pub css: &'static str,
178347 pub arg: TagArg,
179− /// Bare text children are wrapped in `<span>` (flex containers).
180− pub wraps_text: bool,
181− /// Accepts layout attributes for its children (`spacing`, ...).
182− pub container: bool,
183− /// Void element: no children, no closing tag (`<hr>`, `<input>`).
184− pub void: bool,
185− /// Children are joined with spaces, like a paragraph (headings).
186− pub inline: bool,
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,
187358}
188359
189360impl TagSpec {
@@ -192,83 +363,413 @@ impl TagSpec {
192363 html: "",
193364 css: "",
194365 arg: TagArg::Child,
195− wraps_text: false,
196− container: false,
197− void: false,
198− inline: false,
366+ layout: Layout::Native,
367+ literal: false,
368+ verbatim: false,
199369 };
200370}
201371
202372/// Every element besides the core ones in [`ElementKind`].
373+#[rustfmt::skip]
203374pub static TAGS: &[TagSpec] = &[
204− TagSpec { name: "nav", html: "nav", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
205− TagSpec { name: "header", html: "header", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
206− TagSpec { name: "footer", html: "footer", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
207− TagSpec { name: "main", html: "main", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
208− TagSpec { name: "section", html: "section", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
209− TagSpec { name: "article", html: "article", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
210− TagSpec { name: "aside", html: "aside", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
211− TagSpec { name: "details", html: "details", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
212− TagSpec { name: "dialog", html: "dialog", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
213− TagSpec { name: "search", html: "search", wraps_text: true, container: true, ..TagSpec::DEFAULT },
214− TagSpec { name: "address", html: "address", wraps_text: true, container: true, ..TagSpec::DEFAULT },
215− TagSpec { name: "noscript", html: "noscript", wraps_text: true, container: true, ..TagSpec::DEFAULT },
216− TagSpec { name: "form", html: "form", css: "display:flex;flex-direction:column;", arg: TagArg::Attr("action"), wraps_text: true, container: true, ..TagSpec::DEFAULT },
217− TagSpec { name: "figure", html: "figure", css: "display:flex;flex-direction:column;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
218− TagSpec { name: "blockquote", html: "blockquote", css: "display:flex;flex-direction:column;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
219− TagSpec { name: "fieldset", html: "fieldset", css: "display:flex;flex-direction:column;border:1px solid currentColor;padding:8px;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
220− TagSpec { name: "datalist", html: "datalist", wraps_text: true, container: true, ..TagSpec::DEFAULT },
221− TagSpec { name: "list", html: "ul", css: "margin:0;padding-left:0;list-style:none;", container: true, ..TagSpec::DEFAULT },
222− TagSpec { name: "item", html: "li", css: "display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
223− TagSpec { name: "dl", html: "dl", css: "margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
224− TagSpec { name: "dt", html: "dt", arg: TagArg::Text, container: true, ..TagSpec::DEFAULT },
225− TagSpec { name: "dd", html: "dd", css: "margin:0;display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
226− TagSpec { name: "table", html: "table", ..TagSpec::DEFAULT },
227− TagSpec { name: "thead", html: "thead", ..TagSpec::DEFAULT },
228− TagSpec { name: "tbody", html: "tbody", ..TagSpec::DEFAULT },
229− TagSpec { name: "tr", html: "tr", ..TagSpec::DEFAULT },
230− TagSpec { name: "select", html: "select", ..TagSpec::DEFAULT },
231− TagSpec { name: "picture", html: "picture", ..TagSpec::DEFAULT },
232− TagSpec { name: "progress", html: "progress", ..TagSpec::DEFAULT },
233− TagSpec { name: "meter", html: "meter", ..TagSpec::DEFAULT },
234− TagSpec { name: "output", html: "output", ..TagSpec::DEFAULT },
235− TagSpec { name: "canvas", html: "canvas", container: true, ..TagSpec::DEFAULT },
236− TagSpec { name: "td", html: "td", arg: TagArg::Text, ..TagSpec::DEFAULT },
237− TagSpec { name: "th", html: "th", arg: TagArg::Text, ..TagSpec::DEFAULT },
238− TagSpec { name: "button", html: "button", arg: TagArg::Text, ..TagSpec::DEFAULT },
239− TagSpec { name: "label", html: "label", arg: TagArg::Text, ..TagSpec::DEFAULT },
240− TagSpec { name: "option", html: "option", arg: TagArg::Text, ..TagSpec::DEFAULT },
241− TagSpec { name: "textarea", html: "textarea", arg: TagArg::Text, ..TagSpec::DEFAULT },
242− TagSpec { name: "summary", html: "summary", arg: TagArg::Text, ..TagSpec::DEFAULT },
243− TagSpec { name: "cite", html: "cite", arg: TagArg::Text, ..TagSpec::DEFAULT },
244− TagSpec { name: "figcaption", html: "figcaption", arg: TagArg::Text, ..TagSpec::DEFAULT },
245− TagSpec { name: "legend", html: "legend", arg: TagArg::Text, ..TagSpec::DEFAULT },
246− TagSpec { name: "time", html: "time", arg: TagArg::Text, ..TagSpec::DEFAULT },
247− TagSpec { name: "mark", html: "mark", arg: TagArg::Text, ..TagSpec::DEFAULT },
248− TagSpec { name: "abbr", html: "abbr", arg: TagArg::Text, ..TagSpec::DEFAULT },
249− TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
250− TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
251− TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", ..TagSpec::DEFAULT },
252− TagSpec { name: "h1", html: "h1", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
253− TagSpec { name: "h2", html: "h2", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
254− TagSpec { name: "h3", html: "h3", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
255− TagSpec { name: "h4", html: "h4", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
256− TagSpec { name: "h5", html: "h5", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
257− TagSpec { name: "h6", html: "h6", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
258− TagSpec { name: "input", html: "input", void: true, ..TagSpec::DEFAULT },
259− TagSpec { name: "hr", html: "hr", void: true, ..TagSpec::DEFAULT },
260− TagSpec { name: "source", html: "source", arg: TagArg::Attr("src"), void: true, ..TagSpec::DEFAULT },
261− TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
262− TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
263− TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), container: true, ..TagSpec::DEFAULT },
264− TagSpec { name: "grid", html: "div", css: "display:grid;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
265− TagSpec { name: "stack", html: "div", css: "position:relative;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
266− TagSpec { name: "in-front", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;", container: true, ..TagSpec::DEFAULT },
267− TagSpec { name: "behind", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;z-index:-1;", container: true, ..TagSpec::DEFAULT },
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 },
268478];
269479
270−/// Elements that print their argument as their text (`@text Hello`,
271−/// `@item First`).
272−pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
273− *kind == ElementKind::Text || kind.spec().is_some_and(|spec| spec.arg == TagArg::Text)
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+ }
274775}
crates/htmlang-core/src/codegen.rs+1576−2327
@@ -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);
32− }
33− let mut tail = Vec::with_capacity(len - 1);
34− for _ in 1..len {
35− tail.push(REST[n % 36]);
36− n /= 36;
37− }
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
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+ }
217+ }
218+ out
219+}
220+
221+/// The rule for the elements an element prefix picks out inside the
222+/// element with the class `class` (see [`Condition::inside`]): the scope's
223+/// root, from the selector prefixes before it (`hover:@td:` is inside a
224+/// hovered element), and the selector for the elements, from those after
225+/// it (`@td:hover:` is a hovered cell). `:scope td` leaves out the root
226+/// itself, so `@ul [@ul:...]` styles the lists inside, not its own. `None`
227+/// when the name has no tag of its own (the parser reports it).
228+fn scoped_selector(class: &str, chain: &[String], at: usize) -> Option<(String, String)> {
229+ let name = crate::vocab::element_prefix(&chain[at])?;
230+ let tag = ElementKind::from_name(name)?.own_tag()?;
231+ let root = selector(class, &chain[..at]);
232+ let mut target = format!(":scope {}", tag);
233+ for prefix in &chain[at + 1..] {
234+ if let Some(pseudo) = crate::vocab::pseudo_selector(prefix) {
235+ target.push_str(&pseudo);
236+ }
237+ }
238+ Some((root, target))
239+}
240+
241+/// The `@media` or `@container` rule an at-rule prefix stands for, as
242+/// written in readable and in compact output.
243+fn at_rule(rank: usize, dev: bool) -> String {
244+ let prefix = crate::vocab::at_rule_prefixes()
245+ .nth(rank)
246+ .unwrap_or_default();
247+ let name = prefix.trim_end_matches(':');
248+ let width = |name: &str| {
249+ BREAKPOINTS
250+ .iter()
251+ .find(|(n, _)| *n == name)
252+ .map_or("0", |(_, w)| *w)
253+ };
254+ let (kind, feature, value) = if let Some(size) = name.strip_prefix("cq-") {
255+ ("@container", "min-width", width(size))
256+ } else {
257+ match name {
258+ "print" => {
259+ return "@media print".to_string();
260+ }
261+ "dark" => ("@media", "prefers-color-scheme", "dark"),
262+ "motion-safe" => ("@media", "prefers-reduced-motion", "no-preference"),
263+ "motion-reduce" => ("@media", "prefers-reduced-motion", "reduce"),
264+ "landscape" | "portrait" => ("@media", "orientation", name),
265+ _ => ("@media", "min-width", width(name)),
266+ }
267+ };
268+ if dev {
269+ format!("{} ({}: {})", kind, feature, value)
270+ } else {
271+ format!("{}({}:{})", kind, feature, value)
272+ }
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,313 +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− /// All generated rules, wrapped in `@layer htmlang`.
159− fn to_css_formatted(&self, dev: bool) -> String {
160− let mut css = String::new();
161− let inner_indent = if dev { " " } else { "" };
162− css.push_str(if dev { "@layer htmlang {\n" } else { "@layer htmlang{" });
163−
164− // Non-responsive rules. Base and pseudo rules are merged separately by
165− // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
166− // replaces two identical rules. Pseudo variants are grouped per selector
167− // suffix (`:hover` with `:hover`, etc.) to keep each pseudo's cascade
168− // position independent of others.
169− let mut base_pairs: Vec<(&str, &str)> = Vec::with_capacity(self.entries.len());
170− for e in &self.entries {
171− if !e.base.is_empty() {
172− base_pairs.push((e.class_name.as_str(), e.base.as_str()));
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+ }
399+
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(","));
423+ if dev {
424+ kind.push_str(&format!(" {} {{{}}}\n", selector, body));
425+ } else {
426+ kind.push_str(&format!("{}{{{}}}", selector, body));
173427 }
174428 }
175− emit_grouped_rules(&mut css, &base_pairs, "", inner_indent, dev);
176429
177− // Collect pseudo rules grouped by selector suffix, preserving the order
178− // in which suffixes first appear across entries.
179− let mut pseudo_order: Vec<&str> = Vec::new();
180− let mut pseudo_buckets: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
181− for e in &self.entries {
182− for (selector, body) in &e.pseudo {
183− if body.is_empty() {
184− continue;
185− }
186− let key = selector.as_str();
187− if !pseudo_buckets.contains_key(key) {
188− pseudo_order.push(key);
189− }
190− pseudo_buckets
191− .entry(key)
192− .or_default()
193− .push((e.class_name.as_str(), body.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));
194438 }
195439 }
196− for selector in &pseudo_order {
197− if let Some(pairs) = pseudo_buckets.get(selector) {
198− emit_grouped_rules(&mut css, pairs, selector, inner_indent, dev);
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;
449+ }
450+ if dev {
451+ css.push_str(&format!("@layer {} {{\n{}}}\n", name, rules));
452+ } else {
453+ css.push_str(&format!("@layer {}{{{}}}", name, rules));
199454 }
200455 }
456+ css
457+ }
201458
202− // Responsive rules grouped by breakpoint
203− for &(bp_name, bp_width) in BREAKPOINTS {
204− let mut bp_pairs: Vec<(&str, &str)> = Vec::new();
205− for e in &self.entries {
206− for (bp, rule_css) in &e.responsive {
207− if bp == bp_name && !rule_css.is_empty() {
208− 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+ }
209538 }
210539 }
211− }
212− emit_media_block(
213− &mut css,
214− &format!("@media (min-width: {})", bp_width),
215− &format!("@media(min-width:{})", bp_width),
216− &bp_pairs,
217− "",
218− dev,
219− );
220− }
221−
222− // Dark mode rules
223− let dark_pairs: Vec<(&str, &str)> = self
224− .entries
225− .iter()
226− .filter(|e| !e.dark.is_empty())
227− .map(|e| (e.class_name.as_str(), e.dark.as_str()))
228− .collect();
229− emit_media_block(
230− &mut css,
231− "@media (prefers-color-scheme: dark)",
232− "@media(prefers-color-scheme:dark)",
233− &dark_pairs,
234− "",
235− dev,
236− );
237−
238− // Print rules
239− let print_pairs: Vec<(&str, &str)> = self
240− .entries
241− .iter()
242− .filter(|e| !e.print.is_empty())
243− .map(|e| (e.class_name.as_str(), e.print.as_str()))
244− .collect();
245− emit_media_block(
246− &mut css,
247− "@media print",
248− "@media print",
249− &print_pairs,
250− "",
251− dev,
252− );
253−
254− // Motion safe rules
255− let motion_safe_pairs: Vec<(&str, &str)> = self
256− .entries
257− .iter()
258− .filter(|e| !e.motion_safe.is_empty())
259− .map(|e| (e.class_name.as_str(), e.motion_safe.as_str()))
260− .collect();
261− emit_media_block(
262− &mut css,
263− "@media (prefers-reduced-motion: no-preference)",
264− "@media(prefers-reduced-motion:no-preference)",
265− &motion_safe_pairs,
266− "",
267− dev,
268− );
269−
270− // Motion reduce rules
271− let motion_reduce_pairs: Vec<(&str, &str)> = self
272− .entries
273− .iter()
274− .filter(|e| !e.motion_reduce.is_empty())
275− .map(|e| (e.class_name.as_str(), e.motion_reduce.as_str()))
276− .collect();
277− emit_media_block(
278− &mut css,
279− "@media (prefers-reduced-motion: reduce)",
280− "@media(prefers-reduced-motion:reduce)",
281− &motion_reduce_pairs,
282− "",
283− dev,
284− );
285−
286− // Landscape rules
287− let landscape_pairs: Vec<(&str, &str)> = self
288− .entries
289− .iter()
290− .filter(|e| !e.landscape.is_empty())
291− .map(|e| (e.class_name.as_str(), e.landscape.as_str()))
292− .collect();
293− emit_media_block(
294− &mut css,
295− "@media (orientation: landscape)",
296− "@media(orientation:landscape)",
297− &landscape_pairs,
298− "",
299− dev,
300− );
301−
302− // Portrait rules
303− let portrait_pairs: Vec<(&str, &str)> = self
304− .entries
305− .iter()
306− .filter(|e| !e.portrait.is_empty())
307− .map(|e| (e.class_name.as_str(), e.portrait.as_str()))
308− .collect();
309− emit_media_block(
310− &mut css,
311− "@media (orientation: portrait)",
312− "@media(orientation:portrait)",
313− &portrait_pairs,
314− "",
315− dev,
316− );
317−
318− // Container query rules grouped by breakpoint
319− for &(bp_name, bp_width) in BREAKPOINTS {
320− let mut cq_pairs: Vec<(&str, &str)> = Vec::new();
321− for e in &self.entries {
322− for (bp, rule_css) in &e.container {
323− if bp == bp_name && !rule_css.is_empty() {
324− cq_pairs.push((e.class_name.as_str(), rule_css.as_str()));
325− }
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 { "}" });
326554 }
555+ } else {
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;
327567 }
328− emit_media_block(
329− &mut css,
330− &format!("@container (min-width: {})", bp_width),
331− &format!("@container(min-width:{})", bp_width),
332− &cq_pairs,
333− "",
334− dev,
335− );
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);
336582 }
337−
338− css.push_str(if dev { "}\n" } else { "}" });
339583 css
340584 }
341585}
342586
343−/// Emit CSS rules from `(class_name, body)` pairs, merging identical bodies
587+/// Emit CSS rules from `(selector, body)` pairs, merging identical bodies
344588/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
345589/// of each distinct body determines ordering, so output stays deterministic
346−/// across runs. `selector_suffix` is appended to each class (e.g. `":hover"`,
347−/// or `""` for plain class rules). `indent` is prepended to each rule line.
348−fn emit_grouped_rules(
349− out: &mut String,
350− pairs: &[(&str, &str)],
351− selector_suffix: &str,
352− indent: &str,
353− dev: bool,
354−) {
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) {
355592 if pairs.is_empty() {
356593 return;
357594 }
358595 // Group in first-occurrence order.
359596 let mut order: Vec<&str> = Vec::new();
360597 let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
361− for &(name, body) in pairs {
598+ for (selector, body) in pairs {
362599 if body.is_empty() {
363600 continue;
364601 }
365602 if !groups.contains_key(body) {
366603 order.push(body);
367604 }
368− groups.entry(body).or_default().push(name);
605+ groups.entry(body).or_default().push(selector);
369606 }
370607 let sp = if dev { " " } else { "" };
371608 let nl = if dev { "\n" } else { "" };
372609 for body in &order {
373− let names = &groups[body];
374610 out.push_str(indent);
375− for (i, n) in names.iter().enumerate() {
376− if i > 0 {
377− out.push(',');
378− }
379− out.push('.');
380− out.push_str(n);
381− out.push_str(selector_suffix);
382− }
611+ out.push_str(&groups[body].join(","));
383612 out.push_str(sp);
384613 out.push('{');
385614 out.push_str(body);
@@ -388,37 +617,44 @@ fn emit_grouped_rules(
388617 }
389618}
390619
391−/// Emit an `@media` / `@container` block containing grouped class rules.
392−/// Skips the block entirely if no non-empty bodies are present.
393−fn emit_media_block(
394− out: &mut String,
395− header_dev: &str,
396− header_min: &str,
397− pairs: &[(&str, &str)],
398− selector_suffix: &str,
399− dev: bool,
400−) {
401− if pairs.iter().all(|(_, body)| body.is_empty()) {
402− return;
403− }
404− let mut inner = String::new();
405− let inner_indent = if dev { " " } else { "" };
406− emit_grouped_rules(&mut inner, pairs, selector_suffix, inner_indent, dev);
407− if inner.is_empty() {
408− return;
409− }
410− if dev {
411− out.push_str(&format!("{} {{\n{}}}\n", header_dev, inner));
412− } else {
413− 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;
414643 }
415644}
416645
417646struct GenContext {
418647 dev: bool,
419648 depth: usize,
420− image_count: usize,
421649 has_interactive: 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,
422658}
423659
424660impl GenContext {
@@ -444,23 +680,26 @@ pub struct CodegenOptions {
444680 pub dev: bool,
445681 pub partial: bool,
446682 pub minify: bool,
447− pub compat: bool,
448683}
449684
450685/// Generate HTML from a parsed document using the given options.
451686pub 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();
452694 let mut html = if opts.partial {
453− generate_partial_inner(doc, opts.dev)
695+ generate_partial_inner(doc, opts.dev, &mut classes)
454696 } else {
455− generate_full_inner(doc, opts.dev)
697+ generate_full_inner(doc, opts.dev, &mut classes)
456698 };
457699 if opts.minify {
458700 html = minify_html(&html);
459701 }
460− if opts.compat {
461− html = add_vendor_prefixes(&html);
462− }
463− html
702+ (html, classes)
464703}
465704
466705pub fn generate(doc: &Document) -> String {
@@ -508,38 +747,6 @@ pub fn generate_minified(doc: &Document) -> String {
508747 )
509748}
510749
511−pub fn generate_compat(doc: &Document) -> String {
512− generate_with(
513− doc,
514− &CodegenOptions {
515− compat: true,
516− ..Default::default()
517− },
518− )
519−}
520−
521−pub fn generate_dev_compat(doc: &Document) -> String {
522− generate_with(
523− doc,
524− &CodegenOptions {
525− dev: true,
526− compat: true,
527− ..Default::default()
528− },
529− )
530−}
531−
532−pub fn generate_minified_compat(doc: &Document) -> String {
533− generate_with(
534− doc,
535− &CodegenOptions {
536− minify: true,
537− compat: true,
538− ..Default::default()
539− },
540− )
541−}
542−
543750fn minify_html(html: &str) -> String {
544751 let mut result = String::with_capacity(html.len());
545752 let mut in_pre = false;
@@ -618,499 +825,132 @@ fn minify_html(html: &str) -> String {
618825 result
619826}
620827
621−/// Add vendor prefixes to CSS within <style> tags for broader browser compatibility.
622−fn add_vendor_prefixes(html: &str) -> String {
623− // Find CSS within <style>...</style> and add vendor prefixes
624− let mut result = String::with_capacity(html.len() + 512);
625− let mut rest = html;
626− while let Some(start) = rest.find("<style>") {
627− let after_tag = start + 7;
628− result.push_str(&rest[..after_tag]);
629− rest = &rest[after_tag..];
630− if let Some(end) = rest.find("</style>") {
631− let css = &rest[..end];
632− result.push_str(&prefix_css(css));
633− result.push_str("</style>");
634− rest = &rest[end + 8..];
635− } else {
636− break;
637− }
638− }
639− result.push_str(rest);
640− result
641−}
642−
643−fn prefix_css(css: &str) -> String {
644− let mut out = String::with_capacity(css.len() + 256);
645− let mut i = 0;
646− let bytes = css.as_bytes();
647− while i < bytes.len() {
648− // Find property declarations
649− if let Some(pos) = css[i..].find('{') {
650− let brace = i + pos;
651− out.push_str(&css[i..=brace]);
652− i = brace + 1;
653− // Process declarations within this block
654− if let Some(close) = css[i..].find('}') {
655− let block = &css[i..i + close];
656− out.push_str(&prefix_declarations(block));
657− out.push('}');
658− i = i + close + 1;
659− }
660− } else {
661− out.push_str(&css[i..]);
662− break;
663− }
664− }
665− out
666−}
667−
668−fn prefix_declarations(block: &str) -> String {
669− let mut out = String::with_capacity(block.len() + 128);
670− for decl in block.split(';') {
671− let decl = decl.trim();
672− if decl.is_empty() {
673− continue;
674− }
675− if let Some((prop, val)) = decl.split_once(':') {
676− let prop = prop.trim();
677− let val = val.trim();
678− match prop {
679− "backdrop-filter" => {
680− out.push_str(&format!("-webkit-backdrop-filter:{};", val));
681− out.push_str(&format!("backdrop-filter:{};", val));
682− }
683− "user-select" => {
684− out.push_str(&format!("-webkit-user-select:{};", val));
685− out.push_str(&format!("-moz-user-select:{};", val));
686− out.push_str(&format!("user-select:{};", val));
687− }
688− "appearance" => {
689− out.push_str(&format!("-webkit-appearance:{};", val));
690− out.push_str(&format!("-moz-appearance:{};", val));
691− out.push_str(&format!("appearance:{};", val));
692− }
693− "background-clip" if val.contains("text") => {
694− out.push_str(&format!("-webkit-background-clip:{};", val));
695− out.push_str(&format!("background-clip:{};", val));
696− }
697− "hyphens" => {
698− out.push_str(&format!("-webkit-hyphens:{};", val));
699− out.push_str(&format!("-ms-hyphens:{};", val));
700− out.push_str(&format!("hyphens:{};", val));
701− }
702− "text-size-adjust" => {
703− out.push_str(&format!("-webkit-text-size-adjust:{};", val));
704− out.push_str(&format!("-ms-text-size-adjust:{};", val));
705− out.push_str(&format!("text-size-adjust:{};", val));
706− }
707− "mask-image" | "mask-size" | "mask-repeat" | "mask-position" => {
708− out.push_str(&format!("-webkit-{}:{};", prop, val));
709− out.push_str(&format!("{}:{};", prop, val));
710− }
711− _ => {
712− out.push_str(decl);
713− out.push(';');
714− }
715− }
716− } else {
717− out.push_str(decl);
718− out.push(';');
719− }
720− }
721− out
722−}
723−
724−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+ };
725833 let mut styles = StyleCollector::new();
726834 let mut ctx = GenContext {
727835 dev,
728836 depth: 0,
729− image_count: 0,
730837 has_interactive: false,
838+ in_text: false,
839+ flow: Flow::default(),
840+ in_picture: false,
731841 };
732842
733− // Check if document has @main for skip-to-content link
734− let has_main = has_tag(&doc.nodes, "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;
735866
736867 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+ );
737875
738− // Skip-to-content link for accessibility (only when @main exists)
739− if has_main {
740− if dev {
741− body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>\n");
742− } else {
743− body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>");
744− }
745− }
876+ let element_css = build_element_css(doc, &styles, dev);
877+ let nl = if dev { "\n" } else { "" };
746878
747− for node in &doc.nodes {
748− 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);
749918 }
750919
751− // Collect external domains for DNS prefetch
752− let dns_prefetch_html = collect_dns_prefetch(&body, dev);
753−
754− 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);
755927
756− // Build meta tags string
757− let meta_html = if doc.meta_tags.is_empty() {
758− String::new()
759− } else {
760− let mut m = String::new();
761− for (name, content) in &doc.meta_tags {
762− if dev {
763− m.push_str(&format!(
764− "<meta name=\"{}\" content=\"{}\">\n",
765− html_escape(name),
766− html_escape(content)
767− ));
768− } else {
769− m.push_str(&format!(
770− "<meta name=\"{}\" content=\"{}\">",
771− html_escape(name),
772− html_escape(content)
773− ));
774− }
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+ )
775941 }
776− m
777− };
778−
779− // Build OG meta tags
780− let og_html = if doc.og_tags.is_empty() {
781− String::new()
782942 } else {
783− let mut o = String::new();
784− for (property, content) in &doc.og_tags {
785− if dev {
786− o.push_str(&format!(
787− "<meta property=\"og:{}\" content=\"{}\">\n",
788− html_escape(property),
789− html_escape(content)
790− ));
791− } else {
792− o.push_str(&format!(
793− "<meta property=\"og:{}\" content=\"{}\">",
794− html_escape(property),
795− html_escape(content)
796− ));
797− }
798− }
799− o
800− };
801−
802− // Build head blocks string
803− let head_html = if doc.head_blocks.is_empty() {
804943 String::new()
805− } else {
806− let mut h = String::new();
807− for block in &doc.head_blocks {
808− h.push_str(block);
809− if dev {
810− h.push('\n');
811− }
812− }
813− h
814− };
815−
816− let lang_attr = match &doc.lang {
817− Some(lang) => format!(" lang=\"{}\"", html_escape(lang)),
818− None => String::new(),
819944 };
945+ let reset_css = reset_css(dev, true, &focus_visible_css);
946+ classes.extend(styles.classes());
820947
821− let favicon_html = match &doc.favicon {
822− Some(path) => {
823− // Try to read and inline the favicon
824− if let Ok(data) = std::fs::read(path) {
825− let mime = if path.ends_with(".ico") {
826− "image/x-icon"
827− } else if path.ends_with(".png") {
828− "image/png"
829− } else if path.ends_with(".svg") {
830− "image/svg+xml"
831− } else {
832− "image/x-icon"
833− };
834− let b64 = base64_encode(&data);
835− if dev {
836− format!(
837− "<link rel=\"icon\" href=\"data:{};base64,{}\">\n",
838− mime, b64
839− )
840− } else {
841− format!("<link rel=\"icon\" href=\"data:{};base64,{}\">", mime, b64)
842− }
843− } else {
844− // Fall back to href
845− if dev {
846− format!("<link rel=\"icon\" href=\"{}\">\n", html_escape(path))
847− } else {
848− format!("<link rel=\"icon\" href=\"{}\">", html_escape(path))
849− }
850− }
851− }
852− None => String::new(),
853− };
854−
855− // Canonical URL
856− let canonical_html = match &doc.canonical {
857− Some(url) => {
858− if dev {
859− format!("<link rel=\"canonical\" href=\"{}\">\n", html_escape(url))
860− } else {
861− format!("<link rel=\"canonical\" href=\"{}\">", html_escape(url))
862− }
863− }
864− None => String::new(),
865− };
866−
867− // Base URL
868− let base_html = match &doc.base_url {
869− Some(url) => {
870− if dev {
871− format!("<base href=\"{}\">\n", html_escape(url))
872− } else {
873− format!("<base href=\"{}\">", html_escape(url))
874− }
875− }
876− None => String::new(),
877− };
878−
879− // JSON-LD blocks
880− let json_ld_html: String = doc
881− .json_ld_blocks
882− .iter()
883− .map(|block| {
884− if dev {
885− format!(
886− "<script type=\"application/ld+json\">\n{}\n</script>\n",
887− block
888− )
889− } else {
890− format!("<script type=\"application/ld+json\">{}</script>", block)
891− }
892− })
893− .collect();
894−
895− // Manifest link
896− let manifest_html = if let Some(ref manifest) = doc.manifest {
897− let mut json = String::from("{");
898− json.push_str(&format!("\"name\":{},", json_str(&manifest.name)));
899− if let Some(ref short) = manifest.short_name {
900− json.push_str(&format!("\"short_name\":{},", json_str(short)));
901− }
902− json.push_str(&format!("\"start_url\":{},", json_str(&manifest.start_url)));
903− json.push_str(&format!("\"display\":{}", json_str(&manifest.display)));
904− if let Some(ref bg) = manifest.background_color {
905− json.push_str(&format!(",\"background_color\":{}", json_str(bg)));
906− }
907− if let Some(ref tc) = manifest.theme_color {
908− json.push_str(&format!(",\"theme_color\":{}", json_str(tc)));
909− }
910− if let Some(ref desc) = manifest.description {
911− json.push_str(&format!(",\"description\":{}", json_str(desc)));
912− }
913− if !manifest.icons.is_empty() {
914− json.push_str(",\"icons\":[");
915− for (i, (src, sizes)) in manifest.icons.iter().enumerate() {
916− if i > 0 {
917− json.push(',');
918− }
919− json.push_str(&format!(
920− "{{\"src\":{},\"sizes\":{},\"type\":\"image/png\"}}",
921− json_str(src),
922− json_str(sizes)
923− ));
924− }
925− json.push(']');
926− }
927− json.push('}');
928−
929− // Inline the manifest as a data URI
930− let encoded = base64_encode(json.as_bytes());
931− if dev {
932− format!(
933− "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">\n",
934− encoded
935− )
936− } else {
937− format!(
938− "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">",
939− encoded
940− )
941− }
942− } else {
943− String::new()
944− };
945−
946− // Preload hints (auto-detect fonts from @font-face)
947− let mut preload_html = String::new();
948− for (_, url) in &doc.font_faces {
949− if dev {
950− preload_html.push_str(&format!(
951− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
952− url
953− ));
954− } else {
955− preload_html.push_str(&format!(
956− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>",
957− url
958− ));
959− }
960− }
961− // Explicit preload hints from the document
962− for hint in &doc.preload_hints {
963− if dev {
964− preload_html.push_str(&format!(
965− "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>\n",
966− html_escape(&hint.href),
967− hint.as_type,
968− if hint.crossorigin { " crossorigin" } else { "" }
969− ));
970− } else {
971− preload_html.push_str(&format!(
972− "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>",
973− html_escape(&hint.href),
974− hint.as_type,
975− if hint.crossorigin { " crossorigin" } else { "" }
976− ));
977− }
978− }
979−
980− // Auto theme-color meta from @theme primary token
981− let theme_color_html = doc
982− .theme_tokens
983− .iter()
984− .find(|(name, _)| name == "primary" || name == "theme-color")
985− .map(|(_, value)| {
986− if dev {
987− format!(
988− "<meta name=\"theme-color\" content=\"{}\">\n",
989− html_escape(value)
990− )
991− } else {
992− format!(
993− "<meta name=\"theme-color\" content=\"{}\">",
994− html_escape(value)
995− )
996− }
997− })
998− .unwrap_or_default();
999−
1000− // Focus-visible CSS for interactive elements (accessibility)
1001− let focus_visible_css = if ctx.has_interactive {
1002− if dev {
1003− "a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n"
1004− } else {
1005− "a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}"
1006− }
1007− } else {
1008− ""
1009− };
1010−
1011− // Skip-to-content CSS (visually hidden but accessible)
1012− let skip_link_css = if has_main {
1013− if dev {
1014− ".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"
1015− } else {
1016− ".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}"
1017− }
1018− } else {
1019− ""
1020− };
1021−
1022− let reset_css = reset_css(dev, focus_visible_css, skip_link_css);
1023−
1024− match &doc.page_title {
1025− Some(title) => {
1026− if dev {
1027− format!(
1028− "\
1029−<!DOCTYPE html>
1030−<html{lang_attr}>
1031−<head>
1032−<meta charset=\"utf-8\">
1033−<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
1034−<title>{title}</title>
1035−{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}\
1036−<style>
1037−{reset_css}{element_css}\
1038−</style>
1039−</head>
1040−<body>
1041−{body}\
1042−</body>
1043−</html>
1044−",
1045− title = html_escape(title),
1046− lang_attr = lang_attr,
1047− theme_color_html = theme_color_html,
1048− base_html = base_html,
1049− canonical_html = canonical_html,
1050− manifest_html = manifest_html,
1051− preload_html = preload_html,
1052− dns_prefetch_html = dns_prefetch_html,
1053− meta_html = meta_html,
1054− favicon_html = favicon_html,
1055− json_ld_html = json_ld_html,
1056− head_html = head_html,
1057− og_html = og_html,
1058− reset_css = reset_css,
1059− element_css = element_css,
1060− body = body,
1061− )
1062− } else {
1063− format!(
1064− "<!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></head><body>{body}</body></html>",
1065− title = html_escape(title),
1066− lang_attr = lang_attr,
1067− theme_color_html = theme_color_html,
1068− base_html = base_html,
1069− canonical_html = canonical_html,
1070− manifest_html = manifest_html,
1071− preload_html = preload_html,
1072− dns_prefetch_html = dns_prefetch_html,
1073− meta_html = meta_html,
1074− og_html = og_html,
1075− favicon_html = favicon_html,
1076− json_ld_html = json_ld_html,
1077− head_html = head_html,
1078− reset_css = reset_css,
1079− element_css = element_css,
1080− body = body,
1081− )
1082− }
1083− }
1084− None => {
1085− if element_css.is_empty() {
1086− body
1087− } else if dev {
1088− format!("<style>\n{}</style>\n{}", element_css, body)
1089− } else {
1090− format!("<style>{}</style>{}", element_css, body)
1091− }
1092− }
1093− }
1094−}
1095−
1096−/// Encode `s` as a JSON string literal (with surrounding quotes).
1097−pub(crate) fn json_str(s: &str) -> String {
1098− let mut out = String::with_capacity(s.len() + 2);
1099− out.push('"');
1100− for c in s.chars() {
1101− match c {
1102− '"' => out.push_str("\\\""),
1103− '\\' => out.push_str("\\\\"),
1104− '\n' => out.push_str("\\n"),
1105− '\r' => out.push_str("\\r"),
1106− '\t' => out.push_str("\\t"),
1107− c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1108− c => out.push(c),
1109− }
1110− }
1111− out.push('"');
1112− out
1113−}
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+}
1114954
1115955/// Assemble every CSS block the document needs (font faces, custom
1116956/// properties, generated class rules, keyframes, and user CSS). Shared by
@@ -1118,63 +958,16 @@ pub(crate) fn json_str(s: &str) -> String {
1118958fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
1119959 let mut element_css = String::new();
1120960
1121− // @font-face rules, in declaration order
1122− for (name, url) in &doc.font_faces {
1123− let format_hint = if url.ends_with(".woff2") {
1124− " format('woff2')"
1125− } else if url.ends_with(".woff") {
1126− " format('woff')"
1127− } else if url.ends_with(".ttf") {
1128− " format('truetype')"
1129− } else if url.ends_with(".otf") {
1130− " format('opentype')"
1131− } else {
1132− ""
1133− };
1134− if dev {
1135− element_css.push_str(&format!(
1136− "@font-face {{\n font-family: '{}';\n src: url('{}'){};\n font-display: swap;\n}}\n",
1137− name, url, format_hint
1138− ));
1139− } else {
1140− element_css.push_str(&format!(
1141− "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
1142− name, url, format_hint
1143− ));
1144− }
1145− }
1146−
1147− // Collect all CSS custom properties (explicit `@let --name` / `@theme`
1148− // tokens, plus any auto-extracted repeats) so they can be emitted in a
1149− // 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.
1150963 let mut root_vars: Vec<(String, String)> = Vec::new();
1151964 for (name, value) in &doc.css_vars {
1152965 root_vars.push((name.clone(), value.clone()));
1153966 }
1154− for (name, value) in &doc.theme_tokens {
1155− let css_name = format!("--{}", name);
1156− if !root_vars.iter().any(|(n, _)| *n == css_name) {
1157− root_vars.push((css_name, value.clone()));
1158− }
1159− }
1160967
1161− // Generated rules always go in `@layer htmlang`, so unlayered user CSS
968+ // Generated rules always go in htmlang's layers, so unlayered user CSS
1162969 // (`@style`, `@raw`) overrides them regardless of specificity.
1163− let styles_css = styles.to_css_formatted(dev);
1164− // Fold literal values declared via `@theme` / `@let --name` back into
1165− // `var(--name)` references so the generated CSS actually uses the
1166− // custom properties emitted in `:root`.
1167− let styles_css = substitute_css_vars(&styles_css, &root_vars);
1168− // Further compress the CSS by auto-extracting any remaining literal
1169− // values that appear often enough for `var(--hN)` references to come out
1170− // shorter overall. Disabled in dev mode to keep the CSS readable.
1171− let styles_css = if dev {
1172− styles_css
1173− } else {
1174− let (new_css, auto_vars) = auto_extract_repeats(&styles_css, &root_vars);
1175− root_vars.extend(auto_vars);
1176− new_css
1177− };
970+ let styles_css = styles.to_css_formatted(dev, &doc.scoped_css);
1178971
1179972 // Emit the :root block first so the cascade picks up the custom
1180973 // properties before the class rules consume them.
@@ -1199,71 +992,121 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
1199992
1200993 element_css.push_str(&styles_css);
1201994
1202− // @keyframes
1203− for (name, kf_body) in &doc.keyframes {
1204− if dev {
1205− element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
1206− } else {
1207− element_css.push_str(&format!("@keyframes {}{{{}}}", name, kf_body));
1208− }
1209− }
1210−
1211− // Keyframes for the standard library's `$skeleton` bundle, when used
1212− if element_css.contains("hl-skeleton") {
1213− if dev {
1214− element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
1215− } else {
1216− element_css.push_str("@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}");
1217− }
1218− }
1219−
1220995 // @style blocks (custom CSS)
1221996 for block in &doc.custom_css {
1222997 if dev {
1223998 element_css.push_str(block);
1224999 element_css.push('\n');
12251000 } else {
1226− let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1227− element_css.push_str(&minified);
1001+ element_css.push_str(&compact_css(block));
12281002 }
12291003 }
12301004
12311005 element_css
12321006}
12331007
1008+/// An `@style` body on one line: each line trimmed, and the lines joined with
1009+/// a space, so a value that goes on over two lines (`margin: 0` then `auto;`)
1010+/// keeps its words apart.
1011+fn compact_css(block: &str) -> String {
1012+ block
1013+ .lines()
1014+ .map(str::trim)
1015+ .filter(|l| !l.is_empty())
1016+ .collect::<Vec<_>>()
1017+ .join(" ")
1018+}
1019+
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+
12341036/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1235−/// every layer, so an unlayered `a{color:inherit}` would override
1236−/// `@link [color red]`.
1237−fn reset_css(dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
1238− let base = if dev {
1239− "*, *::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"
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+ });
12401058 } else {
1241− "*,*::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}"
1242− };
1243− let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
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);
12441077 if dev {
1245− format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
1078+ format!(
1079+ "@layer {};\n@layer hl-reset {{\n{}}}\n",
1080+ LAYERS.join(", "),
1081+ rules
1082+ )
12461083 } else {
1247− format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
1084+ format!("@layer {};@layer hl-reset{{{}}}", LAYERS.join(","), rules)
12481085 }
12491086}
12501087
12511088/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1252−fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1089+fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
12531090 let mut styles = StyleCollector::new();
12541091 let mut ctx = GenContext {
12551092 dev,
12561093 depth: 0,
1257− image_count: 0,
12581094 has_interactive: false,
1095+ in_text: false,
1096+ flow: Flow::default(),
1097+ in_picture: false,
12591098 };
12601099 let mut body = String::new();
12611100
1262− for node in &doc.nodes {
1263− generate_node(node, None, &mut body, &mut styles, &mut ctx);
1264− }
1101+ generate_children(&doc.nodes, None, &mut body, &mut styles, &mut ctx);
12651102
1266− 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());
12671110
12681111 if element_css.is_empty() {
12691112 body
@@ -1278,22 +1121,20 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
12781121// Node generation
12791122// ---------------------------------------------------------------------------
12801123
1124+/// Write `node`, which is inside an element with the layout `parent`
1125+/// (`None` at the top of the page).
12811126fn generate_node(
12821127 node: &Node,
1283− parent_kind: Option<&ElementKind>,
1128+ parent: Option<Layout>,
12841129 out: &mut String,
12851130 styles: &mut StyleCollector,
12861131 ctx: &mut GenContext,
12871132) {
12881133 match node {
1289− Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
1134+ Node::Element(elem) => generate_element(elem, parent, out, styles, ctx),
12901135 Node::Text(segments) => {
1291− let needs_wrap = matches!(
1292− parent_kind,
1293− Some(ElementKind::Row | ElementKind::Column | ElementKind::El)
1294− ) || parent_kind
1295− .and_then(ElementKind::spec)
1296− .is_some_and(|spec| spec.wraps_text);
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);
12971138 if needs_wrap {
12981139 out.push_str(&ctx.indent());
12991140 out.push_str("<span>");
@@ -1312,73 +1153,156 @@ fn generate_node(
13121153 }
13131154}
13141155
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+}
13151170
1316−/// Emit the argument of an element whose argument is an HTML attribute
1317−/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`).
1318−fn emit_argument_attr(out: &mut String, elem: &Element) {
1319− if let Some(TagSpec {
1320− arg: TagArg::Attr(attr),
1321− ..
1322− }) = elem.kind.spec()
1323− && let Some(value) = &elem.argument
1324− {
1325− out.push(' ');
1326− out.push_str(attr);
1327− out.push_str("=\"");
1328− out.push_str(&html_escape(value));
1329− out.push('"');
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+}
1183+
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);
1201+ continue;
1202+ }
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;
1216+ }
1217+ *previous = Some(matches!(child, Node::Text(_)));
13301218 }
13311219}
13321220
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+
13331259/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1334−/// are emitted with the generated class) and bare booleans like `required`.
1260+/// are emitted with the generated class) and flags like `required`.
13351261fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1336− for attr in attrs {
1262+ for attr in html_attrs(attrs) {
13371263 let key = attr.key.as_str();
1338− if attr.html && key != "id" && key != "class" {
1339− out.push(' ');
1340− out.push_str(key);
1341− out.push_str("=\"");
1342− out.push_str(&html_escape(strip_string_quotes(
1343− attr.value.as_deref().unwrap_or(""),
1344− )));
1345− out.push('"');
1346− } else if !attr.html
1347− && attr.value.is_none()
1348− && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&key)
1349− {
1350− out.push(' ');
1351− out.push_str(key);
1264+ if key != "id" && key != "class" {
1265+ push_html_attr(out, attr);
13521266 }
13531267 }
13541268}
13551269
1356−/// If the value is wrapped in a single pair of matching quotes (e.g.
1357−/// `"Avatar"`), return the inner content. Quotes act as source-level
1358−/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute
1359−/// values. Multi-quoted values like `"h h" "s m"` are left unchanged —
1360−/// those are real string tokens (used e.g. by CSS `grid-template-areas`).
1361−fn strip_string_quotes(val: &str) -> &str {
1362− let bytes = val.as_bytes();
1363− if bytes.len() < 2 {
1364− return val;
1365− }
1366− let first = bytes[0];
1367− let last = bytes[bytes.len() - 1];
1368− if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1369− let inner = &val[1..val.len() - 1];
1370− if !inner.as_bytes().contains(&first) {
1371− return inner;
1372− }
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('"');
13731294 }
1374− val
13751295}
13761296
13771297// True if any direct child is `@in-front` or `@behind`. Such children render
13781298// as absolutely positioned overlays, so the parent automatically becomes a
13791299// positioning context (position:relative + isolation:isolate).
13801300fn has_overlay_children(elem: &Element) -> bool {
1381− elem.children.iter().any(|child| {
1301+ holds_overlays(&elem.children)
1302+}
1303+
1304+fn holds_overlays(children: &[Node]) -> bool {
1305+ children.iter().any(|child| {
13821306 matches!(
13831307 child,
13841308 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
@@ -1388,48 +1312,32 @@ fn has_overlay_children(elem: &Element) -> bool {
13881312
13891313fn generate_element(
13901314 elem: &Element,
1391− parent_kind: Option<&ElementKind>,
1315+ parent: Option<Layout>,
13921316 out: &mut String,
13931317 styles: &mut StyleCollector,
13941318 ctx: &mut GenContext,
13951319) {
13961320 // Self-closing elements
1397− if elem.kind == ElementKind::Image || elem.kind.spec().is_some_and(|spec| spec.void) {
1398− 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);
13991323 return;
14001324 }
1401− // @script renders as <script> with raw body content (no HTML escaping)
1402− 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);
14031329 out.push_str(&ctx.indent());
1404− out.push_str("<script");
1405− // Pass through src, type, defer, async, etc.
1406− for attr in &elem.attrs {
1407− let key = attr.key.as_str();
1408− if matches!(
1409− key,
1410− "src"
1411− | "type"
1412− | "defer"
1413− | "async"
1414− | "crossorigin"
1415− | "integrity"
1416− | "nomodule"
1417− | "id"
1418− ) {
1419− if let Some(val) = &attr.value {
1420− out.push(' ');
1421− out.push_str(key);
1422− out.push_str("=\"");
1423− out.push_str(&html_escape(val));
1424− out.push('"');
1425− } else {
1426− out.push(' ');
1427− out.push_str(key);
1428− }
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)));
14291337 }
14301338 }
1339+ emit_html_attrs(out, &elem.attrs);
14311340 out.push('>');
1432− // Children are raw JS code, not HTML
14331341 for child in &elem.children {
14341342 match child {
14351343 Node::Text(segments) => {
@@ -1443,7 +1351,9 @@ fn generate_element(
14431351 _ => {}
14441352 }
14451353 }
1446− out.push_str("</script>");
1354+ out.push_str("</");
1355+ out.push_str(tag);
1356+ out.push('>');
14471357 out.push_str(ctx.nl());
14481358 return;
14491359 }
@@ -1454,26 +1364,26 @@ fn generate_element(
14541364 return;
14551365 }
14561366 if elem.kind == ElementKind::Fragment {
1457− // Render children without a wrapper element
1458− for child in &elem.children {
1459− generate_node(child, parent_kind, out, styles, ctx);
1460− }
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);
14611370 return;
14621371 }
14631372
1373+ let mut own = Flow::of(elem);
14641374 let tag = match &elem.kind {
1465− ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
1375+ ElementKind::Row | ElementKind::El => "div",
14661376 ElementKind::Text => "span",
14671377 ElementKind::Paragraph => "p",
14681378 ElementKind::Link => "a",
1469− ElementKind::Tag(spec)
1470− if spec.name == "list" && elem.attrs.iter().any(|a| a.key == "ordered") =>
1471− {
1472− "ol"
1473− }
14741379 ElementKind::Tag(spec) => spec.html,
14751380 _ => "",
14761381 };
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 };
14771387 let kind_label = elem.kind.name();
14781388
14791389 // Track interactive elements for focus-visible CSS
@@ -1484,14 +1394,8 @@ fn generate_element(
14841394 }
14851395
14861396 // Compute CSS for each state and get a class name
1487− let overlay_children = has_overlay_children(elem);
1488− let gen_class = compute_class(
1489− &elem.attrs,
1490− &elem.kind,
1491− parent_kind,
1492− styles,
1493− overlay_children,
1494− );
1397+ let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1398+ own.class = gen_class.clone();
14951399 let (id, user_class) = extract_id_class(&elem.attrs);
14961400
14971401 if ctx.dev && elem.line_num > 0 {
@@ -1505,51 +1409,9 @@ fn generate_element(
15051409 out.push('<');
15061410 out.push_str(tag);
15071411
1508− // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1509− if elem.kind.is_tag("main") && id.is_none() {
1510− out.push_str(" id=\"hl-main\"");
1511− }
1512−
1513− if elem.kind == ElementKind::Link
1514− && let Some(url) = &elem.argument
1515− {
1516− out.push_str(" href=\"");
1517− out.push_str(&html_escape(url));
1518− out.push('"');
1519− // Auto rel="noopener noreferrer" and target="_blank" for external links
1520− let is_external = url.starts_with("http://") || url.starts_with("https://");
1521− if is_external {
1522− let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1523− let has_target = elem.attrs.iter().any(|a| a.key == "target");
1524− if !has_rel {
1525− out.push_str(" rel=\"noopener noreferrer\"");
1526− }
1527− if !has_target {
1528− out.push_str(" target=\"_blank\"");
1529− }
1530− }
1531− }
1532−
1533− emit_argument_attr(out, elem);
1412+ emit_argument_attr(out, elem, ctx.in_picture);
15341413
1535− // Critical CSS: inline styles directly instead of using a class
1536− let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
1537− if is_critical {
1538− let inline_css = attrs_to_css(&elem.attrs, "", &elem.kind, parent_kind, overlay_children);
1539− if !inline_css.is_empty() {
1540− out.push_str(" style=\"");
1541− out.push_str(&html_escape(&inline_css));
1542− out.push('"');
1543− }
1544− // Still use class for non-base styles (pseudo, responsive, etc.)
1545− if gen_class.as_deref().is_some_and(|c| !c.is_empty()) {
1546− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1547− } else if let Some(ref uc) = user_class {
1548− emit_class_attr(out, None, Some(uc));
1549− }
1550− } else {
1551− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1552− }
1414+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
15531415
15541416 if let Some(id) = id {
15551417 out.push_str(" id=\"");
@@ -1568,28 +1430,27 @@ fn generate_element(
15681430 }
15691431
15701432 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");
15711438 out.push_str(ctx.nl());
15721439
1573− // Inline text argument
1574− if renders_argument_as_text(&elem.kind)
1575− && let Some(text) = &elem.argument
1576− {
1577− out.push_str(&html_escape(text));
1578− }
1579−
1580− // Children
1440+ let layout = elem.kind.layout();
15811441 ctx.depth += 1;
1582− let is_paragraph = elem.kind == ElementKind::Paragraph
1583− || elem.kind.spec().is_some_and(|spec| spec.inline);
1584− for (i, child) in elem.children.iter().enumerate() {
1585− generate_node(child, Some(&elem.kind), out, styles, ctx);
1586− if is_paragraph && i < elem.children.len() - 1 {
1587− out.push(' ');
1588− }
1589− }
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;
15901450 ctx.depth -= 1;
15911451
15921452 out.push_str(&ctx.indent());
1453+ ctx.dev = outer_dev;
15931454 out.push_str("</");
15941455 out.push_str(tag);
15951456 out.push('>');
@@ -1598,12 +1459,12 @@ fn generate_element(
15981459
15991460fn generate_self_closing(
16001461 elem: &Element,
1601− parent_kind: Option<&ElementKind>,
1462+ parent: Option<Layout>,
16021463 out: &mut String,
16031464 styles: &mut StyleCollector,
16041465 ctx: &mut GenContext,
16051466) {
1606− 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);
16071468 let (id, user_class) = extract_id_class(&elem.attrs);
16081469
16091470 let (tag, kind_label) = match &elem.kind {
@@ -1622,44 +1483,7 @@ fn generate_self_closing(
16221483 out.push_str(&ctx.indent());
16231484 out.push('<');
16241485 out.push_str(tag);
1625− emit_argument_attr(out, elem);
1626−
1627− // Image src (with optional non-SVG base64 inlining)
1628− if elem.kind == ElementKind::Image {
1629− let src = elem.argument.as_deref().unwrap_or("");
1630− let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
1631− if is_inline && !src.is_empty() && !src.ends_with(".svg") {
1632− let mime = if src.ends_with(".png") {
1633− "image/png"
1634− } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
1635− "image/jpeg"
1636− } else if src.ends_with(".gif") {
1637− "image/gif"
1638− } else if src.ends_with(".webp") {
1639− "image/webp"
1640− } else if src.ends_with(".avif") {
1641− "image/avif"
1642− } else {
1643− "application/octet-stream"
1644− };
1645− if let Ok(data) = std::fs::read(src) {
1646− let b64 = base64_encode(&data);
1647− out.push_str(" src=\"data:");
1648− out.push_str(mime);
1649− out.push_str(";base64,");
1650− out.push_str(&b64);
1651− out.push('"');
1652− } else {
1653− out.push_str(" src=\"");
1654− out.push_str(&html_escape(src));
1655− out.push('"');
1656− }
1657− } else {
1658− out.push_str(" src=\"");
1659− out.push_str(&html_escape(src));
1660− out.push('"');
1661− }
1662− }
1486+ emit_argument_attr(out, elem, ctx.in_picture);
16631487
16641488 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
16651489
@@ -1679,93 +1503,6 @@ fn generate_self_closing(
16791503 ));
16801504 }
16811505
1682− // Image optimization: auto-add loading="lazy" and decoding="async"
1683− if elem.kind == ElementKind::Image {
1684− // SVG inlining: @image [inline] logo.svg
1685− if elem.attrs.iter().any(|a| a.key == "inline")
1686− && let Some(src) = &elem.argument
1687− && src.ends_with(".svg")
1688− && let Ok(svg_content) = std::fs::read_to_string(src)
1689− {
1690− // Close the tag we opened, then emit inline SVG instead
1691− out.truncate(out.rfind('<').unwrap_or(0));
1692− out.push_str(&ctx.indent());
1693− out.push_str(svg_content.trim());
1694− out.push_str(ctx.nl());
1695− return;
1696− }
1697− // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
1698− let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
1699− if let Some(resp) = responsive_attr
1700− && let Some(ref sizes_str) = resp.value
1701− {
1702− let widths: Vec<&str> = sizes_str.split_whitespace().collect();
1703− if !widths.is_empty() {
1704− let src = elem.argument.as_deref().unwrap_or("");
1705− if !src.is_empty() {
1706− // Generate srcset with width descriptors
1707− // Convention: file-{width}.ext (e.g., photo-400.jpg)
1708− let dot_pos = src.rfind('.').unwrap_or(src.len());
1709− let base = &src[..dot_pos];
1710− let ext = &src[dot_pos..];
1711− let mut srcset_parts = Vec::new();
1712− for w in &widths {
1713− srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
1714− }
1715− out.push_str(" srcset=\"");
1716− out.push_str(&srcset_parts.join(", "));
1717− out.push('"');
1718− // Generate sizes attribute
1719− let max_width = widths.last().unwrap_or(&"100vw");
1720− out.push_str(&format!(
1721− " sizes=\"(max-width: {}px) 100vw, {}px\"",
1722− max_width, max_width
1723− ));
1724− }
1725− }
1726− }
1727−
1728− // Auto image dimensions: read local image file to inject width/height + aspect-ratio
1729− let has_width = elem.attrs.iter().any(|a| a.key == "width");
1730− let has_height = elem.attrs.iter().any(|a| a.key == "height");
1731− if (!has_width || !has_height)
1732− && let Some(ref src) = elem.argument
1733− && !src.starts_with("http://")
1734− && !src.starts_with("https://")
1735− && !src.starts_with("data:")
1736− && let Some((w, h)) = read_image_dimensions(src)
1737− {
1738− // Intrinsic size attributes only when neither dimension is set in
1739− // CSS; with one CSS dimension, a leftover intrinsic attribute for
1740− // the other would distort the image (200 wide but 1000 tall).
1741− if !has_width && !has_height {
1742− out.push_str(&format!(" width=\"{}\" height=\"{}\"", w, h));
1743− }
1744− // Auto aspect-ratio to prevent CLS
1745− if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
1746− out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
1747− }
1748− }
1749−
1750− // Smart image loading: first 3 images get fetchpriority="high" (above the fold),
1751− // subsequent images get loading="lazy" + decoding="async"
1752− ctx.image_count += 1;
1753− if ctx.image_count <= 3 {
1754− // Above-the-fold: eager loading with high priority
1755− if !elem.attrs.iter().any(|a| a.key == "fetchpriority") {
1756− out.push_str(" fetchpriority=\"high\"");
1757− }
1758− } else {
1759− // Below-the-fold: lazy loading
1760− if !elem.attrs.iter().any(|a| a.key == "loading") {
1761− out.push_str(" loading=\"lazy\"");
1762− }
1763− if !elem.attrs.iter().any(|a| a.key == "decoding") {
1764− out.push_str(" decoding=\"async\"");
1765− }
1766− }
1767− }
1768−
17691506 out.push('>');
17701507 out.push_str(ctx.nl());
17711508}
@@ -1781,7 +1518,10 @@ fn generate_text_segments(
17811518 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
17821519 TextSegment::Inline(elem) => {
17831520 let mut buf = String::new();
1784− 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;
17851525 out.push_str(buf.trim_end());
17861526 }
17871527 }
@@ -1794,135 +1534,97 @@ fn generate_text_segments(
17941534
17951535fn compute_class(
17961536 attrs: &[Attribute],
1797− kind: &ElementKind,
1798− parent_kind: Option<&ElementKind>,
1537+ site: &Site,
17991538 styles: &mut StyleCollector,
1800− has_overlay_children: bool,
1539+ distinct: String,
18011540) -> Option<String> {
1802− let base = attrs_to_css(attrs, "", kind, parent_kind, has_overlay_children);
1803−
1804− // Collect pseudo-state overrides
1805− let mut pseudo = Vec::new();
1806− for &(prefix, selector) in crate::vocab::PSEUDO_PREFIXES {
1807− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1808− if !css.is_empty() {
1809− pseudo.push((selector.to_string(), css));
1810− }
1811− }
1812−
1813− // Collect nth:EXPR: dynamic pseudo selectors
1814− let mut nth_prefixes: Vec<String> = Vec::new();
1815− for attr in attrs {
1816− if attr.key.starts_with("nth:") {
1817− let rest = &attr.key[4..];
1818− if let Some(colon_pos) = rest.find(':') {
1819− let prefix = format!("nth:{}:", &rest[..colon_pos]);
1820− if !nth_prefixes.contains(&prefix) {
1821− nth_prefixes.push(prefix);
1822− }
1823− }
1824− }
1825− }
1826− for prefix in &nth_prefixes {
1827− let expr = &prefix[4..prefix.len() - 1];
1828− let selector = format!(":nth-child({})", expr);
1829− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1830− if !css.is_empty() {
1831− 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);
18321547 }
18331548 }
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+}
18341572
1835− // Collect has(...): dynamic pseudo selectors
1836− let mut has_prefixes: Vec<String> = Vec::new();
1837− for attr in attrs {
1838− if attr.key.starts_with("has(")
1839− && let Some(close) = attr.key.find("):")
1840− {
1841− let prefix = format!("{}:", &attr.key[..close + 1]);
1842− if !has_prefixes.contains(&prefix) {
1843− has_prefixes.push(prefix);
1844− }
1845− }
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;");
18461581 }
1847− for prefix in &has_prefixes {
1848− let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
1849− let selector = format!(":has({})", inner);
1850− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1851− if !css.is_empty() {
1852− pseudo.push((selector, css));
1853− }
1582+ if !site.root {
1583+ css.push_str(layout_css(site.kind.layout(), site.inline));
18541584 }
1585+ css.push_str(site.kind.css());
1586+ css
1587+}
18551588
1856− // Collect responsive overrides
1857− let mut responsive = Vec::new();
1858− for &(bp_name, _) in BREAKPOINTS {
1859− let prefix = format!("{}:", bp_name);
1860− let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1861− if !css.is_empty() {
1862− 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;
18631597 }
1864− }
1865−
1866− // Collect container query overrides
1867− let mut container = Vec::new();
1868− for &(bp_name, _) in BREAKPOINTS {
1869− let prefix = format!("cq-{}:", bp_name);
1870− let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1871− if !css.is_empty() {
1872− 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+ }
18731615 }
1874− }
1875−
1876− let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
1877− let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
1878− let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind, has_overlay_children);
1879− let motion_reduce =
1880− attrs_to_css(attrs, "motion-reduce:", kind, parent_kind, has_overlay_children);
1881− let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
1882− let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
1883−
1884− // Dedupe: if a property is declared twice within a single rule, keep only
1885− // the last occurrence (element-kind defaults are written before
1886− // user attributes, so a user [list-style disc] correctly overrides the
1887− // default list-style:none, and we don't need to ship both).
1888− let base = dedupe_declarations(&base);
1889− let pseudo: Vec<(String, String)> = pseudo
1890− .into_iter()
1891− .map(|(sel, css)| (sel, dedupe_declarations(&css)))
1892− .collect();
1893− let responsive: Vec<(String, String)> = responsive
1894− .into_iter()
1895− .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1896− .collect();
1897− let dark = dedupe_declarations(&dark);
1898− let print = dedupe_declarations(&print);
1899− let motion_safe = dedupe_declarations(&motion_safe);
1900− let motion_reduce = dedupe_declarations(&motion_reduce);
1901− let landscape = dedupe_declarations(&landscape);
1902− let portrait = dedupe_declarations(&portrait);
1903− let container: Vec<(String, String)> = container
1904− .into_iter()
1905− .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1906− .collect();
1907−
1908− styles.get_class(
1909− base,
1910− pseudo,
1911− responsive,
1912− dark,
1913− print,
1914− motion_safe,
1915− motion_reduce,
1916− landscape,
1917− portrait,
1918− container,
1919− )
1920−}
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+ })
1621+}
19211622
19221623/// Dedupe CSS declarations within a single rule body: for any property
19231624/// declared more than once, keep only the last occurrence. Unparseable
1924−/// segments (no `:`) are preserved as-is. Semicolons inside parentheses are
1925−/// 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.
19261628fn dedupe_declarations(css: &str) -> String {
19271629 if css.is_empty() {
19281630 return String::new();
@@ -1937,8 +1639,26 @@ fn dedupe_declarations(css: &str) -> String {
19371639 let mut decls: Vec<(Option<String>, String)> = Vec::new();
19381640 let mut current = String::new();
19391641 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;
19401646 for ch in css.chars() {
1941− 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 == '(' {
19421662 depth += 1;
19431663 current.push(ch);
19441664 } else if ch == ')' {
@@ -2020,599 +1740,553 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
20201740// Attribute → CSS mapping
20211741// ---------------------------------------------------------------------------
20221742
2023−/// (htmlang prefix, CSS selector suffix)
2024−/// `display:flex;flex-direction:column;` — base layout for `@el` and every
2025−/// semantic wrapper that behaves like a column.
2026−const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
2027−
2028−
2029−
2030−fn attrs_to_css(
2031− attrs: &[Attribute],
2032− state_prefix: &str,
2033− kind: &ElementKind,
2034− parent_kind: Option<&ElementKind>,
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.
20351750 has_overlay_children: bool,
2036−) -> String {
2037− let mut css = String::new();
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,
1755+}
20381756
2039− // Base element styles only for the default (non-state) pass
2040− if state_prefix.is_empty() {
2041− // Elements with @in-front / @behind children become positioning
2042− // contexts. Pushed before user attrs so an explicit `position` wins
2043− // via dedupe (only the last declaration of a property is kept).
2044− if has_overlay_children {
2045− css.push_str("position:relative;isolation:isolate;");
2046− }
2047− match kind {
2048− ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2049− ElementKind::Column | ElementKind::El => css.push_str(FLEX_COLUMN),
2050− ElementKind::Paragraph => css.push_str("margin:0;"),
2051− ElementKind::Tag(spec) => css.push_str(spec.css),
2052− _ => {}
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,
20531767 }
20541768 }
1769+}
20551770
2056− for attr in attrs {
2057− if attr.html {
2058− continue;
2059− }
2060− // Determine the effective key for this pass
2061− let effective_key = if state_prefix.is_empty() {
2062− if crate::vocab::is_prefixed(&attr.key) {
2063− continue;
2064− }
2065− attr.key.as_str()
2066− } else {
2067− match attr.key.strip_prefix(state_prefix) {
2068− Some(k) => k,
2069− None => continue,
2070− }
2071− };
1771+// ---------------------------------------------------------------------------
1772+// Direction: what a child's `fill` and `shrink` compile against
1773+// ---------------------------------------------------------------------------
20721774
2073− let val = attr.value.as_deref();
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+}
20741781
2075− match effective_key {
2076− // Layout
2077− "spacing" | "gap" => {
2078− if let Some(v) = val {
2079− push_css(&mut css, "gap", &css_px(v));
2080− }
2081− }
2082− "padding" => {
2083− if let Some(v) = val {
2084− push_css(&mut css, "padding", &css_px_multi(v));
2085− }
2086− }
2087− "padding-x" => {
2088− if let Some(v) = val {
2089− // Logical property covers both inline sides in one
2090− // declaration; for symmetric values this is visually
2091− // identical to padding-left/right in LTR and RTL.
2092− push_css(&mut css, "padding-inline", &css_px(v));
2093− }
2094− }
2095− "padding-y" => {
2096− if let Some(v) = val {
2097− push_css(&mut css, "padding-block", &css_px(v));
2098− }
2099− }
2100− "padding-top" => {
2101− if let Some(v) = val {
2102− push_css(&mut css, "padding-top", &css_px(v));
2103− }
2104− }
2105− "padding-bottom" => {
2106− if let Some(v) = val {
2107− push_css(&mut css, "padding-bottom", &css_px(v));
2108− }
2109− }
2110− "padding-left" => {
2111− if let Some(v) = val {
2112− push_css(&mut css, "padding-left", &css_px(v));
2113− }
2114− }
2115− "padding-right" => {
2116− if let Some(v) = val {
2117− push_css(&mut css, "padding-right", &css_px(v));
2118− }
2119− }
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+}
21201793
2121− // Sizing
2122− "width" => {
2123− if let Some(v) = val {
2124− match v {
2125− "fill" => match parent_kind {
2126− Some(ElementKind::Row) => {
2127− push_css(&mut css, "flex", "1");
2128− push_css(&mut css, "min-width", "0");
2129− }
2130− _ => push_css(&mut css, "width", "100%"),
2131− },
2132− "shrink" => push_css(&mut css, "flex-shrink", "0"),
2133− _ => push_css(&mut css, "width", &css_px(v)),
2134− }
2135− }
2136− }
2137− "height" => {
2138− if let Some(v) = val {
2139− match v {
2140− "fill" => match parent_kind {
2141− Some(ElementKind::Column) => {
2142− push_css(&mut css, "flex", "1");
2143− push_css(&mut css, "min-height", "0");
2144− }
2145− _ => push_css(&mut css, "height", "100%"),
2146− },
2147− "shrink" => push_css(&mut css, "flex-shrink", "0"),
2148− _ => push_css(&mut css, "height", &css_px(v)),
2149− }
2150− }
2151− }
2152− "min-width" => {
2153− if let Some(v) = val {
2154− push_css(&mut css, "min-width", &css_px(v));
2155− }
2156− }
2157− "max-width" => {
2158− if let Some(v) = val {
2159− push_css(&mut css, "max-width", &css_px(v));
2160− }
2161− }
2162− "min-height" => {
2163− if let Some(v) = val {
2164− push_css(&mut css, "min-height", &css_px(v));
2165− }
2166− }
2167− "max-height" => {
2168− if let Some(v) = val {
2169− push_css(&mut css, "max-height", &css_px(v));
2170− }
2171− }
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+}
21721813
2173− // Alignment
2174− "center-x" => match parent_kind {
2175− Some(ElementKind::Column) | Some(ElementKind::El) => {
2176− push_css(&mut css, "align-self", "center");
2177− }
2178− _ => {
2179− push_css(&mut css, "margin-left", "auto");
2180− push_css(&mut css, "margin-right", "auto");
2181− }
2182− },
2183− "center-y" => match parent_kind {
2184− Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
2185− _ => {
2186− push_css(&mut css, "margin-top", "auto");
2187− push_css(&mut css, "margin-bottom", "auto");
2188− }
2189− },
2190− "align-left" => match parent_kind {
2191− Some(ElementKind::Column) | Some(ElementKind::El) => {
2192− push_css(&mut css, "align-self", "flex-start");
2193− }
2194− _ => push_css(&mut css, "margin-right", "auto"),
2195− },
2196− "align-right" => match parent_kind {
2197− Some(ElementKind::Column) | Some(ElementKind::El) => {
2198− push_css(&mut css, "align-self", "flex-end");
2199− }
2200− _ => push_css(&mut css, "margin-left", "auto"),
2201− },
2202− "align-top" => match parent_kind {
2203− Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
2204− _ => push_css(&mut css, "margin-bottom", "auto"),
2205− },
2206− "align-bottom" => match parent_kind {
2207− Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
2208− _ => push_css(&mut css, "margin-top", "auto"),
2209− },
2210−
2211− // Style
2212− "border" => {
2213− if let Some(v) = val {
2214− let parts: Vec<&str> = v.splitn(2, ' ').collect();
2215− if parts.len() == 2 {
2216− push_css(
2217− &mut css,
2218− "border",
2219− &format!("{} solid {}", css_px(parts[0]), parts[1]),
2220− );
2221− } else {
2222− push_css(
2223− &mut css,
2224− "border",
2225− &format!("{} solid currentColor", css_px(parts[0])),
2226− );
2227− }
2228− }
2229− }
2230− "border-top" | "border-bottom" | "border-left" | "border-right" => {
2231− if let Some(v) = val {
2232− let parts: Vec<&str> = v.splitn(2, ' ').collect();
2233− if parts.len() == 2 {
2234− push_css(
2235− &mut css,
2236− effective_key,
2237− &format!("{} solid {}", css_px(parts[0]), parts[1]),
2238− );
2239− } else {
2240− push_css(
2241− &mut css,
2242− effective_key,
2243− &format!("{} solid currentColor", css_px(parts[0])),
2244− );
2245− }
2246− }
2247− }
2248− "rounded" => {
2249− if let Some(v) = val {
2250− push_css(&mut css, "border-radius", &css_px(v));
2251− }
2252− }
2253− "bold" => push_css(&mut css, "font-weight", "bold"),
2254− "italic" => push_css(&mut css, "font-style", "italic"),
2255− "underline" => push_css(&mut css, "text-decoration", "underline"),
2256− "size" => {
2257− if let Some(v) = val {
2258− push_css(&mut css, "font-size", &css_px(v));
2259− }
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;
22601826 }
2261− "font" => {
2262− if let Some(v) = val {
2263− // `font "Inter, sans-serif"` quotes a whole font stack so
2264− // its commas don't split the attribute list.
2265− let v = match v.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
2266− Some(stack) if stack.contains(',') => stack,
2267− _ => v,
2268− };
2269− push_css(&mut css, "font-family", v);
2270− }
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));
22711836 }
1837+ }
1838+ changes.sort_by_key(|(at, _)| at_order(at));
1839+ Flow {
1840+ base: Some(base),
1841+ changes,
1842+ class: None,
1843+ }
1844+ }
22721845
2273− // Typography
2274− "line-height" => {
2275− if let Some(v) = val {
2276− push_css(&mut css, "line-height", &css_line_height(v));
2277− }
2278− }
2279− "letter-spacing" => {
2280− if let Some(v) = val {
2281− push_css(&mut css, "letter-spacing", &css_px(v));
2282− }
2283− }
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+}
22841861
2285− // Overflow & positioning
2286− "top" => {
2287− if let Some(v) = val {
2288− push_css(&mut css, "top", &css_px(v));
2289− }
2290− }
2291− "right" => {
2292− if let Some(v) = val {
2293− push_css(&mut css, "right", &css_px(v));
2294− }
2295− }
2296− "bottom" => {
2297− if let Some(v) = val {
2298− push_css(&mut css, "bottom", &css_px(v));
2299− }
2300− }
2301− "left" => {
2302− if let Some(v) = val {
2303− push_css(&mut css, "left", &css_px(v));
2304− }
2305− }
1862+/// `width` or `height`.
1863+#[derive(Clone, Copy, PartialEq, Eq)]
1864+enum Dim {
1865+ Width,
1866+ Height,
1867+}
23061868
2307− // Display & visibility
2308− "hidden" => push_css(&mut css, "display", "none"),
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+}
23091877
2310− // Effects
2311− "shadow" => {
2312− if let Some(v) = val {
2313− push_css(&mut css, "box-shadow", v);
2314− }
2315− }
1878+impl Size {
1879+ fn of(value: &str) -> Size {
1880+ match value.trim() {
1881+ "fill" => Size::Fill,
1882+ "shrink" => Size::Shrink,
1883+ _ => Size::Set,
1884+ }
1885+ }
1886+}
23161887
2317− // Flow
2318− "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
2319− "gap-x" => {
2320− if let Some(v) = val {
2321− push_css(&mut css, "column-gap", &css_px(v));
2322− }
2323− }
2324− "gap-y" => {
2325− if let Some(v) = val {
2326− push_css(&mut css, "row-gap", &css_px(v));
2327− }
2328− }
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+}
23291906
2330− // Grid
2331− "grid" => {
2332− push_css(&mut css, "display", "grid");
2333− }
2334− "grid-cols" => {
2335− if let Some(v) = val {
2336− if let Ok(n) = v.parse::<u32>() {
2337− push_css(
2338− &mut css,
2339− "grid-template-columns",
2340− &format!("repeat({},1fr)", n),
2341− );
2342− } else {
2343− push_css(&mut css, "grid-template-columns", v);
2344− }
2345− }
2346− }
2347− "grid-rows" => {
2348− if let Some(v) = val {
2349− if let Ok(n) = v.parse::<u32>() {
2350− push_css(
2351− &mut css,
2352− "grid-template-rows",
2353− &format!("repeat({},1fr)", n),
2354− );
2355− } else {
2356− push_css(&mut css, "grid-template-rows", v);
2357− }
2358− }
2359− }
2360− "col-span" => {
2361− if let Some(v) = val {
2362− push_css(&mut css, "grid-column", &format!("span {}", v));
2363− }
2364− }
2365− "row-span" => {
2366− if let Some(v) = val {
2367− push_css(&mut css, "grid-row", &format!("span {}", v));
2368− }
2369− }
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+}
23701923
2371− // CSS containment for rendering performance
2372− "contain" => {
2373− if let Some(v) = val {
2374− push_css(&mut css, "contain", v);
2375− } else {
2376− push_css(&mut css, "contain", "layout style paint");
2377− }
2378− }
2379− "content-visibility" => {
2380− if let Some(v) = val {
2381− push_css(&mut css, "content-visibility", v);
2382− } else {
2383− push_css(&mut css, "content-visibility", "auto");
2384− }
2385− }
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+}
23861943
2387− // Outline (like border but doesn't affect layout)
2388− "outline" => {
2389− if let Some(v) = val {
2390− let parts: Vec<&str> = v.splitn(2, ' ').collect();
2391− if parts.len() == 2 {
2392− push_css(
2393− &mut css,
2394− "outline",
2395− &format!("{} solid {}", css_px(parts[0]), parts[1]),
2396− );
2397− } else {
2398− push_css(
2399− &mut css,
2400− "outline",
2401− &format!("{} solid currentColor", css_px(parts[0])),
2402− );
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 {
1953+ 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+ _ => {}
1979+ }
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);
24032022 }
24042023 }
2024+ return;
24052025 }
2026+ }
2027+ if !self.writes(property) {
2028+ push_css(css, property, value);
2029+ }
2030+ }
2031+}
24062032
2407− // Logical properties (i18n-aware)
2408− "padding-inline" => {
2409− if let Some(v) = val {
2410− push_css(&mut css, "padding-inline", &css_px_multi(v));
2411− }
2412− }
2413− "padding-block" => {
2414− if let Some(v) = val {
2415− push_css(&mut css, "padding-block", &css_px_multi(v));
2416− }
2417− }
2418− "margin-inline" => {
2419− if let Some(v) = val {
2420− push_css(&mut css, "margin-inline", &css_px_multi(v));
2421− }
2422− }
2423− "margin-block" => {
2424− if let Some(v) = val {
2425− push_css(&mut css, "margin-block", &css_px_multi(v));
2426− }
2427− }
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+}
24282068
2429− // Logical property start/end variants
2430− "padding-inline-start"
2431− | "padding-inline-end"
2432− | "padding-block-start"
2433− | "padding-block-end"
2434− | "margin-inline-start"
2435− | "margin-inline-end"
2436− | "margin-block-start"
2437− | "margin-block-end" => {
2438− if let Some(v) = val {
2439− push_css(&mut css, effective_key, &css_px(v));
2440− }
2441− }
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+}
24422079
2443− // Logical inset
2444− "inset-inline" | "inset-block" => {
2445− if let Some(v) = val {
2446− push_css(&mut css, effective_key, &css_px_multi(v));
2447− }
2448− }
2449− "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
2450− if let Some(v) = val {
2451− push_css(&mut css, effective_key, &css_px(v));
2452− }
2453− }
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+}
24542103
2455− // Logical border
2456− "border-inline"
2457− | "border-block"
2458− | "border-inline-start"
2459− | "border-inline-end"
2460− | "border-block-start"
2461− | "border-block-end" => {
2462− if let Some(v) = val {
2463− push_css(&mut css, effective_key, v);
2464− }
2465− }
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+}
24662117
2467− // Logical border-radius
2468− "border-start-start-radius"
2469− | "border-start-end-radius"
2470− | "border-end-start-radius"
2471− | "border-end-end-radius" => {
2472− if let Some(v) = val {
2473− push_css(&mut css, effective_key, &css_px(v));
2474− }
2475− }
2118+/// The declarations of the attributes under `condition` (the defaults of
2119+/// the element's kind are [`default_css`]'s).
2120+fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> String {
2121+ let mut css = String::new();
2122+ // The auto margins of `center-x`, `align-left`, ..., written at the end
2123+ let mut aligned = String::new();
2124+ let kind = site.kind;
2125+ // Under `children:` the styles go on the children, whose parent is this
2126+ // element: htmlang's words that place an element in its parent are an
2127+ // error there (the parser reports them), and left out
2128+ let on_children = condition.on_children();
2129+ let inside = condition.inside().is_some();
2130+ // The direction `fill` and `shrink` compile against
2131+ let axis = site.parent.at(&condition.at);
2132+
2133+ // htmlang's words for laying out children (`spacing`, `wrap`,
2134+ // `grid-cols`) mean nothing on an element that doesn't lay out its
2135+ // children, which the parser reports; they are left out. Under
2136+ // `children:` they go on the children, whose layout isn't known here.
2137+ let lays_out_children = kind.layout().is_container() || on_children;
2138+ fn under<'k>(key: &'k str, condition: &Condition) -> Option<&'k str> {
2139+ let (c, name) = Condition::of(key);
2140+ (c == *condition).then_some(name)
2141+ }
2142+
2143+ for (index, attr) in attrs.iter().enumerate() {
2144+ if attr.html {
2145+ continue;
2146+ }
2147+ let Some(effective_key) = under(&attr.key, condition) else {
2148+ continue;
2149+ };
2150+ if on_children && crate::vocab::places_in_parent(effective_key, attr.value.as_deref()) {
2151+ continue;
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+ }
24762159
2477− // Logical scroll margins & padding
2478− "scroll-margin-inline"
2479− | "scroll-margin-block"
2480− | "scroll-padding-inline"
2481− | "scroll-padding-block" => {
2482− if let Some(v) = val {
2483− push_css(&mut css, effective_key, &css_px_multi(v));
2484− }
2485− }
2160+ let val = attr.value.as_deref();
2161+ // A style whose value came out empty (a field a record doesn't
2162+ // have, `${if()}` without its other branch) is left out
2163+ if val.is_some_and(|v| v.trim().is_empty()) {
2164+ continue;
2165+ }
24862166
2487− // Logical sizing
2488− "inline-size" | "block-size" | "min-inline-size" | "max-inline-size"
2489− | "min-block-size" | "max-block-size" => {
2167+ match effective_key {
2168+ // htmlang's own words: they lay out children or place the
2169+ // element in its parent, and mean more than one CSS property
2170+ "spacing" if !lays_out_children => {}
2171+ "spacing" => {
24902172 if let Some(v) = val {
2491− push_css(&mut css, effective_key, &css_px(v));
2173+ push_css(&mut css, "gap", &with_px("gap", v));
24922174 }
24932175 }
24942176
2495− // Margin
2496− "margin" => {
2497− if let Some(v) = val {
2498− push_css(&mut css, "margin", &css_px_multi(v));
2499− }
2500− }
2501− "margin-x" => {
2502− if let Some(v) = val {
2503− push_css(&mut css, "margin-inline", &css_px(v));
2504− }
2505− }
2506− "margin-y" => {
2507− if let Some(v) = val {
2508− push_css(&mut css, "margin-block", &css_px(v));
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;
25092188 }
2510− }
2511−
2512− // Container queries
2513− "container" => {
2514− push_css(&mut css, "container-type", "inline-size");
2515− }
2516−
2517− // Inset (shorthand for top/right/bottom/left)
2518− "inset" => {
2519− if let Some(v) = val {
2520− push_css(&mut css, "inset", &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);
2236+ }
25212237 }
2522− }
2523−
2524− // Table
2525− "border-spacing" => {
25262238 if let Some(v) = val {
2527− push_css(&mut css, "border-spacing", &css_px(v));
2239+ push_css(&mut css, effective_key, &with_px(effective_key, v));
25282240 }
25292241 }
25302242
2531− // Text decoration
2532− "text-decoration-thickness" => {
2533− if let Some(v) = val {
2534− push_css(&mut css, "text-decoration-thickness", &css_px(v));
2535− }
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");
25362249 }
2537− "text-underline-offset" => {
2538− if let Some(v) = val {
2539− push_css(&mut css, "text-underline-offset", &css_px(v));
2540− }
2250+ "center-y" => {
2251+ push_css(&mut aligned, "margin-top", "auto");
2252+ push_css(&mut aligned, "margin-bottom", "auto");
25412253 }
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"),
25422258
2543− // Multi-column
2544− "column-width" => {
2545− if let Some(v) = val {
2546− push_css(&mut css, "column-width", &css_px(v));
2547− }
2548− }
2259+ "wrap" if lays_out_children => push_css(&mut css, "flex-wrap", "wrap"),
2260+ "wrap" => {}
25492261
2550− // New CSS properties
2551− "column-gap" => {
2552− if let Some(v) = val {
2553− push_css(&mut css, "column-gap", &css_px(v));
2554− }
2555− }
2556− "text-indent" => {
2557− if let Some(v) = val {
2558− push_css(&mut css, "text-indent", &css_px(v));
2559− }
2560− }
2561− "flex-basis" => {
2562− if let Some(v) = val {
2563− push_css(&mut css, "flex-basis", &css_px(v));
2564− }
2565− }
2566− "scroll-margin" => {
2567− if let Some(v) = val {
2568− push_css(&mut css, "scroll-margin", &css_px(v));
2569− }
2570− }
2571− "scroll-margin-top"
2572− | "scroll-margin-bottom"
2573− | "scroll-margin-left"
2574− | "scroll-margin-right" => {
2575− if let Some(v) = val {
2576− push_css(&mut css, effective_key, &css_px(v));
2577− }
2578− }
2579− "scroll-padding" => {
2580− if let Some(v) = val {
2581− push_css(&mut css, "scroll-padding", &css_px(v));
2582− }
2583− }
2584− "scroll-padding-top"
2585− | "scroll-padding-bottom"
2586− | "scroll-padding-left"
2587− | "scroll-padding-right" => {
2262+ // Grid
2263+ "grid-cols" | "grid-rows" if !lays_out_children => {}
2264+ "grid-cols" | "grid-rows" => {
25882265 if let Some(v) = val {
2589− push_css(&mut css, effective_key, &css_px(v));
2266+ let property = if effective_key == "grid-cols" {
2267+ "grid-template-columns"
2268+ } else {
2269+ "grid-template-rows"
2270+ };
2271+ match v.parse::<u32>() {
2272+ Ok(n) => push_css(&mut css, property, &format!("repeat({},1fr)", n)),
2273+ Err(_) => push_css(&mut css, property, &with_px(property, v)),
2274+ }
25902275 }
25912276 }
2592−
2593− "content" => {
2277+ "col-span" | "row-span" => {
25942278 if let Some(v) = val {
2595− // Wrap in quotes if not already quoted and not a CSS keyword
2596− if v.starts_with('"')
2597− || v.starts_with('\'')
2598− || v == "none"
2599− || v == "normal"
2600− || v.starts_with("attr(")
2601− || v.starts_with("counter(")
2602− {
2603− push_css(&mut css, "content", v);
2279+ let property = if effective_key == "col-span" {
2280+ "grid-column"
26042281 } else {
2605− push_css(&mut css, "content", &format!("\"{}\"", v));
2606− }
2282+ "grid-row"
2283+ };
2284+ push_css(&mut css, property, &format!("span {}", v));
26072285 }
26082286 }
26092287
2610− // --- CSS Shorthands ---
2611− "truncate" => {
2612− push_css(&mut css, "overflow", "hidden");
2613− push_css(&mut css, "text-overflow", "ellipsis");
2614− push_css(&mut css, "white-space", "nowrap");
2615− }
2288+ // CSS's `line-clamp` has no support yet without its
2289+ // `-webkit-` fallback, which needs a box to clamp
26162290 "line-clamp" => {
26172291 if let Some(v) = val {
26182292 push_css(&mut css, "display", "-webkit-box");
@@ -2621,29 +2295,15 @@ fn attrs_to_css(
26212295 push_css(&mut css, "overflow", "hidden");
26222296 }
26232297 }
2624− "blur" => {
2625− if let Some(v) = val {
2626− push_css(&mut css, "filter", &format!("blur({})", css_px(v)));
2627− }
2628− }
2629− "backdrop-blur" => {
2630− if let Some(v) = val {
2631− push_css(&mut css, "backdrop-filter", &format!("blur({})", css_px(v)));
2632− }
2633− }
2634−
2635− // Critical CSS hint — not CSS, handled elsewhere
2636− "critical" => {}
26372298
2638− // Any other standard CSS property is copied through, with `px`
2639− // added to bare numbers where the property takes a length.
2640− key if crate::vocab::is_css_property(key) => {
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) => {
26412305 if let Some(v) = val {
2642− if crate::vocab::is_length_property(key) {
2643− push_css(&mut css, key, &css_px_multi(v));
2644− } else {
2645− push_css(&mut css, key, v);
2646− }
2306+ push_css(&mut css, key, &with_px(key, v));
26472307 }
26482308 }
26492309
@@ -2651,6 +2311,7 @@ fn attrs_to_css(
26512311 }
26522312 }
26532313
2314+ css.push_str(&aligned);
26542315 css
26552316}
26562317
@@ -2661,258 +2322,6 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
26612322 css.push(';');
26622323}
26632324
2664−/// Known CSS units — if a value ends with one, skip appending `px`.
2665−/// Format a length value: a bare number gets `px` appended; anything else
2666−/// (values with units, keywords like `auto`, functions like `calc(...)`) is
2667−/// passed through unchanged.
2668−fn css_px(value: &str) -> String {
2669− let v = value.trim();
2670− if v == "0" {
2671− return "0".to_string();
2672− }
2673− let is_bare_number = !v.is_empty()
2674− && v.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
2675− && v.parse::<f64>().is_ok();
2676− if is_bare_number {
2677− format!("{}px", v)
2678− } else {
2679− v.to_string()
2680− }
2681−}
2682−
2683−/// Rewrite literal values that match a declared CSS custom property (from
2684−/// `@theme` or `@let --name value`) to `var(--name)` references. Matches are
2685−/// anchored to CSS value boundaries so e.g. `#3b82f6` inside a longer hex or
2686−/// inside an identifier is not replaced. The `:root` block is emitted before
2687−/// this call runs, so its declarations are not affected.
2688−fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
2689− if css.is_empty() || vars.is_empty() {
2690− return css.to_string();
2691− }
2692− // Prefer longer values first so that if two vars share a prefix, the
2693− // longer (more specific) match wins.
2694− let mut pairs: Vec<(&str, String)> = vars
2695− .iter()
2696− .filter(|(name, value)| name.starts_with("--") && !value.is_empty())
2697− .map(|(name, value)| (value.as_str(), format!("var({})", name)))
2698− .collect();
2699− pairs.sort_by_key(|(v, _)| std::cmp::Reverse(v.len()));
2700− if pairs.is_empty() {
2701− return css.to_string();
2702− }
2703−
2704− let is_boundary_before = |b: Option<u8>| match b {
2705− None => true,
2706− Some(c) => matches!(c, b':' | b' ' | b',' | b'(' | b';' | b'{' | b'\n' | b'\t'),
2707− };
2708− let is_boundary_after = |b: Option<u8>| match b {
2709− None => true,
2710− Some(c) => matches!(c, b';' | b'}' | b',' | b' ' | b')' | b'\n' | b'\t'),
2711− };
2712−
2713− let bytes = css.as_bytes();
2714− let mut out = String::with_capacity(css.len());
2715− let mut i = 0;
2716− let mut prev: Option<u8> = None;
2717− // Only declaration values are rewritten — never selectors or at-rule
2718− // preludes such as `@media (min-width:768px)`. `blocks` records, for
2719− // each open `{`, whether it holds declarations (a style rule) or nested
2720− // rules (an at-rule like `@media` / `@layer`).
2721− let mut blocks: Vec<bool> = Vec::new();
2722− let mut prelude_start = 0;
2723− let mut in_value = false;
2724− while i < bytes.len() {
2725− match bytes[i] {
2726− b'{' => {
2727− let prelude = css[prelude_start..i].trim_start();
2728− blocks.push(!prelude.starts_with('@'));
2729− in_value = false;
2730− prelude_start = i + 1;
2731− }
2732− b'}' => {
2733− blocks.pop();
2734− in_value = false;
2735− prelude_start = i + 1;
2736− }
2737− b';' => {
2738− in_value = false;
2739− prelude_start = i + 1;
2740− }
2741− b':' if blocks.last() == Some(&true) => in_value = true,
2742− _ => {}
2743− }
2744− if in_value && is_boundary_before(prev) {
2745− let mut matched = false;
2746− for (val, repl) in &pairs {
2747− let vb = val.as_bytes();
2748− if i + vb.len() <= bytes.len() && &bytes[i..i + vb.len()] == vb {
2749− let next = bytes.get(i + vb.len()).copied();
2750− if is_boundary_after(next) {
2751− out.push_str(repl);
2752− prev = repl.as_bytes().last().copied();
2753− i += vb.len();
2754− matched = true;
2755− break;
2756− }
2757− }
2758− }
2759− if matched {
2760− continue;
2761− }
2762− }
2763− // Advance by one UTF-8 code point.
2764− let start = i;
2765− i += 1;
2766− while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
2767− i += 1;
2768− }
2769− out.push_str(&css[start..i]);
2770− prev = bytes.get(i - 1).copied();
2771− }
2772− out
2773−}
2774−
2775−/// Scan generated CSS for literal property values that repeat often enough
2776−/// that substituting them with an auto-named custom property would reduce
2777−/// total byte count. Returns the rewritten CSS and any new vars that should
2778−/// be appended to `:root`.
2779−///
2780−/// The algorithm only promotes a value if it strictly saves bytes after
2781−/// accounting for the `--xN:value;` declaration overhead plus the
2782−/// `var(--xN)` reference cost at each call site. Values that are already
2783−/// `var(...)` references or contain `var(` calls are skipped so we never
2784−/// nest references. Names avoid collisions with `existing_vars`.
2785−fn auto_extract_repeats(
2786− css: &str,
2787− existing_vars: &[(String, String)],
2788−) -> (String, Vec<(String, String)>) {
2789− if css.is_empty() {
2790− return (css.to_string(), Vec::new());
2791− }
2792− // Count occurrences of each distinct property value (the text between
2793− // `:` and the next `;` or `}` at the top level of a declaration block,
2794− // ignoring balanced parentheses so that e.g. `rgba(...)` stays intact).
2795− let bytes = css.as_bytes();
2796− let mut counts: HashMap<String, usize> = HashMap::new();
2797− let mut depth: i32 = 0;
2798− let mut i = 0;
2799− while i < bytes.len() {
2800− match bytes[i] {
2801− b'{' => {
2802− depth += 1;
2803− i += 1;
2804− continue;
2805− }
2806− b'}' => {
2807− if depth > 0 {
2808− depth -= 1;
2809− }
2810− i += 1;
2811− continue;
2812− }
2813− _ => {}
2814− }
2815− if depth > 0 && bytes[i] == b':' {
2816− // Skip past `:` then walk to the terminator.
2817− let start = i + 1;
2818− let mut end = start;
2819− let mut pdepth = 0i32;
2820− while end < bytes.len() {
2821− match bytes[end] {
2822− b'(' => pdepth += 1,
2823− b')' if pdepth > 0 => pdepth -= 1,
2824− b';' | b'}' if pdepth == 0 => break,
2825− b'{' if pdepth == 0 => break,
2826− _ => {}
2827− }
2828− end += 1;
2829− }
2830− let val = css[start..end].trim();
2831− if !val.is_empty() && !val.starts_with("var(") && !val.contains("var(") {
2832− *counts.entry(val.to_string()).or_default() += 1;
2833− }
2834− i = end;
2835− continue;
2836− }
2837− i += 1;
2838− }
2839−
2840− // Decide which values are worth extracting. A value of length L appearing
2841− // N times costs N*L bytes inline; promoting it costs (L + 6) for the
2842− // `--hK:V;` declaration plus N * ref_len for the call sites. We estimate
2843− // ref_len optimistically as `var(--h0)` (9 bytes) and fall back to 10 for
2844− // two-digit indices, which only affects very large extraction counts.
2845− let existing: std::collections::HashSet<&str> =
2846− existing_vars.iter().map(|(n, _)| n.as_str()).collect();
2847− let mut candidates: Vec<(String, usize)> =
2848− counts.into_iter().filter(|(_, n)| *n >= 2).collect();
2849− // Stable, deterministic ordering — longest values first, tiebreak by text.
2850− candidates.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
2851−
2852− let mut extracted: Vec<(String, String)> = Vec::new();
2853− let mut next_idx: usize = 0;
2854− for (value, n) in candidates {
2855− let l = value.len();
2856− // Allocate the next unused `--hN` name.
2857− let (name, ref_len) = loop {
2858− let candidate = format!("--h{}", next_idx);
2859− next_idx += 1;
2860− let rl = candidate.len() + 6; // `var(` + name + `)`
2861− if !existing.contains(candidate.as_str())
2862− && !extracted.iter().any(|(n, _)| *n == candidate)
2863− {
2864− break (candidate, rl);
2865− }
2866− };
2867− // Skip if promotion would not strictly reduce byte count.
2868− // Before: n * l bytes. After: (l + name.len() + 2) for the :root
2869− // declaration (`<name>:<value>;`) plus n * ref_len for the sites.
2870− let decl_overhead = l + name.len() + 2;
2871− let before = n * l;
2872− let after = decl_overhead + n * ref_len;
2873− if after < before {
2874− extracted.push((name, value));
2875− } else {
2876− // Rewind the index so the next value can reuse this slot.
2877− next_idx -= 1;
2878− }
2879− }
2880−
2881− if extracted.is_empty() {
2882− return (css.to_string(), Vec::new());
2883− }
2884− let new_css = substitute_css_vars(css, &extracted);
2885− (new_css, extracted)
2886−}
2887−
2888−/// Format a `line-height` value. CSS accepts either a unitless multiplier
2889−/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
2890−/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which
2891−/// CSS interprets as 24× font-size — almost never what anyone wants). Treat
2892−/// integers ≥ 2 as pixel lengths; anything with a decimal, an existing unit,
2893−/// or the value `0`/`1` passes through unchanged.
2894−fn css_line_height(value: &str) -> String {
2895− let v = value.trim();
2896− if v == "0" || v == "1" {
2897− return v.to_string();
2898− }
2899− // Plain integer — treat as pixel length. Decimals, units, keywords and
2900− // functions pass through.
2901− if !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()) {
2902− return format!("{}px", v);
2903− }
2904− v.to_string()
2905−}
2906−
2907−/// Format multiple space-separated values, each getting px if needed.
2908−fn css_px_multi(value: &str) -> String {
2909− value
2910− .split_whitespace()
2911− .map(css_px)
2912− .collect::<Vec<_>>()
2913− .join(" ")
2914−}
2915−
29162325fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
29172326 let mut id = None;
29182327 let mut class = None;
@@ -2926,153 +2335,6 @@ fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
29262335 (id, class)
29272336}
29282337
2929−/// Read image dimensions from a local file by parsing the header bytes.
2930−/// Supports PNG, JPEG, GIF, WebP, AVIF, and SVG.
2931−fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
2932− // SVG: parse as text for viewBox/width/height attributes
2933− if path.ends_with(".svg") || path.ends_with(".SVG") {
2934− let text = std::fs::read_to_string(path).ok()?;
2935− return read_svg_dimensions(&text);
2936− }
2937−
2938− let data = std::fs::read(path).ok()?;
2939− if data.len() < 12 {
2940− return None;
2941− }
2942−
2943− // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
2944− if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") {
2945− let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
2946− let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
2947− return Some((w, h));
2948− }
2949−
2950− // GIF: "GIF87a" or "GIF89a", width/height at bytes 6-9 (little-endian)
2951− if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
2952− let w = u16::from_le_bytes([data[6], data[7]]) as u32;
2953− let h = u16::from_le_bytes([data[8], data[9]]) as u32;
2954− return Some((w, h));
2955− }
2956−
2957− // JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2)
2958− if data.starts_with(b"\xff\xd8") {
2959− let mut i = 2;
2960− while i + 9 < data.len() {
2961− if data[i] != 0xFF {
2962− i += 1;
2963− continue;
2964− }
2965− let marker = data[i + 1];
2966− if marker == 0xC0 || marker == 0xC2 {
2967− let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
2968− let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
2969− return Some((w, h));
2970− }
2971− if i + 3 < data.len() {
2972− let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2973− i += 2 + len;
2974− } else {
2975− break;
2976− }
2977− }
2978− }
2979−
2980− // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
2981− if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
2982− if &data[12..16] == b"VP8 " && data.len() >= 30 {
2983− let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
2984− let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
2985− return Some((w, h));
2986− }
2987− if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
2988− let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
2989− let w = (bits & 0x3FFF) + 1;
2990− let h = ((bits >> 14) & 0x3FFF) + 1;
2991− return Some((w, h));
2992− }
2993− }
2994−
2995− // AVIF: ISOBMFF container with "ftyp" box containing "avif"/"avis" brand,
2996− // then "ispe" box with width/height
2997− if data.len() >= 12 && &data[4..8] == b"ftyp" {
2998− let brand = &data[8..12];
2999− if brand == b"avif" || brand == b"avis" || brand == b"mif1" {
3000− return read_avif_dimensions(&data);
3001− }
3002− }
3003−
3004− None
3005−}
3006−
3007−/// Parse SVG viewBox or width/height attributes to get dimensions.
3008−fn read_svg_dimensions(text: &str) -> Option<(u32, u32)> {
3009− // Try viewBox first: viewBox="minX minY width height"
3010− if let Some(vb_start) = text.find("viewBox=\"") {
3011− let rest = &text[vb_start + 9..];
3012− if let Some(end) = rest.find('"') {
3013− let parts: Vec<&str> = rest[..end].split_whitespace().collect();
3014− if parts.len() == 4
3015− && let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
3016− && w > 0.0
3017− && h > 0.0
3018− {
3019− return Some((w.round() as u32, h.round() as u32));
3020− }
3021− }
3022− }
3023− // Fall back to width/height attributes on <svg>
3024− let svg_tag = text.find("<svg")?;
3025− let tag_end = text[svg_tag..].find('>')? + svg_tag;
3026− let tag = &text[svg_tag..tag_end];
3027− let w = extract_svg_attr(tag, "width")?;
3028− let h = extract_svg_attr(tag, "height")?;
3029− Some((w, h))
3030−}
3031−
3032−fn extract_svg_attr(tag: &str, attr: &str) -> Option<u32> {
3033− let needle = format!("{}=\"", attr);
3034− let start = tag.find(&needle)? + needle.len();
3035− let rest = &tag[start..];
3036− let end = rest.find('"')?;
3037− let val = rest[..end].trim_end_matches("px");
3038− val.parse::<f64>().ok().map(|v| v.round() as u32)
3039−}
3040−
3041−/// Parse AVIF (ISOBMFF) container to find ispe box with image dimensions.
3042−fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
3043− // Walk ISOBMFF boxes looking for "ispe" (image spatial extents)
3044− let mut i = 0;
3045− while i + 8 <= data.len() {
3046− let box_size =
3047− u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
3048− let box_type = &data[i + 4..i + 8];
3049− if box_size < 8 {
3050− break;
3051− }
3052− let box_end = (i + box_size).min(data.len());
3053− // ispe box: 4 bytes version/flags + 4 bytes width + 4 bytes height
3054− if box_type == b"ispe" && box_end >= i + 20 {
3055− let w = u32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
3056− let h = u32::from_be_bytes([data[i + 16], data[i + 17], data[i + 18], data[i + 19]]);
3057− return Some((w, h));
3058− }
3059− // Recurse into container boxes (meta, iprp, ipco)
3060− if matches!(
3061− box_type,
3062− b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia"
3063− ) {
3064− let header_size = if box_type == b"meta" { 12 } else { 8 };
3065− if i + header_size < box_end
3066− && let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end])
3067− {
3068− return Some(dims);
3069− }
3070− }
3071− i = box_end;
3072− }
3073− None
3074−}
3075−
30762338fn html_escape(s: &str) -> String {
30772339 s.replace('&', "&amp;")
30782340 .replace('<', "&lt;")
@@ -3188,43 +2450,30 @@ fn vlq_encode(value: i64, out: &mut String) {
31882450 }
31892451}
31902452
3191−
3192−/// Does the tree contain the table element `name` (e.g. `"main"`)?
3193−fn has_tag(nodes: &[Node], name: &str) -> bool {
3194− nodes.iter().any(|node| match node {
3195− Node::Element(elem) => elem.kind.is_tag(name) || has_tag(&elem.children, name),
3196− _ => false,
3197− })
3198−}
3199−
3200−/// Collect unique external domains from generated HTML for DNS prefetch hints.
3201−fn collect_dns_prefetch(html: &str, dev: bool) -> String {
3202− let mut domains = Vec::new();
3203− let mut rest = html;
3204− while let Some(pos) = rest.find("https://") {
3205− let start = pos + 8; // skip "https://"
3206− rest = &rest[start..];
3207− let end = rest
3208− .find(['/', '"', '\'', ' ', '>', ')'])
3209− .unwrap_or(rest.len());
3210− let domain = &rest[..end];
3211− if !domain.is_empty() && domain.contains('.') && !domains.contains(&domain.to_string()) {
3212− domains.push(domain.to_string());
3213− }
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()));
32142478 }
3215− let mut out = String::new();
3216− for domain in &domains {
3217− if dev {
3218− out.push_str(&format!(
3219− "<link rel=\"dns-prefetch\" href=\"//{}\">\n",
3220− domain
3221− ));
3222− } else {
3223− out.push_str(&format!(
3224− "<link rel=\"dns-prefetch\" href=\"//{}\">",
3225− domain
3226− ));
3227− }
3228− }
3229− out
32302479}
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.rs+532−182
@@ -1,82 +1,113 @@
11//! Expressions: conditions (`@if $count > 2 and not $hidden`), computed
2−//! values (`@let gap = $base * 2`) and `if(cond, a, b)`.
2+//! values (`@let gap = $base * 2`) and `${...}` in text and values
3+//! (`${if($featured, 24, 0)}`).
34//!
45//! Expressions are tokenized and evaluated as a whole. Variables are looked
56//! up while evaluating, never pasted into the text first, so a value that
67//! happens to contain `==` or spaces can't change how an expression parses.
78//!
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+//!
814//! ```text
915//! expr := or
1016//! or := and ("or" and)*
1117//! and := not ("and" not)*
1218//! not := "not" not | compare
13−//! compare := concat (("==" | "!=" | "<" | ">" | "<=" | ">=" | "contains"
14−//! | "starts-with" | "ends-with") concat)?
15−//! concat := sum ("~" sum)*
19+//! compare := range (("==" | "!=" | "<" | ">" | "<=" | ">=") range)?
20+//! range := sum (".." sum ("step" sum)?)?
1621//! sum := product (("+" | "-") product)*
1722//! product := unary (("*" | "/" | "%") unary)*
1823//! unary := "-" unary | primary
1924//! primary := NUMBER | "STRING" | $VAR | WORD | true | false
20−//! | if(expr, expr, expr) | "(" expr ")"
25+//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
2126//! ```
2227//!
23−//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string.
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.
2444
2545use std::fmt;
2646
47+use crate::interp::{self, Problem, Reference, Scope};
48+
49+pub use crate::value::Value;
50+
51+/// Why an expression has no value.
2752#[derive(Debug, Clone, PartialEq)]
28−pub enum Value {
29− Num(f64),
30− Str(String),
31− Bool(bool),
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),
3258}
3359
34−impl Value {
35− /// Empty strings, `false`, `0` and `"false"` / `"0"` are false.
36− pub fn truthy(&self) -> bool {
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 {
3764 match self {
38− Value::Bool(b) => *b,
39− Value::Num(n) => *n != 0.0,
40− Value::Str(s) => !s.is_empty() && s != "false" && s != "0",
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+ },
4173 }
4274 }
75+}
4376
44− fn as_num(&self) -> Option<f64> {
45− match self {
46− Value::Num(n) => Some(*n),
47− Value::Str(s) => s.trim().parse().ok(),
48− Value::Bool(_) => None,
49− }
77+impl From<String> for Error {
78+ fn from(message: String) -> Self {
79+ Error::Invalid(message)
5080 }
5181}
5282
53−impl fmt::Display for Value {
83+impl fmt::Display for Error {
5484 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5585 match self {
56− Value::Num(n) if n.fract() == 0.0 && n.abs() < 1e15 => write!(f, "{}", *n as i64),
57− Value::Num(n) => write!(f, "{}", n),
58− Value::Str(s) => f.write_str(s),
59− Value::Bool(b) => write!(f, "{}", b),
86+ Error::Undefined { name, .. } => write!(f, "undefined variable '${}'", name),
87+ Error::Invalid(message) => f.write_str(message),
6088 }
6189 }
6290}
6391
64−/// Resolves a variable reference: the text after `$`, including any
65−/// filters (`name|uppercase`). Returns `None` for undefined variables.
66−pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<String>;
67−
68−/// Evaluate `src`. Undefined variables are empty strings.
69−pub fn eval(src: &str, resolve: Resolver) -> Result<Value, String> {
70− let tokens = tokenize(src)?;
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)?;
7197 let mut parser = Parser {
7298 tokens: &tokens,
7399 pos: 0,
74− resolve,
100+ scope,
101+ skip: false,
75102 };
76103 let value = parser.expr()?;
77104 match parser.tokens.get(parser.pos) {
78105 None => Ok(value),
79− Some(tok) => Err(format!("unexpected {} in `{}`", tok, src.trim())),
106+ Some((tok, _)) => Err(Error::Invalid(format!(
107+ "unexpected {} in `{}`",
108+ tok,
109+ src.trim()
110+ ))),
80111 }
81112}
82113
@@ -107,85 +138,116 @@ impl fmt::Display for Token {
107138 }
108139}
109140
110−const OPERATORS: &[&str] = &["==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%", "~"];
111−
112−fn tokenize(src: &str) -> Result<Vec<Token>, String> {
113− let chars: Vec<char> = src.chars().collect();
114− let mut tokens = Vec::new();
115− let mut i = 0;
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> {
116154 let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
117− while i < chars.len() {
118− let c = chars[i];
155+ let mut i = 0;
156+ while let Some(c) = src[i..].chars().next() {
157+ let at = base + i;
119158 if c.is_whitespace() {
120− i += 1;
159+ i += c.len_utf8();
121160 } else if c == '(' {
122− tokens.push(Token::LParen);
161+ tokens.push((Token::LParen, at));
123162 i += 1;
124163 } else if c == ')' {
125− tokens.push(Token::RParen);
164+ tokens.push((Token::RParen, at));
126165 i += 1;
127166 } else if c == ',' {
128− tokens.push(Token::Comma);
167+ tokens.push((Token::Comma, at));
129168 i += 1;
130169 } else if c == '"' {
131170 let start = i + 1;
132− let end = chars[start..]
133− .iter()
134− .position(|&c| c == '"')
171+ let end = src[start..]
172+ .find('"')
135173 .map(|p| start + p)
136174 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
137− tokens.push(Token::Str(chars[start..end].iter().collect()));
175+ tokens.push((Token::Str(src[start..end].to_string()), at));
138176 i = end + 1;
139177 } else if c == '$' {
140− // $name, with optional |filter:args chains
141− let start = i + 1;
142− let mut end = start;
143− while end < chars.len()
144− && !chars[end].is_whitespace()
145− && !matches!(chars[end], '(' | ')' | ',' | '"')
146− {
147− end += 1;
148− }
149− if end == start {
150− return Err(format!("`$` without a variable name in `{}`", src.trim()));
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+ }
151196 }
152− tokens.push(Token::Var(chars[start..end].iter().collect()));
153− i = end;
154197 } else if let Some(op) = OPERATORS
155198 .iter()
156− .find(|op| chars[i..].iter().take(op.len()).copied().eq(op.chars()))
199+ .find(|op| src[i..].starts_with(**op))
157200 // A `-` followed by a letter starts a word (`-webkit-box`);
158201 // inside words (`ease-in`) it is consumed by the word itself.
159− .filter(|op| **op != "-" || !chars.get(i + 1).is_some_and(|c| c.is_alphabetic()))
202+ .filter(|op| **op != "-" || !src[i + 1..].starts_with(char::is_alphabetic))
160203 {
161− tokens.push(Token::Op(op));
204+ tokens.push((Token::Op(op), at));
162205 i += op.len();
163206 } else if is_word_char(c) {
164207 let start = i;
165− while i < chars.len() && is_word_char(chars[i]) {
166− i += 1;
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();
167215 }
168− let word: String = chars[start..i].iter().collect();
169− tokens.push(match word.parse::<f64>() {
170− Ok(n) if !word.ends_with('.') => Token::Num(n),
171− _ => Token::Word(word),
172− });
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+ ));
173224 } else {
174− return Err(format!("unexpected `{}` in `{}`", c, src.trim()));
225+ return Err(Error::Invalid(format!(
226+ "unexpected `{}` in `{}`",
227+ c,
228+ src.trim()
229+ )));
175230 }
176231 }
177− Ok(tokens)
232+ Ok(())
178233}
179234
180235struct Parser<'a> {
181− tokens: &'a [Token],
236+ tokens: &'a [(Token, usize)],
182237 pos: usize,
183− resolve: Resolver<'a>,
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,
184243}
185244
245+/// The value a skipped part stands for; nothing reads it.
246+const SKIPPED: Value = Value::Bool(false);
247+
186248impl Parser<'_> {
187249 fn peek(&self) -> Option<&Token> {
188− self.tokens.get(self.pos)
250+ self.tokens.get(self.pos).map(|(token, _)| token)
189251 }
190252
191253 fn eat_word(&mut self, word: &str) -> bool {
@@ -208,137 +270,183 @@ impl Parser<'_> {
208270 }
209271 }
210272
211− fn expect(&mut self, token: Token) -> Result<(), String> {
273+ fn expect(&mut self, token: Token) -> Result<(), Error> {
212274 match self.peek() {
213275 Some(t) if *t == token => {
214276 self.pos += 1;
215277 Ok(())
216278 }
217− Some(t) => Err(format!("expected {} but found {}", token, t)),
218− None => Err(format!("expected {} at the end", token)),
279+ Some(t) => Err(format!("expected {} but found {}", token, t).into()),
280+ None => Err(format!("expected {} at the end", token).into()),
219281 }
220282 }
221283
222− fn expr(&mut self) -> Result<Value, String> {
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> {
223299 let mut left = self.and()?;
224300 while self.eat_word("or") {
225− let right = self.and()?;
226− left = Value::Bool(left.truthy() || right.truthy());
301+ let decided = left.truthy();
302+ let right = self.skipping(decided, Self::and)?;
303+ left = Value::Bool(decided || right.truthy());
227304 }
228305 Ok(left)
229306 }
230307
231− fn and(&mut self) -> Result<Value, String> {
308+ fn and(&mut self) -> Result<Value, Error> {
232309 let mut left = self.not()?;
233310 while self.eat_word("and") {
234− let right = self.not()?;
235− left = Value::Bool(left.truthy() && right.truthy());
311+ let decided = !left.truthy();
312+ let right = self.skipping(decided, Self::not)?;
313+ left = Value::Bool(!decided && right.truthy());
236314 }
237315 Ok(left)
238316 }
239317
240− fn not(&mut self) -> Result<Value, String> {
318+ fn not(&mut self) -> Result<Value, Error> {
241319 if self.eat_word("not") {
242320 return Ok(Value::Bool(!self.not()?.truthy()));
243321 }
244322 self.compare()
245323 }
246324
247− fn compare(&mut self) -> Result<Value, String> {
248− let left = self.concat()?;
249− let op = if let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) {
250− op
251− } else if self.eat_word("contains") {
252− "contains"
253− } else if self.eat_word("starts-with") {
254− "starts-with"
255− } else if self.eat_word("ends-with") {
256− "ends-with"
257− } else {
325+ fn compare(&mut self) -> Result<Value, Error> {
326+ let left = self.range()?;
327+ let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
258328 return Ok(left);
259329 };
260− let right = self.concat()?;
261− let numbers = left.as_num().zip(right.as_num());
262− let (l, r) = (left.to_string(), right.to_string());
263− Ok(Value::Bool(match (op, numbers) {
264− ("==", Some((a, b))) => a == b,
265− ("!=", Some((a, b))) => a != b,
266− ("<", Some((a, b))) => a < b,
267− (">", Some((a, b))) => a > b,
268− ("<=", Some((a, b))) => a <= b,
269− (">=", Some((a, b))) => a >= b,
270− ("==", None) => l == r,
271− ("!=", None) => l != r,
272− ("<", None) => l < r,
273− (">", None) => l > r,
274− ("<=", None) => l <= r,
275− (">=", None) => l >= r,
276− ("contains", _) => l.contains(&r),
277− ("starts-with", _) => l.starts_with(&r),
278− ("ends-with", _) => l.ends_with(&r),
279− _ => unreachable!(),
280− }))
281− }
282−
283− fn concat(&mut self) -> Result<Value, String> {
284− let mut left = self.sum()?;
285− while self.eat_op(&["~"]).is_some() {
286− let right = self.sum()?;
287− left = Value::Str(format!("{}{}", left, right));
330+ let right = self.range()?;
331+ if self.skip {
332+ return Ok(SKIPPED);
288333 }
289− Ok(left)
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)?)
290365 }
291366
292− fn sum(&mut self) -> Result<Value, String> {
367+ fn sum(&mut self) -> Result<Value, Error> {
293368 let mut left = self.product()?;
294369 while let Some(op) = self.eat_op(&["+", "-"]) {
295370 let right = self.product()?;
296− left = arithmetic(op, &left, &right)?;
371+ left = self.arithmetic(op, &left, &right)?;
297372 }
298373 Ok(left)
299374 }
300375
301− fn product(&mut self) -> Result<Value, String> {
376+ fn product(&mut self) -> Result<Value, Error> {
302377 let mut left = self.unary()?;
303378 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
304379 let right = self.unary()?;
305− left = arithmetic(op, &left, &right)?;
380+ left = self.arithmetic(op, &left, &right)?;
306381 }
307382 Ok(left)
308383 }
309384
310− fn unary(&mut self) -> Result<Value, String> {
385+ fn unary(&mut self) -> Result<Value, Error> {
311386 if self.eat_op(&["-"]).is_some() {
312387 let value = self.unary()?;
388+ if self.skip {
389+ return Ok(SKIPPED);
390+ }
313391 return value
314392 .as_num()
315393 .map(|n| Value::Num(-n))
316− .ok_or_else(|| format!("cannot negate `{}`", value));
394+ .ok_or_else(|| Error::Invalid(format!("cannot negate `{}`", value)));
317395 }
318396 self.primary()
319397 }
320398
321− fn primary(&mut self) -> Result<Value, String> {
322− let token = self
323− .peek()
399+ fn primary(&mut self) -> Result<Value, Error> {
400+ let (token, at) = self
401+ .tokens
402+ .get(self.pos)
324403 .cloned()
325404 .ok_or_else(|| "expected a value at the end".to_string())?;
326405 self.pos += 1;
327406 match token {
328407 Token::Num(n) => Ok(Value::Num(n)),
329− Token::Str(s) => Ok(Value::Str(interpolate(&s, self.resolve))),
330− Token::Var(name) => Ok(Value::Str((self.resolve)(&name).unwrap_or_default())),
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+ }
331428 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
332429 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
333− Token::Word(w) if w == "if" && self.peek() == Some(&Token::LParen) => {
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) => {
334435 self.pos += 1;
335− let condition = self.expr()?;
336− self.expect(Token::Comma)?;
337− let then = self.expr()?;
338− self.expect(Token::Comma)?;
339− let otherwise = self.expr()?;
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+ }
340444 self.expect(Token::RParen)?;
341− Ok(if condition.truthy() { then } else { otherwise })
445+ check_call(&name, args.len())?;
446+ if self.skip {
447+ return Ok(SKIPPED);
448+ }
449+ Ok(call(&name, args)?)
342450 }
343451 Token::Word(w) => Ok(Value::Str(w)),
344452 Token::LParen => {
@@ -346,8 +454,43 @@ impl Parser<'_> {
346454 self.expect(Token::RParen)?;
347455 Ok(value)
348456 }
349− other => Err(format!("unexpected {}", other)),
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());
350484 }
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)?)
351494 }
352495}
353496
@@ -367,42 +510,137 @@ fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
367510 }))
368511}
369512
370−/// Replace `$name` references inside a string literal.
371−fn interpolate(s: &str, resolve: Resolver) -> String {
372− let mut out = String::with_capacity(s.len());
373− let mut rest = s;
374− while let Some(pos) = rest.find('$') {
375− out.push_str(&rest[..pos]);
376− let after = &rest[pos + 1..];
377− let end = after
378− .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '|' | ':')))
379− .unwrap_or(after.len());
380− if end == 0 {
381− out.push('$');
382− } else {
383− out.push_str(&resolve(&after[..end]).unwrap_or_default());
384− }
385− rest = &after[end..];
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+ ))
386544 }
387− out.push_str(rest);
388− out
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+ })
389622}
390623
391624#[cfg(test)]
392625mod tests {
393626 use super::*;
394627
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+
395642 fn ev(src: &str) -> Value {
396− let resolve = |name: &str| match name {
397− "count" => Some("3".to_string()),
398− "name" => Some("World".to_string()),
399− "theme" => Some("dark".to_string()),
400− "tricky" => Some("a == b".to_string()),
401− "empty" => Some(String::new()),
402− "name|uppercase" => Some("WORLD".to_string()),
403− _ => None,
404− };
405− eval(src, &resolve).unwrap()
643+ eval(src, &scope()).unwrap()
406644 }
407645
408646 #[test]
@@ -420,8 +658,9 @@ mod tests {
420658 assert!(ev("$theme == dark").truthy());
421659 assert!(ev("$theme != light and not $empty").truthy());
422660 assert!(!ev("$count < 2 or $empty").truthy());
423− assert!(ev("$name contains or").truthy());
424− assert!(ev("$name|uppercase == WORLD").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());
425664 assert!(ev("#3b82f6 != red").truthy());
426665 }
427666
@@ -434,18 +673,129 @@ mod tests {
434673
435674 #[test]
436675 fn strings_and_if() {
437− assert_eq!(ev("\"Hello \" ~ $name").to_string(), "Hello World");
438676 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
439677 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
440− assert_eq!(ev("$missing").to_string(), "");
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());
441779 }
442780
443781 #[test]
444782 fn errors_are_reported() {
445− let none = |_: &str| None;
783+ let none = Map::new(&[]);
446784 assert!(eval("dark * 2", &none).is_err());
447785 assert!(eval("1 +", &none).is_err());
448786 assert!(eval("(1", &none).is_err());
449787 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);
450800 }
451801}
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+4
@@ -1,7 +1,11 @@
11pub mod ast;
22pub mod codegen;
3+pub mod diagnostic;
34pub mod expr;
5+pub mod interp;
46pub mod parser;
7+pub mod syntax;
8+pub mod value;
59pub mod vocab;
610
711#[cfg(test)]
crates/htmlang-core/src/parser.rs+6188−3881

This diff is too big to show here.

crates/htmlang-core/src/std.hl+4−45

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.rs+777−157

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+254−184

This diff is too big to show here.

editors/vscode/syntaxes/htmlang.tmLanguage.json+253−30

This diff is too big to show here.

examples/blog.hl+86−48

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

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

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

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

This diff is too big to show here.

src/bin/htmlang_lsp/completion.rs+1484−1103

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

This diff is too big to show here.

src/bin/htmlang_lsp/main.rs+56−115

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+6−10

This diff is too big to show here.

src/convert.rsdeleted−1425

This diff is too big to show here.

src/fmt.rs+401−408

This diff is too big to show here.

src/lib.rs+3−2

This diff is too big to show here.

src/main.rs+228−549

This diff is too big to show here.

src/serve.rs+128−157

This diff is too big to show here.

src/upgrade.rsdeleted−1001

This diff is too big to show here.

tests/common/mod.rsnew file+140

This diff is too big to show here.

tests/docs.rs+116−19

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+2154−56

This diff is too big to show here.

tests/snapshots.rs+4433−1179

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+11−7

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

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

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.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+3−3

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+4−4

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+19−8

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

This diff is too big to show here.

tests/snapshots/new_elements_2.hl+1−1

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

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

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+3−3

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

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

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

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+129−129

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

This diff is too big to show here.