on master help sign in
.gitattributesnew file+3
since splitsnapshots
unifiedsplit
@@ -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:
40 --out-dir target/wasm-pkg \
40 --out-dir target/wasm-pkg \
41 target/wasm32-unknown-unknown/release/htmlang_wasm.wasm
41 target/wasm32-unknown-unknown/release/htmlang_wasm.wasm
42
42
43− - name: Build htmlang CLI
43+ - name: Assemble the playground
44− run: cargo build --release --bin htmlang
45−
46− - name: Generate playground HTML
47 run: |
44 run: |
48 mkdir -p dist
45 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/
50
49
51 - name: Upload Pages artifact
50 - name: Upload Pages artifact
52 uses: actions/upload-pages-artifact@v3
51 uses: actions/upload-pages-artifact@v3
.gitignore+1
@@ -1,3 +1,4 @@
1/target
1/target
2*.html
2*.html
3!tests/snapshots/*.html
3!tests/snapshots/*.html
4+!crates/htmlang-wasm/playground/*.html
CONTRIBUTING.md+113−9
@@ -19,12 +19,100 @@ Linux, macOS, and Windows. Your change should pass all three.
19## Repository layout
19## Repository layout
20
20
21- `crates/htmlang-core/` — parser, AST, code generator. No I/O lives here.
21- `crates/htmlang-core/` — parser, AST, code generator. No I/O lives here.
22+ - `syntax.rs` — the syntax tree: every line as written, with spans. The
23+ compiler evaluates it, the formatter prints it back, and the language
24+ server takes definitions, directives and verbatim bodies from it. It
25+ also holds `ESCAPES`, the one escape table of every htmlang string,
26+ and decides where `{@...}` is an inline element: in every line of text
27+ except the text of a `literal` element.
28+ - `parser.rs` — evaluates the tree: definitions, data, loops and
29+ conditions, and the checks over code that doesn't run. Names live in
30+ `Env`, one frame per block, so a definition is visible to the end of
31+ its block and a function keeps the frames of its definition.
32+ - `diagnostic.rs` — `Diagnostic` and the stable diagnostic codes.
33+ - `codegen.rs` — writes the HTML and the CSS. A parent's `Flow` (the
34+ direction its CSS sets, with the changes under media and container
35+ prefixes) is what its children's `fill` and `shrink` compile against;
36+ the rules for a change are keyed on the parent's class in that block.
37+ A page's `<body>` is written like an `@el` whose styles are `@page`'s
38+ (the reset makes it the column), so the top level of a page is laid
39+ out like any column; a fragment's top level has no layout of its own.
40+ Every CSS property goes through one generic path (its name, its value
41+ as written, with `vocab::with_px`'s pixels); only htmlang's layout
42+ words and `line-clamp` have code of their own. Generated classes are
43+ `hl-` + 7 base-36 digits of `stable_hash` of the style
44+ (`CLASS_DIGITS`), so a style has one name in every file, and
45+ `htmlang build` checks no name stands for two styles; an element's
46+ defaults (its layout,
47+ `ElementKind::css`) go with its class, as `:where(.hl-2k9xq0m)`, never a
48+ rule on a global element selector, so `@markdown`/`@raw` HTML and a
49+ page embedding a fragment are untouched. The rules are in cascade
50+ layers (`LAYERS`): `hl-reset`, `hl-kind` (the defaults), `hl-inside`
51+ (element prefixes, `@td:`, each an `@scope` on the class, and
52+ functions' scoped `@style`, written when a function is called) and
53+ `htmlang` (the elements' own styles and `children:`), so which one
54+ wins is the layer's order, not specificity.
55+ A style's prefixes are a `Condition`: its at-rule prefixes (sorted, so
56+ `dark:md:` is `md:dark:`, and written as nested `@media`/`@container`
57+ blocks) and its selector chain (read left to right; an element prefix
58+ splits it into the scope's root and the elements it styles). Each
59+ class keeps one list of (condition, declarations), and every block and
60+ chain is written in one fixed order, never the order of the source.
61+ - `ast.rs` — the element kinds; every plain HTML element is one row in
62+ `TAGS` with its one `Layout` (column, row, grid, text, native or void,
63+ which decides its text lines, `spacing` and its children's layout
64+ words), whether its text is shown as written (`literal`: `@code`,
65+ `@textarea`, whose indented body is verbatim text, HTML-escaped), the
66+ one HTML attribute its first word fills (`arg`: `src`, `action`, ...;
67+ `ElementKind::arg` gives `@link`'s and `@image`'s too) and whether its
68+ body is foreign text written into the page as it is (`verbatim`:
69+ `@script`), and every directive one row in `DIRECTIVES` (its argument
70+ and its kind of body; a verbatim directive's line is its one-line body
71+ or file, and it takes no attributes). The list of
72+ elements is fixed; `HTML_NAMES_WRITTEN_OTHERWISE` maps HTML's `a`, `img`,
73+ `span`, `p` and `div` to htmlang's own names for the unknown-element
74+ suggestion only.
75+ - `vocab.rs` — the attribute vocabulary (htmlang attributes, CSS properties,
76+ HTML attributes, `@page`'s own `favicon`), the pixel rule
77+ (`LENGTH_PROPERTIES`, the one table of properties whose bare numbers
78+ are px, and `with_px`, the one function that applies it) and the
79+ prefixes: `PSEUDOS`, CSS's pseudo-classes and pseudo-elements under
80+ their CSS names, each marked `:`, `::` or taking an argument, in the
81+ order their rules are written; `children:`; element prefixes (`@td:`,
82+ any name; the parser checks it is an element with a tag of its own,
83+ `ElementKind::own_tag`); and the width, media and container prefixes,
84+ with the rank that orders the at-rule blocks. A new pseudo-class is one
85+ row in `PSEUDOS` and one word in DESIGN.md's list.
86+ - `expr.rs` — the expression language for conditions, computed values and
87+ `${...}`. It evaluates only what decides the result (the branch `if()`
88+ takes, the side of `and`/`or` that decides) and only reads the rest.
89+ - `value.rs` — the value types (text, number, true/false, list,
90+ record) and the rules defined once on them: truthiness, comparison,
91+ how numbers and lists print, ranges.
92+ - `interp.rs` — `$name` and `${...}`: where a name ends and how one slot
93+ of text (a text run, an attribute's value, an argument, a path) is
94+ filled in, including what quoted text inserts in CSS and in text. The
95+ one place variables are interpolated.
96+ - `std.hl` — the standard library, written in htmlang and loaded before
97+ every file.
22- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
98- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
23−- `src/` — CLI, dev server, formatter, and HTML-to-hl converter.
99+- `src/` — CLI, dev server and formatter (`fmt.rs`, which prints the syntax
100+ tree back with its comments and blank lines).
24−- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`).
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`.
25- `editors/vscode/` — VS Code extension.
104- `editors/vscode/` — VS Code extension.
26- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
105- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
27−- `examples/` — sample `.hl` files used as smoke tests and documentation.
106+ These tests, and `tests/regressions.rs`, read the output with its
107+ hashed classes numbered (`hl-a`, `hl-b`, ..., see `tests/common`), so a
108+ snapshot changes when a style does, not when a hash does.
109+- `tests/regressions.rs` — one test per fixed bug.
110+- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`,
111+ and every page in `examples/`: any diagnostic fails it.
112+- `tests/fmt.rs` — formats every example and snapshot input: the result is
113+ stable, compiles to the same HTML and keeps every comment.
114+- `examples/` — complete pages: a landing page, a blog, a docs page, and a
115+ tour of the whole language (`demo.hl`).
28
116
29## Adding a feature
117## Adding a feature
30
118
@@ -32,19 +120,35 @@ Linux, macOS, and Windows. Your change should pass all three.
32 `tests/snapshots.rs`; prefer integration tests that exercise the full
120 `tests/snapshots.rs`; prefer integration tests that exercise the full
33 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
121 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
34 tests alongside the code.
122 tests alongside the code.
123+2. Prefer the smallest mechanism: a component belongs in `std.hl`, an HTML
124+ element is a row in `TAGS` (and a line in DESIGN.md's table of layouts
125+ and a name in the VS Code grammar's element list, which tests check), and
126+ a CSS property needs no code at all. Only
35−2. Thread the feature through the parser, then codegen, then the LSP (hover,
127+ thread a feature through the parser and codegen when it needs to be, and
36− completions, diagnostics).
128+ describe it in the LSP's `docs.rs`.
129+3. Document it in `DESIGN.md` (the examples there are compiled by the tests).
37−3. Document it in `DESIGN.md`. If it's user-facing, also update `README.md`.
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.
38−4. If it changes the CLI surface, update the `--help` output and the shell
132+4. If it changes the CLI surface, update the `--help` output.
39− completions.
40
133
41## Style
134## Style
42
135
43- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
136- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
44- Keep `@` prefixes and bracket-attribute syntax consistent with existing
137- Keep `@` prefixes and bracket-attribute syntax consistent with existing
45− directives. Add `KNOWN_DIRECTIVES` entries when you add a new `@foo`.
138+ directives. A new `@foo` is a row in `ast::DIRECTIVES`; the parser, the
139+ formatter and the language server read that table, and `tests/editor.rs`
140+ fails when the VS Code grammar's word lists drift from it.
141+- Every diagnostic has a code from `diagnostic::code`; add one there when no
142+ existing code fits, and key tool behavior (quick fixes, tests) on the code
143+ rather than on the message. Put the name the diagnostic is about in
144+ `subject` and a replacement in `suggestion`.
46- Diagnostics should include `line` and, when practical, `column` and a
145- Diagnostics should include `line` and, when practical, `column` and a
47 `source_line` excerpt. Use `Severity::Help` for suggestions, not `Warning`.
146 `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.
48- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
152- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
49 diagnostic so the compiler keeps going.
153 diagnostic so the compiler keeps going.
50
154
Cargo.lock−211
@@ -24,28 +24,6 @@ dependencies = [
24 "syn",
24 "syn",
25]
25]
26
26
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−
49[[package]]
27[[package]]
50name = "bitflags"
28name = "bitflags"
51version = "1.3.2"
29version = "1.3.2"
@@ -70,33 +48,12 @@ version = "1.11.1"
70source = "registry+https://github.com/rust-lang/crates.io-index"
48source = "registry+https://github.com/rust-lang/crates.io-index"
71checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
49checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
72
50
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−
85[[package]]
51[[package]]
86name = "cfg-if"
52name = "cfg-if"
87version = "1.0.4"
53version = "1.0.4"
88source = "registry+https://github.com/rust-lang/crates.io-index"
54source = "registry+https://github.com/rust-lang/crates.io-index"
89checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
55checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
90
56
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−
100[[package]]
57[[package]]
101name = "dashmap"
58name = "dashmap"
102version = "5.5.3"
59version = "5.5.3"
@@ -121,12 +78,6 @@ dependencies = [
121 "syn",
78 "syn",
122]
79]
123
80
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−
130[[package]]
81[[package]]
131name = "filetime"
82name = "filetime"
132version = "0.2.27"
83version = "0.2.27"
@@ -138,12 +89,6 @@ dependencies = [
138 "libredox",
89 "libredox",
139]
90]
140
91
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−
147[[package]]
92[[package]]
148name = "form_urlencoded"
93name = "form_urlencoded"
149version = "1.2.2"
94version = "1.2.2"
@@ -153,12 +98,6 @@ dependencies = [
153 "percent-encoding",
98 "percent-encoding",
154]
99]
155
100
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−
162[[package]]
101[[package]]
163name = "fsevent-sys"
102name = "fsevent-sys"
164version = "4.1.0"
103version = "4.1.0"
@@ -244,29 +183,6 @@ dependencies = [
244 "slab",
183 "slab",
245]
184]
246
185
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−
270[[package]]
186[[package]]
271name = "hashbrown"
187name = "hashbrown"
272version = "0.14.5"
188version = "0.14.5"
@@ -279,10 +195,8 @@ version = "0.1.0"
279dependencies = [
195dependencies = [
280 "htmlang-core",
196 "htmlang-core",
281 "notify",
197 "notify",
282− "rustls-pemfile",
283 "serde_json",
198 "serde_json",
284 "tokio",
199 "tokio",
285− "tokio-rustls",
286 "tower-lsp",
200 "tower-lsp",
287]
201]
288
202
@@ -442,16 +356,6 @@ version = "1.0.18"
442source = "registry+https://github.com/rust-lang/crates.io-index"
356source = "registry+https://github.com/rust-lang/crates.io-index"
443checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
357checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
444
358
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−
455[[package]]
359[[package]]
456name = "kqueue"
360name = "kqueue"
457version = "1.1.1"
361version = "1.1.1"
@@ -654,12 +558,6 @@ dependencies = [
654 "proc-macro2",
558 "proc-macro2",
655]
559]
656
560
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−
663[[package]]
561[[package]]
664name = "redox_syscall"
562name = "redox_syscall"
665version = "0.5.18"
563version = "0.5.18"
@@ -678,66 +576,6 @@ dependencies = [
678 "bitflags 2.11.1",
576 "bitflags 2.11.1",
679]
577]
680
578
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−
741[[package]]
579[[package]]
742name = "rustversion"
580name = "rustversion"
743version = "1.0.22"
581version = "1.0.22"
@@ -813,12 +651,6 @@ dependencies = [
813 "syn",
651 "syn",
814]
652]
815
653
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−
822[[package]]
654[[package]]
823name = "slab"
655name = "slab"
824version = "0.4.12"
656version = "0.4.12"
@@ -847,12 +679,6 @@ version = "1.2.1"
847source = "registry+https://github.com/rust-lang/crates.io-index"
679source = "registry+https://github.com/rust-lang/crates.io-index"
848checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
680checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
849
681
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−
856[[package]]
682[[package]]
857name = "syn"
683name = "syn"
858version = "2.0.117"
684version = "2.0.117"
@@ -911,16 +737,6 @@ dependencies = [
911 "syn",
737 "syn",
912]
738]
913
739
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−
924[[package]]
740[[package]]
925name = "tokio-util"
741name = "tokio-util"
926version = "0.7.18"
742version = "0.7.18"
@@ -1031,12 +847,6 @@ version = "1.0.24"
1031source = "registry+https://github.com/rust-lang/crates.io-index"
847source = "registry+https://github.com/rust-lang/crates.io-index"
1032checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
848checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1033
849
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−
1040[[package]]
850[[package]]
1041name = "url"
851name = "url"
1042version = "2.5.8"
852version = "2.5.8"
@@ -1072,15 +882,6 @@ version = "0.11.1+wasi-snapshot-preview1"
1072source = "registry+https://github.com/rust-lang/crates.io-index"
882source = "registry+https://github.com/rust-lang/crates.io-index"
1073checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
883checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1074
884
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−
1084[[package]]
885[[package]]
1085name = "wasm-bindgen"
886name = "wasm-bindgen"
1086version = "0.2.114"
887version = "0.2.114"
@@ -1223,12 +1024,6 @@ version = "0.52.6"
1223source = "registry+https://github.com/rust-lang/crates.io-index"
1024source = "registry+https://github.com/rust-lang/crates.io-index"
1224checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1025checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1225
1026
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−
1232[[package]]
1027[[package]]
1233name = "writeable"
1028name = "writeable"
1234version = "0.6.3"
1029version = "0.6.3"
@@ -1279,12 +1074,6 @@ dependencies = [
1279 "synstructure",
1074 "synstructure",
1280]
1075]
1281
1076
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−
1288[[package]]
1077[[package]]
1289name = "zerotrie"
1078name = "zerotrie"
1290version = "0.2.4"
1079version = "0.2.4"
Cargo.toml−2
@@ -13,10 +13,8 @@ path = "src/bin/htmlang_lsp/main.rs"
13[dependencies]
13[dependencies]
14htmlang-core = { path = "crates/htmlang-core" }
14htmlang-core = { path = "crates/htmlang-core" }
15notify = "7"
15notify = "7"
16−rustls-pemfile = "2.2.0"
17serde_json = "1"
16serde_json = "1"
18tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
17tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
19−tokio-rustls = { version = "0.26.4", features = ["ring"] }
20tower-lsp = "0.20"
18tower-lsp = "0.20"
21
19
22[profile.release]
20[profile.release]
DESIGN.md+1453−261
@@ -1,427 +1,1619 @@
1−# htmlang (.hl)
1+# htmlang language reference
2
2
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:
4
5
5−## Principles
6+```
6−
7+@page Hello
7−- `@` means structure, bare lines mean content.
8+@el [width fill, max-width 640, center-x, padding 40, spacing 16]
8−- Layout is explicit and compositional: every element declares its own layout
9+ @h1 Hello
9− role, and there is no CSS cascade to reason about.
10+ @paragraph
10−- Output is one self-contained HTML file with embedded CSS (flexbox), and no
11+ This page was written in {@b htmlang}.
11− JavaScript unless you write some.
12+```
12
13
13Every example block in this file is compiled by the test suite
14Every example block in this file is compiled by the test suite
14(`tests/docs.rs`), so the examples stay correct.
15(`tests/docs.rs`), so the examples stay correct.
15
16
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+
16## Syntax
45## Syntax
17
46
18−### Structure
47+### Elements
19
48
20```
49```
21@element [attributes] argument
50@element [attributes] argument
22 children
51 children
23```
52```
24
53
25−Children are indented under their parent. Attributes are comma-separated inside
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 `[...]`,
26−`[...]` and may span several lines:
57+separated by commas, and the list may span several lines:
27
58
28```
59```
29@el [
60@el [
30 padding 20,
61 padding 20,
31 background white,
62 background white,
32− rounded 8,
63+ border-radius 8,
33− shadow 0 2px 4px rgba(0,0,0,0.1)
64+ box-shadow 0 2 4 rgba(0,0,0,0.1)
34]
65]
35 Content
66 Content
36```
67```
37
68
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+
38−A comma inside `(...)` or `"..."` does not split attributes, so a font stack is
80+A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else,
39−written `font "Inter, sans-serif"`.
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+```
40
108
41### Text
109### Text
42
110
43−Any line that doesn't start with `@` or `[` is text. Use `@text` when text needs
111+A line that doesn't start with `@` is text. Text after an element's
44−attributes, and `{...}` to put elements inside a line of text:
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:
45
115
46```
116```
47@paragraph
117@paragraph
48− This is {@text [bold] important} and this is a {@link https://example.com link}.
118+ This is {@strong important}, and this is {@link https://example.com a link}.
49−@text [bold, size 24, color #333] Hello world
119+@text [font-weight bold, font-size 24, color #333] Hello world
120+@h2 Welcome to {@text [color #3b82f6] htmlang}
121+@ul
122+ @li Read {@link /docs the docs}
123+@section [padding 8] Text after the attributes is content too.
124+```
125+
126+How an element's lines combine is its [layout](#rows-columns-and-text)'s
127+business: in a row or column each line is a child; in a text element lines
128+flow. Text that `@if`, `@each`, `@fragment` or a function writes counts as
129+lines written in its place, so in a text element it flows with the rest,
130+joined with a space, and in a native element it is on lines of its own.
131+
132+The text of `@code` and `@textarea` is shown as written. On its line and
133+inline, a `{@...}` in it is text, while escapes and `$names` still work.
134+The lines indented under it are a [verbatim](#verbatim-bodies) sample:
135+nothing in them is htmlang (`@`, `$`, `{`, `\` and `--` are text), and
136+they are HTML-escaped with their line breaks and indentation kept. This
137+is how a page shows code, htmlang included. `@pre` is an ordinary element,
138+so `@pre > @code` is HTML's `<pre><code>`, and a function can wrap `@pre`
139+around a sample. A sample is text on the line or lines in the block, not
140+both.
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]
50```
183```
51
184
52### Comments
185### Comments
53
186
54−`--` at the start of a line begins a comment. Comments must be on their own
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
55−line: `--` later in a line is ordinary text.
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).
56
193
57```
194```
58-- this is a comment
195-- this is a comment
59@row [spacing 10]
196@row [spacing 10]
60− -- 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
61```
207```
62
208
63−### Shorthands
209+### Chains
64
210
65−A line starting with `[` is an anonymous `@el`. `>` chains single-child
211+`>` puts elements that have one child each on one line. The last element in
66−elements on one line; the last element in the chain gets the indented children.
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).
67
218
68```
219```
69−[padding 20, background white]
70− Hello
71−
72−@el [padding 16, background blue, rounded 8] > @link https://example.com
220+@el [padding 16, background blue, border-radius 8] > @link https://example.com
73 @text [color white] Get Started
221 @text [color white] Get Started
74```
222```
75
223
76−### 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`).
77
233
78−`@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.
79
240
80```
241```
81−@raw """
242+@raw <hr class="fancy">
243+@raw
82−<div class="custom-widget"></div>
244+ <div class="custom-widget">
83−"""
245+ <span>Hand-written HTML</span>
246+ </div>
247+@style .note { color: gray; }
84```
248```
85
249
86−## Elements
250+### Directives
87
251
88−Layout elements set up flexbox; the rest map to the HTML element of the same
252+A directive is a built-in word that isn't an element. Each one takes a
89−name.
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)).
90
256
91−| Element | Output | Purpose |
257+| Directive | Argument | Body |
92|---|---|---|
258|---|---|---|
93−| `@row` | div, flex row | Horizontal layout |
259+| `@page` | attributes (styles for `<body>`, `key=value` for `<html>`) and a title | none |
94−| `@column` | div, flex column | Vertical layout |
260+| `@let` | a name and a value or bundle, or `@name` and parameters | a function's body |
95−| `@el` | div, flex column | Generic container |
261+| `@include` | a file | none |
96−| `@grid` | div, grid | Grid container (use `grid-cols`) |
262+| `@data` | a variable and a source | none |
97−| `@stack` | div, position relative | Children layered on top of each other |
263+| `@meta` | a name and a value | none |
98−| `@in-front` / `@behind` | div, absolute | Overlay layers filling the parent (see below) |
264+| `@if`, `@else`, `@each` | a condition or a loop | htmlang |
99−| `@spacer` | div, flex 1 | Pushes siblings apart |
265+| `@raw`, `@style`, `@head` | the rest of the line: a one-line body | verbatim, instead of the line |
100−| `@text` | span | Styled inline text |
266+| `@markdown` | a file | verbatim Markdown, instead of the file |
101−| `@paragraph` | p | Flowing text with inline elements |
267+
102−| `@link url` | a | Link; text after the URL becomes its content |
268+An indented line under a directive that takes no body is an error, because
103−| `@image src` | img | Image |
269+it would otherwise silently become a sibling.
104−| `@h1` … `@h6` | h1 … h6 | Headings |
270+
105−
271+### Diagnostics
106−Semantic containers, all laid out as columns: `@nav`, `@header`, `@footer`,
272+
107−`@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@form`,
273+The compiler checks the whole file, including code that doesn't run: the
108−`@details` / `@summary`, `@dialog`, `@figure` / `@figcaption`, `@blockquote` /
274+branch of an `@if` or an `if()` that isn't taken, the body of a function that is never
109−`@cite`, `@fieldset` / `@legend`, `@noscript`.
275+called and the body of a loop over an empty list. Unknown elements,
110−
276+functions, attributes and prefixes are reported there too. In code that
111−Content: `@list` / `@item` (`@list [ordered]` for `<ol>`), `@dl` / `@dt` /
277+doesn't run, a call is checked against every function in the file and the
112−`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
278+files it includes; where the code runs, a name is looked up where it is
113−`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
279+written, so a function's body sees only what is defined above the function
114−`@canvas`, `@iframe src`, `@video`, `@audio`, `@picture` / `@source`,
280+(a call to a function defined further down is reported when the body
115−`@script`, `@breadcrumb`, `@fragment` (children without a wrapper).
281+runs, and two functions can't call each other). A `$name` is checked only
116−
282+where the code runs, since any of them may come from data: an undefined
117−Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
283+variable in a branch that isn't taken, or in a function that is never
118−`@label`, `@datalist`.
284+called, isn't reported.
119−
285+
120−Styled components: `@badge`, `@chip`, `@tag`, `@avatar`, `@tooltip`,
286+**Names are checked, and values are CSS's.** A misspelled element, prefix,
121−`@carousel`.
287+parameter or slot is an error, and so is an undefined variable. A CSS
122−
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+
123−```
330+```
124−@list [ordered]
331+@el [spacing 4]
125− @item First
332+ First line
126− @item Second
333+ Second line
127−@form [method post] /subscribe
334+@button [type=button]
128− @label [for email] Email
335+ Save
129− @input [type email, name email, id email, required]
336+ changes
130− @button [type submit] Send
337+```
131−@details [open]
338+
132− @summary Question
339+The column shows two lines 4px apart; the button says "Save changes".
133− @text The answer.
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
410+```
411+
412+On a phone the header is a column and its `@el [width fill]` is the full
413+width; from md up it is a row and the `@el` takes the free space, pushing
414+the navigation to the right. The section's `@el` is the usual centred
415+column: the full width, at most 800px, with auto margins on both sides.
416+
417+Everything else about layout is plain CSS: `justify-content`, `align-items`,
418+`flex 1 1 240px`, `grid-template-areas`, `position`.
419+
420+### Layout inside text
421+
422+A row, column or grid written inside text, inline in a line (`{@el ...}`) or
423+as a child of a text element, is laid out inline (`display: inline-flex`, or
424+`inline-grid`), and keeps its attributes. Anywhere inside text, `@el`, `@row`
425+and `@grid` (and `@in-front` and `@behind`) are written as a `<span>` rather
426+than a `<div>`, since text can't hold a `<div>`.
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
134```
434```
135
435
136−### `@in-front` / `@behind`
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>`.
137
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+
138−Overlay layers, like elm-ui's `inFront` and `behind`. Their children fill the
453+These layers work like elm-ui's `inFront` and `behind`. Their children fill
139−parent's bounds; the parent automatically becomes a positioning context
454+the parent's bounds, and the parent becomes a positioning context
140−(`position: relative; isolation: isolate`). An explicit `position` on the parent
455+(`position: relative; isolation: isolate`). An explicit `position` on the
141−wins.
456+parent takes precedence.
142
457
143```
458```
144@el [width 200, height 200, background blue]
459@el [width 200, height 200, background blue]
145 @text [color white] Main content
460 @text [color white] Main content
146 @in-front
461 @in-front
147− @text [color yellow] Painted on top
462+ @text [color yellow, align-right, align-bottom] Painted on top
148 @behind
463 @behind
149− @el [background red, width fill, height fill]
464+ @el [background red, height fill]
150```
465```
151
466
152−## Attributes
467+### Standard library
153
468
154−### Layout and sizing
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`.
155
474
156−| Attribute | Effect |
475+```
157−|---|---|
476+@row [spacing 8, align-items center]
158−| `spacing N` | Gap between children (also `gap-x N`, `gap-y N`) |
477+ @text Logo
159−| `padding N` / `padding Y X` / `padding T R B L` | Padding (also `padding-x`, `padding-y`, per side) |
478+ @spacer
160−| `margin ...` | Margin, same forms as padding |
479+ @text [$truncate, max-width 200] A long title that gets cut off
161−| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
480+```
162−| `height fill` / `height N` / `height shrink` | Same for height |
163−| `min-width`, `max-width`, `min-height`, `max-height` | Size limits |
164−| `center-x`, `center-y` | Center within the parent |
165−| `align-left`, `align-right`, `align-top`, `align-bottom` | Align within the parent |
166−| `wrap` | Let a row wrap |
167−| `grid-cols N`, `grid-rows N`, `col-span N`, `row-span N` | Grid layout |
168−| `hidden` | `display: none` |
169−
170−### Style
171
481
172−| 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 |
173|---|---|
513|---|---|
174−| `background COLOR`, `color COLOR` | Colors |
514+| Size | `width`, `height`, `min-*` and `max-*` of both, `block-size`, `inline-size` and their `min-`/`max-`, `flex-basis`, `column-width`, `contain-intrinsic-*` |
175−| `border N COLOR` (also `border-top` etc.) | Border |
515+| Space | `margin`, `padding`, `scroll-margin`, `scroll-padding` and every side of them, `gap`, `row-gap`, `column-gap`, `border-spacing` |
176−| `rounded N` | Border radius |
516+| Position | `top`, `right`, `bottom`, `left`, `inset` and `inset-*`, `translate`, `transform-origin`, `perspective`, `perspective-origin`, `offset-position`, `offset-anchor`, `offset-distance` |
177−| `shadow VALUE` | Box shadow |
517+| Border | `border` and every `border-*` shorthand, width and radius (not `border-image-*`), `outline`, `outline-width`, `outline-offset`, `column-rule`, `column-rule-width` |
178−| `bold`, `italic`, `underline` | Text style |
518+| Shadow | `box-shadow`, `text-shadow` |
179−| `size N` | Font size |
519+| Grid | `grid-template-columns`, `grid-template-rows`, `grid-auto-columns`, `grid-auto-rows` |
180−| `font NAME` | Font family |
520+| Background | `background-position` (and `-x`, `-y`), `background-size`, `object-position`, `mask-position`, `mask-size` |
181−| `transition VALUE`, `animation VALUE` | Motion |
521+| Text | `font-size`, `letter-spacing`, `word-spacing`, `text-indent`, `text-decoration-thickness`, `text-underline-offset`, `vertical-align`, `baseline-shift` |
182−
522+| Other | `shape-margin`, `overflow-clip-margin`, `view-timeline-inset` |
183−Most CSS properties can also be written directly as attributes with the same
523+
184−name and value: `opacity 0.5`, `cursor pointer`, `z-index 10`,
524+Shorthands that mix lengths with numbers of other kinds (`font`,
185−`text-align center`, `position absolute`, `grid-template-areas "a b"`, and so on.
525+`background`, `mask`, `grid`, `grid-template`, `offset`, `flex`) are not in
186−
526+the table, so the lengths in them are written with their unit:
187−**Units.** A bare number is pixels (`padding 20` is `20px`). Values with a unit,
527+`font 700 16px/1.5 Inter`.
188−keywords and CSS functions are passed through: `width 50%`, `margin 0 auto`,
528+
189−`max-width min(100%, 800px)`.
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.
190
543
191−### HTML attributes
544+```
545+@el [-webkit-tap-highlight-color transparent, -webkit-text-stroke 1px black]
546+ Passed through as written
547+```
192
548
193−`id`, `class`, and element attributes such as `href`, `type`, `name`, `value`,
549+A value is checked only for what is wrong in any CSS. A `;`, `{` or `}`
194−`placeholder`, `alt`, `for`, `required`, `disabled`, `target`, `rel`, `role`,
550+(outside a quoted string, and for `;` outside parentheses, as in CSS's own
195−`tabindex`, `title`, `data-*` and `aria-*` are emitted as HTML attributes.
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`.
196
570
197−### State and media prefixes
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+```
198
582
199−Prefix a style attribute to apply it conditionally:
583+`@let --name value` declares a custom property for the whole page, on
584+`:root` (see [CSS custom properties](#css-custom-properties));
585+`[--name value]` sets it on one element and everything inside it. So a
586+design token is declared once with `@let`, and a theme redefines it where
587+it changes, once, rather than repeating a colour on every element that
588+uses it:
589+
590+```
591+@let --ink #0f172a
592+@let --surface white
593+@let --subtle #f8fafc
594+@page [color var(--ink), background var(--surface), dark:[--ink #e2e8f0, --surface #0b1220, --subtle #111a2e]] Tokens
595+@section [padding 96 24, background var(--subtle)]
596+ @article [padding 24, background var(--surface)]
597+ Both follow dark mode, from the one place that sets it.
598+```
599+
600+### Prefixes
601+
602+A prefix applies a style only in some condition:
200
603
201| Prefix | Applies |
604| Prefix | Applies |
202|---|---|
605|---|---|
203−| `hover:`, `active:`, `focus:`, `focus-visible:`, `focus-within:`, `disabled:`, `checked:`, `visited:`, `target:`, `valid:`, `invalid:`, `empty:`, `placeholder:`, `selection:` | In that state |
606+| `hover:`, `focus:`, `first-child:`, `checked:`, `user-invalid:`, `open:`, ... | When that CSS pseudo-class matches (`:hover`, `:first-child`, ...) |
204−| `first:`, `last:`, `odd:`, `even:`, `nth(EXPR):` | By position among siblings |
607+| `nth-child(odd):`, `not(.featured):`, `has(> img):`, `is(...):`, ... | When that pseudo-class, with its argument, matches |
205−| `before:`, `after:` | On the `::before` / `::after` pseudo-element (with `content`) |
608+| `before:`, `after:`, `marker:`, `placeholder:`, `backdrop:`, ... | To that pseudo-element (`::before`, `::marker`, ...) |
206−| `has(SELECTOR):` | When the element contains a match |
609+| `children:` | To each direct child |
610+| `@td:`, `@h2:`, `@link:`, ... | To every such element inside it, at any depth (see [Styling the elements inside](#styling-the-elements-inside)) |
207−| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640–1536px) |
611+| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640, 768, 1024, 1280, 1536px) |
208−| `cq-sm:` … `cq-2xl:` | Container queries |
612+| `cq-sm:` … `cq-2xl:` | From that container width up (the same widths, for an ancestor with `container-type inline-size`) |
209−| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Media conditions |
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+```
210
692
211−`@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.
212
742
213```
743```
744+@let card [padding 16, border 1 solid #e5e7eb, border-radius 8]
745+@let active true
214−@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
746+@el [padding 16, background #3b82f6, hover:background #2563eb, md:[padding 32, font-size 20], dark:[background #1e3a8a, hover:background #1e40af]]
215 @text [color white] Click me
747 @text [color white] Click me
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
216−@el [before:content "→ ", before:color red]
752+@el [before:content "→ ", before:color red, hover:before:color blue]
217 Item with an arrow
753 Item with an arrow
754+@section [spacing 16, md:$card, hover:if($active, [background #eef2ff, color #3730a3])]
755+ A card from md up
218```
756```
219
757
758+The styles of one element follow a fixed order, whatever the order they
759+are written in: those without a prefix, then those with selector
760+prefixes only, then each width, media and container condition in the
761+order of the table (a stack like `md:dark:` after `dark:` on its own),
762+with its selector chains inside it. So where two styles both hold, the
763+one later in the table wins (`dark:` over `md:`, `lg:` over `md:`), and a
764+stack wins over each of its prefixes on its own (`md:dark:` over `dark:`
765+and `md:`). Selector chains follow CSS's usual order: `link:` and
766+`visited:`, the position and form states, then `hover:`, `focus:`,
767+`active:`, and `enabled:` and `disabled:` last. So a hovered row wins over
768+`nth-child(odd):`, and a pressed button's `active:` over its `focus:`.
769+
220### Conditional attributes
770### Conditional attributes
221
771
772+`if(CONDITION, A, B)`, written as a whole attribute, is `A` when the
773+condition holds and `B` otherwise. Each branch is an attribute, a `[group]`
774+of attributes or a `$bundle`. An empty branch leaves the attribute out, and
775+`B` itself can be left out. Several attributes that depend on one condition
776+are one `if()` with a group:
777+
222```
778```
223@let active true
779@let active true
224−@el [background if($active, blue, gray), bold if $active]
780+@el [if($active, background blue, background gray), if($active, font-weight bold)]
225 Conditionally styled
781 Conditionally styled
782+@link [if($active, [background #eef2ff, font-weight 600, aria-current=page])] /docs Docs
783+```
784+
785+A value that depends on a condition is an [expression](#expressions),
786+`${if(CONDITION, A, B)}`. Its branches are expressions, so a number or a
787+single word is written as it is, and other CSS text is quoted:
788+`padding ${if($active, 24, 0)}`, `color ${if($active, #10b981, "var(--muted)")}`.
789+An `if()` written in a value without `${...}` is CSS's own `if()`, which the
790+browser decides, and it is passed through like any CSS function:
791+`width if(media(width > 40em): 50%; else: 100%)`.
792+
793+## HTML
794+
795+### HTML elements
796+
797+The layout and text elements have elm-ui's names. Every other element has
798+its HTML name: `@strong`, `@em`, `@small`, `@br`, `@sub`, `@caption`,
799+`@tfoot`, `@optgroup`, `@hgroup` and the others in the table of layouts
800+below.
801+
802+| Element | Output | Purpose |
803+|---|---|---|
804+| `@el` | div, flex column | The container: children laid out top to bottom |
805+| `@row` | div, flex row | Children laid out side by side |
806+| `@grid` | div, grid | Grid container (with `grid-cols` or `grid-template-*`) |
807+| `@in-front` / `@behind` | div, absolute | Overlay layers that fill the parent |
808+| `@text` | span | Styled inline text |
809+| `@paragraph` | p | A paragraph of flowing text |
810+| `@link URL` | a | Link: the URL is its `href`, and the text after it is its content |
811+| `@image SRC` | img | Image: the one word after its attributes is its `src` |
812+| `@fragment` | (none) | Its children, without a wrapper element (so it takes no attributes) |
813+
814+Every element has one [layout](#rows-columns-and-text):
815+
816+| Layout | Elements |
817+|---|---|
818+| column | `@el`, `@in-front`, `@behind`, `@nav`, `@header`, `@footer`, `@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@noscript`, `@form`, `@details`, `@dialog`, `@figure`, `@blockquote`, `@fieldset`, `@hgroup`, `@ul`, `@ol`, `@menu`, `@li`, `@dl`, `@dd` |
819+| row | `@row` |
820+| grid | `@grid` |
821+| text | `@text`, `@paragraph`, `@link`, `@h1`, `@h2`, `@h3`, `@h4`, `@h5`, `@h6`, `@button`, `@label`, `@legend`, `@summary`, `@figcaption`, `@cite`, `@dt`, `@td`, `@th`, `@code`, `@kbd`, `@mark`, `@abbr`, `@time`, `@b`, `@i`, `@strong`, `@em`, `@small`, `@s`, `@u`, `@sub`, `@sup`, `@q`, `@var`, `@samp`, `@dfn`, `@bdi`, `@bdo`, `@ins`, `@del` |
822+| native | `@table`, `@caption`, `@colgroup`, `@thead`, `@tbody`, `@tfoot`, `@tr`, `@select`, `@optgroup`, `@option`, `@datalist`, `@textarea`, `@progress`, `@meter`, `@output`, `@pre`, `@ruby`, `@rt`, `@rp`, `@picture`, `@video`, `@audio`, `@iframe`, `@object`, `@map`, `@canvas`, `@script` |
823+| void | `@image`, `@input`, `@hr`, `@br`, `@wbr`, `@col`, `@source`, `@track`, `@embed`, `@area` |
824+
825+`@fragment`, `@children` and `@slot` have no element of their own: what
826+they hold takes the layout of the element they are in. The top of a page is
827+a column, `<body>` (see [Page and head](#page-and-head)). In a native
828+element, and at the top of a fragment, lines of text are separated by a line break,
829+which HTML shows as a space except where whitespace is kept, as in `@pre`
830+and `@textarea`.
831+
832+The text-level elements (`@strong`, `@em`, `@b`, `@i`, `@small`, `@sub`,
833+`@q`, `@del`, ...) are text with the browser's own style and no CSS of
834+htmlang's, so they work inside a line like any inline element. Table parts,
835+`@optgroup` and `@ruby` keep HTML's own layout:
836+
837+```
838+@paragraph
839+ {@strong Note:} water is H{@sub 2}O,{@br}
840+ and {@em this} is {@small fine print}.
841+@table
842+ @caption Team
843+ @thead
844+ @tr
845+ @th Name
846+ @tbody
847+ @tr
848+ @td Ada
849+ @tfoot
850+ @tr
851+ @td One person
852+@select [aria-label=Fruit]
853+ @optgroup Citrus
854+ @option Lemon
855+```
856+
857+The list of elements is fixed: a name that isn't in it is an unknown
858+element, not a new HTML tag, since a function is called with the same `@`
859+and a misspelled one would otherwise become a tag. HTML's `<div>`, `<span>`,
860+`<p>`, `<a>` and `<img>` are htmlang's own `@el`, `@text`, `@paragraph`,
861+`@link` and `@image`, so `@a` is an unknown element whose error suggests
862+`@link`. The head's elements (`<meta>`, `<style>`, `<title>`, `<link>`) are
863+written with [directives](#page-and-head). `<data>` and `<slot>` share their
864+names with `@data` and `@slot`, and `<template>` holds content that only a
865+script uses; these, and `<svg>` and `<math>`, are written with `@raw`.
866+
867+An element without a closing tag (`@input`, `@hr`, `@br`, `@image`,
868+`@source`, ...) takes no content.
869+
870+Browser default margins on headings, paragraphs, lists and figures are reset
871+to 0 (in the element's own class, so Markdown and raw HTML keep theirs; see
872+[CSS](#css)), so `spacing` controls the gaps. Lists (`@ul`, `@ol`, `@menu`) and list
873+items are columns like other containers, so `spacing` on a list is the gap between its items, and
874+a list shows no markers. To bring them back, write
875+`[list-style disc, padding-inline-start 20, children:display list-item]` on
876+the list, and `marker:` styles the markers themselves (`::marker`).
877+
878+```
879+@ol [spacing 4]
880+ @li First
881+ @li Second
882+@ul [list-style disc, padding-inline-start 20, children:display list-item, children:marker:color #2563eb]
883+ @li [first-child:font-weight 700] Markers
884+ @li Back
885+@form [method=post, spacing 8] /subscribe
886+ @label [for=email] Email
887+ @input [type=email, name=email, id=email, required]
888+ @button [type=submit] Send
889+@details [open]
890+ @summary Question
891+ @text The answer.
226```
892```
227
893
228−## Variables and functions
894+### The leading argument
229
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
957+
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+
230−`@let` defines everything reusable. What it defines depends on its shape:
979+`@let` defines everything reusable. What it defines depends on its form:
231
980
232```
981```
233-- A value, used as $primary
982-- A value, used as $primary
234@let primary #3b82f6
983@let primary #3b82f6
235−-- A computed value (= is optional)
984+-- A computed value: `=` makes the rest an expression
236@let gap = 8 * 2
985@let gap = 8 * 2
237−-- A quoted string: interpolated, never computed
986+-- Quoted text, with $variables filled in (see Variables for its quotes)
238−@let greeting "Hello $primary"
987+@let greeting "Hello from $primary"
239-- An attribute bundle, used as [$card]
988-- An attribute bundle, used as [$card]
240−@let card [padding 20, background white, rounded 8]
989+@let card [padding 20, background white, border-radius 8]
241−-- A function: a @let with an indented body
990+-- A function: `@` before its name, its parameters in brackets, and a body
242−@let panel $title $tone=neutral
991+@let @panel [title]
243− @el [$card]
992+ @el [$card, spacing $gap]
244− @text [bold] $title
993+ @text [font-weight bold, color $primary] $title
245 @children
994 @children
246
995
247@panel [title Welcome]
996@panel [title Welcome]
248− Body text goes into @children.
997+ $greeting
249@el [$card, background #f9fafb]
998@el [$card, background #f9fafb]
250 Attributes after a bundle override it.
999 Attributes after a bundle override it.
251```
1000```
252
1001
253−Function arguments are passed as attributes; parameters with `=default` may be
1002+The line alone says what a `@let` defines: an `@` before the name makes a
254−omitted. `@component name $params` defines a function whose indented `@style`
1003+function, a `[` after the name an attribute bundle, and anything else a
255−block is scoped to its output. Inside a function, `@children` is replaced by the caller's children,
1004+value. A value or a bundle is named without `$` and used with it; a function
256−and `@slot name` by the caller's `@slot name` block (the slot's own children are
1005+is named with `@` and called with it. Only a function has a body, so an
257−the default).
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.
258
1008
259−A multi-line string uses triple quotes; its indented lines are the value:
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:
260
1015
261```
1016```
262−@let intro """
1017+@let gap 8
263− First line
1018+@el [spacing $gap]
264− Second line
1019+ @let gap 16
265− """
1020+ @el [padding $gap] Sixteen
266−@text $intro
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
267```
1048```
268
1049
269−`@let --name value` also emits a CSS custom property. `@theme` declares a group
1050+A name starts with a letter or `_` and goes on with letters, digits, `_`
270−of design tokens at once; each becomes both `$name` and `--name`:
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.
271
1057
272```
1058```
273−@theme
1059+@let lang fr
274− brand #3b82f6
1060+@let size 4
275− radius 8
1061+@data $post {"title": "Hello"}
276−@el [background $brand, rounded $radius] Themed
1062+@text locales/$lang.json, ${size}px, $post.title, $5
277```
1063```
278
1064
279−### Filters
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`.
280
1069
281−`$name|filter` transforms a value: `uppercase`, `lowercase`, `capitalize`,
1070+### Values
282−`trim`, `length`, `reverse`, `truncate:N`, `replace:OLD:NEW`, `default:VALUE`,
1071+
283−and for colors `lighten:N`, `darken:N`, `alpha:N`, `mix:COLOR:N`.
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:
284
1076
285```
1077```
286−@let name htmlang
1078+-- Text, as written
287−@let base #3b82f6
1079+@let size 16px
288−@text [color $base|darken:10] $name|uppercase
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:
1202+
1203+```
1204+@let @notice
1205+ @el [padding 12, background #fef3c7]
1206+ @children
1207+ @text Nothing to report.
1208+
1209+@notice
1210+@notice Deploys are paused today.
1211+```
1212+
1213+Content that would go nowhere is an error, so a mistake can't drop it:
1214+
1215+- a `@slot NAME` block for a slot the function doesn't have (the message
1216+ lists the slots it has);
1217+- text or lines passed to a function whose body has no `@children`;
1218+- a `@slot NAME` block inside an element at the call, instead of directly
1219+ under the call;
1220+- `@slot` or `@children` outside a function's body, or inline in text;
1221+- `@children` or `@slot` after an element's attributes (`@td [padding 8]
1222+ @children`), where it would be text: it goes on a line of its own.
1223+
1224+A slot's name is one word of letters, digits, `-` and `_`, starting with a
1225+letter, like a function's. Inside a body, a call can pass on what the
1226+function got: `@children` under the call passes the content, and a
1227+`@slot footer` block holding `@slot footer` passes that slot.
1228+
1229+A parameter that is off unless the call names it is a flag: give it the
1230+default `false`, and name it alone to turn it on.
1231+
1232+```
1233+@let @post-card [post, featured false, label "About $post"]
1234+ @article [spacing 8, padding ${if($featured, 24, 0)}]
1235+ @if $featured
1236+ @text [font-weight bold] Featured
1237+ @h3 $label
1238+
1239+@post-card [post htmlang, featured]
1240+@post-card [post CSS]
1241+```
1242+
1243+An `@style` block at the top of a function body is **scoped** to the
1244+function. Its rules apply inside the function's root element, and `&` is the
1245+root itself. This requires the body to have a single root element. The
1246+stylesheet goes into the page once, when the function is first called, so
1247+a function that is never called adds nothing. It is in the layer
1248+`hl-inside`, with the styles of element prefixes (see [CSS](#css)): what an
1249+element says about itself wins over it, so a call's `[padding 20]` wins
1250+over the function's `& { padding: 12px; }`, as it would over the root's own
1251+defaults. Where CSS properties are all it needs, an element prefix on the
1252+root says the same without a stylesheet (`@aside [@h2:font-size 18]`).
1253+Only an `@style` at the top of the body is scoped: one under an `@if` or an
1254+element is the page's, where `&` is the whole page, so the compiler warns
1255+about a `&` there.
1256+
1257+```
1258+@let @note [kind Note]
1259+ @style
1260+ & { border-left: 4px solid #3b82f6; }
1261+ .title { font-weight: bold; }
1262+ @el [padding 12, border-radius 6, background #eff6ff]
1263+ @text [class=title] $kind
1264+ @children
1265+
1266+@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
289```
1267```
290
1268
291−### Checks
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:
1272+
1273+```
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
292
1280
293−`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
1281+@paragraph
294−warning; `@debug` and `@log` print values at compile time. `@deprecated MESSAGE`
1282+ @intro
295−before a function warns every caller.
1283+@paragraph
1284+ In short: {@intro} Press {@key Ctrl+K} to search.
1285+```
1286+
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.
1293+
1294+```
1295+@data $menu [
1296+ {"label": "Guide", "children": [{"label": "Install"}, {"label": "Usage"}]},
1297+ {"label": "Reference"}
1298+]
1299+@let @tree [items]
1300+ @ul [padding-left 16]
1301+ @each $item in $items
1302+ @li
1303+ @text $item.label
1304+ @if $item.children
1305+ @tree [items $item.children]
1306+
1307+@tree [items $menu]
1308+```
1309+
1310+The elements a function's body writes are the call's: a problem with
1311+one of them, such as low contrast or `spacing` on an element that isn't a
1312+row, column or grid, is reported at the call, once, and names the function. A
1313+problem in the text of a body is reported on its own line, or at the call
1314+when the function comes from `std.hl` or another file.
1315+
1316+Naming a function after a built-in element or a directive is a warning:
1317+`@let @button` replaces `@button` in every call after it, and a function
1318+named `@if` could never be called. Inside its own body `@button` is the
1319+function too, so a function that wraps the element it is named after,
1320+outside an `@if`, is an error: give it another name.
1321+
1322+### CSS custom properties
1323+
1324+`@let --name value` declares the CSS custom property `--name` on `:root`,
1325+for the whole page. Its value is written as it is, with no pixels added,
1326+so a length takes its unit (`@let --radius 8px`). A token is read one way,
1327+`var(--name)`, in the browser, so an element (`[dark:--name value]`, see
1328+[Custom properties](#custom-properties)) or a stylesheet can still
1329+redefine it. It is not a variable: `$--name` is an error that says to
1330+write `var(--name)`. Since `:root` is one place for the whole page, a
1331+`@let --name` goes at the top level of the file, once: inside an element,
1332+an `@if`, a loop or a function's body it is an error (set the property on an
1333+element with `[--name value]` instead), and declaring it again warns.
1334+
1335+A colour variant of a token is CSS too, so it follows the token wherever
1336+it is redefined: `color-mix()` mixes two colours, and a relative colour
1337+(`rgb(from ...)`) keeps a colour's channels except those you write (`/ 0.5`
1338+for its alpha). `in srgb` mixes the red, green and blue channels, as most
1339+colour tools do; `in oklab` gives steps that look more even, a good choice
1340+for new tokens.
1341+
1342+| For | Write |
1343+|---|---|
1344+| A darker colour | `color-mix(in srgb, var(--brand), black 10%)` |
1345+| A lighter colour | `color-mix(in srgb, var(--brand), white 10%)` |
1346+| A transparent colour | `rgb(from var(--brand) r g b / 0.5)` |
1347+| Two colours mixed | `color-mix(in srgb, var(--brand), #f43f5e 50%)` |
1348+
1349+```
1350+@let --brand #3b82f6
1351+@let --radius 8px
1352+@page [dark:--brand #60a5fa] Themed
1353+@el [background var(--brand), border-radius var(--radius), border 1 solid color-mix(in srgb, var(--brand), black 10%)] Themed
1354+@row
1355+ @each $shade in 0, 10, 20, 30
1356+ @el [background color-mix(in srgb, var(--brand), black $shade%)] $shade%
1357+```
1358+
1359+## Expressions
1360+
1361+Conditions and computed values (`@let x = ...`) are expressions:
1362+
1363+| | |
1364+|---|---|
1365+| Values | numbers, `"strings"` (with `$var` interpolation), `$variables` (with their type: a list or a record too), `$record.field`, `true`, `false`, ranges `A..B` and `A..B step N`, and bare words (`dark`, `#fff`) as text |
1366+| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
1367+| Comparison | `== != < > <= >=`: numbers as numbers, text exactly, lists and records item by item (see [Values](#values)) |
1368+| Logic | `and`, `or`, `not`. `false`, empty text, `0` and an empty list are false |
1369+| Choice | `if(CONDITION, A, B)`, where `B` may be left out (empty) |
1370+| Tests | `contains(s, x)` (in a text, or as an item of a list), `starts-with(s, x)`, `ends-with(s, x)` |
1371+| Text | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)` (the items of a list, or the characters of a text), `reverse(s)` (a list's items, or a text's characters), `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` (the fallback when `s` is empty text or an empty list) |
1372+
1373+Colour maths is CSS's, not an expression's: see
1374+[CSS custom properties](#css-custom-properties). A CSS function such as
1375+`color-mix()` goes in an attribute's value as it is, or in a plain
1376+`@let` value (`@let hover color-mix(in srgb, var(--brand), black 8%)`),
1377+not in `@let x = ...` or `${...}`.
1378+
1379+Variables are looked up during evaluation, so a value that contains `==` or
1380+spaces is still one value. An invalid expression is a compile error, and so
1381+is an undefined variable. A text function given a list is an error too,
1382+instead of a guess: `uppercase()` of `Fast, simple` (a list, because of its
1383+comma) says so, and the text is written `"Fast, simple"` or `Fast\, simple`.
1384+A variable's name ends at `..`, so `${$from..$to}` is a range.
1385+
1386+Only what decides the result is evaluated: `if()` evaluates the branch it
1387+takes, and `and` and `or` stop at the side that decides. So
1388+`${if($n != 0, 10 / $n, 0)}` and `@if $n != 0 and 10 / $n > 1` work when
1389+`$n` is 0. The rest is still read, and its syntax and function names are
1390+checked.
1391+
1392+In text, values and file paths, `${EXPR}` inserts the value of any
1393+expression (see [Variables](#variables)):
1394+
1395+```
1396+@let name htmlang
1397+@let size 12
1398+@text [font-size ${$size + 4}] ${uppercase($name)} has ${length($name)} letters
1399+```
296
1400
297## Control flow
1401## Control flow
298
1402
299All control flow runs at compile time.
1403All control flow runs at compile time.
300
1404
301```
1405```
302−@let items apple, banana, cherry
303@let count 3
1406@let count 3
1407+@let hidden false
304
1408
305−@if $count > 2
1409+@if $count > 2 and not $hidden
306 @text Many
1410 @text Many
307@else if $count == 0
1411@else if $count == 0
308 @text None
1412 @text None
309@else
1413@else
310 @text Few
1414 @text Few
1415+```
311
1416
312−@if not $count
1417+`@each $item in LIST` repeats its body for each item. Its variables are
313− @text Nothing to show
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.
314
1426
315−@each $item in $items
1427+```
316− @text $_index: $item
1428+@let fruits apple, banana, cherry
1429+@each $fruit, $i in $fruits
1430+ @text ${$i + 1}. $fruit
317@else
1431@else
318− @text The list is empty.
1432+ @text No fruit.
319
1433
1434+@row [spacing 8]
320−@each $i in 1..10 step 3
1435+ @each $n in 10..0 step 5
321− @text $i
1436+ @text $n
322−
323−@each $label, $url in Home /, About /about
324− @link $url $label
325−
326−@match $count
327− @case 3
328− @text Three
329− @default
330− @text Other
331```
1437```
332
1438
333−`@defer` keeps its children hidden until they scroll near the viewport (they
1439+## Data and files
334−are shown immediately when JavaScript is off).
335−
336−Conditions support `==`, `!=`, `<`, `>`, `<=`, `>=`, `contains`, `starts-with`,
337−`ends-with`, `not`, and truthiness (empty, `false` and `0` are false). A range
338−counts down when its start is greater than its end.
339−
340−## Files and data
341
1440
342| Directive | Effect |
1441| Directive | Effect |
343|---|---|
1442|---|---|
344−| `@include file.hl` | Insert another file here, content and definitions |
1443+| `@data $name file.json` | Load a JSON file. Objects are records (`$name.key`) and arrays are lists |
345−| `@import file.hl` | Take only its definitions (`@let`, bundles, functions) |
1444+| `@data $name [...]` / `@data $name {...}` | Inline JSON, which may span several lines |
346−| `@extends layout.hl` | Render this page inside a layout (below) |
1445+| `@data $name dir/*.json` | A list with one record per file, in name order. `$item.file` is the file's name |
347−| `@data file.json` / `@data $prefix file.json` | Load JSON values as variables |
348−| `@fetch $prefix http://...` | Like `@data`, fetched at build time (http only) |
349−| `@collection $name "glob"` | List files matching a pattern |
350−| `@translations` | Per-locale strings, used as `$t.key` |
351−| `@env NAME default` | Read an environment variable |
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)) |
352−| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
1448+| `@markdown` / `@markdown file.md` | Markdown (an indented body or a file), converted to HTML |
353−| `@svg file.svg` | Inline an SVG file |
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
354
1468
355−With `@extends`, the layout marks where content goes with `@slot name` (named
1469+A layout is an ordinary function in a file of its own. It marks where content
356−blocks) and `@children` (everything in the page outside `@slot` blocks):
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.
357
1473
358```
1474```
359-- layout.hl
1475-- layout.hl
1476+@let @layout
360−@page My Site
1477+ @page [lang=en, background #f8fafc] My Site
361−@column [max-width 800, center-x]
1478+ @el [width fill, max-width 800, center-x, spacing 24]
1479+ @header
362− @slot header
1480+ @slot header
363− @text Default header
1481+ @text Default header
1482+ @main
364− @children
1483+ @children
365```
1484```
366
1485
367```
1486```
368-- page.hl
1487-- page.hl
369−@extends layout.hl
1488+@include layout.hl
1489+@layout
370−@slot header
1490+ @slot header
371− @text About us
1491+ @h1 About us
372−@paragraph This fills @children.
1492+ @paragraph This fills @children.
373```
1493```
374
1494
1495+The layout holds the page's `@page`, whose attributes can come from its
1496+parameters (`@page [background $bg] $title`). A page has one `@page`, so a
1497+second one is an error (`duplicate-page`): the page's own next to its
1498+layout's, or a layout called twice. The same `@meta` tag twice is written
1499+once. A file that holds only `@let`s, such as `layout.hl`, is a library
1500+(it may also read `@data` and `@include` other libraries): its definitions
1501+aren't reported unused, in it or in a page that includes it and uses only
1502+some of them, and `htmlang build`, `serve` and `watch` don't build it into
1503+a page of its own. An included file's unused definitions are never
1504+reported in the file that includes it.
1505+
375## Page and head
1506## Page and head
376
1507
377−`@page TITLE` produces a full HTML document; without it the output is a fragment.
1508+`@page TITLE` makes the output a full HTML document. Without it, the output
378−Head content comes from `@lang`, `@favicon`, `@meta NAME VALUE`,
1509+is a fragment, which has no `<head>`: `@meta` and `@head` in it are a
379−`@og KEY VALUE`, `@canonical URL`, `@base URL`, `@manifest NAME` (with
1510+warning. `--partial` writes a fragment of a page too, and warns that
380−indented settings), `@font-face NAME URL`, `@json-ld` (indented JSON) and `@head`
1511+`@page`, `@meta` and `@head` are left out. The page is the root element, and `@page`'s attributes are
381−(indented raw HTML).
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.
382
1532
383```
1533```
384−@page My Site
1534+@page [lang=en, favicon favicon.png, color #1f2937, dark:background #0b1220, dark:color #e5e7eb] My Site
385−@lang en
386@meta description A small site
1535@meta description A small site
387−@og title My Site
1536+@meta og:title My Site
1537+@head <link rel="canonical" href="https://example.com/">
1538+@head
1539+ <script type="application/ld+json">{"@type": "WebSite"}</script>
1540+@header [padding 16] My Site
1541+@main [height fill, padding 16]
1542+ The main part takes the rest of the window.
1543+@footer [padding 16] © 2026
388```
1544```
389
1545
1546+Translations are a JSON file per locale: `@data $t locales/$lang.json`
1547+loads `locales/fr.json` when `$lang` is `fr`.
1548+
390## CSS
1549## CSS
391
1550
392−- `@style` holds raw CSS, including at-rules such as `@scope`, `@property` and
1551+`@style` holds raw CSS, including at-rules such as `@keyframes`,
393− `@starting-style`.
1552+`@font-face`, `@property` and `@starting-style`, in its indented block or,
394−- `@keyframes name` takes htmlang attribute syntax (`from [opacity 0]`) or raw
1553+for a single rule, on its line:
395− CSS.
396−- Generated rules live in `@layer htmlang` (after a `hl-reset` layer), so any
397− CSS in `@style` or `@raw` overrides them.
398
1554
399```
1555```
400−@keyframes fade-in
401− from [opacity 0]
402− to [opacity 1]
403@style
1556@style
1557+ @keyframes fade-in {
1558+ from { opacity: 0; }
1559+ to { opacity: 1; }
1560+ }
404 .note { color: gray; }
1561 .note { color: gray; }
1562+@style .quiet { opacity: 0.6; }
405−@el [animation fade-in 0.3s ease, class note] Fades in
1563+@el [animation fade-in 0.3s ease, class=note] Fades in
406```
1564```
407
1565
408−## Upgrading older files
1566+Each element with styles gets a generated class, and elements with the
409−
1567+same styles share one. A class is named by its style: `hl-` and seven
410−`htmlang upgrade [dir|file]` rewrites removed syntax:
1568+characters of a hash of what it sets (`hl-2k9xq0m`), so one style has one
411−
1569+name in every file. A fragment put into a page (`--partial` output that a
412−| Removed | Use instead |
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 |
413|---|---|
1598|---|---|
414−| `@fn`, `@define`, `@mixin` | `@let` (function, bundle) |
1599+| `hl-reset` | The reset |
415−| `@unless COND` | `@if not COND` |
1600+| `hl-kind` | The defaults of each element's kind, as `:where(.hl-2k9xq0m)` |
416−| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
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` |
417−| `@switch` | `@match` |
1602+| `htmlang` | Each element's own styles, and its parent's `children:` styles |
418−| `@use "file" names` | `@import file` |
1603+
419−| `@with $x as y` | `@let y $x` |
1604+So an element's own styles win over everything else htmlang writes for
420−| `@layout file` | `@extends file` |
1605+it; its parent's `children:` styles and the element prefixes of the
421−| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
1606+elements around it win over its defaults.
422−| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@column`, `@paragraph`, `@image`, `@item`, `@button`, `@list`, `@hr`, `@option` |
1607+
423−| `...$bundle` | `$bundle` |
1608+A page's reset sets `box-sizing: border-box` everywhere and makes
424−| `animate`, `inset-area` | `animation`, `position-area` |
1609+`<body>` a column that fills the window. A fragment (a file
425−| `\|upper`, `\|lower`, `\|cap`, `\|len` | `\|uppercase`, `\|lowercase`, `\|capitalize`, `\|length` |
1610+without `@page`, or `--partial` output) goes into a page it doesn't own, so
426−
1611+its reset touches only htmlang's own elements (those with an `hl-` class),
427−The compiler reports each removed form with its replacement.
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+194−201
@@ -1,34 +1,123 @@
1# htmlang
1# htmlang
2
2
3+A small layout language that compiles to static HTML. The layout model comes
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+from [elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest/):
4−
5+rows and columns, spacing between children, and elements that fill or center
5−`@` means structure. Bare lines mean content. No CSS required.
6+themselves. Styling uses CSS properties under their CSS names, and every other
6−
7+element uses its HTML name.
7−## Example
8
8
9```
9```
10−@page My Site
10+@page [lang=en, background #f8fafc] My Site
11−@let primary #3b82f6
11+@let --brand #3b82f6
12
12
13−@let card $title
13+@let @card [title]
14− @el [padding 20, background white, rounded 8, border 1 #e5e7eb, hover:border 1 $primary, transition all 0.15s ease]
14+ @article [padding 20, spacing 8, border 1 solid #e5e7eb, border-radius 8, hover:border-color var(--brand)]
15− @text [bold] $title
15+ @h3 $title
16 @children
16 @children
17
17
18−@column [max-width 800, center-x, padding 40, spacing 20]
18+@el [width fill, max-width 800, center-x, padding 40, spacing 24]
19− @text [bold, size 32] Hello
19+ @h1 [font-size 32] Hello
20−
21 @paragraph
20 @paragraph
22− Built with {@text [bold, color $primary] htmlang}.
21+ Built with {@text [font-weight bold, color var(--brand)] htmlang}.
23−
24− @row [wrap, spacing 10]
22+ @row [spacing 16, wrap]
25 @card [title Simple]
23 @card [title Simple]
26− Write layouts without CSS
24+ Layout without writing a stylesheet.
27− @card [title Fast]
25+ @card [title Static]
28− Compiles to a single HTML file
26+ One HTML file, with its CSS inside.
29−```
27+```
30−
28+
31−Compiles to a self-contained `.html` file with flexbox layout and 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.
32
121
33## Install
122## Install
34
123
@@ -38,215 +127,119 @@ cargo install --path .
38
127
39## Usage
128## Usage
40
129
41−Everyday workflow:
42−
43```
130```
44−htmlang init # scaffold a new project
45−htmlang page.hl # compile page.hl -> page.html
131+htmlang page.hl # compile page.hl to page.html
46−htmlang --watch page.hl # recompile on change
132+htmlang page.hl -o out.html # choose the output file
47htmlang serve . # dev server with live reload
133htmlang serve . # dev server with live reload
48−htmlang serve --https --cert cert.pem --key key.pem .
134+htmlang build src -o dist # compile a whole site
49−htmlang fmt page.hl # format a file in place
50−htmlang check page.hl # parse only, report diagnostics
51−```
52−
53−More commands (run with `--help` for flags):
54−
55−| Command | Purpose |
56−|--------------------|------------------------------------------------|
57−| `build [dir]` | Compile every `.hl` under a directory |
58−| `watch [path]` | Watch mode without a server |
59−| `serve [path]` | Dev server with live reload (`--https` ready) |
60−| `convert file.html`| Convert HTML back to htmlang |
61−| `new name` | Scaffold a new page from a template |
62−| `components` | List function (`@let` with body) definitions |
63−| `deps file.hl` | Print the include / import graph |
64−| `dead-code [dir]` | Report unused functions, variables and bundles |
65−| `feed [dir]` | Generate an Atom feed from `@article` pages |
66−| `sitemap [dir]` | Generate `sitemap.xml` |
67−| `lint [path]` | Run warnings-only checks, optional JSON output |
68−| `stats file.hl` | Compile-time statistics |
69−| `preview file.hl` | Open a one-off preview |
70−| `diff a.hl b.hl` | Diff two files at the AST level |
71−| `export file.hl` | Export to plain HTML (`--format pdf` planned) |
72−| `repl` | Interactive REPL for expressions |
73−| `deploy` | Preset-driven deploy helper |
74−| `playground` | Write a self-contained playground HTML |
75−| `clean [dir]` | Remove generated `.html` files |
76−| `upgrade` | Apply mechanical migrations to out-of-date `.hl` |
77−| `outline file.hl` | Print the document tree |
78−| `explain CODE` | Longer explanation of a diagnostic code |
79−| `lsp` | Launch the language server (stdio) |
80−| `benchmark [dir]` | Measure parse / codegen timing |
81−
82−## Editor support
83−
84−A VS Code extension with syntax highlighting and LSP integration is available in [`editors/vscode`](editors/vscode). The language server (`htmlang-lsp`) provides diagnostics, completions, and hover documentation.
85−
86−## Language overview
87−
88−### Elements
89−
90−| Element | Purpose |
91−|--------------|------------------------------|
92−| `@row` | Horizontal flex layout |
93−| `@column` | Vertical flex layout |
94−| `@el` | Generic container |
95−| `@text` | Styled inline text |
96−| `@paragraph` | Flowing text with inline elements |
97−| `@image` | Image |
98−| `@link` | Anchor |
99−| `@raw` | Verbatim HTML escape hatch |
100−| `@form` | Form container |
101−| `@details` | Disclosure widget |
102−| `@summary` | Summary for `@details` |
103−| `@blockquote`| Block quotation |
104−| `@cite` | Citation reference |
105−| `@code` | Inline code (monospace) |
106−| `@pre` | Preformatted text |
107−| `@hr` | Horizontal rule / divider |
108−| `@figure` | Figure with caption |
109−| `@figcaption`| Caption for `@figure` |
110−| `@progress` | Progress bar |
111−| `@meter` | Meter/gauge |
112−| `@in-front` | Overlay layer on top of parent |
113−| `@behind` | Layer behind parent content |
114−
115−### Layout attributes
116−
117−```
118−-- gap between children
119−@row [spacing 20]
120−-- separate horizontal/vertical gaps
121−@row [gap-x 10, gap-y 20]
122−-- uniform padding (also padding-x, padding-y)
123−@el [padding 20]
124−-- take remaining space (also width 200, width shrink)
125−@el [width fill]
126−-- center horizontally (also align-left, align-right)
127−@el [center-x]
128−-- overflow behavior (hidden, scroll, auto)
129−@el [overflow hidden]
130```
135```
131
136
132−Comments start with `--` and run to the end of the line; they must be on a
137+| Command | Purpose |
133−line of their own.
138+|---|---|
139+| `build <dir> [-o out] [--minify] [--strict]` | Compile every `.hl` file under a directory (into `out/` by default), except libraries: files that hold only `@let`s. Two files whose different styles got one class name are an error (`class-collision`) |
140+| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload. It serves files, so it answers GET and HEAD (other methods get 405), and reloads pages, not fragments |
141+| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
142+| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
143+| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
144+| `fmt <file.hl>...` | Format files in place |
145+| `lsp` | Run the language server over stdio |
134
146
135−### Style attributes
147+Compiling a file directly also takes `-w` / `--watch`, `--dev` (readable
148+output and source maps), `--strict`, `--partial` (a fragment, without the
149+document wrapper), `--format json`, `-s` / `--serve`, `-p` / `--port` and
150+`--open`.
136
151
137−```
152+## A short tour
138−@el [background #3b82f6, color white, rounded 8, border 1 #e5e7eb]
139−@text [bold, italic, underline, size 18, font Inter]
140−@el [opacity 0.5, cursor pointer, transition all 0.15s ease]
141−@el [shadow 0 2px 4px rgba(0,0,0,0.1)]
142−@paragraph [text-align center, line-height 1.5]
143−```
144−
145−### Positioning
146−
147−```
148−@el [position relative]
149− @el [position absolute, z-index 10]
150− Overlay content
151−```
152
153
153−For overlays, prefer `@in-front` and `@behind` — they make the parent a
154+Layout attributes place an element. Everything else is CSS, and `key=value`
154−positioning context and stretch to its bounds automatically:
155+is an HTML attribute:
155
156
156```
157```
157−@el [width 200, height 200, background blue]
158+@row [spacing 12, align-items center, padding 12 20, border-bottom 1 solid #e5e7eb, id=toolbar]
158− @text [color white] Main content
159+ @text [font-weight 700] Acme
159− @in-front
160+ @spacer
160− @text [color yellow] Top layer
161+ @input [type=search, placeholder=Search, aria-label=Search, width 240, padding 6 10]
161− @behind
162+ @button [type=submit, padding 6 14, hover:background #f3f4f6] Go
162− @el [background red, width fill, height fill]
163```
163```
164
164
165−### Pseudo-states and media prefixes
165+A prefix applies a style only in a state, from a screen width up, or under a
166−
166+media condition. Prefixes stack, and a prefix before a `[group]` applies to
167−Prefix any style attribute with `hover:`, `active:`, or `focus:`:
167+each style in it:
168
168
169```
169```
170−@el [background #3b82f6, hover:background #2563eb, active:background #1d4ed8]
170+@el [padding 16, md:[padding 32, font-size 20], background #3b82f6, hover:background #2563eb, dark:[background #1e3a8a, hover:background #1e40af]]
171+ @text [color white] Click me
171```
172```
172
173
173−Use `dark:` for dark mode and `print:` for print styles:
174+An element prefix, `@td:`, styles every such element inside, so rows a
175+loop writes and HTML from `@markdown` need no attributes of their own:
174
176
175```
177```
176−@el [background white, dark:background #1a1a2e, print:display none]
178+@table [border-collapse collapse, @th:[padding 8, text-align left], @td:padding 8, @tr:nth-child(even):background #f8fafc]
179+ @tr
180+ @th Name
181+ @tr
182+ @td Ada
177```
183```
178
184
179−### Variables and attribute bundles
185+`@let` defines values, bundles and functions, in one namespace. A function
186+has `@` before its name and its parameters in brackets (`label`, or
187+`href #` with a default). It is called like an element, on its own line,
188+in a chain or inline in text: its parameters are attributes, `name value`
189+(a name alone is `true`), a parameter without a default must be passed,
190+and any other attributes style its root element and are checked like
191+attributes written there.
180
192
181```
193```
182−-- simple variable, used as $primary
183−@let primary #3b82f6
194+@let --primary #3b82f6
184−-- quoted string interpolation
195+@let gap = 8 * 2
185−@let greeting "Hello $name"
196+@let rounded [border-radius 8, overflow hidden]
186−-- attribute bundle, used as [$card]
187−@let card [padding 20, rounded 8]
188−```
189−
190−### Functions
191
197
192−A `@let` with an indented body defines a function. `@children` is where the
198+@let @cta [label, href #]
193−caller's children go:
194−
195−```
196−@let primary #3b82f6
197−@let button $label
198− @el [padding 12, background $primary, rounded 8]
199+ @link [$rounded, padding 10 16, background var(--primary), color white] $href
199− @text [color white, bold] $label
200+ $label
201+@let @key
202+ @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
200 @children
203 @children
201
204
202−@button [label Click me]
205+@row [spacing $gap]
206+ @cta [label Sign up]
207+ @cta [label Learn more, href /about, background #64748b]
208+@paragraph
209+ Press {@key Ctrl+K} to search.
203```
210```
204
211
205−### File includes
212+Data, loops and conditions run at compile time:
206
213
207```
214```
208−-- header.hl
215+@data $links [
209−@nav [spacing 10]
210− @link / Home
216+ {"label": "Home", "url": "/"},
211−```
217+ {"label": "Blog", "url": "/blog"},
218+ {"label": "About us", "url": "/about"}
219+]
220+@let current /blog
212
221
213−```
222+@nav
214−-- page.hl: insert header.hl here
223+ @row [spacing 16]
215−@include header.hl
224+ @each $link in $links
225+ @link [if($link.url == $current, [font-weight bold, aria-current=page])] $link.url $link.label
216```
226```
217
227
218−### Multi-line attributes
228+The [`examples/`](examples) directory has complete pages (a landing page, a
229+blog, a docs page, and a tour of the whole language). [DESIGN.md](DESIGN.md)
230+is the language reference.
219
231
220−Attribute lists can span multiple lines:
232+## Editor support
221
233
222−```
234+The VS Code extension in [`editors/vscode`](editors/vscode) provides syntax
223−@el [
235+highlighting, snippets, and the language server (`htmlang-lsp`). The server
224− padding 20,
236+gives diagnostics, completions, hover documentation, go to definition, rename
225− background white,
237+and formatting.
226− rounded 8,
238+
227− shadow 0 2px 4px rgba(0,0,0,0.1)
239+The compiler, the formatter and the language server read a file with the
228−]
240+same parser, so the editor sees exactly what the compiler sees, relative
229− Content here
241+`@include` and `@data` paths included. Every diagnostic has a stable code
230−```
242+(`error[unknown-element]` on the command line, a `code` field in
231−
243+`--format json`), and the whole file is checked, including branches and
232−### Other features
244+functions that don't run (a `$name` there is checked only where the code
233−
245+runs, since it may come from data).
234−- `--` comments
235−- `{@text [bold] inline}` elements in text
236−- `@el [attrs] > @link url` single-child chaining
237−- `[padding 20]` bare attributes as implicit `@el`
238−- `@raw """..."""` for embedding arbitrary HTML/CSS/JS
239−- `@image [inline] logo.svg` inlines SVG content
240−- `@each $name, $url in Home /, About /about` destructuring
241−- `@let large = $base * 2` computed variables
242−- `[grid-template-areas "header main", grid-area header]` named grid areas
243−- `[animate fade-in 0.3s ease]` animation shorthand
244−- `[view-transition-name hero]` View Transitions API
245−- `[has(.active):background blue]` `:has()` pseudo-selector
246−- `@slot header` / `@slot content` named slots in functions
247−- `@theme` design tokens as runtime CSS custom properties
248−- VS Code snippets for common patterns
249−
250−## Documentation
251−
252−See [DESIGN.md](DESIGN.md) for the full language specification.
crates/htmlang-core/src/ast.rs+720−153
@@ -3,45 +3,33 @@ use std::collections::HashMap;
3#[derive(Debug)]
3#[derive(Debug)]
4#[allow(dead_code)]
4#[allow(dead_code)]
5pub struct Document {
5pub struct Document {
6− pub page_title: Option<String>,
6+ /// The page `@page` makes: without one, the output is a fragment.
7− pub lang: Option<String>,
8− pub favicon: Option<String>,
7+ pub page: Option<Page>,
9 pub meta_tags: Vec<(String, String)>,
8 pub meta_tags: Vec<(String, String)>,
10 pub head_blocks: Vec<String>,
9 pub head_blocks: Vec<String>,
11 pub variables: HashMap<String, String>,
10 pub variables: HashMap<String, String>,
12 pub defines: HashMap<String, Vec<Attribute>>,
11 pub defines: HashMap<String, Vec<Attribute>>,
13− pub keyframes: Vec<(String, String)>,
14 pub css_vars: Vec<(String, String)>,
12 pub css_vars: Vec<(String, String)>,
15 pub custom_css: Vec<String>,
13 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>,
16 pub og_tags: Vec<(String, String)>,
17 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>,
25 pub nodes: Vec<Node>,
18 pub nodes: Vec<Node>,
26}
19}
27
20
28−#[derive(Debug, Clone)]
21+/// What `@page` says: the page is the root element. Its styles style
29−pub struct ManifestConfig {
22+/// `<body>`, which lays out the page's top-level elements as a column; its
30− pub name: String,
23+/// HTML attributes (`lang=en`, `dir=rtl`, `class=x`) go on `<html>`.
31− pub short_name: Option<String>,
24+#[derive(Debug, Clone, Default)]
32− pub start_url: String,
25+pub struct Page {
33− pub display: String,
26+ pub title: String,
34− pub background_color: Option<String>,
27+ /// `key=value` attributes, and bare boolean HTML attributes, for `<html>`
35− pub theme_color: Option<String>,
28+ pub html_attrs: Vec<Attribute>,
36− pub description: Option<String>,
29+ /// Styles, prefixed ones included, for `<body>`
37− pub icons: Vec<(String, String)>, // (src, sizes)
30+ pub styles: Vec<Attribute>,
38−}
31+ /// `favicon FILE`, `@page`'s one word of htmlang's own
39−
32+ pub favicon: Option<String>,
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,
45}
33}
46
34
47#[derive(Debug, Clone)]
35#[derive(Debug, Clone)]
@@ -61,148 +49,727 @@ pub enum TextSegment {
61pub struct Element {
49pub struct Element {
62 pub kind: ElementKind,
50 pub kind: ElementKind,
63 pub attrs: Vec<Attribute>,
51 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.
64 pub argument: Option<String>,
56 pub argument: Option<String>,
65 pub children: Vec<Node>,
57 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.
66 pub line_num: usize,
60 pub line_num: usize,
61+ /// The function whose body wrote it, when it comes from a call.
62+ pub function: Option<String>,
63+}
64+
65+#[derive(Debug, Clone)]
66+pub struct Attribute {
67+ pub key: String,
68+ /// The value as it goes into the output: a style's with the quotes of
69+ /// quoted text, an HTML attribute's without them.
70+ pub value: Option<String>,
71+ /// Written `key=value`: an HTML attribute rather than a style.
72+ pub html: bool,
73+ /// When the value is quoted text (`"..."`, or a variable holding
74+ /// quoted text), both of its forms, for a parameter it binds.
75+ pub quoted: Option<Quoted>,
76+}
77+
78+/// Quoted text, which remembers that it was quoted: a CSS value writes it
79+/// with its quotes, text and HTML attribute values without them.
80+#[derive(Debug, Clone, PartialEq)]
81+pub struct Quoted {
82+ /// What it says: `a"b` for `"a\"b"`.
83+ pub text: String,
84+ /// As CSS writes it, quotes included: `"a\"b"`.
85+ pub css: String,
67}
86}
68
87
69#[derive(Debug, Clone, PartialEq)]
88#[derive(Debug, Clone, PartialEq)]
70pub enum ElementKind {
89pub enum ElementKind {
90+ // Layout and text: the core of the language
71 Row,
91 Row,
72− Column,
73 El,
92 El,
74 Text,
93 Text,
75 Paragraph,
94 Paragraph,
76− Image,
77 Link,
95 Link,
96+ Image,
97+ // Elements generated in a special way
98+ Fragment,
99+ // Function bodies: placeholders for the caller's content
78 Children,
100 Children,
79− // Form elements
80− Input,
81− Button,
82− Select,
83− Textarea,
84− Option,
85− Label,
86− // Slots
87 Slot(String),
101 Slot(String),
88− // Semantic elements
102+ /// Any other element, described by its row in [`TAGS`].
89− Nav,
103+ Tag(&'static TagSpec),
90− Header,
104+}
91− Footer,
105+
92− Main,
106+impl ElementKind {
93− Section,
107+ /// The element's name in htmlang source, without the `@`.
94− Article,
108+ pub fn name(&self) -> &str {
95− Aside,
109+ match self {
96− // List elements
110+ ElementKind::Row => "row",
97− List,
111+ ElementKind::El => "el",
98− ListItem,
112+ ElementKind::Text => "text",
99− // Table elements
113+ ElementKind::Paragraph => "paragraph",
100− Table,
114+ ElementKind::Link => "link",
101− TableHead,
115+ ElementKind::Image => "image",
102− TableBody,
116+ ElementKind::Fragment => "fragment",
103− TableRow,
117+ ElementKind::Children => "children",
104− TableCell,
118+ ElementKind::Slot(_) => "slot",
105− TableHeaderCell,
119+ ElementKind::Tag(spec) => spec.name,
106− // Media elements
120+ }
107− Video,
121+ }
108− Audio,
122+
109− // Additional semantic elements
123+ pub fn spec(&self) -> Option<&'static TagSpec> {
110− Form,
124+ match self {
111− Details,
125+ ElementKind::Tag(spec) => Some(spec),
112− Summary,
126+ _ => None,
113− Blockquote,
127+ }
114− Cite,
128+ }
115− Code,
129+
116− Pre,
130+ /// How the element lays out what is inside it. `@fragment`, `@children`
117− HorizontalRule,
131+ /// and `@slot` have no element of their own: their content lands in the
118− Figure,
132+ /// element around them and takes its layout.
119− FigCaption,
133+ pub fn layout(&self) -> Layout {
120− Progress,
134+ match self {
121− Meter,
135+ ElementKind::Row => Layout::Row,
122− Fragment,
136+ ElementKind::El => Layout::Column,
123− Dialog,
137+ ElementKind::Text | ElementKind::Paragraph | ElementKind::Link => Layout::Text,
124− DefinitionList,
138+ ElementKind::Image => Layout::Void,
125− DefinitionTerm,
139+ ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_) => Layout::Native,
126− DefinitionDescription,
140+ ElementKind::Tag(spec) => spec.layout,
127− Fieldset,
141+ }
128− Legend,
142+ }
129− Picture,
143+
144+ /// The CSS every such element starts with, besides its layout's
145+ /// `display` (browser margins reset, the list markers of a column, a
146+ /// link's colour). It goes into the element's own generated class,
147+ /// never onto a global element selector, so HTML from `@markdown` and
148+ /// `@raw` keeps the browser's defaults.
149+ pub fn css(&self) -> &'static str {
150+ match self {
151+ ElementKind::Paragraph => "margin:0;",
152+ ElementKind::Link => "text-decoration:none;color:inherit;",
153+ ElementKind::Image => "display:block;",
154+ ElementKind::Tag(spec) => spec.css,
155+ _ => "",
156+ }
157+ }
158+
159+ /// What the first token after its attributes is: `@link`'s `href`,
160+ /// `@image`'s `src`, or its row's [`TagArg`].
161+ pub fn arg(&self) -> TagArg {
162+ match self {
163+ ElementKind::Link => TagArg::Attr("href"),
164+ ElementKind::Image => TagArg::Attr("src"),
165+ ElementKind::Tag(spec) => spec.arg,
166+ _ => TagArg::Child,
167+ }
168+ }
169+
170+ /// The HTML attribute its leading argument fills, when it has one;
171+ /// `in_picture`: the element is directly inside `@picture`.
172+ pub fn leading_attribute(&self, in_picture: bool) -> Option<&'static str> {
173+ self.arg().attribute(in_picture)
174+ }
175+
176+ /// Whether its indented body is foreign text written into the page as
177+ /// it is (`@script`). (`@code` and `@textarea` also have a verbatim
178+ /// body, shown as text: see [`TagSpec::literal`].)
179+ pub fn is_verbatim(&self) -> bool {
180+ self.spec().is_some_and(|spec| spec.verbatim)
181+ }
182+
183+ /// The HTML element it writes (outside text, where htmlang's own
184+ /// `<div>`s are `<span>`s): `None` for `@fragment`, `@children` and
185+ /// `@slot`, which write none of their own.
186+ pub fn tag(&self) -> Option<&'static str> {
187+ match self {
188+ ElementKind::Row | ElementKind::El => Some("div"),
189+ ElementKind::Text => Some("span"),
190+ ElementKind::Paragraph => Some("p"),
191+ ElementKind::Link => Some("a"),
192+ ElementKind::Image => Some("img"),
193+ ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_) => None,
194+ ElementKind::Tag(spec) => Some(spec.html),
195+ }
196+ }
197+
198+ /// The HTML element only this element writes, which a selector can
199+ /// pick out: `@td` is every `<td>`, `@paragraph` every `<p>`. `None`
200+ /// for an element that shares its tag: `@el`, `@row`, `@grid`,
201+ /// `@in-front` and `@behind` are all `<div>`, and `@text` is a
202+ /// `<span>`, as is every line of text in a row or column. `@script`
203+ /// isn't shown, so there is nothing to style.
204+ pub fn own_tag(&self) -> Option<&'static str> {
205+ let tag = self.tag()?;
206+ let shared = matches!(tag, "div" | "span") || self.is_verbatim();
207+ (!shared).then_some(tag)
208+ }
209+
210+ /// Is this the table element named `name` (e.g. `"main"`)?
211+ pub fn is_tag(&self, name: &str) -> bool {
212+ self.spec().is_some_and(|spec| spec.name == name)
213+ }
214+
215+ /// Look up an element by its source name (without the `@`).
216+ pub fn from_name(name: &str) -> Option<ElementKind> {
217+ Some(match name {
218+ "row" => ElementKind::Row,
219+ "el" => ElementKind::El,
220+ "text" => ElementKind::Text,
221+ "paragraph" => ElementKind::Paragraph,
222+ "link" => ElementKind::Link,
223+ "image" => ElementKind::Image,
224+ "fragment" => ElementKind::Fragment,
225+ "children" => ElementKind::Children,
226+ "slot" => ElementKind::Slot(String::new()),
227+ _ => ElementKind::Tag(TAGS.iter().find(|spec| spec.name == name)?),
228+ })
229+ }
230+
231+ /// Every element name, for suggestions and completions.
232+ pub fn all_names() -> impl Iterator<Item = &'static str> {
233+ [
234+ "row",
235+ "el",
236+ "text",
237+ "paragraph",
238+ "link",
239+ "image",
240+ "fragment",
241+ "children",
242+ "slot",
243+ ]
244+ .into_iter()
245+ .chain(TAGS.iter().map(|spec| spec.name))
246+ }
247+}
248+
249+/// What an element does with the text after its name and attributes.
250+#[derive(Debug, Clone, Copy, PartialEq)]
251+pub enum TagArg {
252+ /// Its content: the first line of its text, parsed like any text
253+ /// (`@h1 Hello {@text [color red] world}`). The element's layout
254+ /// decides how it combines with the lines under it.
255+ Child,
256+ /// Its leading attribute: the first token fills this HTML attribute
257+ /// (`@iframe URL` sets `src`), and the rest of the text is content, or
258+ /// an error on an element without content. The token ends at a space
259+ /// outside `"..."` and `${...}`.
260+ Attr(&'static str),
261+ /// `@source`'s leading attribute, which depends on where it is:
262+ /// `srcset` directly inside `@picture`, `src` elsewhere (`@video`,
263+ /// `@audio`).
130 Source,
264 Source,
131− Time,
265+}
132− Mark,
266+
133− Kbd,
267+impl TagArg {
134− Abbr,
268+ /// The HTML attribute the leading argument fills; `in_picture`: the
135− Datalist,
269+ /// element is directly inside `@picture`.
136− Iframe,
270+ pub fn attribute(self, in_picture: bool) -> Option<&'static str> {
137− Output,
271+ match self {
138− Canvas,
272+ TagArg::Child => None,
139− // Heading elements
273+ TagArg::Attr(attr) => Some(attr),
140− H1,
274+ TagArg::Source if in_picture => Some("srcset"),
141− H2,
275+ TagArg::Source => Some("src"),
142− H3,
276+ }
143− H4,
277+ }
144− H5,
278+
145− H6,
279+ /// The attributes the leading argument may fill, wherever the element
146− // New convenience elements
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.
147 Grid,
303 Grid,
148− Stack,
304+ /// Flowing text (`@paragraph`, `@h1`, `@button`, `@td`): the argument,
149− Spacer,
305+ /// the text lines and the children are one run, joined with spaces. It
150− Badge,
306+ /// has no gap for `spacing`, and a row, column or grid inside it is laid
151− Tooltip,
307+ /// out inline (`@el` becomes an inline-flex `<span>`).
152− Avatar,
308+ Text,
153− Carousel,
309+ /// HTML's own layout, which htmlang leaves alone: tables, `@select`,
154− Chip,
310+ /// `@picture`, media and form controls. Text lines are separated by a
155− Tag,
311+ /// line break, which HTML shows as a space except in `@pre` and
156− // Overlay positioning (elm-ui inspired)
312+ /// `@textarea`.
157− InFront,
313+ Native,
158− Behind,
314+ /// No content at all (`@input`, `@hr`, `@image`).
159− // HTML5 elements
315+ Void,
160− Script,
161− Noscript,
162− Address,
163− Search,
164− Breadcrumb,
165}
316}
166
317
167−#[derive(Debug, Clone)]
318+impl Layout {
168−pub struct Attribute {
319+ /// A row, column or grid: an element that lays out its children, so
169− pub key: String,
320+ /// `spacing`, `wrap` and `grid-cols` work on it.
170− pub value: Option<String>,
321+ pub fn is_container(self) -> bool {
322+ matches!(self, Layout::Column | Layout::Row | Layout::Grid)
323+ }
324+
325+ /// The name used in messages and the reference: `column`, `text`, ...
326+ pub fn name(self) -> &'static str {
327+ match self {
328+ Layout::Column => "column",
329+ Layout::Row => "row",
330+ Layout::Grid => "grid",
331+ Layout::Text => "text",
332+ Layout::Native => "native",
333+ Layout::Void => "void",
334+ }
335+ }
336+}
337+
338+/// How an element compiles: the data behind [`ElementKind::Tag`].
339+#[derive(Debug, PartialEq)]
340+pub struct TagSpec {
341+ /// Name in htmlang source, without the `@`.
342+ pub name: &'static str,
343+ /// HTML element emitted.
344+ pub html: &'static str,
345+ /// Default CSS besides the layout's `display` (margin reset, ...).
346+ pub css: &'static str,
347+ pub arg: TagArg,
348+ /// How it lays out its content.
349+ pub layout: Layout,
350+ /// Its text is shown as written (`@code`, `@textarea`). On its line and
351+ /// inline, a `{@...}` is text, not an inline element (escapes and
352+ /// `$names` still work). Its indented body is verbatim: nothing in it is
353+ /// parsed, and it is shown HTML-escaped, its lines and indentation kept.
354+ pub literal: bool,
355+ /// Its indented body is foreign text written into the page exactly as
356+ /// it is (`@script`'s JavaScript): nothing in it is parsed as htmlang.
357+ pub verbatim: bool,
358+}
359+
360+impl TagSpec {
361+ const DEFAULT: TagSpec = TagSpec {
362+ name: "",
363+ html: "",
364+ css: "",
365+ arg: TagArg::Child,
366+ layout: Layout::Native,
367+ literal: false,
368+ verbatim: false,
369+ };
370+}
371+
372+/// Every element besides the core ones in [`ElementKind`].
373+#[rustfmt::skip]
374+pub static TAGS: &[TagSpec] = &[
375+ TagSpec { name: "nav", html: "nav", layout: Layout::Column, ..TagSpec::DEFAULT },
376+ TagSpec { name: "header", html: "header", layout: Layout::Column, ..TagSpec::DEFAULT },
377+ TagSpec { name: "footer", html: "footer", layout: Layout::Column, ..TagSpec::DEFAULT },
378+ TagSpec { name: "main", html: "main", layout: Layout::Column, ..TagSpec::DEFAULT },
379+ TagSpec { name: "section", html: "section", layout: Layout::Column, ..TagSpec::DEFAULT },
380+ TagSpec { name: "article", html: "article", layout: Layout::Column, ..TagSpec::DEFAULT },
381+ TagSpec { name: "aside", html: "aside", layout: Layout::Column, ..TagSpec::DEFAULT },
382+ TagSpec { name: "details", html: "details", layout: Layout::Column, ..TagSpec::DEFAULT },
383+ TagSpec { name: "dialog", html: "dialog", layout: Layout::Column, ..TagSpec::DEFAULT },
384+ TagSpec { name: "search", html: "search", layout: Layout::Column, ..TagSpec::DEFAULT },
385+ TagSpec { name: "address", html: "address", layout: Layout::Column, ..TagSpec::DEFAULT },
386+ TagSpec { name: "noscript", html: "noscript", layout: Layout::Column, ..TagSpec::DEFAULT },
387+ TagSpec { name: "form", html: "form", arg: TagArg::Attr("action"), layout: Layout::Column, ..TagSpec::DEFAULT },
388+ TagSpec { name: "figure", html: "figure", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
389+ TagSpec { name: "blockquote", html: "blockquote", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
390+ TagSpec { name: "fieldset", html: "fieldset", css: "border:1px solid currentColor;padding:8px;margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
391+ TagSpec { name: "datalist", html: "datalist", layout: Layout::Native, ..TagSpec::DEFAULT },
392+ TagSpec { name: "ul", html: "ul", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
393+ TagSpec { name: "ol", html: "ol", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
394+ TagSpec { name: "li", html: "li", layout: Layout::Column, ..TagSpec::DEFAULT },
395+ TagSpec { name: "dl", html: "dl", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
396+ TagSpec { name: "dt", html: "dt", layout: Layout::Text, ..TagSpec::DEFAULT },
397+ TagSpec { name: "dd", html: "dd", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
398+ TagSpec { name: "table", html: "table", layout: Layout::Native, ..TagSpec::DEFAULT },
399+ TagSpec { name: "thead", html: "thead", layout: Layout::Native, ..TagSpec::DEFAULT },
400+ TagSpec { name: "tbody", html: "tbody", layout: Layout::Native, ..TagSpec::DEFAULT },
401+ TagSpec { name: "tr", html: "tr", layout: Layout::Native, ..TagSpec::DEFAULT },
402+ TagSpec { name: "select", html: "select", layout: Layout::Native, ..TagSpec::DEFAULT },
403+ TagSpec { name: "picture", html: "picture", layout: Layout::Native, ..TagSpec::DEFAULT },
404+ TagSpec { name: "progress", html: "progress", layout: Layout::Native, ..TagSpec::DEFAULT },
405+ TagSpec { name: "meter", html: "meter", layout: Layout::Native, ..TagSpec::DEFAULT },
406+ TagSpec { name: "output", html: "output", layout: Layout::Native, ..TagSpec::DEFAULT },
407+ TagSpec { name: "canvas", html: "canvas", layout: Layout::Native, ..TagSpec::DEFAULT },
408+ TagSpec { name: "td", html: "td", layout: Layout::Text, ..TagSpec::DEFAULT },
409+ TagSpec { name: "th", html: "th", layout: Layout::Text, ..TagSpec::DEFAULT },
410+ TagSpec { name: "button", html: "button", layout: Layout::Text, ..TagSpec::DEFAULT },
411+ TagSpec { name: "label", html: "label", layout: Layout::Text, ..TagSpec::DEFAULT },
412+ TagSpec { name: "option", html: "option", layout: Layout::Native, ..TagSpec::DEFAULT },
413+ TagSpec { name: "textarea", html: "textarea", layout: Layout::Native, literal: true, ..TagSpec::DEFAULT },
414+ TagSpec { name: "summary", html: "summary", layout: Layout::Text, ..TagSpec::DEFAULT },
415+ TagSpec { name: "cite", html: "cite", layout: Layout::Text, ..TagSpec::DEFAULT },
416+ TagSpec { name: "figcaption", html: "figcaption", layout: Layout::Text, ..TagSpec::DEFAULT },
417+ TagSpec { name: "legend", html: "legend", layout: Layout::Text, ..TagSpec::DEFAULT },
418+ TagSpec { name: "time", html: "time", layout: Layout::Text, ..TagSpec::DEFAULT },
419+ TagSpec { name: "mark", html: "mark", layout: Layout::Text, ..TagSpec::DEFAULT },
420+ TagSpec { name: "abbr", html: "abbr", layout: Layout::Text, ..TagSpec::DEFAULT },
421+ TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, literal: true, ..TagSpec::DEFAULT },
422+ TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, ..TagSpec::DEFAULT },
423+ TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", layout: Layout::Native, ..TagSpec::DEFAULT },
424+ TagSpec { name: "h1", html: "h1", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
425+ TagSpec { name: "h2", html: "h2", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
426+ TagSpec { name: "h3", html: "h3", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
427+ TagSpec { name: "h4", html: "h4", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
428+ TagSpec { name: "h5", html: "h5", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
429+ TagSpec { name: "h6", html: "h6", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
430+ TagSpec { name: "input", html: "input", layout: Layout::Void, ..TagSpec::DEFAULT },
431+ TagSpec { name: "hr", html: "hr", layout: Layout::Void, ..TagSpec::DEFAULT },
432+ TagSpec { name: "source", html: "source", arg: TagArg::Source, layout: Layout::Void, ..TagSpec::DEFAULT },
433+ TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
434+ TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
435+ TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
436+ TagSpec { name: "script", html: "script", arg: TagArg::Attr("src"), layout: Layout::Native, verbatim: true, ..TagSpec::DEFAULT },
437+ // Flow containers
438+ TagSpec { name: "hgroup", html: "hgroup", layout: Layout::Column, ..TagSpec::DEFAULT },
439+ TagSpec { name: "menu", html: "menu", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
440+ // Phrasing: text with the browser's own style, and no CSS of htmlang's
441+ TagSpec { name: "b", html: "b", layout: Layout::Text, ..TagSpec::DEFAULT },
442+ TagSpec { name: "i", html: "i", layout: Layout::Text, ..TagSpec::DEFAULT },
443+ TagSpec { name: "strong", html: "strong", layout: Layout::Text, ..TagSpec::DEFAULT },
444+ TagSpec { name: "em", html: "em", layout: Layout::Text, ..TagSpec::DEFAULT },
445+ TagSpec { name: "small", html: "small", layout: Layout::Text, ..TagSpec::DEFAULT },
446+ TagSpec { name: "s", html: "s", layout: Layout::Text, ..TagSpec::DEFAULT },
447+ TagSpec { name: "u", html: "u", layout: Layout::Text, ..TagSpec::DEFAULT },
448+ TagSpec { name: "sub", html: "sub", layout: Layout::Text, ..TagSpec::DEFAULT },
449+ TagSpec { name: "sup", html: "sup", layout: Layout::Text, ..TagSpec::DEFAULT },
450+ TagSpec { name: "q", html: "q", layout: Layout::Text, ..TagSpec::DEFAULT },
451+ TagSpec { name: "var", html: "var", layout: Layout::Text, ..TagSpec::DEFAULT },
452+ TagSpec { name: "samp", html: "samp", layout: Layout::Text, ..TagSpec::DEFAULT },
453+ TagSpec { name: "dfn", html: "dfn", layout: Layout::Text, ..TagSpec::DEFAULT },
454+ TagSpec { name: "bdi", html: "bdi", layout: Layout::Text, ..TagSpec::DEFAULT },
455+ TagSpec { name: "bdo", html: "bdo", layout: Layout::Text, ..TagSpec::DEFAULT },
456+ TagSpec { name: "ins", html: "ins", layout: Layout::Text, ..TagSpec::DEFAULT },
457+ TagSpec { name: "del", html: "del", layout: Layout::Text, ..TagSpec::DEFAULT },
458+ TagSpec { name: "br", html: "br", layout: Layout::Void, ..TagSpec::DEFAULT },
459+ TagSpec { name: "wbr", html: "wbr", layout: Layout::Void, ..TagSpec::DEFAULT },
460+ // HTML's own display: table parts, option groups, ruby, embedded content
461+ TagSpec { name: "caption", html: "caption", layout: Layout::Native, ..TagSpec::DEFAULT },
462+ TagSpec { name: "colgroup", html: "colgroup", layout: Layout::Native, ..TagSpec::DEFAULT },
463+ TagSpec { name: "col", html: "col", layout: Layout::Void, ..TagSpec::DEFAULT },
464+ TagSpec { name: "tfoot", html: "tfoot", layout: Layout::Native, ..TagSpec::DEFAULT },
465+ TagSpec { name: "optgroup", html: "optgroup", arg: TagArg::Attr("label"), layout: Layout::Native, ..TagSpec::DEFAULT },
466+ TagSpec { name: "ruby", html: "ruby", layout: Layout::Native, ..TagSpec::DEFAULT },
467+ TagSpec { name: "rt", html: "rt", layout: Layout::Native, ..TagSpec::DEFAULT },
468+ TagSpec { name: "rp", html: "rp", layout: Layout::Native, ..TagSpec::DEFAULT },
469+ TagSpec { name: "track", html: "track", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
470+ TagSpec { name: "embed", html: "embed", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
471+ TagSpec { name: "object", html: "object", arg: TagArg::Attr("data"), layout: Layout::Native, ..TagSpec::DEFAULT },
472+ TagSpec { name: "map", html: "map", layout: Layout::Native, ..TagSpec::DEFAULT },
473+ TagSpec { name: "area", html: "area", arg: TagArg::Attr("href"), layout: Layout::Void, ..TagSpec::DEFAULT },
474+ // htmlang's own
475+ TagSpec { name: "grid", html: "div", layout: Layout::Grid, ..TagSpec::DEFAULT },
476+ TagSpec { name: "in-front", html: "div", css: "position:absolute;inset:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
477+ TagSpec { name: "behind", html: "div", css: "position:absolute;inset:0;z-index:-1;", layout: Layout::Column, ..TagSpec::DEFAULT },
478+];
479+
480+/// HTML elements htmlang writes under another name. The HTML name isn't an
481+/// element: the unknown-element error for `@a` suggests `@link`. (Not a
482+/// second spelling: only the suggestion reads this.)
483+pub const HTML_NAMES_WRITTEN_OTHERWISE: &[(&str, &str)] = &[
484+ ("a", "link"),
485+ ("img", "image"),
486+ ("span", "text"),
487+ ("p", "paragraph"),
488+ ("div", "el"),
489+];
490+
491+/// Whether the element named `name` (without the `@`) shows its text as
492+/// written: `{@code {@link /x y}}` prints the braces, and its indented body
493+/// is verbatim text, HTML-escaped. The syntax tree reads this, so every
494+/// tool agrees on it.
495+pub fn has_literal_text(name: &str) -> bool {
496+ ElementKind::from_name(name)
497+ .and_then(|kind| kind.spec())
498+ .is_some_and(|spec| spec.literal)
171}
499}
172
500
173−/// Elements that print their argument as their text (`@text Hello`,
501+/// How a directive reads the rest of its line.
174−/// `@item First`).
502+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175−pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
503+pub enum ArgGrammar {
176− matches!(
504+ /// Free text, taken as written (`@include file.hl`, `@meta name value`).
177− kind,
505+ /// For a directive with a verbatim body it is the one-line form of the
178− ElementKind::Text
506+ /// body (`@raw <hr>`, `@style .a { color: red }`) or, for `@markdown`,
179− | ElementKind::Button
507+ /// the file.
180− | ElementKind::Label
508+ Text,
181− | ElementKind::Option
509+ /// An optional `[attributes]` list, then text (`@page [lang=en] Title`).
182− | ElementKind::Textarea
510+ AttrsText,
183− | ElementKind::ListItem
511+ /// An expression (`@if $count > 2`).
184− | ElementKind::TableCell
512+ Expression,
185− | ElementKind::TableHeaderCell
513+ /// A name, then a value, `= EXPR`, `"text"`, `[attributes]` or the
186− | ElementKind::Summary
514+ /// parameters of a function (`@let`).
187− | ElementKind::Cite
515+ Definition,
188− | ElementKind::Code
516+ /// Nothing, or `if EXPR` (`@else`, `@else if`).
189− | ElementKind::FigCaption
517+ Else,
190− | ElementKind::Legend
518+ /// `$item in LIST` or `$item, $index in LIST` (`@each`).
191− | ElementKind::DefinitionTerm
519+ Loop,
192− | ElementKind::Mark
520+ /// `$name SOURCE`; inline JSON may span several lines (`@data`).
193− | ElementKind::Kbd
521+ Data,
194− | ElementKind::Abbr
522+}
195− | ElementKind::Time
523+
196− | ElementKind::DefinitionDescription
524+/// What the lines indented under a directive or element are.
197− | ElementKind::Badge
525+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198− | ElementKind::Tooltip
526+pub enum BodyKind {
199− | ElementKind::Chip
527+ /// Nothing: indented lines are an error.
200− | ElementKind::Tag
528+ None,
201− | ElementKind::H1
529+ /// htmlang: elements, text and directives.
202− | ElementKind::H2
530+ Htmlang,
203− | ElementKind::H3
531+ /// Foreign text (CSS, HTML, JavaScript, Markdown, a code sample), kept
204− | ElementKind::H4
532+ /// exactly as written: nothing in it is parsed, and `--` is not a
205− | ElementKind::H5
533+ /// comment. The text on the header's line is its one-line form (or a
206− | ElementKind::H6
534+ /// file or URL: `@markdown notes.md`, `@script app.js`); the line and a
207− )
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+ }
208}
775}
crates/htmlang-core/src/codegen.rs+1599−3366
@@ -1,7 +1,8 @@
1use std::collections::HashMap;
1use std::collections::HashMap;
2−use std::hash::{Hash, Hasher};
2+use std::hash::Hash;
3
3
4use crate::ast::*;
4use crate::ast::*;
5+use crate::vocab::with_px;
5
6
6// ---------------------------------------------------------------------------
7// ---------------------------------------------------------------------------
7// Style collector: deduplicates CSS and assigns class names
8// Style collector: deduplicates CSS and assigns class names
@@ -16,56 +17,315 @@ const BREAKPOINTS: &[(&str, &str)] = &[
16 ("2xl", "1536px"),
17 ("2xl", "1536px"),
17];
18];
18
19
19−/// Generate short CSS class names: a..z, then aa..a9, ba..b9, ..., z9, then
20+/// The prefix of every generated class (`hl-2k9xq0m`), which keeps them
20−/// aaa, ... The first character is always a letter; later ones are drawn from
21+/// out of the author's own class names.
21−/// [a-z0-9]. The mapping is a bijection, so distinct indices never collide.
22+pub const CLASS_PREFIX: &str = "hl-";
22−pub(crate) fn short_class_name(idx: usize) -> String {
23+
23− const REST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
24+/// How many base-36 digits a class's name has after `hl-`: 7, about 36
24− // Find the name length: 26 names of length 1, 26*36 of length 2, ...
25+/// bits of its style's hash, so a site with 10,000 different styles has
25− let mut n = idx;
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);
26− let mut len = 1;
206+ let mut out = format!(".{}", class);
27− let mut count = 26usize;
207+ for (i, prefix) in chain.iter().enumerate() {
28− while n >= count {
208+ if prefix == crate::vocab::CHILDREN {
29− n -= count;
209+ if Some(i) == last_children {
30− len += 1;
210+ out = format!(":where({})>*", out);
31− count = count.saturating_mul(36);
211+ } else {
212+ out.push_str(">*");
213+ }
214+ } else if let Some(pseudo) = crate::vocab::pseudo_selector(prefix) {
215+ out.push_str(&pseudo);
216+ }
32 }
217 }
33− let mut tail = Vec::with_capacity(len - 1);
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);
34− for _ in 1..len {
233+ for prefix in &chain[at + 1..] {
35− tail.push(REST[n % 36]);
234+ if let Some(pseudo) = crate::vocab::pseudo_selector(prefix) {
36− n /= 36;
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)
37 }
272 }
38− let mut name = String::with_capacity(len);
273+}
39− name.push((b'a' + n as u8) as char);
274+
40− name.extend(tail.iter().rev().map(|&b| b as char));
275+/// A style as text, for its hash: the defaults of its element's kind, its
41− name
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
42}
296}
43
297
44struct StyleEntry {
298struct StyleEntry {
45 class_name: String,
299 class_name: 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.
46− base: String,
307+ defaults: String,
47− /// (CSS selector suffix, css_rules) — e.g. (":hover", "color:red;")
308+ /// The element's rules: under each condition, its declarations, in the
48− pseudo: Vec<(String, String)>,
309+ /// order they are written
49− /// Responsive overrides: (breakpoint_prefix, css)
310+ rules: Vec<(Condition, String)>,
50− responsive: Vec<(String, String)>,
311+ /// What else tells two elements with the same CSS apart: the rules
51− /// Dark mode overrides
312+ /// keyed on this class (see [`StyleCollector::keyed`]). Not written.
52− dark: String,
313+ distinct: 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)>,
61}
314}
62
315
63struct StyleCollector {
316struct StyleCollector {
64 entries: Vec<StyleEntry>,
317 entries: Vec<StyleEntry>,
65− /// Maps a pre-hashed style signature to an index into `entries`.
318+ /// Maps a style's hash to its entries (more than one only when two
66− /// Using u64 as the key keeps lookups allocation-free; on the rare case of
319+ /// styles hash alike, which the full comparison tells apart).
67− /// a hash collision we fall back to a full equality check against the entry.
68 index: HashMap<u64, Vec<usize>>,
320 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)>,
69}
329}
70
330
71impl StyleCollector {
331impl StyleCollector {
@@ -73,313 +333,282 @@ impl StyleCollector {
73 StyleCollector {
333 StyleCollector {
74 entries: Vec::new(),
334 entries: Vec::new(),
75 index: HashMap::new(),
335 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);
76 }
347 }
77 }
348 }
78
349
79 /// Returns a class name for this style combination, or None if all empty.
350 /// Returns a class name for this style combination, or None if all empty.
80− #[allow(clippy::too_many_arguments)]
81 fn get_class(
351 fn get_class(
82 &mut self,
352 &mut self,
83− base: String,
353+ defaults: String,
84− pseudo: Vec<(String, String)>,
354+ mut rules: Vec<(Condition, String)>,
85− responsive: Vec<(String, String)>,
355+ distinct: 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)>,
93 ) -> Option<String> {
356 ) -> Option<String> {
357+ rules.retain(|(_, body)| !body.is_empty());
94− if base.is_empty()
358+ if defaults.is_empty() && rules.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− {
105 return None;
359 return None;
106 }
360 }
107− use std::collections::hash_map::DefaultHasher;
361+ rules.sort_by_cached_key(|(condition, _)| condition.order());
108− let mut h = DefaultHasher::new();
362+ let style = stable_hash(&style_text(&defaults, &rules, &distinct));
109− base.hash(&mut h);
363+
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) {
364+ if let Some(indices) = self.index.get(&style) {
122 for &idx in indices {
365 for &idx in indices {
123 let e = &self.entries[idx];
366 let e = &self.entries[idx];
124− if e.base == base
367+ if e.defaults == defaults && e.rules == rules && e.distinct == distinct {
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− {
135 return Some(e.class_name.clone());
368 return Some(e.class_name.clone());
136 }
369 }
137 }
370 }
138 }
371 }
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
139 let idx = self.entries.len();
375 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());
141 self.entries.push(StyleEntry {
381 self.entries.push(StyleEntry {
142 class_name: name.clone(),
382 class_name: name.clone(),
143− base,
383+ style,
144− pseudo,
384+ defaults,
145− responsive,
385+ rules,
146− dark,
386+ distinct,
147− print,
148− motion_safe,
149− motion_reduce,
150− landscape,
151− portrait,
152− container,
153 });
387 });
154− self.index.entry(sig).or_default().push(idx);
388+ self.index.entry(style).or_default().push(idx);
155 Some(name)
389 Some(name)
156 }
390 }
157
391
158− /// All generated rules, wrapped in `@layer htmlang`.
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`]).
159− fn to_css_formatted(&self, dev: bool) -> String {
410+ fn to_css_formatted(&self, dev: bool, scoped: &[String]) -> String {
411+ // The defaults: `:where(.hl-a,.hl-b){...}`
160− let mut css = String::new();
412+ let mut kind = String::new();
161− let inner_indent = if dev { " " } else { "" };
413+ let mut defaults: Vec<(&str, Vec<&str>)> = Vec::new();
162− css.push_str(if dev { "@layer htmlang {\n" } else { "@layer htmlang{" });
414+ for e in self.entries.iter().filter(|e| !e.defaults.is_empty()) {
163−
415+ match defaults.iter_mut().find(|(body, _)| *body == e.defaults) {
164− // Non-responsive rules. Base and pseudo rules are merged separately by
416+ Some((_, classes)) => classes.push(&e.class_name),
165− // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
417+ None => defaults.push((&e.defaults, vec![&e.class_name])),
166− // replaces two identical rules. Pseudo variants are grouped per selector
418+ }
167− // suffix (`:hover` with `:hover`, etc.) to keep each pseudo's cascade
419+ }
168− // position independent of others.
420+ for (body, classes) in defaults {
169− let mut base_pairs: Vec<(&str, &str)> = Vec::with_capacity(self.entries.len());
421+ let classes: Vec<String> = classes.iter().map(|c| format!(".{}", c)).collect();
170− for e in &self.entries {
422+ let selector = format!(":where({})", classes.join(","));
171− if !e.base.is_empty() {
423+ if dev {
172− base_pairs.push((e.class_name.as_str(), e.base.as_str()));
424+ kind.push_str(&format!(" {} {{{}}}\n", selector, body));
173− }
425+ } else {
174− }
426+ kind.push_str(&format!("{}{{{}}}", selector, body));
175− emit_grouped_rules(&mut css, &base_pairs, "", inner_indent, dev);
176−
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()));
194 }
427 }
195 }
428 }
196− for selector in &pseudo_order {
429+
197− if let Some(pairs) = pseudo_buckets.get(selector) {
430+ let mut inside = self.blocks(true, dev);
198− emit_grouped_rules(&mut css, pairs, selector, inner_indent, 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));
199 }
438 }
200 }
439 }
201
440
202− // Responsive rules grouped by breakpoint
441+ let mut css = String::new();
203− for &(bp_name, bp_width) in BREAKPOINTS {
442+ for (name, rules) in [
204− let mut bp_pairs: Vec<(&str, &str)> = Vec::new();
443+ ("hl-kind", kind),
205− for e in &self.entries {
444+ ("hl-inside", inside),
206− for (bp, rule_css) in &e.responsive {
445+ ("htmlang", self.blocks(false, dev)),
446+ ] {
207− if bp == bp_name && !rule_css.is_empty() {
447+ if rules.is_empty() {
208− bp_pairs.push((e.class_name.as_str(), rule_css.as_str()));
448+ continue;
209− }
449+ }
210− }
450+ if dev {
451+ css.push_str(&format!("@layer {} {{\n{}}}\n", name, rules));
452+ } else {
453+ css.push_str(&format!("@layer {}{{{}}}", name, rules));
211 }
454 }
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 }
455 }
456+ css
457+ }
221
458
222− // Dark mode rules
459+ /// The rules of every block of at-rules, in order: those for the
223− let dark_pairs: Vec<(&str, &str)> = self
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 = || {
224− .entries
465+ self.entries.iter().flat_map(move |e| {
225− .iter()
466+ e.rules
226− .filter(|e| !e.dark.is_empty())
467+ .iter()
227− .map(|e| (e.class_name.as_str(), e.dark.as_str()))
468+ .filter(move |(c, _)| c.inside().is_some() == inside)
228− .collect();
469+ .map(move |(c, body)| (e, c, body))
229− emit_media_block(
470+ })
230− &mut css,
231− "@media (prefers-color-scheme: dark)",
232− "@media(prefers-color-scheme:dark)",
233− &dark_pairs,
234− "",
235− dev,
236− );
471+ };
237−
472+ let mut blocks: Vec<&[usize]> = vec![&[]];
238− // Print rules
473+ let keyed = self.keyed.iter().filter(|_| !inside);
239− let print_pairs: Vec<(&str, &str)> = self
474+ let every = rules()
240− .entries
475+ .map(|(_, c, _)| c.at.as_slice())
241− .iter()
476+ .chain(keyed.map(|(at, _, _)| at.as_slice()));
242− .filter(|e| !e.print.is_empty())
477+ for at in every {
243− .map(|e| (e.class_name.as_str(), e.print.as_str()))
478+ if !blocks.contains(&at) {
244− .collect();
479+ blocks.push(at);
245− emit_media_block(
480+ }
246− &mut css,
481+ }
247− "@media print",
482+ blocks.sort_by_key(|at| at_order(at));
248− "@media print",
483+
249− &print_pairs,
484+ for at in blocks {
250− "",
485+ let depth = at.len();
251− dev,
486+ // Readable output indents a rule inside `@layer` and inside
252− );
487+ // each at-rule but the first
253−
488+ let indent = if dev {
254− // Motion safe rules
489+ " ".repeat(depth.max(1))
255− let motion_safe_pairs: Vec<(&str, &str)> = self
490+ } else {
256− .entries
491+ String::new()
257− .iter()
492+ };
258− .filter(|e| !e.motion_safe.is_empty())
493+ let mut inner = String::new();
259− .map(|e| (e.class_name.as_str(), e.motion_safe.as_str()))
494+ // Rules keyed on a parent's class come first: they have the
260− .collect();
495+ // specificity of one class, like a class rule, so the child's
261− emit_media_block(
496+ // own rules in the block still win
262− &mut css,
497+ if !inside {
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
498+ let keyed: Vec<(&str, &str)> = self
288− .entries
499+ .keyed
289− .iter()
500+ .iter()
290− .filter(|e| !e.landscape.is_empty())
501+ .filter(|(a, _, _)| a.as_slice() == at)
291− .map(|e| (e.class_name.as_str(), e.landscape.as_str()))
502+ .map(|(_, selector, body)| (selector.as_str(), body.as_str()))
292− .collect();
503+ .collect();
293− emit_media_block(
504+ emit_selector_rules(&mut inner, &keyed, &indent, dev);
294− &mut css,
505+ }
295− "@media (orientation: landscape)",
506+ // The selector chains of this block, in order
296− "@media(orientation:landscape)",
507+ let mut chains: Vec<&[String]> = Vec::new();
297− &landscape_pairs,
508+ for (_, condition, _) in rules() {
298− "",
509+ if condition.at == at && !chains.contains(&condition.selector.as_slice()) {
299− dev,
510+ chains.push(&condition.selector);
300− );
511+ }
301−
512+ }
302− // Portrait rules
513+ if inside {
303− let portrait_pairs: Vec<(&str, &str)> = self
514+ chains.sort_by_cached_key(|chain| inside_order(chain));
304− .entries
515+ // One `@scope` for each root, its rules in the order of
305− .iter()
516+ // the chains: rules for one element under two roots (`.a`
306− .filter(|e| !e.portrait.is_empty())
517+ // and `.a:hover`) tie on specificity and proximity, so the
307− .map(|e| (e.class_name.as_str(), e.portrait.as_str()))
518+ // later one wins
308− .collect();
519+ let mut scopes: Vec<(String, Vec<(String, &str)>)> = Vec::new();
309− emit_media_block(
520+ for chain in chains {
310− &mut css,
521+ let Some(element) = chain
311− "@media (orientation: portrait)",
522+ .iter()
312− "@media(orientation:portrait)",
523+ .position(|p| crate::vocab::element_prefix(p).is_some())
313− &portrait_pairs,
524+ else {
314− "",
525+ continue;
315− dev,
526+ };
316− );
527+ for (e, _, body) in
317−
528+ rules().filter(|(_, c, _)| c.at == at && c.selector == chain)
318− // Container query rules grouped by breakpoint
529+ {
319− for &(bp_name, bp_width) in BREAKPOINTS {
530+ let Some((root, target)) = scoped_selector(&e.class_name, chain, element)
320− let mut cq_pairs: Vec<(&str, &str)> = Vec::new();
531+ else {
321− for e in &self.entries {
532+ continue;
322− for (bp, rule_css) in &e.container {
533+ };
323− if bp == bp_name && !rule_css.is_empty() {
534+ match scopes.iter_mut().find(|(r, _)| *r == root) {
324− cq_pairs.push((e.class_name.as_str(), rule_css.as_str()));
535+ Some((_, targets)) => targets.push((target, body)),
536+ None => scopes.push((root, vec![(target, body)])),
537+ }
325 }
538 }
326 }
539 }
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 { "}" });
554+ }
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;
567+ }
568+ // Each at-rule wraps the next, outermost first
569+ for (level, &rank) in at.iter().enumerate().rev() {
570+ let pad = if dev {
571+ " ".repeat(level)
572+ } else {
573+ String::new()
574+ };
575+ inner = if dev {
576+ format!("{pad}{} {{\n{inner}{pad}}}\n", at_rule(rank, true))
577+ } else {
578+ format!("{}{{{inner}}}", at_rule(rank, false))
579+ };
327 }
580 }
328− emit_media_block(
581+ css.push_str(&inner);
329− &mut css,
330− &format!("@container (min-width: {})", bp_width),
331− &format!("@container(min-width:{})", bp_width),
332− &cq_pairs,
333− "",
334− dev,
335− );
336 }
582 }
337−
338− css.push_str(if dev { "}\n" } else { "}" });
339 css
583 css
340 }
584 }
341}
585}
342
586
343−/// Emit CSS rules from `(class_name, body)` pairs, merging identical bodies
587+/// Emit CSS rules from `(selector, body)` pairs, merging identical bodies
344/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
588/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
345/// of each distinct body determines ordering, so output stays deterministic
589/// 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.
590+/// across runs. `indent` is prepended to each rule line.
348−fn emit_grouped_rules(
591+fn emit_grouped_rules(out: &mut String, pairs: &[(String, &str)], indent: &str, dev: bool) {
349− out: &mut String,
350− pairs: &[(&str, &str)],
351− selector_suffix: &str,
352− indent: &str,
353− dev: bool,
354−) {
355 if pairs.is_empty() {
592 if pairs.is_empty() {
356 return;
593 return;
357 }
594 }
358 // Group in first-occurrence order.
595 // Group in first-occurrence order.
359 let mut order: Vec<&str> = Vec::new();
596 let mut order: Vec<&str> = Vec::new();
360 let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
597 let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
361− for &(name, body) in pairs {
598+ for (selector, body) in pairs {
362 if body.is_empty() {
599 if body.is_empty() {
363 continue;
600 continue;
364 }
601 }
365 if !groups.contains_key(body) {
602 if !groups.contains_key(body) {
366 order.push(body);
603 order.push(body);
367 }
604 }
368− groups.entry(body).or_default().push(name);
605+ groups.entry(body).or_default().push(selector);
369 }
606 }
370 let sp = if dev { " " } else { "" };
607 let sp = if dev { " " } else { "" };
371 let nl = if dev { "\n" } else { "" };
608 let nl = if dev { "\n" } else { "" };
372 for body in &order {
609 for body in &order {
373− let names = &groups[body];
374 out.push_str(indent);
610 out.push_str(indent);
375− for (i, n) in names.iter().enumerate() {
611+ out.push_str(&groups[body].join(","));
376− if i > 0 {
377− out.push(',');
378− }
379− out.push('.');
380− out.push_str(n);
381− out.push_str(selector_suffix);
382− }
383 out.push_str(sp);
612 out.push_str(sp);
384 out.push('{');
613 out.push('{');
385 out.push_str(body);
614 out.push_str(body);
@@ -388,38 +617,44 @@ fn emit_grouped_rules(
388 }
617 }
389}
618}
390
619
391−/// Emit an `@media` / `@container` block containing grouped class rules.
620+/// Emit `(selector, body)` rules in their order, merging a run of rules
392−/// Skips the block entirely if no non-empty bodies are present.
621+/// with the same body into one rule with a selector list (the order
393−fn emit_media_block(
622+/// matters: two rules may apply to one element).
394− out: &mut String,
623+fn emit_selector_rules(out: &mut String, rules: &[(&str, &str)], indent: &str, dev: bool) {
395− header_dev: &str,
624+ let mut i = 0;
396− header_min: &str,
625+ while i < rules.len() {
397− pairs: &[(&str, &str)],
626+ let body = rules[i].1;
398− selector_suffix: &str,
627+ let run = rules[i..].iter().take_while(|(_, b)| *b == body).count();
399− dev: bool,
628+ if !body.is_empty() {
400−) {
629+ let selectors: Vec<&str> = rules[i..i + run].iter().map(|(s, _)| *s).collect();
401− if pairs.iter().all(|(_, body)| body.is_empty()) {
630+ out.push_str(indent);
402− return;
631+ out.push_str(&selectors.join(","));
403− }
632+ if dev {
404− let mut inner = String::new();
633+ out.push(' ');
405− let inner_indent = if dev { " " } else { "" };
634+ }
406− emit_grouped_rules(&mut inner, pairs, selector_suffix, inner_indent, dev);
635+ out.push('{');
407− if inner.is_empty() {
636+ out.push_str(body);
408− return;
637+ out.push('}');
409− }
410− if dev {
638+ if dev {
411− out.push_str(&format!("{} {{\n{}}}\n", header_dev, inner));
639+ out.push('\n');
412− } else {
640+ }
413− out.push_str(&format!("{}{{{}}}", header_min, inner));
641+ }
642+ i += run;
414 }
643 }
415}
644}
416
645
417struct GenContext {
646struct GenContext {
418 dev: bool,
647 dev: bool,
419 depth: usize,
648 depth: usize,
420− image_count: usize,
421 has_interactive: bool,
649 has_interactive: bool,
422− has_defer: bool,
650+ /// Writing inside a text element, at any depth: htmlang's own `<div>`s
651+ /// are `<span>`s there, which text can hold.
652+ in_text: bool,
653+ /// How the element whose children are being written lays them out
654+ flow: Flow,
655+ /// Writing the children of a `@picture`, where `@source`'s leading
656+ /// argument is its `srcset`
657+ in_picture: bool,
423}
658}
424
659
425impl GenContext {
660impl GenContext {
@@ -445,23 +680,26 @@ pub struct CodegenOptions {
445 pub dev: bool,
680 pub dev: bool,
446 pub partial: bool,
681 pub partial: bool,
447 pub minify: bool,
682 pub minify: bool,
448− pub compat: bool,
449}
683}
450
684
451/// Generate HTML from a parsed document using the given options.
685/// Generate HTML from a parsed document using the given options.
452pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String {
686pub 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();
453 let mut html = if opts.partial {
694 let mut html = if opts.partial {
454− generate_partial_inner(doc, opts.dev)
695+ generate_partial_inner(doc, opts.dev, &mut classes)
455 } else {
696 } else {
456− generate_full_inner(doc, opts.dev)
697+ generate_full_inner(doc, opts.dev, &mut classes)
457 };
698 };
458 if opts.minify {
699 if opts.minify {
459 html = minify_html(&html);
700 html = minify_html(&html);
460 }
701 }
461− if opts.compat {
702+ (html, classes)
462− html = add_vendor_prefixes(&html);
463− }
464− html
465}
703}
466
704
467pub fn generate(doc: &Document) -> String {
705pub fn generate(doc: &Document) -> String {
@@ -509,38 +747,6 @@ pub fn generate_minified(doc: &Document) -> String {
509 )
747 )
510}
748}
511
749
512−pub fn generate_compat(doc: &Document) -> String {
513− generate_with(
514− doc,
515− &CodegenOptions {
516− compat: true,
517− ..Default::default()
518− },
519− )
520−}
521−
522−pub fn generate_dev_compat(doc: &Document) -> String {
523− generate_with(
524− doc,
525− &CodegenOptions {
526− dev: true,
527− compat: true,
528− ..Default::default()
529− },
530− )
531−}
532−
533−pub fn generate_minified_compat(doc: &Document) -> String {
534− generate_with(
535− doc,
536− &CodegenOptions {
537− minify: true,
538− compat: true,
539− ..Default::default()
540− },
541− )
542−}
543−
544fn minify_html(html: &str) -> String {
750fn minify_html(html: &str) -> String {
545 let mut result = String::with_capacity(html.len());
751 let mut result = String::with_capacity(html.len());
546 let mut in_pre = false;
752 let mut in_pre = false;
@@ -619,514 +825,132 @@ fn minify_html(html: &str) -> String {
619 result
825 result
620}
826}
621
827
622−/// Add vendor prefixes to CSS within <style> tags for broader browser compatibility.
623−fn add_vendor_prefixes(html: &str) -> String {
624− // Find CSS within <style>...</style> and add vendor prefixes
625− let mut result = String::with_capacity(html.len() + 512);
626− let mut rest = html;
627− while let Some(start) = rest.find("<style>") {
628− let after_tag = start + 7;
629− result.push_str(&rest[..after_tag]);
630− rest = &rest[after_tag..];
631− if let Some(end) = rest.find("</style>") {
632− let css = &rest[..end];
633− result.push_str(&prefix_css(css));
634− result.push_str("</style>");
635− rest = &rest[end + 8..];
636− } else {
637− break;
638− }
639− }
640− result.push_str(rest);
641− result
642−}
643−
644−fn prefix_css(css: &str) -> String {
645− let mut out = String::with_capacity(css.len() + 256);
646− let mut i = 0;
647− let bytes = css.as_bytes();
648− while i < bytes.len() {
649− // Find property declarations
650− if let Some(pos) = css[i..].find('{') {
651− let brace = i + pos;
652− out.push_str(&css[i..=brace]);
653− i = brace + 1;
654− // Process declarations within this block
655− if let Some(close) = css[i..].find('}') {
656− let block = &css[i..i + close];
657− out.push_str(&prefix_declarations(block));
658− out.push('}');
659− i = i + close + 1;
660− }
661− } else {
662− out.push_str(&css[i..]);
663− break;
664− }
665− }
666− out
667−}
668−
669−fn prefix_declarations(block: &str) -> String {
670− let mut out = String::with_capacity(block.len() + 128);
671− for decl in block.split(';') {
672− let decl = decl.trim();
673− if decl.is_empty() {
674− continue;
675− }
676− if let Some((prop, val)) = decl.split_once(':') {
677− let prop = prop.trim();
678− let val = val.trim();
679− match prop {
680− "backdrop-filter" => {
681− out.push_str(&format!("-webkit-backdrop-filter:{};", val));
682− out.push_str(&format!("backdrop-filter:{};", val));
683− }
684− "user-select" => {
685− out.push_str(&format!("-webkit-user-select:{};", val));
686− out.push_str(&format!("-moz-user-select:{};", val));
687− out.push_str(&format!("user-select:{};", val));
688− }
689− "appearance" => {
690− out.push_str(&format!("-webkit-appearance:{};", val));
691− out.push_str(&format!("-moz-appearance:{};", val));
692− out.push_str(&format!("appearance:{};", val));
693− }
694− "background-clip" if val.contains("text") => {
695− out.push_str(&format!("-webkit-background-clip:{};", val));
696− out.push_str(&format!("background-clip:{};", val));
697− }
698− "hyphens" => {
699− out.push_str(&format!("-webkit-hyphens:{};", val));
700− out.push_str(&format!("-ms-hyphens:{};", val));
701− out.push_str(&format!("hyphens:{};", val));
702− }
703− "text-size-adjust" => {
704− out.push_str(&format!("-webkit-text-size-adjust:{};", val));
705− out.push_str(&format!("-ms-text-size-adjust:{};", val));
706− out.push_str(&format!("text-size-adjust:{};", val));
707− }
708− "mask-image" | "mask-size" | "mask-repeat" | "mask-position" => {
709− out.push_str(&format!("-webkit-{}:{};", prop, val));
710− out.push_str(&format!("{}:{};", prop, val));
711− }
712− _ => {
713− out.push_str(decl);
714− out.push(';');
715− }
716− }
717− } else {
718− out.push_str(decl);
719− out.push(';');
720− }
721− }
722− out
723−}
724−
725−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+ };
726 let mut styles = StyleCollector::new();
833 let mut styles = StyleCollector::new();
727 let mut ctx = GenContext {
834 let mut ctx = GenContext {
728 dev,
835 dev,
729 depth: 0,
836 depth: 0,
730− image_count: 0,
731 has_interactive: false,
837 has_interactive: false,
732− has_defer: false,
838+ in_text: false,
839+ flow: Flow::default(),
840+ in_picture: false,
733 };
841 };
734
842
735− // Check if document has @main for skip-to-content link
843+ // The page is the root element: `<body>` is a column (the reset makes
736− let has_main = has_element_kind(&doc.nodes, &ElementKind::Main);
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;
737
866
738 let mut body = String::new();
867 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+ );
739
875
740− // Skip-to-content link for accessibility (only when @main exists)
876+ let element_css = build_element_css(doc, &styles, dev);
741− if has_main {
742− if dev {
877+ let nl = if dev { "\n" } else { "" };
743− body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>\n");
744− } else {
745− body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>");
746− }
747− }
748
878
749− for node in &doc.nodes {
879+ let mut meta_html = String::new();
750− generate_node(node, None, &mut body, &mut styles, &mut ctx);
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);
751 }
918 }
752
919
753− // Collect external domains for DNS prefetch
920+ // `@page`'s HTML attributes go on `<html>`
754− let dns_prefetch_html = collect_dns_prefetch(&body, dev);
921+ let mut root_attrs = String::new();
755−
922+ for attr in html_attrs(&page.html_attrs) {
756− let element_css = build_element_css(doc, &styles, dev);
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);
757
927
758− // Build meta tags string
928+ // Focus-visible CSS for interactive elements (accessibility)
759− let meta_html = if doc.meta_tags.is_empty() {
929+ let focus_visible_css = if ctx.has_interactive {
760− String::new()
930+ let focusable = ":where(a,button,input,select,textarea):focus-visible";
761− } else {
762− let mut m = String::new();
763− for (name, content) in &doc.meta_tags {
764− if dev {
931+ if dev {
765− m.push_str(&format!(
932+ format!(
766− "<meta name=\"{}\" content=\"{}\">\n",
933+ "{}{} {{ outline: 2px solid currentColor; outline-offset: 2px; }}\n",
767− html_escape(name),
934+ OWN_ELEMENTS, focusable
768− html_escape(content)
935+ )
769− ));
770− } else {
936+ } else {
771− m.push_str(&format!(
937+ format!(
772− "<meta name=\"{}\" content=\"{}\">",
938+ "{}{}{{outline:2px solid currentColor;outline-offset:2px}}",
773− html_escape(name),
939+ OWN_ELEMENTS, focusable
774− html_escape(content)
940+ )
775− ));
776− }
777 }
941 }
778− m
779− };
780−
781− // Build OG meta tags
782− let og_html = if doc.og_tags.is_empty() {
783− String::new()
784 } else {
942 } else {
785− let mut o = String::new();
786− for (property, content) in &doc.og_tags {
787− if dev {
788− o.push_str(&format!(
789− "<meta property=\"og:{}\" content=\"{}\">\n",
790− html_escape(property),
791− html_escape(content)
792− ));
793− } else {
794− o.push_str(&format!(
795− "<meta property=\"og:{}\" content=\"{}\">",
796− html_escape(property),
797− html_escape(content)
798− ));
799− }
800− }
801− o
802− };
803−
804− // Build head blocks string
805− let head_html = if doc.head_blocks.is_empty() {
806 String::new()
943 String::new()
807− } else {
808− let mut h = String::new();
809− for block in &doc.head_blocks {
810− h.push_str(block);
811− if dev {
812− h.push('\n');
813− }
814− }
815− h
816 };
944 };
945+ let reset_css = reset_css(dev, true, &focus_visible_css);
946+ classes.extend(styles.classes());
817
947
818− let lang_attr = match &doc.lang {
948+ format!(
819− Some(lang) => format!(" lang=\"{}\"", html_escape(lang)),
949+ "<!DOCTYPE html>{nl}<html{root_attrs}>{nl}<head>{nl}<meta charset=\"utf-8\">{nl}\
820− None => String::new(),
950+ {meta_html}<style>{nl}{reset_css}{element_css}</style>{nl}</head>{nl}\
821− };
951+ <body{body_attrs}>{nl}{body}</body>{nl}</html>{nl}",
822−
952+ )
823− let favicon_html = match &doc.favicon {
824− Some(path) => {
825− // Try to read and inline the favicon
826− if let Ok(data) = std::fs::read(path) {
827− let mime = if path.ends_with(".ico") {
828− "image/x-icon"
829− } else if path.ends_with(".png") {
830− "image/png"
831− } else if path.ends_with(".svg") {
832− "image/svg+xml"
833− } else {
834− "image/x-icon"
835− };
836− let b64 = base64_encode(&data);
837− if dev {
838− format!(
839− "<link rel=\"icon\" href=\"data:{};base64,{}\">\n",
840− mime, b64
841− )
842− } else {
843− format!("<link rel=\"icon\" href=\"data:{};base64,{}\">", mime, b64)
844− }
845− } else {
846− // Fall back to href
847− if dev {
848− format!("<link rel=\"icon\" href=\"{}\">\n", html_escape(path))
849− } else {
850− format!("<link rel=\"icon\" href=\"{}\">", html_escape(path))
851− }
852− }
853− }
854− None => String::new(),
855− };
856−
857− // Canonical URL
858− let canonical_html = match &doc.canonical {
859− Some(url) => {
860− if dev {
861− format!("<link rel=\"canonical\" href=\"{}\">\n", html_escape(url))
862− } else {
863− format!("<link rel=\"canonical\" href=\"{}\">", html_escape(url))
864− }
865− }
866− None => String::new(),
867− };
868−
869− // Base URL
870− let base_html = match &doc.base_url {
871− Some(url) => {
872− if dev {
873− format!("<base href=\"{}\">\n", html_escape(url))
874− } else {
875− format!("<base href=\"{}\">", html_escape(url))
876− }
877− }
878− None => String::new(),
879− };
880−
881− // JSON-LD blocks
882− let json_ld_html: String = doc
883− .json_ld_blocks
884− .iter()
885− .map(|block| {
886− if dev {
887− format!(
888− "<script type=\"application/ld+json\">\n{}\n</script>\n",
889− block
890− )
891− } else {
892− format!("<script type=\"application/ld+json\">{}</script>", block)
893− }
894− })
895− .collect();
896−
897− // Manifest link
898− let manifest_html = if let Some(ref manifest) = doc.manifest {
899− let mut json = String::from("{");
900− json.push_str(&format!("\"name\":{},", json_str(&manifest.name)));
901− if let Some(ref short) = manifest.short_name {
902− json.push_str(&format!("\"short_name\":{},", json_str(short)));
903− }
904− json.push_str(&format!("\"start_url\":{},", json_str(&manifest.start_url)));
905− json.push_str(&format!("\"display\":{}", json_str(&manifest.display)));
906− if let Some(ref bg) = manifest.background_color {
907− json.push_str(&format!(",\"background_color\":{}", json_str(bg)));
908− }
909− if let Some(ref tc) = manifest.theme_color {
910− json.push_str(&format!(",\"theme_color\":{}", json_str(tc)));
911− }
912− if let Some(ref desc) = manifest.description {
913− json.push_str(&format!(",\"description\":{}", json_str(desc)));
914− }
915− if !manifest.icons.is_empty() {
916− json.push_str(",\"icons\":[");
917− for (i, (src, sizes)) in manifest.icons.iter().enumerate() {
918− if i > 0 {
919− json.push(',');
920− }
921− json.push_str(&format!(
922− "{{\"src\":{},\"sizes\":{},\"type\":\"image/png\"}}",
923− json_str(src),
924− json_str(sizes)
925− ));
926− }
927− json.push(']');
928− }
929− json.push('}');
930−
931− // Inline the manifest as a data URI
932− let encoded = base64_encode(json.as_bytes());
933− if dev {
934− format!(
935− "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">\n",
936− encoded
937− )
938− } else {
939− format!(
940− "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">",
941− encoded
942− )
943− }
944− } else {
945− String::new()
946− };
947−
948− // Preload hints (auto-detect fonts from @font-face)
949− let mut preload_html = String::new();
950− for (_, url) in &doc.font_faces {
951− if dev {
952− preload_html.push_str(&format!(
953− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
954− url
955− ));
956− } else {
957− preload_html.push_str(&format!(
958− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>",
959− url
960− ));
961− }
962− }
963− // Explicit preload hints from the document
964− for hint in &doc.preload_hints {
965− if dev {
966− preload_html.push_str(&format!(
967− "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>\n",
968− html_escape(&hint.href),
969− hint.as_type,
970− if hint.crossorigin { " crossorigin" } else { "" }
971− ));
972− } else {
973− preload_html.push_str(&format!(
974− "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>",
975− html_escape(&hint.href),
976− hint.as_type,
977− if hint.crossorigin { " crossorigin" } else { "" }
978− ));
979− }
980− }
981−
982− // Auto theme-color meta from @theme primary token
983− let theme_color_html = doc
984− .theme_tokens
985− .iter()
986− .find(|(name, _)| name == "primary" || name == "theme-color")
987− .map(|(_, value)| {
988− if dev {
989− format!(
990− "<meta name=\"theme-color\" content=\"{}\">\n",
991− html_escape(value)
992− )
993− } else {
994− format!(
995− "<meta name=\"theme-color\" content=\"{}\">",
996− html_escape(value)
997− )
998− }
999− })
1000− .unwrap_or_default();
1001−
1002− // Focus-visible CSS for interactive elements (accessibility)
1003− let focus_visible_css = if ctx.has_interactive {
1004− if dev {
1005− "a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n"
1006− } else {
1007− "a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}"
1008− }
1009− } else {
1010− ""
1011− };
1012−
1013− // Skip-to-content CSS (visually hidden but accessible)
1014− let skip_link_css = if has_main {
1015− if dev {
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; }\n.hl-skip:focus { left: 8px; top: 8px; width: auto; height: auto; overflow: visible; }\n"
1017− } else {
1018− ".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}"
1019− }
1020− } else {
1021− ""
1022− };
1023−
1024− // Noscript fallback: show deferred content if JS is disabled
1025− let noscript_html = if ctx.has_defer {
1026− if dev {
1027− "<noscript><style>.hl-defer-placeholder { display: none; }</style></noscript>\n"
1028− } else {
1029− "<noscript><style>.hl-defer-placeholder{display:none}</style></noscript>"
1030− }
1031− } else {
1032− ""
1033− };
1034−
1035− let reset_css = reset_css(dev, focus_visible_css, skip_link_css);
1036−
1037− match &doc.page_title {
1038− Some(title) => {
1039− if dev {
1040− format!(
1041− "\
1042−<!DOCTYPE html>
1043−<html{lang_attr}>
1044−<head>
1045−<meta charset=\"utf-8\">
1046−<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
1047−<title>{title}</title>
1048−{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}\
1049−<style>
1050−{reset_css}{element_css}\
1051−</style>
1052−{noscript_html}\
1053−</head>
1054−<body>
1055−{body}\
1056−</body>
1057−</html>
1058−",
1059− title = html_escape(title),
1060− lang_attr = lang_attr,
1061− theme_color_html = theme_color_html,
1062− base_html = base_html,
1063− canonical_html = canonical_html,
1064− manifest_html = manifest_html,
1065− preload_html = preload_html,
1066− dns_prefetch_html = dns_prefetch_html,
1067− meta_html = meta_html,
1068− favicon_html = favicon_html,
1069− json_ld_html = json_ld_html,
1070− head_html = head_html,
1071− og_html = og_html,
1072− reset_css = reset_css,
1073− noscript_html = noscript_html,
1074− element_css = element_css,
1075− body = body,
1076− )
1077− } else {
1078− format!(
1079− "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}<style>{reset_css}{element_css}</style>{noscript_html}</head><body>{body}</body></html>",
1080− title = html_escape(title),
1081− lang_attr = lang_attr,
1082− theme_color_html = theme_color_html,
1083− base_html = base_html,
1084− canonical_html = canonical_html,
1085− manifest_html = manifest_html,
1086− preload_html = preload_html,
1087− dns_prefetch_html = dns_prefetch_html,
1088− meta_html = meta_html,
1089− og_html = og_html,
1090− favicon_html = favicon_html,
1091− json_ld_html = json_ld_html,
1092− head_html = head_html,
1093− reset_css = reset_css,
1094− noscript_html = noscript_html,
1095− element_css = element_css,
1096− body = body,
1097− )
1098− }
1099− }
1100− None => {
1101− if element_css.is_empty() {
1102− body
1103− } else if dev {
1104− format!("<style>\n{}</style>\n{}", element_css, body)
1105− } else {
1106− format!("<style>{}</style>{}", element_css, body)
1107− }
1108− }
1109− }
1110−}
1111−
1112−/// Encode `s` as a JSON string literal (with surrounding quotes).
1113−pub(crate) fn json_str(s: &str) -> String {
1114− let mut out = String::with_capacity(s.len() + 2);
1115− out.push('"');
1116− for c in s.chars() {
1117− match c {
1118− '"' => out.push_str("\\\""),
1119− '\\' => out.push_str("\\\\"),
1120− '\n' => out.push_str("\\n"),
1121− '\r' => out.push_str("\\r"),
1122− '\t' => out.push_str("\\t"),
1123− c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1124− c => out.push(c),
1125− }
1126− }
1127− out.push('"');
1128− out
1129−}
953+}
1130
954
1131/// Assemble every CSS block the document needs (font faces, custom
955/// Assemble every CSS block the document needs (font faces, custom
1132/// properties, generated class rules, keyframes, and user CSS). Shared by
956/// properties, generated class rules, keyframes, and user CSS). Shared by
@@ -1134,63 +958,16 @@ pub(crate) fn json_str(s: &str) -> String {
1134fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
958fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
1135 let mut element_css = String::new();
959 let mut element_css = String::new();
1136
960
1137− // @font-face rules, in declaration order
1138− for (name, url) in &doc.font_faces {
1139− let format_hint = if url.ends_with(".woff2") {
1140− " format('woff2')"
1141− } else if url.ends_with(".woff") {
1142− " format('woff')"
1143− } else if url.ends_with(".ttf") {
1144− " format('truetype')"
1145− } else if url.ends_with(".otf") {
1146− " format('opentype')"
1147− } else {
1148− ""
1149− };
1150− if dev {
1151− element_css.push_str(&format!(
1152− "@font-face {{\n font-family: '{}';\n src: url('{}'){};\n font-display: swap;\n}}\n",
1153− name, url, format_hint
1154− ));
1155− } else {
1156− element_css.push_str(&format!(
1157− "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
1158− name, url, format_hint
1159− ));
1160− }
1161− }
1162−
1163− // Collect all CSS custom properties (explicit `@let --name` / `@theme`
961+ // Collect the CSS custom properties declared with `@let --name` so they
1164− // tokens, plus any auto-extracted repeats) so they can be emitted in a
1165− // single `:root` block below.
962+ // can be emitted in a single `:root` block below.
1166 let mut root_vars: Vec<(String, String)> = Vec::new();
963 let mut root_vars: Vec<(String, String)> = Vec::new();
1167 for (name, value) in &doc.css_vars {
964 for (name, value) in &doc.css_vars {
1168 root_vars.push((name.clone(), value.clone()));
965 root_vars.push((name.clone(), value.clone()));
1169 }
966 }
1170− for (name, value) in &doc.theme_tokens {
1171− let css_name = format!("--{}", name);
1172− if !root_vars.iter().any(|(n, _)| *n == css_name) {
1173− root_vars.push((css_name, value.clone()));
1174− }
1175− }
1176
967
1177− // Generated rules always go in `@layer htmlang`, so unlayered user CSS
968+ // Generated rules always go in htmlang's layers, so unlayered user CSS
1178 // (`@style`, `@raw`) overrides them regardless of specificity.
969 // (`@style`, `@raw`) overrides them regardless of specificity.
1179− let styles_css = styles.to_css_formatted(dev);
970+ let styles_css = styles.to_css_formatted(dev, &doc.scoped_css);
1180− // Fold literal values declared via `@theme` / `@let --name` back into
1181− // `var(--name)` references so the generated CSS actually uses the
1182− // custom properties emitted in `:root`.
1183− let styles_css = substitute_css_vars(&styles_css, &root_vars);
1184− // Further compress the CSS by auto-extracting any remaining literal
1185− // values that appear often enough for `var(--hN)` references to come out
1186− // shorter overall. Disabled in dev mode to keep the CSS readable.
1187− let styles_css = if dev {
1188− styles_css
1189− } else {
1190− let (new_css, auto_vars) = auto_extract_repeats(&styles_css, &root_vars);
1191− root_vars.extend(auto_vars);
1192− new_css
1193− };
1194
971
1195 // Emit the :root block first so the cascade picks up the custom
972 // Emit the :root block first so the cascade picks up the custom
1196 // properties before the class rules consume them.
973 // properties before the class rules consume them.
@@ -1215,72 +992,121 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
1215
992
1216 element_css.push_str(&styles_css);
993 element_css.push_str(&styles_css);
1217
994
1218− // @keyframes
1219− for (name, kf_body) in &doc.keyframes {
1220− if dev {
1221− element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
1222− } else {
1223− element_css.push_str(&format!("@keyframes {}{{{}}}", name, kf_body));
1224− }
1225− }
1226−
1227− // Auto-inject skeleton keyframe if skeleton attribute is used
1228− if element_css.contains("hl-skeleton") {
1229− if dev {
1230− element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
1231− } else {
1232− element_css.push_str("@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}");
1233− }
1234− }
1235−
1236 // @style blocks (custom CSS)
995 // @style blocks (custom CSS)
1237 for block in &doc.custom_css {
996 for block in &doc.custom_css {
1238 if dev {
997 if dev {
1239 element_css.push_str(block);
998 element_css.push_str(block);
1240 element_css.push('\n');
999 element_css.push('\n');
1241 } else {
1000 } else {
1242− let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1243− element_css.push_str(&minified);
1001+ element_css.push_str(&compact_css(block));
1244 }
1002 }
1245 }
1003 }
1246
1004
1247 element_css
1005 element_css
1248}
1006}
1249
1007
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+
1250/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1036/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1251−/// every layer, so an unlayered `a{color:inherit}` would override
1037+/// every layer, so an unlayered rule here would override the generated
1252−/// `@link [color red]`.
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`.
1253−fn reset_css(dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
1044+fn reset_css(dev: bool, page: bool, focus_visible_css: &str) -> String {
1254− let base = if dev {
1045+ let own = OWN_ELEMENTS;
1255− "*, *::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"
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+ });
1256 } else {
1058 } else {
1257− "*,*::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}"
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}}")
1258− };
1071+ });
1072+ let mut rules = rules.join(if dev { "\n" } else { "" });
1073+ if dev {
1074+ rules.push('\n');
1075+ }
1259− let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
1076+ rules.push_str(focus_visible_css);
1260 if dev {
1077 if dev {
1078+ format!(
1261− format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
1079+ "@layer {};\n@layer hl-reset {{\n{}}}\n",
1080+ LAYERS.join(", "),
1081+ rules
1082+ )
1262 } else {
1083 } else {
1263− format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
1084+ format!("@layer {};@layer hl-reset{{{}}}", LAYERS.join(","), rules)
1264 }
1085 }
1265}
1086}
1266
1087
1267/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1088/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1268−fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1089+fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
1269 let mut styles = StyleCollector::new();
1090 let mut styles = StyleCollector::new();
1270 let mut ctx = GenContext {
1091 let mut ctx = GenContext {
1271 dev,
1092 dev,
1272 depth: 0,
1093 depth: 0,
1273− image_count: 0,
1274 has_interactive: false,
1094 has_interactive: false,
1275− has_defer: false,
1095+ in_text: false,
1096+ flow: Flow::default(),
1097+ in_picture: false,
1276 };
1098 };
1277 let mut body = String::new();
1099 let mut body = String::new();
1278
1100
1279− for node in &doc.nodes {
1280− generate_node(node, None, &mut body, &mut styles, &mut ctx);
1101+ generate_children(&doc.nodes, None, &mut body, &mut styles, &mut ctx);
1281− }
1282
1102
1283− 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());
1284
1110
1285 if element_css.is_empty() {
1111 if element_css.is_empty() {
1286 body
1112 body
@@ -1295,44 +1121,20 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1295// Node generation
1121// Node generation
1296// ---------------------------------------------------------------------------
1122// ---------------------------------------------------------------------------
1297
1123
1124+/// Write `node`, which is inside an element with the layout `parent`
1125+/// (`None` at the top of the page).
1298fn generate_node(
1126fn generate_node(
1299 node: &Node,
1127 node: &Node,
1300− parent_kind: Option<&ElementKind>,
1128+ parent: Option<Layout>,
1301 out: &mut String,
1129 out: &mut String,
1302 styles: &mut StyleCollector,
1130 styles: &mut StyleCollector,
1303 ctx: &mut GenContext,
1131 ctx: &mut GenContext,
1304) {
1132) {
1305 match node {
1133 match node {
1306− Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
1134+ Node::Element(elem) => generate_element(elem, parent, out, styles, ctx),
1307 Node::Text(segments) => {
1135 Node::Text(segments) => {
1308− let needs_wrap = matches!(
1136+ // In a row, column or grid each line of text is a child
1309− parent_kind,
1137+ let needs_wrap = parent.is_some_and(Layout::is_container);
1310− Some(ElementKind::Row)
1311− | Some(ElementKind::Column)
1312− | Some(ElementKind::El)
1313− | Some(ElementKind::Nav)
1314− | Some(ElementKind::Header)
1315− | Some(ElementKind::Footer)
1316− | Some(ElementKind::Main)
1317− | Some(ElementKind::Section)
1318− | Some(ElementKind::Article)
1319− | Some(ElementKind::Aside)
1320− | Some(ElementKind::ListItem)
1321− | Some(ElementKind::Form)
1322− | Some(ElementKind::Details)
1323− | Some(ElementKind::Figure)
1324− | Some(ElementKind::Blockquote)
1325− | Some(ElementKind::Dialog)
1326− | Some(ElementKind::DefinitionList)
1327− | Some(ElementKind::DefinitionDescription)
1328− | Some(ElementKind::Fieldset)
1329− | Some(ElementKind::Datalist)
1330− | Some(ElementKind::Grid)
1331− | Some(ElementKind::Stack)
1332− | Some(ElementKind::Noscript)
1333− | Some(ElementKind::Address)
1334− | Some(ElementKind::Search)
1335− );
1336 if needs_wrap {
1138 if needs_wrap {
1337 out.push_str(&ctx.indent());
1139 out.push_str(&ctx.indent());
1338 out.push_str("<span>");
1140 out.push_str("<span>");
@@ -1351,209 +1153,191 @@ fn generate_node(
1351 }
1153 }
1352}
1154}
1353
1155
1354−/// HTML attributes that are passed through to the HTML tag rather than converted to CSS.
1156+/// What goes between two things written one after the other in an element
1355−const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
1157+/// with this layout (`None`: the top of the page): text flows, so its lines
1356− "type",
1158+/// and children are joined with a space; in HTML's own layout, and at the
1357− "placeholder",
1159+/// top of the page, two lines of text are separated by a line break (a
1358− "name",
1160+/// space, except where whitespace is kept, as in `@pre`).
1359− "value",
1161+fn separator(layout: Option<Layout>, previous_is_text: bool, next: &Node) -> Option<char> {
1360− "disabled",
1162+ match layout {
1361− "required",
1163+ Some(Layout::Text) => Some(' '),
1362− "checked",
1164+ Some(Layout::Native) | None if previous_is_text && matches!(next, Node::Text(_)) => {
1363− "for",
1165+ Some('\n')
1364− "action",
1166+ }
1365− "method",
1167+ _ => None,
1366− "autocomplete",
1168+ }
1367− "min",
1169+}
1368− "max",
1369− "step",
1370− "pattern",
1371− "maxlength",
1372− "rows",
1373− "cols",
1374− "multiple",
1375− "alt",
1376− "role",
1377− "tabindex",
1378− "title",
1379− "autofocus",
1380− // Media
1381− "controls",
1382− "autoplay",
1383− "loop",
1384− "muted",
1385− "playsinline",
1386− "poster",
1387− "preload",
1388− // Image optimization
1389− "loading",
1390− "decoding",
1391− // Media src (explicit attribute form)
1392− "src",
1393− // New element attributes
1394− "datetime",
1395− "media",
1396− "sizes",
1397− "srcset",
1398− "list",
1399− // Details
1400− "open",
1401− // Form
1402− "novalidate",
1403− // Progress/Meter
1404− "low",
1405− "high",
1406− "optimum",
1407− // Table
1408− "colspan",
1409− "rowspan",
1410− "scope",
1411− // Popover API
1412− "popover",
1413− "popovertarget",
1414− "popovertargetaction",
1415− // Modern form/input hints
1416− "inputmode",
1417− "enterkeyhint",
1418− // Performance hints
1419− "fetchpriority",
1420− "blocking",
1421− // Global attrs
1422− "translate",
1423− "spellcheck",
1424− // ARIA live regions
1425− "aria-live",
1426− "aria-atomic",
1427− "aria-relevant",
1428−];
1429
1170
1430−/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
1171+/// Write the children of an element (or of a `@fragment`, which has no
1431−const BOOLEAN_HTML_ATTRS: &[&str] = &[
1172+/// element of its own) laid out as `layout` (`None`: the top of the page).
1432− "disabled",
1173+/// The first line of an element's text is its first child.
1433− "required",
1174+fn generate_children(
1434− "checked",
1175+ children: &[Node],
1435− "multiple",
1176+ layout: Option<Layout>,
1436− "controls",
1177+ out: &mut String,
1437− "autoplay",
1178+ styles: &mut StyleCollector,
1438− "loop",
1179+ ctx: &mut GenContext,
1439− "muted",
1180+) {
1440− "playsinline",
1181+ generate_run(children, layout, &mut None, out, styles, ctx);
1441− "open",
1182+}
1442− "novalidate",
1443− "autofocus",
1444− "defer",
1445− "async",
1446− "nomodule",
1447− // Popover
1448− "popover",
1449−];
1450
1183
1451−fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
1184+/// [`generate_children`], with `previous` saying whether what was written
1452− for attr in attrs {
1185+/// last was a line of text (`None`: nothing yet). A `@fragment`'s children
1453− let key = attr.key.as_str();
1186+/// are written as if they stood in its place, so its lines are separated
1454− let is_passthrough = HTML_PASSTHROUGH_ATTRS.contains(&key)
1187+/// from the lines around it like any other lines.
1455− || key.starts_with("aria-")
1188+fn generate_run(
1456− || key.starts_with("data-");
1189+ children: &[Node],
1457− if !is_passthrough {
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);
1458 continue;
1201 continue;
1459 }
1202 }
1460− if BOOLEAN_HTML_ATTRS.contains(&key) && attr.value.is_none() {
1203+ let start = out.len();
1461− out.push(' ');
1204+ if let Some(previous_is_text) = *previous
1462− out.push_str(key);
1205+ && let Some(sep) = separator(layout, previous_is_text, child)
1463− } else if let Some(val) = &attr.value {
1206+ {
1464− out.push(' ');
1207+ out.push(sep);
1465− out.push_str(key);
1208+ }
1466− out.push_str("=\"");
1209+ let body = out.len();
1467− out.push_str(&html_escape(strip_string_quotes(val)));
1210+ generate_node(child, layout, out, styles, ctx);
1468− out.push('"');
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;
1469 }
1216 }
1217+ *previous = Some(matches!(child, Node::Text(_)));
1470 }
1218 }
1471}
1219}
1472
1220
1473−/// If the value is wrapped in a single pair of matching quotes (e.g.
1221+/// Emit the element's leading argument as its attribute (`@iframe URL` →
1474−/// `"Avatar"`), return the inner content. Quotes act as source-level
1222+/// `src="URL"`, `@form /submit` → `action="/submit"`, `@link /a` →
1475−/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute
1223+/// `href="/a"`), first, before the class. `@image [inline] photo.png` puts
1476−/// values. Multi-quoted values like `"h h" "s m"` are left unchanged —
1224+/// the file itself into the page.
1477−/// those are real string tokens (used e.g. by CSS `grid-template-areas`).
1225+fn emit_argument_attr(out: &mut String, elem: &Element, in_picture: bool) {
1478−fn strip_string_quotes(val: &str) -> &str {
1226+ let (Some(attr), Some(value)) = (elem.kind.leading_attribute(in_picture), &elem.argument)
1479− let bytes = val.as_bytes();
1227+ else {
1480− if bytes.len() < 2 {
1228+ return;
1481− return val;
1229+ };
1482− }
1230+ // `@image [inline]`'s file is already a `data:` URI (see the parser)
1483− let first = bytes[0];
1231+ let value = html_escape(value);
1484− let last = bytes[bytes.len() - 1];
1232+ out.push(' ');
1485− if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1233+ out.push_str(attr);
1486− let inner = &val[1..val.len() - 1];
1234+ out.push_str("=\"");
1487− if !inner.as_bytes().contains(&first) {
1235+ out.push_str(&value);
1488− return inner;
1236+ out.push('"');
1237+}
1238+
1239+/// A file's bytes as a `data:` URI, its type from its extension.
1240+pub(crate) fn data_uri(path: &str, data: &[u8]) -> String {
1241+ let mime = match path
1242+ .rsplit('.')
1243+ .next()
1244+ .map(str::to_ascii_lowercase)
1245+ .as_deref()
1246+ {
1247+ Some("png") => "image/png",
1248+ Some("jpg" | "jpeg") => "image/jpeg",
1249+ Some("gif") => "image/gif",
1250+ Some("webp") => "image/webp",
1251+ Some("avif") => "image/avif",
1252+ Some("svg") => "image/svg+xml",
1253+ Some("ico") => "image/x-icon",
1254+ _ => "application/octet-stream",
1255+ };
1256+ format!("data:{};base64,{}", mime, base64_encode(data))
1257+}
1258+
1259+/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1260+/// are emitted with the generated class) and flags like `required`.
1261+fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1262+ for attr in html_attrs(attrs) {
1263+ let key = attr.key.as_str();
1264+ if key != "id" && key != "class" {
1265+ push_html_attr(out, attr);
1489 }
1266 }
1490 }
1267 }
1491− val
1268+}
1269+
1270+/// The HTML attributes of `attrs`, `key=value` ones and flags, each name
1271+/// once: the later one wins, as a style does. (Of two attributes with one
1272+/// name, the browser keeps the first, so a bundle's attribute or a
1273+/// function's own would win over one written after it.)
1274+fn html_attrs(attrs: &[Attribute]) -> impl Iterator<Item = &Attribute> {
1275+ let is_html =
1276+ |a: &Attribute| a.html || (a.value.is_none() && crate::vocab::is_html_flag(&a.key));
1277+ attrs.iter().enumerate().filter_map(move |(i, attr)| {
1278+ let later = attrs[i + 1..]
1279+ .iter()
1280+ .any(|other| is_html(other) && other.key == attr.key);
1281+ (is_html(attr) && !later).then_some(attr)
1282+ })
1283+}
1284+
1285+/// ` key="value"`, or ` key` for a flag.
1286+fn push_html_attr(out: &mut String, attr: &Attribute) {
1287+ out.push(' ');
1288+ out.push_str(&attr.key);
1289+ if attr.html {
1290+ out.push_str("=\"");
1291+ // Quoted text has already lost its quotes (see parser.rs)
1292+ out.push_str(&html_escape(attr.value.as_deref().unwrap_or("")));
1293+ out.push('"');
1294+ }
1492}
1295}
1493
1296
1494// True if any direct child is `@in-front` or `@behind`. Such children render
1297// True if any direct child is `@in-front` or `@behind`. Such children render
1495// as absolutely positioned overlays, so the parent automatically becomes a
1298// as absolutely positioned overlays, so the parent automatically becomes a
1496// positioning context (position:relative + isolation:isolate).
1299// positioning context (position:relative + isolation:isolate).
1497fn has_overlay_children(elem: &Element) -> bool {
1300fn has_overlay_children(elem: &Element) -> bool {
1301+ holds_overlays(&elem.children)
1302+}
1303+
1304+fn holds_overlays(children: &[Node]) -> bool {
1498− elem.children.iter().any(|child| {
1305+ children.iter().any(|child| {
1499 matches!(
1306 matches!(
1500 child,
1307 child,
1501− Node::Element(e) if matches!(e.kind, ElementKind::InFront | ElementKind::Behind)
1308+ Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
1502 )
1309 )
1503 })
1310 })
1504}
1311}
1505
1312
1506fn generate_element(
1313fn generate_element(
1507 elem: &Element,
1314 elem: &Element,
1508− parent_kind: Option<&ElementKind>,
1315+ parent: Option<Layout>,
1509 out: &mut String,
1316 out: &mut String,
1510 styles: &mut StyleCollector,
1317 styles: &mut StyleCollector,
1511 ctx: &mut GenContext,
1318 ctx: &mut GenContext,
1512) {
1319) {
1513 // Self-closing elements
1320 // Self-closing elements
1514− if matches!(
1515− elem.kind,
1321+ if elem.kind.layout() == Layout::Void {
1516− ElementKind::Image
1517− | ElementKind::Input
1518− | ElementKind::HorizontalRule
1519− | ElementKind::Source
1520− | ElementKind::Spacer
1521− ) {
1522− generate_self_closing(elem, parent_kind, out, styles, ctx);
1322+ generate_self_closing(elem, parent, out, styles, ctx);
1523 return;
1323 return;
1524 }
1324 }
1525− // @script renders as <script> with raw body content (no HTML escaping)
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)
1526− if elem.kind == ElementKind::Script {
1327+ if elem.kind.is_verbatim() {
1328+ let tag = elem.kind.spec().map_or("script", |spec| spec.html);
1527 out.push_str(&ctx.indent());
1329 out.push_str(&ctx.indent());
1528− out.push_str("<script");
1330+ out.push('<');
1529− // Pass through src, type, defer, async, etc.
1331+ out.push_str(tag);
1530− for attr in &elem.attrs {
1332+ emit_argument_attr(out, elem, false);
1531− let key = attr.key.as_str();
1333+ let (id, class) = extract_id_class(&elem.attrs);
1532− if matches!(
1334+ for (key, value) in [("id", id), ("class", class)] {
1533− key,
1534− "src"
1535− | "type"
1536− | "defer"
1537− | "async"
1538− | "crossorigin"
1539− | "integrity"
1540− | "nomodule"
1541− | "id"
1542− ) {
1543− if let Some(val) = &attr.value {
1335+ if let Some(value) = value {
1544− out.push(' ');
1545− out.push_str(key);
1336+ out.push_str(&format!(" {}=\"{}\"", key, html_escape(&value)));
1546− out.push_str("=\"");
1547− out.push_str(&html_escape(val));
1548− out.push('"');
1549− } else {
1550− out.push(' ');
1551− out.push_str(key);
1552− }
1553 }
1337 }
1554 }
1338 }
1339+ emit_html_attrs(out, &elem.attrs);
1555 out.push('>');
1340 out.push('>');
1556− // Children are raw JS code, not HTML
1557 for child in &elem.children {
1341 for child in &elem.children {
1558 match child {
1342 match child {
1559 Node::Text(segments) => {
1343 Node::Text(segments) => {
@@ -1567,46 +1351,10 @@ fn generate_element(
1567 _ => {}
1351 _ => {}
1568 }
1352 }
1569 }
1353 }
1570− out.push_str("</script>");
1354+ out.push_str("</");
1571− out.push_str(ctx.nl());
1355+ out.push_str(tag);
1572− return;
1573− }
1574− // @breadcrumb generates semantic <nav aria-label="breadcrumb"><ol>...</ol></nav>
1575− if elem.kind == ElementKind::Breadcrumb {
1576− let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1577− let (id, user_class) = extract_id_class(&elem.attrs);
1578− out.push_str(&ctx.indent());
1579− out.push_str("<nav aria-label=\"breadcrumb\"");
1580− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1581− if let Some(id) = id {
1582− out.push_str(" id=\"");
1583− out.push_str(&html_escape(&id));
1584− out.push('"');
1585− }
1586 out.push('>');
1356 out.push('>');
1587 out.push_str(ctx.nl());
1357 out.push_str(ctx.nl());
1588− ctx.depth += 1;
1589− out.push_str(&ctx.indent());
1590− out.push_str("<ol>");
1591− out.push_str(ctx.nl());
1592− ctx.depth += 1;
1593− for child in &elem.children {
1594− out.push_str(&ctx.indent());
1595− out.push_str("<li>");
1596− let mut buf = String::new();
1597− generate_node(child, Some(&elem.kind), &mut buf, styles, ctx);
1598− out.push_str(buf.trim());
1599− out.push_str("</li>");
1600− out.push_str(ctx.nl());
1601− }
1602− ctx.depth -= 1;
1603− out.push_str(&ctx.indent());
1604− out.push_str("</ol>");
1605− out.push_str(ctx.nl());
1606− ctx.depth -= 1;
1607− out.push_str(&ctx.indent());
1608− out.push_str("</nav>");
1609− out.push_str(ctx.nl());
1610 return;
1358 return;
1611 }
1359 }
1612 if elem.kind == ElementKind::Children {
1360 if elem.kind == ElementKind::Children {
@@ -1616,209 +1364,38 @@ fn generate_element(
1616 return;
1364 return;
1617 }
1365 }
1618 if elem.kind == ElementKind::Fragment {
1366 if elem.kind == ElementKind::Fragment {
1619− // Render children without a wrapper element
1367+ // Render children without a wrapper element, as if they were
1620− for child in &elem.children {
1368+ // written where the fragment is
1621− generate_node(child, parent_kind, out, styles, ctx);
1369+ generate_children(&elem.children, parent, out, styles, ctx);
1622− }
1623 return;
1370 return;
1624 }
1371 }
1625
1372
1373+ let mut own = Flow::of(elem);
1626 let tag = match &elem.kind {
1374 let tag = match &elem.kind {
1627− ElementKind::Row
1375+ ElementKind::Row | ElementKind::El => "div",
1628− | ElementKind::Column
1629− | ElementKind::El
1630− | ElementKind::Grid
1631− | ElementKind::Stack
1632− | ElementKind::InFront
1633− | ElementKind::Behind => "div",
1634 ElementKind::Text => "span",
1376 ElementKind::Text => "span",
1635 ElementKind::Paragraph => "p",
1377 ElementKind::Paragraph => "p",
1636 ElementKind::Link => "a",
1378 ElementKind::Link => "a",
1637− ElementKind::Button => "button",
1379+ ElementKind::Tag(spec) => spec.html,
1638− ElementKind::Select => "select",
1639− ElementKind::Textarea => "textarea",
1640− ElementKind::Option => "option",
1641− ElementKind::Label => "label",
1642− // Semantic elements
1643− ElementKind::Nav => "nav",
1644− ElementKind::Header => "header",
1645− ElementKind::Footer => "footer",
1646− ElementKind::Main => "main",
1647− ElementKind::Section => "section",
1648− ElementKind::Article => "article",
1649− ElementKind::Aside => "aside",
1650− // List
1651− ElementKind::List => {
1652− if elem.attrs.iter().any(|a| a.key == "ordered") {
1653− "ol"
1654− } else {
1655− "ul"
1656− }
1657− }
1658− ElementKind::ListItem => "li",
1659− // Table
1660− ElementKind::Table => "table",
1661− ElementKind::TableHead => "thead",
1662− ElementKind::TableBody => "tbody",
1663− ElementKind::TableRow => "tr",
1664− ElementKind::TableCell => "td",
1665− ElementKind::TableHeaderCell => "th",
1666− // Media
1667− ElementKind::Video => "video",
1668− ElementKind::Audio => "audio",
1669− // Additional semantic elements
1670− ElementKind::Form => "form",
1671− ElementKind::Details => "details",
1672− ElementKind::Summary => "summary",
1673− ElementKind::Blockquote => "blockquote",
1674− ElementKind::Cite => "cite",
1675− ElementKind::Code => "code",
1676− ElementKind::Pre => "pre",
1677− ElementKind::Figure => "figure",
1678− ElementKind::FigCaption => "figcaption",
1679− ElementKind::Progress => "progress",
1680− ElementKind::Meter => "meter",
1681− // New elements
1682− ElementKind::Dialog => "dialog",
1683− ElementKind::DefinitionList => "dl",
1684− ElementKind::DefinitionTerm => "dt",
1685− ElementKind::DefinitionDescription => "dd",
1686− ElementKind::Fieldset => "fieldset",
1687− ElementKind::Legend => "legend",
1688− ElementKind::Picture => "picture",
1689− ElementKind::Time => "time",
1690− ElementKind::Mark => "mark",
1691− ElementKind::Kbd => "kbd",
1692− ElementKind::Abbr => "abbr",
1693− ElementKind::Datalist => "datalist",
1694− ElementKind::Iframe => "iframe",
1695− ElementKind::Output => "output",
1696− ElementKind::Canvas => "canvas",
1697− ElementKind::Badge => "span",
1698− ElementKind::Tooltip => "span",
1699− ElementKind::Avatar => "div",
1700− ElementKind::Carousel => "div",
1701− ElementKind::Chip => "span",
1702− ElementKind::Tag => "span",
1703− ElementKind::Noscript => "noscript",
1704− ElementKind::Address => "address",
1705− ElementKind::Search => "search",
1706− // Heading elements
1707− ElementKind::H1 => "h1",
1708− ElementKind::H2 => "h2",
1709− ElementKind::H3 => "h3",
1710− ElementKind::H4 => "h4",
1711− ElementKind::H5 => "h5",
1712− ElementKind::H6 => "h6",
1713− ElementKind::Image
1714− | ElementKind::Input
1715− | ElementKind::HorizontalRule
1716− | ElementKind::Children
1717− | ElementKind::Slot(_)
1718− | ElementKind::Fragment
1719− | ElementKind::Source
1720− | ElementKind::Spacer
1721− | ElementKind::Script
1722− | ElementKind::Breadcrumb => unreachable!(),
1723− };
1724−
1725− let kind_label = match elem.kind {
1726− ElementKind::Row => "row",
1727− ElementKind::Column => "column",
1728− ElementKind::El => "el",
1729− ElementKind::Text => "text",
1730− ElementKind::Paragraph => "paragraph",
1731− ElementKind::Link => "link",
1732− ElementKind::Button => "button",
1733− ElementKind::Select => "select",
1734− ElementKind::Textarea => "textarea",
1735− ElementKind::Option => "option",
1736− ElementKind::Label => "label",
1737− ElementKind::Nav => "nav",
1738− ElementKind::Header => "header",
1739− ElementKind::Footer => "footer",
1740− ElementKind::Main => "main",
1741− ElementKind::Section => "section",
1742− ElementKind::Article => "article",
1743− ElementKind::Aside => "aside",
1744− ElementKind::List => "list",
1745− ElementKind::ListItem => "item",
1746− ElementKind::Table => "table",
1747− ElementKind::TableHead => "thead",
1748− ElementKind::TableBody => "tbody",
1749− ElementKind::TableRow => "tr",
1750− ElementKind::TableCell => "td",
1751− ElementKind::TableHeaderCell => "th",
1752− ElementKind::Video => "video",
1753− ElementKind::Audio => "audio",
1754− ElementKind::Form => "form",
1755− ElementKind::Details => "details",
1756− ElementKind::Summary => "summary",
1757− ElementKind::Blockquote => "blockquote",
1758− ElementKind::Cite => "cite",
1759− ElementKind::Code => "code",
1760− ElementKind::Pre => "pre",
1761− ElementKind::Figure => "figure",
1762− ElementKind::FigCaption => "figcaption",
1763− ElementKind::Fragment => "fragment",
1764− ElementKind::Dialog => "dialog",
1765− ElementKind::DefinitionList => "dl",
1766− ElementKind::DefinitionTerm => "dt",
1767− ElementKind::DefinitionDescription => "dd",
1768− ElementKind::Fieldset => "fieldset",
1769− ElementKind::Legend => "legend",
1770− ElementKind::Picture => "picture",
1771− ElementKind::Time => "time",
1772− ElementKind::Mark => "mark",
1773− ElementKind::Kbd => "kbd",
1774− ElementKind::Abbr => "abbr",
1775− ElementKind::Datalist => "datalist",
1776− ElementKind::Iframe => "iframe",
1777− ElementKind::Output => "output",
1778− ElementKind::Canvas => "canvas",
1779− ElementKind::Grid => "grid",
1780− ElementKind::Stack => "stack",
1781− ElementKind::Badge => "badge",
1782− ElementKind::Tooltip => "tooltip",
1783− ElementKind::Avatar => "avatar",
1784− ElementKind::Carousel => "carousel",
1785− ElementKind::Chip => "chip",
1786− ElementKind::Tag => "tag",
1787− ElementKind::Noscript => "noscript",
1788− ElementKind::Address => "address",
1789− ElementKind::Search => "search",
1790− ElementKind::InFront => "in-front",
1791− ElementKind::Behind => "behind",
1792− ElementKind::H1 => "h1",
1793− ElementKind::H2 => "h2",
1794− ElementKind::H3 => "h3",
1795− ElementKind::H4 => "h4",
1796− ElementKind::H5 => "h5",
1797− ElementKind::H6 => "h6",
1798 _ => "",
1380 _ => "",
1799 };
1381 };
1382+ // Inside text, htmlang's own `<div>` is a `<span>`, which text can
1383+ // hold (directly in text, a row, column or grid is also laid out
1384+ // inline: see `Site::inline`)
1385+ let in_text = ctx.in_text || parent == Some(Layout::Text);
1386+ let tag = if in_text && tag == "div" { "span" } else { tag };
1387+ let kind_label = elem.kind.name();
1800
1388
1801 // Track interactive elements for focus-visible CSS
1389 // Track interactive elements for focus-visible CSS
1802− if matches!(
1390+ if elem.kind == ElementKind::Link
1803− elem.kind,
1391+ || matches!(kind_label, "button" | "input" | "select" | "textarea")
1804− ElementKind::Link
1805− | ElementKind::Button
1806− | ElementKind::Input
1807− | ElementKind::Select
1808− | ElementKind::Textarea
1809− ) {
1392+ {
1810 ctx.has_interactive = true;
1393 ctx.has_interactive = true;
1811 }
1394 }
1812
1395
1813 // Compute CSS for each state and get a class name
1396 // Compute CSS for each state and get a class name
1814− let overlay_children = has_overlay_children(elem);
1397+ let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1815− let gen_class = compute_class(
1398+ own.class = gen_class.clone();
1816− &elem.attrs,
1817− &elem.kind,
1818− parent_kind,
1819− styles,
1820− overlay_children,
1821− );
1822 let (id, user_class) = extract_id_class(&elem.attrs);
1399 let (id, user_class) = extract_id_class(&elem.attrs);
1823
1400
1824 if ctx.dev && elem.line_num > 0 {
1401 if ctx.dev && elem.line_num > 0 {
@@ -1832,85 +1409,9 @@ fn generate_element(
1832 out.push('<');
1409 out.push('<');
1833 out.push_str(tag);
1410 out.push_str(tag);
1834
1411
1835− // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1412+ emit_argument_attr(out, elem, ctx.in_picture);
1836− if elem.kind == ElementKind::Main && id.is_none() {
1837− out.push_str(" id=\"hl-main\"");
1838− }
1839−
1840− if elem.kind == ElementKind::Link
1841− && let Some(url) = &elem.argument
1842− {
1843− out.push_str(" href=\"");
1844− out.push_str(&html_escape(url));
1845− out.push('"');
1846− // Auto rel="noopener noreferrer" and target="_blank" for external links
1847− let is_external = url.starts_with("http://") || url.starts_with("https://");
1848− if is_external {
1849− let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1850− let has_target = elem.attrs.iter().any(|a| a.key == "target");
1851− if !has_rel {
1852− out.push_str(" rel=\"noopener noreferrer\"");
1853− }
1854− if !has_target {
1855− out.push_str(" target=\"_blank\"");
1856− }
1857− }
1858− }
1859−
1860− // Video/Audio src
1861− if matches!(elem.kind, ElementKind::Video | ElementKind::Audio)
1862− && let Some(src) = &elem.argument
1863− {
1864− out.push_str(" src=\"");
1865− out.push_str(&html_escape(src));
1866− out.push('"');
1867− }
1868−
1869− // Form action
1870− if elem.kind == ElementKind::Form
1871− && let Some(action) = &elem.argument
1872− {
1873− out.push_str(" action=\"");
1874− out.push_str(&html_escape(action));
1875− out.push('"');
1876− }
1877−
1878− // Iframe src
1879− if elem.kind == ElementKind::Iframe
1880− && let Some(src) = &elem.argument
1881− {
1882− out.push_str(" src=\"");
1883− out.push_str(&html_escape(src));
1884− out.push('"');
1885− }
1886−
1887− // Tooltip title
1888− if elem.kind == ElementKind::Tooltip
1889− && let Some(text) = &elem.argument
1890− {
1891− out.push_str(" title=\"");
1892− out.push_str(&html_escape(text));
1893− out.push('"');
1894− }
1895
1413
1896− // Critical CSS: inline styles directly instead of using a class
1414+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1897− let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
1898− if is_critical {
1899− let inline_css = attrs_to_css(&elem.attrs, "", &elem.kind, parent_kind, overlay_children);
1900− if !inline_css.is_empty() {
1901− out.push_str(" style=\"");
1902− out.push_str(&html_escape(&inline_css));
1903− out.push('"');
1904− }
1905− // Still use class for non-base styles (pseudo, responsive, etc.)
1906− if gen_class.as_deref().is_some_and(|c| !c.is_empty()) {
1907− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1908− } else if let Some(ref uc) = user_class {
1909− emit_class_attr(out, None, Some(uc));
1910− }
1911− } else {
1912− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1913− }
1914
1415
1915 if let Some(id) = id {
1416 if let Some(id) = id {
1916 out.push_str(" id=\"");
1417 out.push_str(" id=\"");
@@ -1918,7 +1419,7 @@ fn generate_element(
1918 out.push('"');
1419 out.push('"');
1919 }
1420 }
1920
1421
1921− emit_html_passthrough_attrs(out, &elem.attrs);
1422+ emit_html_attrs(out, &elem.attrs);
1922
1423
1923 // Source map attributes in dev mode
1424 // Source map attributes in dev mode
1924 if ctx.dev && elem.line_num > 0 {
1425 if ctx.dev && elem.line_num > 0 {
@@ -1929,36 +1430,27 @@ fn generate_element(
1929 }
1430 }
1930
1431
1931 out.push('>');
1432 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");
1932 out.push_str(ctx.nl());
1438 out.push_str(ctx.nl());
1933
1439
1934− // Inline text argument
1440+ let layout = elem.kind.layout();
1935− if renders_argument_as_text(&elem.kind)
1936− && let Some(text) = &elem.argument
1937− {
1938− out.push_str(&html_escape(text));
1939− }
1940−
1941− // Children
1942 ctx.depth += 1;
1441 ctx.depth += 1;
1943− let is_paragraph = matches!(
1442+ let outer_in_text = ctx.in_text;
1944− elem.kind,
1443+ ctx.in_text = in_text || layout == Layout::Text;
1945− ElementKind::Paragraph
1444+ let outer_flow = std::mem::replace(&mut ctx.flow, own);
1946− | ElementKind::H1
1445+ let outer_picture = std::mem::replace(&mut ctx.in_picture, elem.kind.is_tag("picture"));
1947− | ElementKind::H2
1446+ generate_children(&elem.children, Some(layout), out, styles, ctx);
1948− | ElementKind::H3
1447+ ctx.in_picture = outer_picture;
1949− | ElementKind::H4
1448+ ctx.flow = outer_flow;
1950− | ElementKind::H5
1449+ ctx.in_text = outer_in_text;
1951− | ElementKind::H6
1952− );
1953− for (i, child) in elem.children.iter().enumerate() {
1954− generate_node(child, Some(&elem.kind), out, styles, ctx);
1955− if is_paragraph && i < elem.children.len() - 1 {
1956− out.push(' ');
1957− }
1958− }
1959 ctx.depth -= 1;
1450 ctx.depth -= 1;
1960
1451
1961 out.push_str(&ctx.indent());
1452 out.push_str(&ctx.indent());
1453+ ctx.dev = outer_dev;
1962 out.push_str("</");
1454 out.push_str("</");
1963 out.push_str(tag);
1455 out.push_str(tag);
1964 out.push('>');
1456 out.push('>');
@@ -1967,21 +1459,18 @@ fn generate_element(
1967
1459
1968fn generate_self_closing(
1460fn generate_self_closing(
1969 elem: &Element,
1461 elem: &Element,
1970− parent_kind: Option<&ElementKind>,
1462+ parent: Option<Layout>,
1971 out: &mut String,
1463 out: &mut String,
1972 styles: &mut StyleCollector,
1464 styles: &mut StyleCollector,
1973 ctx: &mut GenContext,
1465 ctx: &mut GenContext,
1974) {
1466) {
1975− 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);
1976 let (id, user_class) = extract_id_class(&elem.attrs);
1468 let (id, user_class) = extract_id_class(&elem.attrs);
1977
1469
1978− let (tag, kind_label) = match elem.kind {
1470+ let (tag, kind_label) = match &elem.kind {
1979 ElementKind::Image => ("img", "image"),
1471 ElementKind::Image => ("img", "image"),
1980− ElementKind::Input => ("input", "input"),
1472+ ElementKind::Tag(spec) => (spec.html, spec.name),
1981− ElementKind::HorizontalRule => ("hr", "hr"),
1473+ _ => unreachable!("not a void element: {:?}", elem.kind),
1982− ElementKind::Source => ("source", "source"),
1983− ElementKind::Spacer => ("div", "spacer"),
1984− _ => unreachable!(),
1985 };
1474 };
1986
1475
1987 if ctx.dev && elem.line_num > 0 {
1476 if ctx.dev && elem.line_num > 0 {
@@ -1994,43 +1483,7 @@ fn generate_self_closing(
1994 out.push_str(&ctx.indent());
1483 out.push_str(&ctx.indent());
1995 out.push('<');
1484 out.push('<');
1996 out.push_str(tag);
1485 out.push_str(tag);
1997−
1486+ emit_argument_attr(out, elem, ctx.in_picture);
1998− // Image src (with optional non-SVG base64 inlining)
1999− if elem.kind == ElementKind::Image {
2000− let src = elem.argument.as_deref().unwrap_or("");
2001− let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
2002− if is_inline && !src.is_empty() && !src.ends_with(".svg") {
2003− let mime = if src.ends_with(".png") {
2004− "image/png"
2005− } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
2006− "image/jpeg"
2007− } else if src.ends_with(".gif") {
2008− "image/gif"
2009− } else if src.ends_with(".webp") {
2010− "image/webp"
2011− } else if src.ends_with(".avif") {
2012− "image/avif"
2013− } else {
2014− "application/octet-stream"
2015− };
2016− if let Ok(data) = std::fs::read(src) {
2017− let b64 = base64_encode(&data);
2018− out.push_str(" src=\"data:");
2019− out.push_str(mime);
2020− out.push_str(";base64,");
2021− out.push_str(&b64);
2022− out.push('"');
2023− } else {
2024− out.push_str(" src=\"");
2025− out.push_str(&html_escape(src));
2026− out.push('"');
2027− }
2028− } else {
2029− out.push_str(" src=\"");
2030− out.push_str(&html_escape(src));
2031− out.push('"');
2032− }
2033− }
2034
1487
2035 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1488 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
2036
1489
@@ -2040,7 +1493,7 @@ fn generate_self_closing(
2040 out.push('"');
1493 out.push('"');
2041 }
1494 }
2042
1495
2043− emit_html_passthrough_attrs(out, &elem.attrs);
1496+ emit_html_attrs(out, &elem.attrs);
2044
1497
2045 // Source map attributes in dev mode (self-closing)
1498 // Source map attributes in dev mode (self-closing)
2046 if ctx.dev && elem.line_num > 0 {
1499 if ctx.dev && elem.line_num > 0 {
@@ -2050,93 +1503,6 @@ fn generate_self_closing(
2050 ));
1503 ));
2051 }
1504 }
2052
1505
2053− // Image optimization: auto-add loading="lazy" and decoding="async"
2054− if elem.kind == ElementKind::Image {
2055− // SVG inlining: @image [inline] logo.svg
2056− if elem.attrs.iter().any(|a| a.key == "inline")
2057− && let Some(src) = &elem.argument
2058− && src.ends_with(".svg")
2059− && let Ok(svg_content) = std::fs::read_to_string(src)
2060− {
2061− // Close the tag we opened, then emit inline SVG instead
2062− out.truncate(out.rfind('<').unwrap_or(0));
2063− out.push_str(&ctx.indent());
2064− out.push_str(svg_content.trim());
2065− out.push_str(ctx.nl());
2066− return;
2067− }
2068− // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
2069− let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
2070− if let Some(resp) = responsive_attr
2071− && let Some(ref sizes_str) = resp.value
2072− {
2073− let widths: Vec<&str> = sizes_str.split_whitespace().collect();
2074− if !widths.is_empty() {
2075− let src = elem.argument.as_deref().unwrap_or("");
2076− if !src.is_empty() {
2077− // Generate srcset with width descriptors
2078− // Convention: file-{width}.ext (e.g., photo-400.jpg)
2079− let dot_pos = src.rfind('.').unwrap_or(src.len());
2080− let base = &src[..dot_pos];
2081− let ext = &src[dot_pos..];
2082− let mut srcset_parts = Vec::new();
2083− for w in &widths {
2084− srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
2085− }
2086− out.push_str(" srcset=\"");
2087− out.push_str(&srcset_parts.join(", "));
2088− out.push('"');
2089− // Generate sizes attribute
2090− let max_width = widths.last().unwrap_or(&"100vw");
2091− out.push_str(&format!(
2092− " sizes=\"(max-width: {}px) 100vw, {}px\"",
2093− max_width, max_width
2094− ));
2095− }
2096− }
2097− }
2098−
2099− // Auto image dimensions: read local image file to inject width/height + aspect-ratio
2100− let has_width = elem.attrs.iter().any(|a| a.key == "width");
2101− let has_height = elem.attrs.iter().any(|a| a.key == "height");
2102− if (!has_width || !has_height)
2103− && let Some(ref src) = elem.argument
2104− && !src.starts_with("http://")
2105− && !src.starts_with("https://")
2106− && !src.starts_with("data:")
2107− && let Some((w, h)) = read_image_dimensions(src)
2108− {
2109− // Intrinsic size attributes only when neither dimension is set in
2110− // CSS; with one CSS dimension, a leftover intrinsic attribute for
2111− // the other would distort the image (200 wide but 1000 tall).
2112− if !has_width && !has_height {
2113− out.push_str(&format!(" width=\"{}\" height=\"{}\"", w, h));
2114− }
2115− // Auto aspect-ratio to prevent CLS
2116− if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
2117− out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
2118− }
2119− }
2120−
2121− // Smart image loading: first 3 images get fetchpriority="high" (above the fold),
2122− // subsequent images get loading="lazy" + decoding="async"
2123− ctx.image_count += 1;
2124− if ctx.image_count <= 3 {
2125− // Above-the-fold: eager loading with high priority
2126− if !elem.attrs.iter().any(|a| a.key == "fetchpriority") {
2127− out.push_str(" fetchpriority=\"high\"");
2128− }
2129− } else {
2130− // Below-the-fold: lazy loading
2131− if !elem.attrs.iter().any(|a| a.key == "loading") {
2132− out.push_str(" loading=\"lazy\"");
2133− }
2134− if !elem.attrs.iter().any(|a| a.key == "decoding") {
2135− out.push_str(" decoding=\"async\"");
2136− }
2137− }
2138− }
2139−
2140 out.push('>');
1506 out.push('>');
2141 out.push_str(ctx.nl());
1507 out.push_str(ctx.nl());
2142}
1508}
@@ -2152,7 +1518,10 @@ fn generate_text_segments(
2152 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1518 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
2153 TextSegment::Inline(elem) => {
1519 TextSegment::Inline(elem) => {
2154 let mut buf = String::new();
1520 let mut buf = String::new();
2155− 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;
2156 out.push_str(buf.trim_end());
1525 out.push_str(buf.trim_end());
2157 }
1526 }
2158 }
1527 }
@@ -2165,143 +1534,97 @@ fn generate_text_segments(
2165
1534
2166fn compute_class(
1535fn compute_class(
2167 attrs: &[Attribute],
1536 attrs: &[Attribute],
2168− kind: &ElementKind,
1537+ site: &Site,
2169− parent_kind: Option<&ElementKind>,
2170 styles: &mut StyleCollector,
1538 styles: &mut StyleCollector,
2171− has_overlay_children: bool,
1539+ distinct: String,
2172) -> Option<String> {
1540) -> Option<String> {
2173− let base = attrs_to_css(attrs, "", kind, parent_kind, has_overlay_children);
1541+ // Every condition an attribute is under, and none
2174−
1542+ let mut conditions = vec![Condition::default()];
2175− // Collect pseudo-state overrides
1543+ for attr in attrs.iter().filter(|a| !a.html) {
2176− let mut pseudo = Vec::new();
1544+ let (condition, _) = Condition::of(&attr.key);
2177− for &(prefix, selector) in PSEUDO_PREFIXES {
1545+ if !conditions.contains(&condition) {
2178− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1546+ conditions.push(condition);
2179− if !css.is_empty() {
2180− pseudo.push((selector.to_string(), css));
2181− }
2182− }
2183−
2184− // Auto-add ::-webkit-scrollbar pseudo for no-scrollbar attribute
2185− if attrs.iter().any(|a| a.key == "no-scrollbar") {
2186− pseudo.push((
2187− "::-webkit-scrollbar".to_string(),
2188− "display:none;".to_string(),
2189− ));
2190− }
2191−
2192− // Collect nth:EXPR: dynamic pseudo selectors
2193− let mut nth_prefixes: Vec<String> = Vec::new();
2194− for attr in attrs {
2195− if attr.key.starts_with("nth:") {
2196− let rest = &attr.key[4..];
2197− if let Some(colon_pos) = rest.find(':') {
2198− let prefix = format!("nth:{}:", &rest[..colon_pos]);
2199− if !nth_prefixes.contains(&prefix) {
2200− nth_prefixes.push(prefix);
2201− }
2202− }
2203− }
2204− }
2205− for prefix in &nth_prefixes {
2206− let expr = &prefix[4..prefix.len() - 1];
2207− let selector = format!(":nth-child({})", expr);
2208− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
2209− if !css.is_empty() {
2210− pseudo.push((selector, css));
2211 }
1547 }
2212 }
1548 }
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+}
2213
1572
2214− // Collect has(...): dynamic pseudo selectors
1573+/// The defaults of an element's kind where it is: an overlay's positioning
2215− let mut has_prefixes: Vec<String> = Vec::new();
1574+/// context, its layout, and its kind's own CSS (a heading's `margin:0`).
2216− for attr in attrs {
1575+fn default_css(site: &Site) -> String {
2217− if attr.key.starts_with("has(")
1576+ let mut css = String::new();
2218− && let Some(close) = attr.key.find("):")
1577+ // Elements with @in-front / @behind children become positioning
2219− {
1578+ // contexts
2220− let prefix = format!("{}:", &attr.key[..close + 1]);
1579+ if site.has_overlay_children {
2221− if !has_prefixes.contains(&prefix) {
1580+ css.push_str("position:relative;isolation:isolate;");
2222− has_prefixes.push(prefix);
2223− }
2224− }
2225 }
1581 }
2226− for prefix in &has_prefixes {
1582+ if !site.root {
2227− let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
1583+ css.push_str(layout_css(site.kind.layout(), site.inline));
2228− let selector = format!(":has({})", inner);
2229− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
2230− if !css.is_empty() {
2231− pseudo.push((selector, css));
2232− }
2233 }
1584 }
1585+ css.push_str(site.kind.css());
1586+ css
1587+}
2234
1588
2235− // Collect responsive overrides
1589+/// The declarations of a rule body, as (property, `property:value;`), for
2236− let mut responsive = Vec::new();
1590+/// bodies htmlang wrote: every declaration ends in `;`, and a `;` inside
2237− for &(bp_name, _) in BREAKPOINTS {
1591+/// quotes or parentheses doesn't end one.
2238− let prefix = format!("{}:", bp_name);
1592+fn declarations(css: &str) -> impl Iterator<Item = (&str, &str)> {
2239− let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1593+ let mut rest = css;
2240− if !css.is_empty() {
1594+ std::iter::from_fn(move || {
2241− responsive.push((bp_name.to_string(), css));
1595+ if rest.is_empty() {
1596+ return None;
2242 }
1597 }
2243− }
1598+ let (mut depth, mut quote, mut escaped) = (0i32, None, false);
2244−
1599+ let mut end = rest.len();
2245− // Collect container query overrides
1600+ for (i, c) in rest.char_indices() {
2246− let mut container = Vec::new();
1601+ match c {
2247− for &(bp_name, _) in BREAKPOINTS {
1602+ _ if escaped => escaped = false,
2248− let prefix = format!("cq-{}:", bp_name);
1603+ '\\' => escaped = true,
2249− let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1604+ c if Some(c) == quote => quote = None,
2250− if !css.is_empty() {
1605+ _ if quote.is_some() => {}
2251− container.push((bp_name.to_string(), css));
1606+ '"' | '\'' => quote = Some(c),
1607+ '(' => depth += 1,
1608+ ')' => depth -= 1,
1609+ ';' if depth == 0 => {
1610+ end = i + 1;
1611+ break;
1612+ }
1613+ _ => {}
1614+ }
2252 }
1615 }
2253− }
1616+ let (declaration, after) = rest.split_at(end);
2254−
1617+ rest = after;
2255− let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
1618+ let name = declaration.split(':').next().unwrap_or("").trim();
2256− let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
1619+ Some((name, declaration))
2257− let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind, has_overlay_children);
1620+ })
2258− let motion_reduce =
2259− attrs_to_css(attrs, "motion-reduce:", kind, parent_kind, has_overlay_children);
2260− let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
2261− let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
2262−
2263− // Dedupe: if a property is declared twice within a single rule, keep only
2264− // the last occurrence (element-kind defaults are written before
2265− // user attributes, so a user [list-style disc] correctly overrides the
2266− // default list-style:none, and we don't need to ship both).
2267− let base = dedupe_declarations(&base);
2268− let pseudo: Vec<(String, String)> = pseudo
2269− .into_iter()
2270− .map(|(sel, css)| (sel, dedupe_declarations(&css)))
2271− .collect();
2272− let responsive: Vec<(String, String)> = responsive
2273− .into_iter()
2274− .map(|(bp, css)| (bp, dedupe_declarations(&css)))
2275− .collect();
2276− let dark = dedupe_declarations(&dark);
2277− let print = dedupe_declarations(&print);
2278− let motion_safe = dedupe_declarations(&motion_safe);
2279− let motion_reduce = dedupe_declarations(&motion_reduce);
2280− let landscape = dedupe_declarations(&landscape);
2281− let portrait = dedupe_declarations(&portrait);
2282− let container: Vec<(String, String)> = container
2283− .into_iter()
2284− .map(|(bp, css)| (bp, dedupe_declarations(&css)))
2285− .collect();
2286−
2287− styles.get_class(
2288− base,
2289− pseudo,
2290− responsive,
2291− dark,
2292− print,
2293− motion_safe,
2294− motion_reduce,
2295− landscape,
2296− portrait,
2297− container,
2298− )
2299}
1621}
2300
1622
2301/// Dedupe CSS declarations within a single rule body: for any property
1623/// Dedupe CSS declarations within a single rule body: for any property
2302/// declared more than once, keep only the last occurrence. Unparseable
1624/// declared more than once, keep only the last occurrence. Unparseable
2303−/// segments (no `:`) are preserved as-is. Semicolons inside parentheses are
1625+/// segments (no `:`) are preserved as-is. Semicolons inside parentheses or
2304−/// treated as part of a value, not as declaration separators.
1626+/// quoted strings are treated as part of a value, not as declaration
1627+/// separators.
2305fn dedupe_declarations(css: &str) -> String {
1628fn dedupe_declarations(css: &str) -> String {
2306 if css.is_empty() {
1629 if css.is_empty() {
2307 return String::new();
1630 return String::new();
@@ -2316,8 +1639,26 @@ fn dedupe_declarations(css: &str) -> String {
2316 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1639 let mut decls: Vec<(Option<String>, String)> = Vec::new();
2317 let mut current = String::new();
1640 let mut current = String::new();
2318 let mut depth: i32 = 0;
1641 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;
2319 for ch in css.chars() {
1646 for ch in css.chars() {
2320− 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 == '(' {
2321 depth += 1;
1662 depth += 1;
2322 current.push(ch);
1663 current.push(ch);
2323 } else if ch == ')' {
1664 } else if ch == ')' {
@@ -2399,1268 +1740,578 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
2399// Attribute → CSS mapping
1740// Attribute → CSS mapping
2400// ---------------------------------------------------------------------------
1741// ---------------------------------------------------------------------------
2401
1742
2402−/// (htmlang prefix, CSS selector suffix)
1743+/// Where an element is written, which its CSS depends on.
2403−const PSEUDO_PREFIXES: &[(&str, &str)] = &[
1744+struct Site<'a> {
2404− ("hover:", ":hover"),
1745+ kind: &'a ElementKind,
2405− ("active:", ":active"),
1746+ /// How its parent lays out its children: its `width fill`, `height
2406− ("focus:", ":focus"),
1747+ /// fill` and `shrink` compile against it.
2407− ("focus-visible:", ":focus-visible"),
1748+ parent: &'a Flow,
2408− ("focus-within:", ":focus-within"),
1749+ /// It has `@in-front` / `@behind` children.
2409− ("disabled:", ":disabled"),
1750+ has_overlay_children: bool,
2410− ("checked:", ":checked"),
1751+ /// A row, column or grid inside text, laid out inline.
2411− ("placeholder:", "::placeholder"),
1752+ inline: bool,
2412− ("first:", ":first-child"),
1753+ /// The page's `<body>`, which the reset already makes a column.
2413− ("last:", ":last-child"),
1754+ root: bool,
2414− ("odd:", ":nth-child(odd)"),
2415− ("even:", ":nth-child(even)"),
2416− ("before:", "::before"),
2417− ("after:", "::after"),
2418− ("selection:", "::selection"),
2419− ("visited:", ":visited"),
2420− ("empty:", ":empty"),
2421− ("target:", ":target"),
2422− ("valid:", ":valid"),
2423− ("invalid:", ":invalid"),
2424−];
2425−const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
2426−const MEDIA_PREFIXES: &[&str] = &[
2427− "dark:",
2428− "print:",
2429− "motion-safe:",
2430− "motion-reduce:",
2431− "landscape:",
2432− "portrait:",
2433−];
2434−const CONTAINER_QUERY_PREFIXES: &[&str] = &["cq-sm:", "cq-md:", "cq-lg:", "cq-xl:", "cq-2xl:"];
2435−
2436−fn is_prefixed_attr(key: &str) -> bool {
2437− PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
2438− || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
2439− || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
2440− || CONTAINER_QUERY_PREFIXES.iter().any(|p| key.starts_with(p))
2441− || key.starts_with("nth:")
2442− || key.starts_with("has(")
2443}
1755}
2444
1756
2445−/// `display:flex;flex-direction:column;` — base layout for `@el` and every
1757+impl Site<'_> {
2446−/// semantic wrapper that behaves like a column.
1758+ /// Where an element in a parent with the layout `layout` and the flow
2447−const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
1759+ /// `parent` is.
1760+ fn new<'a>(elem: &'a Element, layout: Option<Layout>, parent: &'a Flow) -> Site<'a> {
1761+ Site {
1762+ kind: &elem.kind,
1763+ parent,
1764+ has_overlay_children: has_overlay_children(elem),
1765+ inline: layout == Some(Layout::Text) && elem.kind.layout().is_container(),
1766+ root: false,
1767+ }
1768+ }
1769+}
2448
1770
2449−/// Font stack shared by `@pre`, `@code`, `@kbd`.
1771+// ---------------------------------------------------------------------------
2450−const MONOSPACE_STACK: &str = "font-family:ui-monospace,monospace;";
1772+// Direction: what a child's `fill` and `shrink` compile against
1773+// ---------------------------------------------------------------------------
2451
1774
2452−/// Declarations common to `@badge` and `@tag`. They differ only in
1775+/// The way a flex row or column lays out its children.
2453−/// `border-radius` (and `@badge` additionally centers content / pins
1776+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2454−/// line-height:1), so keeping the shared base here avoids drift.
1777+enum Axis {
2455−const PILL_BASE: &str =
1778+ Row,
2456− "display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;";
1779+ Column,
1780+}
2457
1781
2458−fn attrs_to_css(
1782+impl Axis {
2459− attrs: &[Attribute],
1783+ /// The direction a `flex-direction` or `flex-flow` value sets, if it
2460− state_prefix: &str,
1784+ /// names one (not `var(--dir)`, which only the browser knows).
2461− kind: &ElementKind,
1785+ fn of_value(value: &str) -> Option<Axis> {
2462− parent_kind: Option<&ElementKind>,
1786+ value.split_whitespace().find_map(|word| match word {
2463− has_overlay_children: bool,
1787+ "row" | "row-reverse" => Some(Axis::Row),
2464−) -> String {
1788+ "column" | "column-reverse" => Some(Axis::Column),
2465− let mut css = String::new();
1789+ _ => None,
1790+ })
1791+ }
1792+}
2466
1793
2467− // Base element styles only for the default (non-state) pass
1794+/// How an element lays out its children, as its CSS sets it: the
2468− if state_prefix.is_empty() {
1795+/// direction its children's `width`/`height` `fill` and `shrink` compile
2469− // Elements with @in-front / @behind children become positioning
1796+/// against. That is the direction of `@row` or of a column, or the one its
2470− // contexts. Pushed before user attrs so an explicit `position` wins
1797+/// own `flex-direction` (or `flex-flow`) sets, with or without media or
2471− // via dedupe (only the last declaration of a property is kept).
1798+/// container prefixes. Like elm-ui's `.r > .wf`, the rules for a direction
2472− if has_overlay_children {
1799+/// set under a prefix are keyed on the element's class: `@media (...) {
2473− css.push_str("position:relative;isolation:isolate;");
1800+/// :where(.a)>.b {...} }`.
2474− }
1801+#[derive(Clone, Default)]
2475− match kind {
1802+struct Flow {
2476− ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
1803+ /// Without a prefix; `None` when the element isn't a flex row or column
2477− ElementKind::Column
1804+ /// (a grid, text, HTML's own layout, the top of the page)
2478− | ElementKind::El
1805+ base: Option<Axis>,
2479− // Semantic elements get flex column layout like @el
1806+ /// The direction under each chain of at-rule prefixes that sets one, in
2480− | ElementKind::Nav
1807+ /// the order their blocks are written
2481− | ElementKind::Header
1808+ changes: Vec<(Vec<usize>, Axis)>,
2482− | ElementKind::Footer
1809+ /// The element's generated class, which the rules for the changes are
2483− | ElementKind::Main
1810+ /// keyed on
2484− | ElementKind::Section
1811+ class: Option<String>,
2485− | ElementKind::Article
1812+}
2486− | ElementKind::Aside
1813+
2487− | ElementKind::ListItem
1814+impl Flow {
2488− | ElementKind::Form
1815+ fn of(elem: &Element) -> Flow {
2489− | ElementKind::Details
1816+ let mut base = match elem.kind.layout() {
2490− | ElementKind::Dialog => css.push_str(FLEX_COLUMN),
1817+ Layout::Row => Axis::Row,
2491− ElementKind::Paragraph
1818+ Layout::Column => Axis::Column,
2492− | ElementKind::H1
1819+ _ => return Flow::default(),
2493− | ElementKind::H2
1820+ };
2494− | ElementKind::H3
1821+ let mut changes: Vec<(Vec<usize>, Axis)> = Vec::new();
2495− | ElementKind::H4
1822+ for attr in elem.attrs.iter().filter(|a| !a.html) {
2496− | ElementKind::H5
1823+ let (condition, name) = Condition::of(&attr.key);
2497− | ElementKind::H6 => css.push_str("margin:0;"),
1824+ if !matches!(name, "flex-direction" | "flex-flow") || !condition.selector.is_empty() {
2498− // Lists: reset browser defaults
1825+ continue;
2499− ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
2500− // Figure / Blockquote: flex column with browser margin reset
2501− ElementKind::Figure | ElementKind::Blockquote => {
2502− css.push_str(FLEX_COLUMN);
2503− css.push_str("margin:0;");
2504− }
2505− // Pre: preserve whitespace
2506− ElementKind::Pre => {
2507− css.push_str("margin:0;white-space:pre;");
2508− css.push_str(MONOSPACE_STACK);
2509− }
2510− // Code / Kbd: monospace font
2511− ElementKind::Code | ElementKind::Kbd => css.push_str(MONOSPACE_STACK),
2512− ElementKind::DefinitionList => css.push_str("margin:0;"),
2513− ElementKind::DefinitionDescription => {
2514− css.push_str("margin:0;");
2515− css.push_str(FLEX_COLUMN);
2516− }
2517− ElementKind::Fieldset => {
2518− css.push_str(FLEX_COLUMN);
2519− // Align padding to the px-based scale used by Badge/Tag/Chip
2520− // rather than the old 0.5em (which depended on font-size).
2521− css.push_str("border:1px solid currentColor;padding:8px;margin:0;");
2522− }
2523− ElementKind::Grid => css.push_str("display:grid;"),
2524− ElementKind::Stack => css.push_str("position:relative;"),
2525− ElementKind::Badge => {
2526− css.push_str(PILL_BASE);
2527− css.push_str("justify-content:center;border-radius:9999px;line-height:1;");
2528− }
2529− ElementKind::Tag => {
2530− css.push_str(PILL_BASE);
2531− css.push_str("border-radius:4px;");
2532− }
2533− ElementKind::Tooltip => css.push_str("position:relative;cursor:help;"),
2534− ElementKind::Spacer => css.push_str("flex:1;"),
2535− ElementKind::Avatar => css.push_str("display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;"),
2536− ElementKind::Carousel => css.push_str("display:flex;flex-direction:row;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;"),
2537− ElementKind::Chip => css.push_str("display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:9999px;font-size:0.875rem;border:1px solid currentColor;"),
2538− // Overlay layers fill the parent. Parent gets position:relative
2539− // applied automatically (see has_overlay_children check below).
2540− ElementKind::InFront => {
2541− css.push_str(FLEX_COLUMN);
2542− css.push_str("position:absolute;inset:0;");
2543 }
1826 }
2544− ElementKind::Behind => {
1827+ let Some(axis) = attr.value.as_deref().and_then(Axis::of_value) else {
2545− css.push_str(FLEX_COLUMN);
1828+ continue;
2546− css.push_str("position:absolute;inset:0;z-index:-1;");
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));
2547 }
1836 }
2548− _ => {}
2549 }
1837 }
2550− // Children of @carousel get scroll-snap-align and flex-shrink
1838+ changes.sort_by_key(|(at, _)| at_order(at));
2551− if matches!(parent_kind, Some(ElementKind::Carousel)) {
1839+ Flow {
2552− css.push_str("scroll-snap-align:start;flex-shrink:0;");
1840+ base: Some(base),
1841+ changes,
1842+ class: None,
1843+ }
1844+ }
1845+
1846+ /// The direction under the at-rules `at`: the latest one set among
1847+ /// the conditions that hold there (`md:` holds at `lg:`), else the one
1848+ /// without a prefix.
1849+ fn at(&self, at: &[usize]) -> Option<Axis> {
1850+ let base = self.base?;
1851+ let here = Condition::at_only(at);
1852+ let changed = self
1853+ .changes
1854+ .iter()
1855+ .rev()
1856+ .find(|(chain, _)| Condition::at_only(chain).holds_at(&here))
1857+ .map(|&(_, axis)| axis);
1858+ Some(changed.unwrap_or(base))
1859+ }
1860+}
1861+
1862+/// `width` or `height`.
1863+#[derive(Clone, Copy, PartialEq, Eq)]
1864+enum Dim {
1865+ Width,
1866+ Height,
1867+}
1868+
1869+/// What a `width` or `height` says.
1870+#[derive(Clone, Copy, PartialEq, Eq)]
1871+enum Size {
1872+ Fill,
1873+ Shrink,
1874+ /// A size of its own (`width 300`)
1875+ Set,
1876+}
1877+
1878+impl Size {
1879+ fn of(value: &str) -> Size {
1880+ match value.trim() {
1881+ "fill" => Size::Fill,
1882+ "shrink" => Size::Shrink,
1883+ _ => Size::Set,
2553 }
1884 }
2554 }
1885 }
1886+}
1887+
1888+/// The declarations `fill` and `shrink` make in a parent laid out along
1889+/// `axis` (`None`: not a flex row or column). Along the parent's direction,
1890+/// `fill` takes the remaining space and `shrink` keeps the content's size;
1891+/// across it (or outside flex), `fill` is the full size and `shrink` fits
1892+/// the content.
1893+fn sizing(dim: Dim, size: Size, axis: Option<Axis>) -> &'static [(&'static str, &'static str)] {
1894+ match (dim, size, axis) {
1895+ (_, Size::Set, _) => &[],
1896+ (Dim::Width, Size::Fill, Some(Axis::Row)) => &[("flex", "1"), ("min-width", "0")],
1897+ (Dim::Width, Size::Fill, _) => &[("width", "100%")],
1898+ (Dim::Width, Size::Shrink, Some(Axis::Row)) => &[("flex-shrink", "0")],
1899+ (Dim::Width, Size::Shrink, _) => &[("width", "fit-content")],
1900+ (Dim::Height, Size::Fill, Some(Axis::Column)) => &[("flex", "1"), ("min-height", "0")],
1901+ (Dim::Height, Size::Fill, _) => &[("height", "100%")],
1902+ (Dim::Height, Size::Shrink, Some(Axis::Column)) => &[("flex-shrink", "0")],
1903+ (Dim::Height, Size::Shrink, _) => &[("height", "fit-content")],
1904+ }
1905+}
1906+
1907+/// Every property `fill` and `shrink` of `dim` can set, with its initial
1908+/// value (`flex` covers `flex-shrink`).
1909+fn sizing_properties(dim: Dim) -> [(&'static str, &'static str); 3] {
1910+ match dim {
1911+ Dim::Width => [
1912+ ("flex", "0 1 auto"),
1913+ ("min-width", "auto"),
1914+ ("width", "auto"),
1915+ ],
1916+ Dim::Height => [
1917+ ("flex", "0 1 auto"),
1918+ ("min-height", "auto"),
1919+ ("height", "auto"),
1920+ ],
1921+ }
1922+}
1923+
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+}
2555
1943
2556− for attr in attrs {
1944+impl Sizes {
2557− // Determine the effective key for this pass
1945+ /// Read the attributes under the conditions that hold wherever `at`
2558− let effective_key = if state_prefix.is_empty() {
1946+ /// does, a later condition over an earlier one.
2559− if is_prefixed_attr(&attr.key) {
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 {
2560 continue;
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+ _ => {}
2561 }
1979 }
2562− attr.key.as_str()
1980+ }
2563− } else {
1981+ sizes
2564− match attr.key.strip_prefix(state_prefix) {
1982+ }
2565− Some(k) => k,
1983+
2566− None => continue,
1984+ /// The element writes `property` itself: a layout word leaves it alone.
1985+ fn writes(&self, property: &str) -> bool {
1986+ match property {
1987+ "flex" => self.flex,
1988+ "flex-grow" => self.flex || self.flex_grow,
1989+ "flex-shrink" => self.flex || self.flex_shrink,
1990+ "flex-basis" => self.flex || self.flex_basis,
1991+ "min-width" => self.min_width,
1992+ "min-height" => self.min_height,
1993+ "width" => self.width == Some(Size::Set),
1994+ "height" => self.height == Some(Size::Set),
1995+ _ => false,
1996+ }
1997+ }
1998+
1999+ /// Write a layout word's `property: value`, unless the element writes
2000+ /// the property itself. When it writes some of `flex`'s longhands, a
2001+ /// `flex` is written as the others (`flex-shrink 0, width fill` still
2002+ /// grows).
2003+ fn push(&self, css: &mut String, property: &str, value: &str) {
2004+ if property == "flex" && !self.flex {
2005+ let longhands = match value {
2006+ "1" => [
2007+ ("flex-grow", "1"),
2008+ ("flex-shrink", "1"),
2009+ ("flex-basis", "0%"),
2010+ ],
2011+ // The reset, `0 1 auto`
2012+ _ => [
2013+ ("flex-grow", "0"),
2014+ ("flex-shrink", "1"),
2015+ ("flex-basis", "auto"),
2016+ ],
2017+ };
2018+ if longhands.iter().any(|&(p, _)| self.writes(p)) {
2019+ for (longhand, value) in longhands {
2020+ if !self.writes(longhand) {
2021+ push_css(css, longhand, value);
2022+ }
2023+ }
2024+ return;
2567 }
2025 }
2026+ }
2027+ if !self.writes(property) {
2028+ push_css(css, property, value);
2029+ }
2030+ }
2031+}
2032+
2033+/// The rule that gives an element's `fill` and `shrink`, as its attributes
2034+/// under the at-rules `at` set them, their meaning in a parent laid out
2035+/// along `axis`. Every other property a layout word could have set is put
2036+/// back to its initial value, so the rule is right whichever earlier rule
2037+/// it overrides. Properties the element writes itself are left alone.
2038+fn sizing_rule(attrs: &[Attribute], at: &[usize], axis: Axis) -> String {
2039+ let sizes = Sizes::of(attrs, &Condition::at_only(at));
2040+ let mut sets: Vec<(&str, &str)> = Vec::new();
2041+ let mut resets: Vec<(&str, &str)> = Vec::new();
2042+ for (dim, size, word) in [
2043+ (Dim::Width, sizes.width, sizes.width_word),
2044+ (Dim::Height, sizes.height, sizes.height_word),
2045+ ] {
2046+ if !word {
2047+ continue;
2048+ }
2049+ if let Some(size) = size {
2050+ sets.extend_from_slice(sizing(dim, size, Some(axis)));
2051+ }
2052+ resets.extend_from_slice(&sizing_properties(dim));
2053+ }
2054+ let mut css = String::new();
2055+ let mut written: Vec<&str> = Vec::new();
2056+ for &(property, value) in &resets {
2057+ if sets.iter().any(|&(p, _)| p == property) || written.contains(&property) {
2058+ continue;
2059+ }
2060+ written.push(property);
2061+ sizes.push(&mut css, property, value);
2062+ }
2063+ for &(property, value) in &sets {
2064+ sizes.push(&mut css, property, value);
2065+ }
2066+ css
2067+}
2068+
2069+/// The rules an element's `fill` and `shrink` need under each chain of
2070+/// at-rules where the direction of `flow` (its parent's) changes: `(at,
2071+/// body)`.
2072+fn sizing_changes(attrs: &[Attribute], flow: &Flow) -> Vec<(Vec<usize>, String)> {
2073+ flow.changes
2074+ .iter()
2075+ .map(|(at, axis)| (at.clone(), sizing_rule(attrs, at, *axis)))
2076+ .filter(|(_, body)| !body.is_empty())
2077+ .collect()
2078+}
2079+
2080+/// An element's generated class, with the rules keyed on its parent's
2081+/// class that its `fill` and `shrink` need where the parent's direction
2082+/// changes under a prefix (see [`Flow`]): `:where(.a)>.b`, one class of
2083+/// specificity like any class rule.
2084+fn element_class(elem: &Element, site: &Site, styles: &mut StyleCollector) -> Option<String> {
2085+ let as_child = match site.parent.class {
2086+ Some(_) => sizing_changes(&elem.attrs, site.parent),
2087+ None => Vec::new(),
2088+ };
2089+ // Two elements with the same CSS but different rules keyed on them
2090+ // need two classes
2091+ let mut distinct = String::new();
2092+ for (at, body) in &as_child {
2093+ distinct.push_str(&format!("<{:?}{}", at, body));
2094+ }
2095+ let class = compute_class(&elem.attrs, site, styles, distinct)?;
2096+ if let Some(parent) = &site.parent.class {
2097+ for (at, body) in as_child {
2098+ styles.add_keyed(at, format!(":where(.{})>.{}", parent, class), body);
2099+ }
2100+ }
2101+ Some(class)
2102+}
2103+
2104+/// The `display` of a layout (nothing for text, native and void elements,
2105+/// which keep HTML's own).
2106+fn layout_css(layout: Layout, inline: bool) -> &'static str {
2107+ match (layout, inline) {
2108+ (Layout::Column, false) => "display:flex;flex-direction:column;",
2109+ (Layout::Column, true) => "display:inline-flex;flex-direction:column;",
2110+ (Layout::Row, false) => "display:flex;flex-direction:row;",
2111+ (Layout::Row, true) => "display:inline-flex;flex-direction:row;",
2112+ (Layout::Grid, false) => "display:grid;",
2113+ (Layout::Grid, true) => "display:inline-grid;",
2114+ _ => "",
2115+ }
2116+}
2117+
2118+/// The declarations of the attributes under `condition` (the defaults of
2119+/// the element's kind are [`default_css`]'s).
2120+fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> String {
2121+ let mut css = String::new();
2122+ // The auto margins of `center-x`, `align-left`, ..., written at the end
2123+ let mut aligned = String::new();
2124+ let kind = site.kind;
2125+ // Under `children:` the styles go on the children, whose parent is this
2126+ // element: htmlang's words that place an element in its parent are an
2127+ // error there (the parser reports them), and left out
2128+ let on_children = condition.on_children();
2129+ let inside = condition.inside().is_some();
2130+ // The direction `fill` and `shrink` compile against
2131+ let axis = site.parent.at(&condition.at);
2132+
2133+ // htmlang's words for laying out children (`spacing`, `wrap`,
2134+ // `grid-cols`) mean nothing on an element that doesn't lay out its
2135+ // children, which the parser reports; they are left out. Under
2136+ // `children:` they go on the children, whose layout isn't known here.
2137+ let lays_out_children = kind.layout().is_container() || on_children;
2138+ fn under<'k>(key: &'k str, condition: &Condition) -> Option<&'k str> {
2139+ let (c, name) = Condition::of(key);
2140+ (c == *condition).then_some(name)
2141+ }
2142+
2143+ for (index, attr) in attrs.iter().enumerate() {
2144+ if attr.html {
2145+ continue;
2146+ }
2147+ let Some(effective_key) = under(&attr.key, condition) else {
2148+ continue;
2568 };
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+ }
2569
2159
2570 let val = attr.value.as_deref();
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+ }
2571
2166
2572 match effective_key {
2167 match effective_key {
2573− // Layout
2168+ // htmlang's own words: they lay out children or place the
2574− "spacing" | "gap" => {
2169+ // element in its parent, and mean more than one CSS property
2575− if let Some(v) = val {
2170+ "spacing" if !lays_out_children => {}
2576− push_css(&mut css, "gap", &css_px(v));
2171+ "spacing" => {
2577− }
2578− }
2579− "padding" => {
2580− if let Some(v) = val {
2581− push_css(&mut css, "padding", &css_px_multi(v));
2582− }
2583− }
2584− "padding-x" => {
2585− if let Some(v) = val {
2586− // Logical property covers both inline sides in one
2587− // declaration; for symmetric values this is visually
2588− // identical to padding-left/right in LTR and RTL.
2589− push_css(&mut css, "padding-inline", &css_px(v));
2590− }
2591− }
2592− "padding-y" => {
2593− if let Some(v) = val {
2594− push_css(&mut css, "padding-block", &css_px(v));
2595− }
2596− }
2597− "padding-top" => {
2598− if let Some(v) = val {
2599− push_css(&mut css, "padding-top", &css_px(v));
2600− }
2601− }
2602− "padding-bottom" => {
2603− if let Some(v) = val {
2604− push_css(&mut css, "padding-bottom", &css_px(v));
2605− }
2606− }
2607− "padding-left" => {
2608− if let Some(v) = val {
2609− push_css(&mut css, "padding-left", &css_px(v));
2610− }
2611− }
2612− "padding-right" => {
2613 if let Some(v) = val {
2172 if let Some(v) = val {
2614− push_css(&mut css, "padding-right", &css_px(v));
2173+ push_css(&mut css, "gap", &with_px("gap", v));
2615 }
2174 }
2616 }
2175 }
2617
2176
2618− // Sizing
2177+ // Sizing: `fill` and `shrink` compile against the parent's
2619− "width" => {
2178+ // direction. Only the last `width` (`height`) counts, as for
2620− if let Some(v) = val {
2179+ // any style written twice.
2621− match v {
2180+ "width" | "height" if val.is_some_and(|v| Size::of(v) != Size::Set) => {
2622− "fill" => match parent_kind {
2181+ let later = attrs[index + 1..].iter().any(|a| {
2623− Some(ElementKind::Row) => {
2182+ !a.html
2624− push_css(&mut css, "flex", "1");
2183+ && under(&a.key, condition) == Some(effective_key)
2625− push_css(&mut css, "min-width", "0");
2184+ && a.value.as_deref().is_some_and(|v| !v.trim().is_empty())
2626− }
2185+ });
2627− _ => push_css(&mut css, "width", "100%"),
2186+ if later {
2628− },
2187+ continue;
2629− "shrink" => push_css(&mut css, "flex-shrink", "0"),
2630− _ => push_css(&mut css, "width", &css_px(v)),
2631− }
2632 }
2188 }
2633− }
2189+ let dim = if effective_key == "width" {
2634− "height" => {
2190+ Dim::Width
2635− if let Some(v) = val {
2191+ } else {
2636− match v {
2192+ Dim::Height
2637− "fill" => match parent_kind {
2193+ };
2638− Some(ElementKind::Column) => {
2194+ let size = Size::of(val.unwrap_or_default());
2639− push_css(&mut css, "flex", "1");
2195+ // What the element writes itself, which a layout word
2640− push_css(&mut css, "min-height", "0");
2196+ // leaves alone
2641− }
2197+ let sizes = Sizes::of(attrs, condition);
2642− _ => push_css(&mut css, "height", "100%"),
2198+ for &(property, value) in sizing(dim, size, axis) {
2643− },
2199+ sizes.push(&mut css, property, value);
2644− "shrink" => push_css(&mut css, "flex-shrink", "0"),
2200+ }
2645− _ => push_css(&mut css, "height", &css_px(v)),
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);
2646 }
2236 }
2647 }
2237 }
2648− }
2649− "min-width" => {
2650− if let Some(v) = val {
2651− push_css(&mut css, "min-width", &css_px(v));
2652− }
2653− }
2654− "max-width" => {
2655− if let Some(v) = val {
2656− push_css(&mut css, "max-width", &css_px(v));
2657− }
2658− }
2659− "min-height" => {
2660− if let Some(v) = val {
2661− push_css(&mut css, "min-height", &css_px(v));
2662− }
2663− }
2664− "max-height" => {
2665 if let Some(v) = val {
2238 if let Some(v) = val {
2666− push_css(&mut css, "max-height", &css_px(v));
2239+ push_css(&mut css, effective_key, &with_px(effective_key, v));
2667 }
2240 }
2668 }
2241 }
2669
2242
2670− // Alignment
2243+ // Alignment: auto margins, which work along either direction.
2671− "center-x" => match parent_kind {
2244+ // They are written last, so a `margin` on the same element
2672− Some(ElementKind::Column) | Some(ElementKind::El) => {
2245+ // keeps its other sides (`center-x, margin 20`)
2673− push_css(&mut css, "align-self", "center");
2246+ "center-x" => {
2674− }
2247+ push_css(&mut aligned, "margin-left", "auto");
2675− _ => {
2248+ push_css(&mut aligned, "margin-right", "auto");
2676− push_css(&mut css, "margin-left", "auto");
2677− push_css(&mut css, "margin-right", "auto");
2678− }
2679− },
2680− "center-y" => match parent_kind {
2681− Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
2682− _ => {
2683− push_css(&mut css, "margin-top", "auto");
2684− push_css(&mut css, "margin-bottom", "auto");
2685− }
2686− },
2687− "align-left" => match parent_kind {
2688− Some(ElementKind::Column) | Some(ElementKind::El) => {
2689− push_css(&mut css, "align-self", "flex-start");
2690− }
2691− _ => push_css(&mut css, "margin-right", "auto"),
2692− },
2693− "align-right" => match parent_kind {
2694− Some(ElementKind::Column) | Some(ElementKind::El) => {
2695− push_css(&mut css, "align-self", "flex-end");
2696− }
2697− _ => push_css(&mut css, "margin-left", "auto"),
2698− },
2699− "align-top" => match parent_kind {
2700− Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
2701− _ => push_css(&mut css, "margin-bottom", "auto"),
2702− },
2703− "align-bottom" => match parent_kind {
2704− Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
2705− _ => push_css(&mut css, "margin-top", "auto"),
2706− },
2707−
2708− // Style
2709− "background" => {
2710− if let Some(v) = val {
2711− push_css(&mut css, "background", v);
2712− }
2713 }
2249 }
2714− "color" => {
2250+ "center-y" => {
2715− if let Some(v) = val {
2251+ push_css(&mut aligned, "margin-top", "auto");
2716− push_css(&mut css, "color", v);
2252+ push_css(&mut aligned, "margin-bottom", "auto");
2717− }
2718 }
2253 }
2719− "border" => {
2254+ "align-left" => push_css(&mut aligned, "margin-right", "auto"),
2255+ "align-right" => push_css(&mut aligned, "margin-left", "auto"),
2256+ "align-top" => push_css(&mut aligned, "margin-bottom", "auto"),
2257+ "align-bottom" => push_css(&mut aligned, "margin-top", "auto"),
2258+
2259+ "wrap" if lays_out_children => push_css(&mut css, "flex-wrap", "wrap"),
2260+ "wrap" => {}
2261+
2262+ // Grid
2263+ "grid-cols" | "grid-rows" if !lays_out_children => {}
2264+ "grid-cols" | "grid-rows" => {
2720 if let Some(v) = val {
2265 if let Some(v) = val {
2721− let parts: Vec<&str> = v.splitn(2, ' ').collect();
2266+ let property = if effective_key == "grid-cols" {
2722− if parts.len() == 2 {
2267+ "grid-template-columns"
2723− push_css(
2724− &mut css,
2725− "border",
2726− &format!("{} solid {}", css_px(parts[0]), parts[1]),
2727− );
2728 } else {
2268 } else {
2729− push_css(
2269+ "grid-template-rows"
2730− &mut css,
2270+ };
2731− "border",
2271+ match v.parse::<u32>() {
2732− &format!("{} solid currentColor", css_px(parts[0])),
2272+ Ok(n) => push_css(&mut css, property, &format!("repeat({},1fr)", n)),
2733− );
2273+ Err(_) => push_css(&mut css, property, &with_px(property, v)),
2734 }
2274 }
2735 }
2275 }
2736 }
2276 }
2737− "border-top" | "border-bottom" | "border-left" | "border-right" => {
2277+ "col-span" | "row-span" => {
2738 if let Some(v) = val {
2278 if let Some(v) = val {
2739− let parts: Vec<&str> = v.splitn(2, ' ').collect();
2279+ let property = if effective_key == "col-span" {
2740− if parts.len() == 2 {
2280+ "grid-column"
2741− push_css(
2742− &mut css,
2743− effective_key,
2744− &format!("{} solid {}", css_px(parts[0]), parts[1]),
2745− );
2746 } else {
2281 } else {
2747− push_css(
2282+ "grid-row"
2748− &mut css,
2749− effective_key,
2750− &format!("{} solid currentColor", css_px(parts[0])),
2751− );
2752− }
2753− }
2754− }
2755− "rounded" => {
2756− if let Some(v) = val {
2757− push_css(&mut css, "border-radius", &css_px(v));
2758− }
2759− }
2760− "bold" => push_css(&mut css, "font-weight", "bold"),
2761− "italic" => push_css(&mut css, "font-style", "italic"),
2762− "underline" => push_css(&mut css, "text-decoration", "underline"),
2763− "size" => {
2764− if let Some(v) = val {
2765− push_css(&mut css, "font-size", &css_px(v));
2766− }
2767− }
2768− "font" => {
2769− if let Some(v) = val {
2770− // `font "Inter, sans-serif"` quotes a whole font stack so
2771− // its commas don't split the attribute list.
2772− let v = match v.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
2773− Some(stack) if stack.contains(',') => stack,
2774− _ => v,
2775 };
2283 };
2776− push_css(&mut css, "font-family", v);
2284+ push_css(&mut css, property, &format!("span {}", v));
2777− }
2778− }
2779− "transition" => {
2780− if let Some(v) = val {
2781− push_css(&mut css, "transition", v);
2782− }
2783− }
2784− "cursor" => {
2785− if let Some(v) = val {
2786− push_css(&mut css, "cursor", v);
2787− }
2788− }
2789− "opacity" => {
2790− if let Some(v) = val {
2791− push_css(&mut css, "opacity", v);
2792 }
2285 }
2793 }
2286 }
2794
2287
2795− // Typography
2288+ // CSS's `line-clamp` has no support yet without its
2796− "text-align" => {
2289+ // `-webkit-` fallback, which needs a box to clamp
2797− if let Some(v) = val {
2290+ "line-clamp" => {
2798− push_css(&mut css, "text-align", v);
2799− }
2800− }
2801− "line-height" => {
2802− if let Some(v) = val {
2803− push_css(&mut css, "line-height", &css_line_height(v));
2804− }
2805− }
2806− "letter-spacing" => {
2807− if let Some(v) = val {
2808− push_css(&mut css, "letter-spacing", &css_px(v));
2809− }
2810− }
2811− "text-transform" => {
2812− if let Some(v) = val {
2813− push_css(&mut css, "text-transform", v);
2814− }
2815− }
2816− "white-space" => {
2817 if let Some(v) = val {
2291 if let Some(v) = val {
2818− push_css(&mut css, "white-space", v);
2292+ push_css(&mut css, "display", "-webkit-box");
2293+ push_css(&mut css, "-webkit-line-clamp", v);
2294+ push_css(&mut css, "-webkit-box-orient", "vertical");
2295+ push_css(&mut css, "overflow", "hidden");
2819 }
2296 }
2820 }
2297 }
2821
2298
2822− // Overflow & positioning
2299+ // Every other name is a CSS property (standard, custom,
2823− "overflow" => {
2300+ // vendor-prefixed, or one htmlang doesn't know, which the
2824− if let Some(v) = val {
2301+ // parser warned about), written under its own name with the
2825− push_css(&mut css, "overflow", v);
2302+ // value as written, except for `px` after a bare number where
2826− }
2303+ // the property takes a length
2827− }
2304+ key if crate::vocab::is_property_name(key) => {
2828− "position" => {
2829 if let Some(v) = val {
2305 if let Some(v) = val {
2830− push_css(&mut css, "position", v);
2306+ push_css(&mut css, key, &with_px(key, v));
2831 }
2307 }
2832 }
2308 }
2833− "top" => {
2834− if let Some(v) = val {
2835− push_css(&mut css, "top", &css_px(v));
2836− }
2837− }
2838− "right" => {
2839− if let Some(v) = val {
2840− push_css(&mut css, "right", &css_px(v));
2841− }
2842− }
2843− "bottom" => {
2844− if let Some(v) = val {
2845− push_css(&mut css, "bottom", &css_px(v));
2846− }
2847− }
2848− "left" => {
2849− if let Some(v) = val {
2850− push_css(&mut css, "left", &css_px(v));
2851− }
2852− }
2853− "z-index" => {
2854− if let Some(v) = val {
2855− push_css(&mut css, "z-index", v);
2856− }
2857− }
2858−
2859− // Display & visibility
2860− "display" => {
2861− if let Some(v) = val {
2862− push_css(&mut css, "display", v);
2863− }
2864− }
2865− "visibility" => {
2866− if let Some(v) = val {
2867− push_css(&mut css, "visibility", v);
2868− }
2869− }
2870− "hidden" => push_css(&mut css, "display", "none"),
2871−
2872− // Transform & filters
2873− "transform" => {
2874− if let Some(v) = val {
2875− push_css(&mut css, "transform", v);
2876− }
2877− }
2878− "backdrop-filter" => {
2879− if let Some(v) = val {
2880− push_css(&mut css, "backdrop-filter", v);
2881− }
2882− }
2883−
2884− // Effects
2885− "shadow" => {
2886− if let Some(v) = val {
2887− push_css(&mut css, "box-shadow", v);
2888− }
2889− }
2890−
2891− // Flow
2892− "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
2893− "gap-x" => {
2894− if let Some(v) = val {
2895− push_css(&mut css, "column-gap", &css_px(v));
2896− }
2897− }
2898− "gap-y" => {
2899− if let Some(v) = val {
2900− push_css(&mut css, "row-gap", &css_px(v));
2901− }
2902− }
2903−
2904− // Grid
2905− "grid" => {
2906− push_css(&mut css, "display", "grid");
2907− }
2908− "grid-cols" => {
2909− if let Some(v) = val {
2910− if let Ok(n) = v.parse::<u32>() {
2911− push_css(
2912− &mut css,
2913− "grid-template-columns",
2914− &format!("repeat({},1fr)", n),
2915− );
2916− } else {
2917− push_css(&mut css, "grid-template-columns", v);
2918− }
2919− }
2920− }
2921− "grid-rows" => {
2922− if let Some(v) = val {
2923− if let Ok(n) = v.parse::<u32>() {
2924− push_css(
2925− &mut css,
2926− "grid-template-rows",
2927− &format!("repeat({},1fr)", n),
2928− );
2929− } else {
2930− push_css(&mut css, "grid-template-rows", v);
2931− }
2932− }
2933− }
2934− "col-span" => {
2935− if let Some(v) = val {
2936− push_css(&mut css, "grid-column", &format!("span {}", v));
2937− }
2938− }
2939− "row-span" => {
2940− if let Some(v) = val {
2941− push_css(&mut css, "grid-row", &format!("span {}", v));
2942− }
2943− }
2944−
2945− // Animation
2946− "animation" => {
2947− if let Some(v) = val {
2948− push_css(&mut css, "animation", v);
2949− }
2950− }
2951−
2952− // Aspect ratio
2953− "aspect-ratio" => {
2954− if let Some(v) = val {
2955− push_css(&mut css, "aspect-ratio", v);
2956− }
2957− }
2958−
2959− // CSS containment for rendering performance
2960− "contain" => {
2961− if let Some(v) = val {
2962− push_css(&mut css, "contain", v);
2963− } else {
2964− push_css(&mut css, "contain", "layout style paint");
2965− }
2966− }
2967− "content-visibility" => {
2968− if let Some(v) = val {
2969− push_css(&mut css, "content-visibility", v);
2970− } else {
2971− push_css(&mut css, "content-visibility", "auto");
2972− }
2973− }
2974−
2975− // Outline (like border but doesn't affect layout)
2976− "outline" => {
2977− if let Some(v) = val {
2978− let parts: Vec<&str> = v.splitn(2, ' ').collect();
2979− if parts.len() == 2 {
2980− push_css(
2981− &mut css,
2982− "outline",
2983− &format!("{} solid {}", css_px(parts[0]), parts[1]),
2984− );
2985− } else {
2986− push_css(
2987− &mut css,
2988− "outline",
2989− &format!("{} solid currentColor", css_px(parts[0])),
2990− );
2991− }
2992− }
2993− }
2994−
2995− // Logical properties (i18n-aware)
2996− "padding-inline" => {
2997− if let Some(v) = val {
2998− push_css(&mut css, "padding-inline", &css_px_multi(v));
2999− }
3000− }
3001− "padding-block" => {
3002− if let Some(v) = val {
3003− push_css(&mut css, "padding-block", &css_px_multi(v));
3004− }
3005− }
3006− "margin-inline" => {
3007− if let Some(v) = val {
3008− push_css(&mut css, "margin-inline", &css_px_multi(v));
3009− }
3010− }
3011− "margin-block" => {
3012− if let Some(v) = val {
3013− push_css(&mut css, "margin-block", &css_px_multi(v));
3014− }
3015− }
3016−
3017− // Logical property start/end variants
3018− "padding-inline-start"
3019− | "padding-inline-end"
3020− | "padding-block-start"
3021− | "padding-block-end"
3022− | "margin-inline-start"
3023− | "margin-inline-end"
3024− | "margin-block-start"
3025− | "margin-block-end" => {
3026− if let Some(v) = val {
3027− push_css(&mut css, effective_key, &css_px(v));
3028− }
3029− }
3030−
3031− // Logical inset
3032− "inset-inline" | "inset-block" => {
3033− if let Some(v) = val {
3034− push_css(&mut css, effective_key, &css_px_multi(v));
3035− }
3036− }
3037− "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
3038− if let Some(v) = val {
3039− push_css(&mut css, effective_key, &css_px(v));
3040− }
3041− }
3042−
3043− // Logical border
3044− "border-inline"
3045− | "border-block"
3046− | "border-inline-start"
3047− | "border-inline-end"
3048− | "border-block-start"
3049− | "border-block-end" => {
3050− if let Some(v) = val {
3051− push_css(&mut css, effective_key, v);
3052− }
3053− }
3054−
3055− // Logical border-radius
3056− "border-start-start-radius"
3057− | "border-start-end-radius"
3058− | "border-end-start-radius"
3059− | "border-end-end-radius" => {
3060− if let Some(v) = val {
3061− push_css(&mut css, effective_key, &css_px(v));
3062− }
3063− }
3064−
3065− // Logical scroll margins & padding
3066− "scroll-margin-inline"
3067− | "scroll-margin-block"
3068− | "scroll-padding-inline"
3069− | "scroll-padding-block" => {
3070− if let Some(v) = val {
3071− push_css(&mut css, effective_key, &css_px_multi(v));
3072− }
3073− }
3074−
3075− // Logical sizing
3076− "inline-size" | "block-size" | "min-inline-size" | "max-inline-size"
3077− | "min-block-size" | "max-block-size" => {
3078− if let Some(v) = val {
3079− push_css(&mut css, effective_key, &css_px(v));
3080− }
3081− }
3082−
3083− // Scroll snap
3084− "scroll-snap-type" => {
3085− if let Some(v) = val {
3086− push_css(&mut css, "scroll-snap-type", v);
3087− }
3088− }
3089− "scroll-snap-align" => {
3090− if let Some(v) = val {
3091− push_css(&mut css, "scroll-snap-align", v);
3092− }
3093− }
3094−
3095− // Margin
3096− "margin" => {
3097− if let Some(v) = val {
3098− push_css(&mut css, "margin", &css_px_multi(v));
3099− }
3100− }
3101− "margin-x" => {
3102− if let Some(v) = val {
3103− push_css(&mut css, "margin-inline", &css_px(v));
3104− }
3105− }
3106− "margin-y" => {
3107− if let Some(v) = val {
3108− push_css(&mut css, "margin-block", &css_px(v));
3109− }
3110− }
3111−
3112− // Filter
3113− "filter" => {
3114− if let Some(v) = val {
3115− push_css(&mut css, "filter", v);
3116− }
3117− }
3118−
3119− // Object fit/position (for images)
3120− "object-fit" => {
3121− if let Some(v) = val {
3122− push_css(&mut css, "object-fit", v);
3123− }
3124− }
3125− "object-position" => {
3126− if let Some(v) = val {
3127− push_css(&mut css, "object-position", v);
3128− }
3129− }
3130−
3131− // Text shadow
3132− "text-shadow" => {
3133− if let Some(v) = val {
3134− push_css(&mut css, "text-shadow", v);
3135− }
3136− }
3137−
3138− // Text overflow
3139− "text-overflow" => {
3140− if let Some(v) = val {
3141− push_css(&mut css, "text-overflow", v);
3142− }
3143− }
3144−
3145− // Interaction
3146− "pointer-events" => {
3147− if let Some(v) = val {
3148− push_css(&mut css, "pointer-events", v);
3149− }
3150− }
3151− "user-select" => {
3152− if let Some(v) = val {
3153− push_css(&mut css, "user-select", v);
3154− }
3155− }
3156−
3157− // Flexbox/grid alignment
3158− "justify-content" => {
3159− if let Some(v) = val {
3160− push_css(&mut css, "justify-content", v);
3161− }
3162− }
3163− "align-items" => {
3164− if let Some(v) = val {
3165− push_css(&mut css, "align-items", v);
3166− }
3167− }
3168−
3169− // Flex item order
3170− "order" => {
3171− if let Some(v) = val {
3172− push_css(&mut css, "order", v);
3173− }
3174− }
3175−
3176− // Background extras
3177− "background-size" => {
3178− if let Some(v) = val {
3179− push_css(&mut css, "background-size", v);
3180− }
3181− }
3182− "background-position" => {
3183− if let Some(v) = val {
3184− push_css(&mut css, "background-position", v);
3185− }
3186− }
3187− "background-repeat" => {
3188− if let Some(v) = val {
3189− push_css(&mut css, "background-repeat", v);
3190− }
3191− }
3192−
3193− // Text wrapping
3194− "word-break" => {
3195− if let Some(v) = val {
3196− push_css(&mut css, "word-break", v);
3197− }
3198− }
3199− "overflow-wrap" => {
3200− if let Some(v) = val {
3201− push_css(&mut css, "overflow-wrap", v);
3202− }
3203− }
3204−
3205− // Container queries
3206− "container" => {
3207− push_css(&mut css, "container-type", "inline-size");
3208− }
3209− "container-name" => {
3210− if let Some(v) = val {
3211− push_css(&mut css, "container-name", v);
3212− }
3213− }
3214− "container-type" => {
3215− if let Some(v) = val {
3216− push_css(&mut css, "container-type", v);
3217− }
3218− }
3219−
3220− // Overflow axis
3221− "overflow-x" => {
3222− if let Some(v) = val {
3223− push_css(&mut css, "overflow-x", v);
3224− }
3225− }
3226− "overflow-y" => {
3227− if let Some(v) = val {
3228− push_css(&mut css, "overflow-y", v);
3229− }
3230− }
3231−
3232− // Inset (shorthand for top/right/bottom/left)
3233− "inset" => {
3234− if let Some(v) = val {
3235− push_css(&mut css, "inset", &css_px(v));
3236− }
3237− }
3238−
3239− // Accent & caret colors
3240− "accent-color" => {
3241− if let Some(v) = val {
3242− push_css(&mut css, "accent-color", v);
3243− }
3244− }
3245− "caret-color" => {
3246− if let Some(v) = val {
3247− push_css(&mut css, "caret-color", v);
3248− }
3249− }
3250−
3251− // Color scheme & appearance
3252− "color-scheme" => {
3253− if let Some(v) = val {
3254− push_css(&mut css, "color-scheme", v);
3255− }
3256− }
3257− "appearance" => {
3258− if let Some(v) = val {
3259− push_css(&mut css, "appearance", v);
3260− }
3261− }
3262−
3263− // List style
3264− "list-style" => {
3265− if let Some(v) = val {
3266− push_css(&mut css, "list-style", v);
3267− }
3268− }
3269−
3270− // Table
3271− "border-collapse" => {
3272− if let Some(v) = val {
3273− push_css(&mut css, "border-collapse", v);
3274− }
3275− }
3276− "border-spacing" => {
3277− if let Some(v) = val {
3278− push_css(&mut css, "border-spacing", &css_px(v));
3279− }
3280− }
3281−
3282− // Text decoration
3283− "text-decoration" => {
3284− if let Some(v) = val {
3285− push_css(&mut css, "text-decoration", v);
3286− }
3287− }
3288− "text-decoration-color" => {
3289− if let Some(v) = val {
3290− push_css(&mut css, "text-decoration-color", v);
3291− }
3292− }
3293− "text-decoration-thickness" => {
3294− if let Some(v) = val {
3295− push_css(&mut css, "text-decoration-thickness", &css_px(v));
3296− }
3297− }
3298− "text-decoration-style" => {
3299− if let Some(v) = val {
3300− push_css(&mut css, "text-decoration-style", v);
3301− }
3302− }
3303− "text-underline-offset" => {
3304− if let Some(v) = val {
3305− push_css(&mut css, "text-underline-offset", &css_px(v));
3306− }
3307− }
3308−
3309− // Multi-column
3310− "column-width" => {
3311− if let Some(v) = val {
3312− push_css(&mut css, "column-width", &css_px(v));
3313− }
3314− }
3315− "column-rule" => {
3316− if let Some(v) = val {
3317− push_css(&mut css, "column-rule", v);
3318− }
3319− }
3320−
3321− // Place items/self
3322− "place-items" => {
3323− if let Some(v) = val {
3324− push_css(&mut css, "place-items", v);
3325− }
3326− }
3327− "place-self" => {
3328− if let Some(v) = val {
3329− push_css(&mut css, "place-self", v);
3330− }
3331− }
3332−
3333− // Scroll behavior
3334− "scroll-behavior" => {
3335− if let Some(v) = val {
3336− push_css(&mut css, "scroll-behavior", v);
3337− }
3338− }
3339−
3340− // Resize
3341− "resize" => {
3342− if let Some(v) = val {
3343− push_css(&mut css, "resize", v);
3344− }
3345− }
3346−
3347− // New CSS properties
3348− "clip-path" => {
3349− if let Some(v) = val {
3350− push_css(&mut css, "clip-path", v);
3351− }
3352− }
3353− "mix-blend-mode" => {
3354− if let Some(v) = val {
3355− push_css(&mut css, "mix-blend-mode", v);
3356− }
3357− }
3358− "background-blend-mode" => {
3359− if let Some(v) = val {
3360− push_css(&mut css, "background-blend-mode", v);
3361− }
3362− }
3363− "writing-mode" => {
3364− if let Some(v) = val {
3365− push_css(&mut css, "writing-mode", v);
3366− }
3367− }
3368− "column-count" => {
3369− if let Some(v) = val {
3370− push_css(&mut css, "column-count", v);
3371− }
3372− }
3373− "column-gap" => {
3374− if let Some(v) = val {
3375− push_css(&mut css, "column-gap", &css_px(v));
3376− }
3377− }
3378− "text-indent" => {
3379− if let Some(v) = val {
3380− push_css(&mut css, "text-indent", &css_px(v));
3381− }
3382− }
3383− "hyphens" => {
3384− if let Some(v) = val {
3385− push_css(&mut css, "hyphens", v);
3386− }
3387− }
3388− "flex-grow" => {
3389− if let Some(v) = val {
3390− push_css(&mut css, "flex-grow", v);
3391− }
3392− }
3393− "flex-shrink" => {
3394− if let Some(v) = val {
3395− push_css(&mut css, "flex-shrink", v);
3396− }
3397− }
3398− "flex-basis" => {
3399− if let Some(v) = val {
3400− push_css(&mut css, "flex-basis", &css_px(v));
3401− }
3402− }
3403− "isolation" => {
3404− if let Some(v) = val {
3405− push_css(&mut css, "isolation", v);
3406− }
3407− }
3408− "place-content" => {
3409− if let Some(v) = val {
3410− push_css(&mut css, "place-content", v);
3411− }
3412− }
3413− "background-image" => {
3414− if let Some(v) = val {
3415− push_css(&mut css, "background-image", v);
3416− }
3417− }
3418− "font-weight" => {
3419− if let Some(v) = val {
3420− push_css(&mut css, "font-weight", v);
3421− }
3422− }
3423− "font-style" => {
3424− if let Some(v) = val {
3425− push_css(&mut css, "font-style", v);
3426− }
3427− }
3428− "text-wrap" => {
3429− if let Some(v) = val {
3430− push_css(&mut css, "text-wrap", v);
3431− }
3432− }
3433− "will-change" => {
3434− if let Some(v) = val {
3435− push_css(&mut css, "will-change", v);
3436− }
3437− }
3438− "touch-action" => {
3439− if let Some(v) = val {
3440− push_css(&mut css, "touch-action", v);
3441− }
3442− }
3443− "vertical-align" => {
3444− if let Some(v) = val {
3445− push_css(&mut css, "vertical-align", v);
3446− }
3447− }
3448− "scroll-margin" => {
3449− if let Some(v) = val {
3450− push_css(&mut css, "scroll-margin", &css_px(v));
3451− }
3452− }
3453− "scroll-margin-top"
3454− | "scroll-margin-bottom"
3455− | "scroll-margin-left"
3456− | "scroll-margin-right" => {
3457− if let Some(v) = val {
3458− push_css(&mut css, effective_key, &css_px(v));
3459− }
3460− }
3461− "scroll-padding" => {
3462− if let Some(v) = val {
3463− push_css(&mut css, "scroll-padding", &css_px(v));
3464− }
3465− }
3466− "scroll-padding-top"
3467− | "scroll-padding-bottom"
3468− | "scroll-padding-left"
3469− | "scroll-padding-right" => {
3470− if let Some(v) = val {
3471− push_css(&mut css, effective_key, &css_px(v));
3472− }
3473− }
3474− "direction" => {
3475− if let Some(v) = val {
3476− push_css(&mut css, "direction", v);
3477− }
3478− }
3479−
3480− "content" => {
3481− if let Some(v) = val {
3482− // Wrap in quotes if not already quoted and not a CSS keyword
3483− if v.starts_with('"')
3484− || v.starts_with('\'')
3485− || v == "none"
3486− || v == "normal"
3487− || v.starts_with("attr(")
3488− || v.starts_with("counter(")
3489− {
3490− push_css(&mut css, "content", v);
3491− } else {
3492− push_css(&mut css, "content", &format!("\"{}\"", v));
3493− }
3494− }
3495− }
3496−
3497− // --- CSS Shorthands ---
3498− "truncate" => {
3499− push_css(&mut css, "overflow", "hidden");
3500− push_css(&mut css, "text-overflow", "ellipsis");
3501− push_css(&mut css, "white-space", "nowrap");
3502− }
3503− "line-clamp" => {
3504− if let Some(v) = val {
3505− push_css(&mut css, "display", "-webkit-box");
3506− push_css(&mut css, "-webkit-line-clamp", v);
3507− push_css(&mut css, "-webkit-box-orient", "vertical");
3508− push_css(&mut css, "overflow", "hidden");
3509− }
3510− }
3511− "blur" => {
3512− if let Some(v) = val {
3513− push_css(&mut css, "filter", &format!("blur({})", css_px(v)));
3514− }
3515− }
3516− "backdrop-blur" => {
3517− if let Some(v) = val {
3518− push_css(&mut css, "backdrop-filter", &format!("blur({})", css_px(v)));
3519− }
3520− }
3521− "no-scrollbar" => {
3522− push_css(&mut css, "scrollbar-width", "none");
3523− push_css(&mut css, "-ms-overflow-style", "none");
3524− }
3525− "skeleton" => {
3526− push_css(
3527− &mut css,
3528− "background",
3529− "linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%)",
3530− );
3531− push_css(&mut css, "background-size", "200% 100%");
3532− push_css(
3533− &mut css,
3534− "animation",
3535− "hl-skeleton 1.5s ease-in-out infinite",
3536− );
3537− }
3538− "gradient" => {
3539− if let Some(v) = val {
3540− // Parse: "from to [angle]" or "color1 color2 [angle]"
3541− let parts: Vec<&str> = v.split_whitespace().collect();
3542− let bg = if parts.len() >= 3
3543− && (parts[2].ends_with("deg")
3544− || parts[2].ends_with("turn")
3545− || parts[2].ends_with("rad"))
3546− {
3547− format!("linear-gradient({},{},{})", parts[2], parts[0], parts[1])
3548− } else if parts.len() >= 2 {
3549− format!("linear-gradient({},{})", parts[0], parts[1])
3550− } else {
3551− format!("linear-gradient({},transparent)", parts[0])
3552− };
3553− push_css(&mut css, "background", &bg);
3554− }
3555− }
3556−
3557− // Grid areas
3558− "grid-template-areas" => {
3559− if let Some(v) = val {
3560− push_css(&mut css, "grid-template-areas", v);
3561− }
3562− }
3563− "grid-area" => {
3564− if let Some(v) = val {
3565− push_css(&mut css, "grid-area", v);
3566− }
3567− }
3568−
3569− // View transitions
3570− "view-transition-name" => {
3571− if let Some(v) = val {
3572− push_css(&mut css, "view-transition-name", v);
3573− }
3574− }
3575−
3576−
3577− // CSS subgrid
3578− "grid-template-columns" => {
3579− if let Some(v) = val {
3580− push_css(&mut css, "grid-template-columns", v);
3581− }
3582− }
3583− "grid-template-rows" => {
3584− if let Some(v) = val {
3585− push_css(&mut css, "grid-template-rows", v);
3586− }
3587− }
3588−
3589− // Scroll-driven animations
3590− "animation-timeline" => {
3591− if let Some(v) = val {
3592− push_css(&mut css, "animation-timeline", v);
3593− }
3594− }
3595− "animation-range" => {
3596− if let Some(v) = val {
3597− push_css(&mut css, "animation-range", v);
3598− }
3599− }
3600− "view-timeline-name" => {
3601− if let Some(v) = val {
3602− push_css(&mut css, "view-timeline-name", v);
3603− }
3604− }
3605− "view-timeline-axis" => {
3606− if let Some(v) = val {
3607− push_css(&mut css, "view-timeline-axis", v);
3608− }
3609− }
3610− "scroll-timeline-name" => {
3611− if let Some(v) = val {
3612− push_css(&mut css, "scroll-timeline-name", v);
3613− }
3614− }
3615− "scroll-timeline-axis" => {
3616− if let Some(v) = val {
3617− push_css(&mut css, "scroll-timeline-axis", v);
3618− }
3619− }
3620−
3621− // Anchor positioning
3622− "anchor-name" => {
3623− if let Some(v) = val {
3624− push_css(&mut css, "anchor-name", v);
3625− }
3626− }
3627− "position-anchor" => {
3628− if let Some(v) = val {
3629− push_css(&mut css, "position-anchor", v);
3630− }
3631− }
3632− "position-area" => {
3633− if let Some(v) = val {
3634− push_css(&mut css, "position-area", v);
3635− }
3636− }
3637−
3638− // initial-letter (drop caps)
3639− "initial-letter" => {
3640− if let Some(v) = val {
3641− push_css(&mut css, "initial-letter", v);
3642− }
3643− }
3644−
3645− // Critical CSS hint — not CSS, handled elsewhere
3646− "critical" => {}
3647−
3648− // Identity and HTML passthrough — not CSS
3649− "id" | "class" => {}
3650− "type" | "placeholder" | "name" | "value" | "disabled" | "required" | "checked"
3651− | "for" | "action" | "method" | "autocomplete" | "min" | "max" | "step" | "pattern"
3652− | "maxlength" | "rows" | "cols" | "multiple" | "alt" | "role" | "tabindex"
3653− | "title" | "controls" | "autoplay" | "loop" | "muted" | "playsinline" | "poster"
3654− | "preload" | "loading" | "decoding" | "ordered" | "src" | "open" | "novalidate"
3655− | "low" | "high" | "optimum" | "colspan" | "rowspan" | "scope" | "inline"
3656− | "responsive" | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list"
3657− | "sandbox" | "allow" | "allowfullscreen" | "referrerpolicy" | "formaction"
3658− | "formmethod" | "formtarget" | "target" | "autofocus" => {}
3659
2309
3660 _ => {}
2310 _ => {}
3661 }
2311 }
3662 }
2312 }
3663
2313
2314+ css.push_str(&aligned);
3664 css
2315 css
3665}
2316}
3666
2317
@@ -3671,262 +2322,10 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
3671 css.push(';');
2322 css.push(';');
3672}
2323}
3673
2324
3674−/// Known CSS units — if a value ends with one, skip appending `px`.
3675−/// Format a length value: a bare number gets `px` appended; anything else
3676−/// (values with units, keywords like `auto`, functions like `calc(...)`) is
3677−/// passed through unchanged.
3678−fn css_px(value: &str) -> String {
3679− let v = value.trim();
3680− if v == "0" {
3681− return "0".to_string();
3682− }
3683− let is_bare_number = !v.is_empty()
3684− && v.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
3685− && v.parse::<f64>().is_ok();
3686− if is_bare_number {
3687− format!("{}px", v)
3688− } else {
3689− v.to_string()
3690− }
3691−}
3692−
3693−/// Rewrite literal values that match a declared CSS custom property (from
3694−/// `@theme` or `@let --name value`) to `var(--name)` references. Matches are
3695−/// anchored to CSS value boundaries so e.g. `#3b82f6` inside a longer hex or
3696−/// inside an identifier is not replaced. The `:root` block is emitted before
3697−/// this call runs, so its declarations are not affected.
3698−fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
3699− if css.is_empty() || vars.is_empty() {
3700− return css.to_string();
3701− }
3702− // Prefer longer values first so that if two vars share a prefix, the
3703− // longer (more specific) match wins.
3704− let mut pairs: Vec<(&str, String)> = vars
3705− .iter()
3706− .filter(|(name, value)| name.starts_with("--") && !value.is_empty())
3707− .map(|(name, value)| (value.as_str(), format!("var({})", name)))
3708− .collect();
3709− pairs.sort_by_key(|(v, _)| std::cmp::Reverse(v.len()));
3710− if pairs.is_empty() {
3711− return css.to_string();
3712− }
3713−
3714− let is_boundary_before = |b: Option<u8>| match b {
3715− None => true,
3716− Some(c) => matches!(c, b':' | b' ' | b',' | b'(' | b';' | b'{' | b'\n' | b'\t'),
3717− };
3718− let is_boundary_after = |b: Option<u8>| match b {
3719− None => true,
3720− Some(c) => matches!(c, b';' | b'}' | b',' | b' ' | b')' | b'\n' | b'\t'),
3721− };
3722−
3723− let bytes = css.as_bytes();
3724− let mut out = String::with_capacity(css.len());
3725− let mut i = 0;
3726− let mut prev: Option<u8> = None;
3727− // Only declaration values are rewritten — never selectors or at-rule
3728− // preludes such as `@media (min-width:768px)`. `blocks` records, for
3729− // each open `{`, whether it holds declarations (a style rule) or nested
3730− // rules (an at-rule like `@media` / `@layer`).
3731− let mut blocks: Vec<bool> = Vec::new();
3732− let mut prelude_start = 0;
3733− let mut in_value = false;
3734− while i < bytes.len() {
3735− match bytes[i] {
3736− b'{' => {
3737− let prelude = css[prelude_start..i].trim_start();
3738− blocks.push(!prelude.starts_with('@'));
3739− in_value = false;
3740− prelude_start = i + 1;
3741− }
3742− b'}' => {
3743− blocks.pop();
3744− in_value = false;
3745− prelude_start = i + 1;
3746− }
3747− b';' => {
3748− in_value = false;
3749− prelude_start = i + 1;
3750− }
3751− b':' if blocks.last() == Some(&true) => in_value = true,
3752− _ => {}
3753− }
3754− if in_value && is_boundary_before(prev) {
3755− let mut matched = false;
3756− for (val, repl) in &pairs {
3757− let vb = val.as_bytes();
3758− if i + vb.len() <= bytes.len() && &bytes[i..i + vb.len()] == vb {
3759− let next = bytes.get(i + vb.len()).copied();
3760− if is_boundary_after(next) {
3761− out.push_str(repl);
3762− prev = repl.as_bytes().last().copied();
3763− i += vb.len();
3764− matched = true;
3765− break;
3766− }
3767− }
3768− }
3769− if matched {
3770− continue;
3771− }
3772− }
3773− // Advance by one UTF-8 code point.
3774− let start = i;
3775− i += 1;
3776− while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
3777− i += 1;
3778− }
3779− out.push_str(&css[start..i]);
3780− prev = bytes.get(i - 1).copied();
3781− }
3782− out
3783−}
3784−
3785−/// Scan generated CSS for literal property values that repeat often enough
3786−/// that substituting them with an auto-named custom property would reduce
3787−/// total byte count. Returns the rewritten CSS and any new vars that should
3788−/// be appended to `:root`.
3789−///
3790−/// The algorithm only promotes a value if it strictly saves bytes after
3791−/// accounting for the `--xN:value;` declaration overhead plus the
3792−/// `var(--xN)` reference cost at each call site. Values that are already
3793−/// `var(...)` references or contain `var(` calls are skipped so we never
3794−/// nest references. Names avoid collisions with `existing_vars`.
3795−fn auto_extract_repeats(
3796− css: &str,
3797− existing_vars: &[(String, String)],
3798−) -> (String, Vec<(String, String)>) {
3799− if css.is_empty() {
3800− return (css.to_string(), Vec::new());
3801− }
3802− // Count occurrences of each distinct property value (the text between
3803− // `:` and the next `;` or `}` at the top level of a declaration block,
3804− // ignoring balanced parentheses so that e.g. `rgba(...)` stays intact).
3805− let bytes = css.as_bytes();
3806− let mut counts: HashMap<String, usize> = HashMap::new();
3807− let mut depth: i32 = 0;
3808− let mut i = 0;
3809− while i < bytes.len() {
3810− match bytes[i] {
3811− b'{' => {
3812− depth += 1;
3813− i += 1;
3814− continue;
3815− }
3816− b'}' => {
3817− if depth > 0 {
3818− depth -= 1;
3819− }
3820− i += 1;
3821− continue;
3822− }
3823− _ => {}
3824− }
3825− if depth > 0 && bytes[i] == b':' {
3826− // Skip past `:` then walk to the terminator.
3827− let start = i + 1;
3828− let mut end = start;
3829− let mut pdepth = 0i32;
3830− while end < bytes.len() {
3831− match bytes[end] {
3832− b'(' => pdepth += 1,
3833− b')' if pdepth > 0 => pdepth -= 1,
3834− b';' | b'}' if pdepth == 0 => break,
3835− b'{' if pdepth == 0 => break,
3836− _ => {}
3837− }
3838− end += 1;
3839− }
3840− let val = css[start..end].trim();
3841− if !val.is_empty() && !val.starts_with("var(") && !val.contains("var(") {
3842− *counts.entry(val.to_string()).or_default() += 1;
3843− }
3844− i = end;
3845− continue;
3846− }
3847− i += 1;
3848− }
3849−
3850− // Decide which values are worth extracting. A value of length L appearing
3851− // N times costs N*L bytes inline; promoting it costs (L + 6) for the
3852− // `--hK:V;` declaration plus N * ref_len for the call sites. We estimate
3853− // ref_len optimistically as `var(--h0)` (9 bytes) and fall back to 10 for
3854− // two-digit indices, which only affects very large extraction counts.
3855− let existing: std::collections::HashSet<&str> =
3856− existing_vars.iter().map(|(n, _)| n.as_str()).collect();
3857− let mut candidates: Vec<(String, usize)> =
3858− counts.into_iter().filter(|(_, n)| *n >= 2).collect();
3859− // Stable, deterministic ordering — longest values first, tiebreak by text.
3860− candidates.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
3861−
3862− let mut extracted: Vec<(String, String)> = Vec::new();
3863− let mut next_idx: usize = 0;
3864− for (value, n) in candidates {
3865− let l = value.len();
3866− // Allocate the next unused `--hN` name.
3867− let (name, ref_len) = loop {
3868− let candidate = format!("--h{}", next_idx);
3869− next_idx += 1;
3870− let rl = candidate.len() + 6; // `var(` + name + `)`
3871− if !existing.contains(candidate.as_str())
3872− && !extracted.iter().any(|(n, _)| *n == candidate)
3873− {
3874− break (candidate, rl);
3875− }
3876− };
3877− // Skip if promotion would not strictly reduce byte count.
3878− // Before: n * l bytes. After: (l + name.len() + 2) for the :root
3879− // declaration (`<name>:<value>;`) plus n * ref_len for the sites.
3880− let decl_overhead = l + name.len() + 2;
3881− let before = n * l;
3882− let after = decl_overhead + n * ref_len;
3883− if after < before {
3884− extracted.push((name, value));
3885− } else {
3886− // Rewind the index so the next value can reuse this slot.
3887− next_idx -= 1;
3888− }
3889− }
3890−
3891− if extracted.is_empty() {
3892− return (css.to_string(), Vec::new());
3893− }
3894− let new_css = substitute_css_vars(css, &extracted);
3895− (new_css, extracted)
3896−}
3897−
3898−/// Format a `line-height` value. CSS accepts either a unitless multiplier
3899−/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
3900−/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which
3901−/// CSS interprets as 24× font-size — almost never what anyone wants). Treat
3902−/// integers ≥ 2 as pixel lengths; anything with a decimal, an existing unit,
3903−/// or the value `0`/`1` passes through unchanged.
3904−fn css_line_height(value: &str) -> String {
3905− let v = value.trim();
3906− if v == "0" || v == "1" {
3907− return v.to_string();
3908− }
3909− // Plain integer — treat as pixel length. Decimals, units, keywords and
3910− // functions pass through.
3911− if !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()) {
3912− return format!("{}px", v);
3913− }
3914− v.to_string()
3915−}
3916−
3917−/// Format multiple space-separated values, each getting px if needed.
3918−fn css_px_multi(value: &str) -> String {
3919− value
3920− .split_whitespace()
3921− .map(css_px)
3922− .collect::<Vec<_>>()
3923− .join(" ")
3924−}
3925−
3926fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2325fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
3927 let mut id = None;
2326 let mut id = None;
3928 let mut class = None;
2327 let mut class = None;
3929− for attr in attrs {
2328+ for attr in attrs.iter().filter(|a| a.html) {
3930 match attr.key.as_str() {
2329 match attr.key.as_str() {
3931 "id" => id = attr.value.clone(),
2330 "id" => id = attr.value.clone(),
3932 "class" => class = attr.value.clone(),
2331 "class" => class = attr.value.clone(),
@@ -3936,153 +2335,6 @@ fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
3936 (id, class)
2335 (id, class)
3937}
2336}
3938
2337
3939−/// Read image dimensions from a local file by parsing the header bytes.
3940−/// Supports PNG, JPEG, GIF, WebP, AVIF, and SVG.
3941−fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
3942− // SVG: parse as text for viewBox/width/height attributes
3943− if path.ends_with(".svg") || path.ends_with(".SVG") {
3944− let text = std::fs::read_to_string(path).ok()?;
3945− return read_svg_dimensions(&text);
3946− }
3947−
3948− let data = std::fs::read(path).ok()?;
3949− if data.len() < 12 {
3950− return None;
3951− }
3952−
3953− // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
3954− if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") {
3955− let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
3956− let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
3957− return Some((w, h));
3958− }
3959−
3960− // GIF: "GIF87a" or "GIF89a", width/height at bytes 6-9 (little-endian)
3961− if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
3962− let w = u16::from_le_bytes([data[6], data[7]]) as u32;
3963− let h = u16::from_le_bytes([data[8], data[9]]) as u32;
3964− return Some((w, h));
3965− }
3966−
3967− // JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2)
3968− if data.starts_with(b"\xff\xd8") {
3969− let mut i = 2;
3970− while i + 9 < data.len() {
3971− if data[i] != 0xFF {
3972− i += 1;
3973− continue;
3974− }
3975− let marker = data[i + 1];
3976− if marker == 0xC0 || marker == 0xC2 {
3977− let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
3978− let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
3979− return Some((w, h));
3980− }
3981− if i + 3 < data.len() {
3982− let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
3983− i += 2 + len;
3984− } else {
3985− break;
3986− }
3987− }
3988− }
3989−
3990− // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
3991− if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
3992− if &data[12..16] == b"VP8 " && data.len() >= 30 {
3993− let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
3994− let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
3995− return Some((w, h));
3996− }
3997− if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
3998− let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
3999− let w = (bits & 0x3FFF) + 1;
4000− let h = ((bits >> 14) & 0x3FFF) + 1;
4001− return Some((w, h));
4002− }
4003− }
4004−
4005− // AVIF: ISOBMFF container with "ftyp" box containing "avif"/"avis" brand,
4006− // then "ispe" box with width/height
4007− if data.len() >= 12 && &data[4..8] == b"ftyp" {
4008− let brand = &data[8..12];
4009− if brand == b"avif" || brand == b"avis" || brand == b"mif1" {
4010− return read_avif_dimensions(&data);
4011− }
4012− }
4013−
4014− None
4015−}
4016−
4017−/// Parse SVG viewBox or width/height attributes to get dimensions.
4018−fn read_svg_dimensions(text: &str) -> Option<(u32, u32)> {
4019− // Try viewBox first: viewBox="minX minY width height"
4020− if let Some(vb_start) = text.find("viewBox=\"") {
4021− let rest = &text[vb_start + 9..];
4022− if let Some(end) = rest.find('"') {
4023− let parts: Vec<&str> = rest[..end].split_whitespace().collect();
4024− if parts.len() == 4
4025− && let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
4026− && w > 0.0
4027− && h > 0.0
4028− {
4029− return Some((w.round() as u32, h.round() as u32));
4030− }
4031− }
4032− }
4033− // Fall back to width/height attributes on <svg>
4034− let svg_tag = text.find("<svg")?;
4035− let tag_end = text[svg_tag..].find('>')? + svg_tag;
4036− let tag = &text[svg_tag..tag_end];
4037− let w = extract_svg_attr(tag, "width")?;
4038− let h = extract_svg_attr(tag, "height")?;
4039− Some((w, h))
4040−}
4041−
4042−fn extract_svg_attr(tag: &str, attr: &str) -> Option<u32> {
4043− let needle = format!("{}=\"", attr);
4044− let start = tag.find(&needle)? + needle.len();
4045− let rest = &tag[start..];
4046− let end = rest.find('"')?;
4047− let val = rest[..end].trim_end_matches("px");
4048− val.parse::<f64>().ok().map(|v| v.round() as u32)
4049−}
4050−
4051−/// Parse AVIF (ISOBMFF) container to find ispe box with image dimensions.
4052−fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
4053− // Walk ISOBMFF boxes looking for "ispe" (image spatial extents)
4054− let mut i = 0;
4055− while i + 8 <= data.len() {
4056− let box_size =
4057− u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
4058− let box_type = &data[i + 4..i + 8];
4059− if box_size < 8 {
4060− break;
4061− }
4062− let box_end = (i + box_size).min(data.len());
4063− // ispe box: 4 bytes version/flags + 4 bytes width + 4 bytes height
4064− if box_type == b"ispe" && box_end >= i + 20 {
4065− let w = u32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
4066− let h = u32::from_be_bytes([data[i + 16], data[i + 17], data[i + 18], data[i + 19]]);
4067− return Some((w, h));
4068− }
4069− // Recurse into container boxes (meta, iprp, ipco)
4070− if matches!(
4071− box_type,
4072− b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia"
4073− ) {
4074− let header_size = if box_type == b"meta" { 12 } else { 8 };
4075− if i + header_size < box_end
4076− && let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end])
4077− {
4078− return Some(dims);
4079− }
4080− }
4081− i = box_end;
4082− }
4083− None
4084−}
4085−
4086fn html_escape(s: &str) -> String {
2338fn html_escape(s: &str) -> String {
4087 s.replace('&', "&amp;")
2339 s.replace('&', "&amp;")
4088 .replace('<', "&lt;")
2340 .replace('<', "&lt;")
@@ -4198,49 +2450,30 @@ fn vlq_encode(value: i64, out: &mut String) {
4198 }
2450 }
4199}
2451}
4200
2452
4201−/// Check if a specific element kind exists anywhere in the node tree.
2453+#[cfg(test)]
4202−fn has_element_kind(nodes: &[Node], target: &ElementKind) -> bool {
2454+mod tests {
4203− for node in nodes {
2455+ use super::*;
4204− if let Node::Element(elem) = node {
2456+
4205− if elem.kind == *target {
2457+ #[test]
4206− return true;
2458+ fn a_name_is_a_stable_hash_of_the_style() {
4207− }
2459+ // The same in every build and on every platform
4208− if has_element_kind(&elem.children, target) {
2460+ assert_eq!(stable_hash(""), 0xf52a_15e9_a9b5_e89b);
4209− return true;
2461+ assert_eq!(hash_digits(35, 3), "z00");
4210− }
2462+ assert_eq!(hash_digits(u64::MAX, 13), "fsgs46211e5w3");
4211− }
2463+ }
4212− }
2464+
4213− false
2465+ #[test]
4214−}
2466+ fn a_name_taken_in_the_file_takes_more_digits() {
4215−
2467+ // These two styles' hashes end in the same 7 digits
4216−/// Collect unique external domains from generated HTML for DNS prefetch hints.
2468+ let mut styles = StyleCollector::new();
4217−fn collect_dns_prefetch(html: &str, dev: bool) -> String {
2469+ let first = styles.get_class("width:129555px;".into(), Vec::new(), String::new());
4218− let mut domains = Vec::new();
2470+ let second = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
4219− let mut rest = html;
2471+ let (first, second) = (first.unwrap(), second.unwrap());
4220− while let Some(pos) = rest.find("https://") {
2472+ assert_eq!(first.len(), 3 + CLASS_DIGITS, "{first}");
4221− let start = pos + 8; // skip "https://"
2473+ assert_eq!(second.len(), 3 + CLASS_DIGITS + 1, "{second}");
4222− rest = &rest[start..];
2474+ assert!(second.starts_with(&first), "{first} {second}");
4223− let end = rest
2475+ // The same style gets the name it got before
4224− .find(['/', '"', '\'', ' ', '>', ')'])
2476+ let again = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
4225− .unwrap_or(rest.len());
2477+ assert_eq!(again.as_deref(), Some(second.as_str()));
4226− let domain = &rest[..end];
4227− if !domain.is_empty() && domain.contains('.') && !domains.contains(&domain.to_string()) {
4228− domains.push(domain.to_string());
4229− }
4230− }
4231− let mut out = String::new();
4232− for domain in &domains {
4233− if dev {
4234− out.push_str(&format!(
4235− "<link rel=\"dns-prefetch\" href=\"//{}\">\n",
4236− domain
4237− ));
4238− } else {
4239− out.push_str(&format!(
4240− "<link rel=\"dns-prefetch\" href=\"//{}\">",
4241− domain
4242− ));
4243− }
4244 }
2478 }
4245− out
4246}
2479}
crates/htmlang-core/src/codegen_tests.rs+33−14
@@ -1,6 +1,8 @@
1#[cfg(test)]
1#[cfg(test)]
2mod tests {
2mod 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+ };
4 use crate::parser::parse;
6 use crate::parser::parse;
5
7
6 fn compile(src: &str) -> String {
8 fn compile(src: &str) -> String {
@@ -47,19 +49,42 @@ mod tests {
47
49
48 #[test]
50 #[test]
49 fn dev_mode_is_deterministic_across_runs() {
51 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";
51 let r1 = parse(src);
54 let r1 = parse(src);
52 let r2 = parse(src);
55 let r2 = parse(src);
53 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
56 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
54 }
57 }
55
58
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+
56 #[test]
68 #[test]
57− fn short_class_name_is_stable() {
69+ fn a_style_has_one_name_in_every_file() {
58− assert_eq!(short_class_name(0), "a");
70+ // A fragment's classes mean in a page what they mean in the
59− assert_eq!(short_class_name(25), "z");
71+ // fragment, whatever else either holds
60− // Higher indexes must produce unique, stable names.
72+ let page = classes("@el [padding 10, background red] a\n@el [gap 4] b\n");
61− let names: std::collections::HashSet<_> = (0..50_000).map(short_class_name).collect();
73+ let fragment = classes("@row [margin 2] x\n@el [padding 10, background red] y\n");
62− assert_eq!(names.len(), 50_000, "short_class_name collisions");
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+ }
63 }
88 }
64
89
65 #[test]
90 #[test]
@@ -93,10 +118,4 @@ mod tests {
93 "duplicate CSS rule emitted ({count} times) in:\n{out}"
118 "duplicate CSS rule emitted ({count} times) in:\n{out}"
94 );
119 );
95 }
120 }
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− }
102}
121}
crates/htmlang-core/src/diagnostic.rsnew file+245
@@ -0,0 +1,245 @@
1+//! Diagnostics: what the compiler reports about a source file.
2+//!
3+//! Every diagnostic has a stable `code` (see [`code`]), so tools such as the
4+//! language server key their quick fixes on the code rather than on the
5+//! wording of the message. `subject` and `suggestion` carry the name the
6+//! diagnostic is about and the name to use instead, when there is one.
7+
8+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9+pub enum Severity {
10+ Error,
11+ Warning,
12+ Info,
13+ Help,
14+}
15+
16+#[derive(Debug, Clone)]
17+pub struct Diagnostic {
18+ /// Stable identifier of the kind of problem, e.g. `unknown-element`.
19+ pub code: &'static str,
20+ pub line: usize,
21+ pub column: Option<usize>,
22+ pub message: String,
23+ pub severity: Severity,
24+ pub source_line: Option<Box<str>>,
25+ /// The name the diagnostic is about, as written (`@nosuch` without the
26+ /// `@`, an attribute key, a definition's name).
27+ pub subject: Option<Box<str>>,
28+ /// The name to write instead of `subject` ("did you mean ...?").
29+ pub suggestion: Option<Box<str>>,
30+}
31+
32+impl Diagnostic {
33+ pub fn new(code: &'static str, severity: Severity, line: usize, message: String) -> Self {
34+ Diagnostic {
35+ code,
36+ line,
37+ column: None,
38+ message,
39+ severity,
40+ source_line: None,
41+ subject: None,
42+ suggestion: None,
43+ }
44+ }
45+
46+ pub fn error(code: &'static str, line: usize, message: String) -> Self {
47+ Self::new(code, Severity::Error, line, message)
48+ }
49+
50+ pub fn warning(code: &'static str, line: usize, message: String) -> Self {
51+ Self::new(code, Severity::Warning, line, message)
52+ }
53+
54+ pub fn column(mut self, column: usize) -> Self {
55+ self.column = Some(column);
56+ self
57+ }
58+
59+ pub fn source(mut self, source: impl Into<String>) -> Self {
60+ self.source_line = Some(source.into().into_boxed_str());
61+ self
62+ }
63+
64+ pub fn subject(mut self, subject: impl Into<String>) -> Self {
65+ self.subject = Some(subject.into().into_boxed_str());
66+ self
67+ }
68+
69+ pub fn suggest(mut self, suggestion: Option<impl Into<String>>) -> Self {
70+ self.suggestion = suggestion.map(|s| s.into().into_boxed_str());
71+ self
72+ }
73+}
74+
75+/// The diagnostic codes. A code names a kind of problem and never changes,
76+/// even when a message is reworded.
77+pub mod code {
78+ // Syntax: found while reading the file, before anything runs
79+ pub const UNCLOSED_BRACKET: &str = "unclosed-bracket";
80+ /// A `]` that ends an attribute list inside what reads as a `'...'`
81+ /// string in a value (`hx-on:click=alert(']')`).
82+ pub const UNBALANCED_QUOTE: &str = "unbalanced-quote";
83+ pub const UNEXPECTED_BODY: &str = "unexpected-body";
84+ pub const UNEXPECTED_ARGUMENT: &str = "unexpected-argument";
85+ pub const MISSING_ARGUMENT: &str = "missing-argument";
86+ pub const INVALID_LOOP: &str = "invalid-loop";
87+ /// A `@let` whose name, parameters or body don't fit its kind.
88+ pub const INVALID_DEFINITION: &str = "invalid-definition";
89+ pub const STRAY_ELSE: &str = "stray-else";
90+ /// Indentation with tabs and spaces mixed, which nests lines in ways
91+ /// the eye doesn't see.
92+ pub const MIXED_INDENTATION: &str = "mixed-indentation";
93+
94+ // Names
95+ pub const UNKNOWN_ELEMENT: &str = "unknown-element";
96+ /// An attribute name htmlang doesn't know: a warning when CSS could
97+ /// have the name (it is written to the CSS as it is), an error otherwise.
98+ pub const UNKNOWN_ATTRIBUTE: &str = "unknown-attribute";
99+ /// A misspelled prefix (`hovr:color red`).
100+ pub const UNKNOWN_PREFIX: &str = "unknown-prefix";
101+ /// A prefix the attribute can't take: a second one (`md:hover:`), or
102+ /// one on an HTML attribute (`hover:required`).
103+ pub const INVALID_PREFIX: &str = "invalid-prefix";
104+ /// A style written without its value (`[padding]`).
105+ pub const MISSING_VALUE: &str = "missing-value";
106+ pub const HTML_ATTRIBUTE_FORM: &str = "html-attribute-form";
107+ /// A second `@page`: a page has one root, so one `@page`.
108+ pub const DUPLICATE_PAGE: &str = "duplicate-page";
109+ pub const UNDEFINED_VARIABLE: &str = "undefined-variable";
110+ /// An attribute, or an attribute's name, made from a variable.
111+ pub const ATTRIBUTE_FROM_VARIABLE: &str = "attribute-from-variable";
112+ pub const DUPLICATE_ATTRIBUTE: &str = "duplicate-attribute";
113+
114+ // Values
115+ /// A value that can't be written into the page's CSS (an error), or
116+ /// one that can't mean what it says (a warning).
117+ pub const INVALID_VALUE: &str = "invalid-value";
118+ pub const INVALID_COLOR: &str = "invalid-color";
119+ pub const INVALID_EXPRESSION: &str = "invalid-expression";
120+ pub const INVALID_JSON: &str = "invalid-json";
121+ /// A name htmlang doesn't know right after a `key=value`: the rest of
122+ /// the value, which a comma ended (`hx-trigger=load, every 2s`).
123+ pub const SPLIT_VALUE: &str = "split-value";
124+ /// A leading argument taken as a URL that reads as text (`@link
125+ /// [hx-get=/x] Open panel`, whose href is `Open`).
126+ pub const TEXT_AS_URL: &str = "text-as-url";
127+
128+ // Definitions and functions
129+ pub const UNUSED_VARIABLE: &str = "unused-variable";
130+ pub const UNUSED_BUNDLE: &str = "unused-bundle";
131+ pub const UNUSED_FUNCTION: &str = "unused-function";
132+ pub const RECURSIVE_CALL: &str = "recursive-call";
133+ pub const NO_SINGLE_ROOT: &str = "no-single-root";
134+ /// A call that leaves out a parameter without a default.
135+ pub const MISSING_PARAMETER: &str = "missing-parameter";
136+ /// A parameter passed `name=value`: parameters are written `name value`.
137+ pub const PARAMETER_FORM: &str = "parameter-form";
138+ /// A function named like a built-in element (which it replaces) or a
139+ /// directive (which it can't replace).
140+ pub const SHADOWS_BUILT_IN: &str = "shadows-built-in";
141+ /// A `@slot NAME` block at a call whose function has no such slot.
142+ pub const UNKNOWN_SLOT: &str = "unknown-slot";
143+ /// Content passed to a function whose body has no `@children`.
144+ pub const UNEXPECTED_CONTENT: &str = "unexpected-content";
145+ /// A `@slot` or `@children` where it marks or fills nothing: outside
146+ /// a function's body, inside an element at a call, or inline in text.
147+ pub const MISPLACED_SLOT: &str = "misplaced-slot";
148+ /// A slot name that isn't one name (`@slot my footer`).
149+ pub const INVALID_SLOT_NAME: &str = "invalid-slot-name";
150+
151+ // Files
152+ pub const UNREADABLE_FILE: &str = "unreadable-file";
153+ pub const CIRCULAR_INCLUDE: &str = "circular-include";
154+ /// Two files of one build whose different styles got one class name:
155+ /// a name is a hash of its style (`htmlang build` checks).
156+ pub const CLASS_COLLISION: &str = "class-collision";
157+ pub const UNSET_ENVIRONMENT: &str = "unset-environment";
158+
159+ // Context: an attribute or element where it has no effect
160+ /// An attribute the element ignores (a warning), or a layout word such
161+ /// as `spacing` on an element that doesn't lay out its children (an
162+ /// error: it is left out).
163+ pub const NO_EFFECT: &str = "no-effect";
164+ /// An element whose HTML ends a `<p>`, written inside a `@paragraph`.
165+ pub const BLOCK_IN_PARAGRAPH: &str = "block-in-paragraph";
166+
167+ // Accessibility
168+ pub const MISSING_ALT: &str = "missing-alt";
169+ pub const MISSING_INPUT_TYPE: &str = "missing-input-type";
170+ pub const MISSING_LINK_TEXT: &str = "missing-link-text";
171+ pub const MISSING_LABEL: &str = "missing-label";
172+ pub const MISSING_TITLE: &str = "missing-title";
173+ pub const MISSING_BUTTON_TEXT: &str = "missing-button-text";
174+ pub const MISSING_CAPTIONS: &str = "missing-captions";
175+ pub const LOW_CONTRAST: &str = "low-contrast";
176+ pub const POSITIVE_TABINDEX: &str = "positive-tabindex";
177+
178+ // `htmlang lint`
179+ pub const DEEP_NESTING: &str = "deep-nesting";
180+ pub const EMPTY_CONTAINER: &str = "empty-container";
181+ pub const MISSING_BUTTON_TYPE: &str = "missing-button-type";
182+
183+ /// A bug in the compiler itself.
184+ pub const INTERNAL: &str = "internal";
185+
186+ /// Every code, for tools and tests.
187+ pub const ALL: &[&str] = &[
188+ UNCLOSED_BRACKET,
189+ UNBALANCED_QUOTE,
190+ UNEXPECTED_BODY,
191+ UNEXPECTED_ARGUMENT,
192+ MISSING_ARGUMENT,
193+ INVALID_LOOP,
194+ INVALID_DEFINITION,
195+ STRAY_ELSE,
196+ MIXED_INDENTATION,
197+ UNKNOWN_ELEMENT,
198+ UNKNOWN_ATTRIBUTE,
199+ UNKNOWN_PREFIX,
200+ INVALID_PREFIX,
201+ MISSING_VALUE,
202+ HTML_ATTRIBUTE_FORM,
203+ DUPLICATE_PAGE,
204+ UNDEFINED_VARIABLE,
205+ ATTRIBUTE_FROM_VARIABLE,
206+ DUPLICATE_ATTRIBUTE,
207+ INVALID_VALUE,
208+ INVALID_COLOR,
209+ INVALID_EXPRESSION,
210+ INVALID_JSON,
211+ SPLIT_VALUE,
212+ TEXT_AS_URL,
213+ UNUSED_VARIABLE,
214+ UNUSED_BUNDLE,
215+ UNUSED_FUNCTION,
216+ RECURSIVE_CALL,
217+ NO_SINGLE_ROOT,
218+ MISSING_PARAMETER,
219+ PARAMETER_FORM,
220+ SHADOWS_BUILT_IN,
221+ UNKNOWN_SLOT,
222+ UNEXPECTED_CONTENT,
223+ MISPLACED_SLOT,
224+ INVALID_SLOT_NAME,
225+ UNREADABLE_FILE,
226+ CIRCULAR_INCLUDE,
227+ CLASS_COLLISION,
228+ UNSET_ENVIRONMENT,
229+ NO_EFFECT,
230+ BLOCK_IN_PARAGRAPH,
231+ MISSING_ALT,
232+ MISSING_INPUT_TYPE,
233+ MISSING_LINK_TEXT,
234+ MISSING_LABEL,
235+ MISSING_TITLE,
236+ MISSING_BUTTON_TEXT,
237+ MISSING_CAPTIONS,
238+ LOW_CONTRAST,
239+ POSITIVE_TABINDEX,
240+ DEEP_NESTING,
241+ EMPTY_CONTAINER,
242+ MISSING_BUTTON_TYPE,
243+ INTERNAL,
244+ ];
245+}
crates/htmlang-core/src/expr.rsnew file+801
@@ -0,0 +1,801 @@
1+//! Expressions: conditions (`@if $count > 2 and not $hidden`), computed
2+//! values (`@let gap = $base * 2`) and `${...}` in text and values
3+//! (`${if($featured, 24, 0)}`).
4+//!
5+//! Expressions are tokenized and evaluated as a whole. Variables are looked
6+//! up while evaluating, never pasted into the text first, so a value that
7+//! happens to contain `==` or spaces can't change how an expression parses.
8+//!
9+//! Only what decides the result is evaluated: `if()` evaluates the branch
10+//! it takes, `and` and `or` stop at the first side that decides. The rest
11+//! is still parsed (and its function names and argument counts checked),
12+//! so `$n != 0 and 10 / $n > 1` is false for `$n` = 0, not an error.
13+//!
14+//! ```text
15+//! expr := or
16+//! or := and ("or" and)*
17+//! and := not ("and" not)*
18+//! not := "not" not | compare
19+//! compare := range (("==" | "!=" | "<" | ">" | "<=" | ">=") range)?
20+//! range := sum (".." sum ("step" sum)?)?
21+//! sum := product (("+" | "-") product)*
22+//! product := unary (("*" | "/" | "%") unary)*
23+//! unary := "-" unary | primary
24+//! primary := NUMBER | "STRING" | $VAR | WORD | true | false
25+//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
26+//! ```
27+//!
28+//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is text. Values are
29+//! typed (see `value.rs`, which also defines truthiness and comparison):
30+//! `$name` keeps whatever it holds, a list or a record included, and
31+//! `A..B` (with `step N`) is a list of whole numbers. Functions:
32+//! `if(cond, a, b)` (`b` may be left out: empty), tests (`contains(s, x)`,
33+//! `starts-with(s, x)`, `ends-with(s, x)`), text (`uppercase`, `lowercase`,
34+//! `capitalize`, `trim`, `length`, `reverse`, `truncate(s, n)`,
35+//! `replace(s, old, new)`, `default(s, fallback)`). A colour variant is
36+//! CSS, not a function here: `color-mix(in srgb, var(--brand), black 8%)`
37+//! is an attribute's value, written as it is.
38+//! `length`, `contains` and `reverse` work on the items of a list; the other
39+//! text functions take text, and a list is an error (a text with commas in
40+//! it is written in quotes or with `\,`, so it isn't a list).
41+//!
42+//! A `$name` ends as it does in text (see `interp.rs`), so `$post.title`
43+//! reads a field and `$a..$b` is two names. An undefined name is an error.
44+
45+use std::fmt;
46+
47+use crate::interp::{self, Problem, Reference, Scope};
48+
49+pub use crate::value::Value;
50+
51+/// Why an expression has no value.
52+#[derive(Debug, Clone, PartialEq)]
53+pub enum Error {
54+ /// `$name` has no definition; `offset` is where the `$` is.
55+ Undefined { name: String, offset: usize },
56+ /// Anything else: a syntax error, a type error, division by zero.
57+ Invalid(String),
58+}
59+
60+impl Error {
61+ /// This error as a problem of a text in which the expression starts
62+ /// at `base`.
63+ pub(crate) fn at(self, base: usize) -> Problem {
64+ match self {
65+ Error::Undefined { name, offset } => Problem::Undefined {
66+ name,
67+ offset: base + offset,
68+ },
69+ Error::Invalid(message) => Problem::Invalid {
70+ message,
71+ offset: base,
72+ },
73+ }
74+ }
75+}
76+
77+impl From<String> for Error {
78+ fn from(message: String) -> Self {
79+ Error::Invalid(message)
80+ }
81+}
82+
83+impl fmt::Display for Error {
84+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85+ match self {
86+ Error::Undefined { name, .. } => write!(f, "undefined variable '${}'", name),
87+ Error::Invalid(message) => f.write_str(message),
88+ }
89+ }
90+}
91+
92+/// Evaluate `src` with the variables of `scope`. An undefined variable is
93+/// an error; a field that a record doesn't have is empty.
94+pub fn eval(src: &str, scope: &dyn Scope) -> Result<Value, Error> {
95+ let mut tokens = Vec::new();
96+ tokenize(src, scope, 0, &mut tokens)?;
97+ let mut parser = Parser {
98+ tokens: &tokens,
99+ pos: 0,
100+ scope,
101+ skip: false,
102+ };
103+ let value = parser.expr()?;
104+ match parser.tokens.get(parser.pos) {
105+ None => Ok(value),
106+ Some((tok, _)) => Err(Error::Invalid(format!(
107+ "unexpected {} in `{}`",
108+ tok,
109+ src.trim()
110+ ))),
111+ }
112+}
113+
114+#[derive(Debug, Clone, PartialEq)]
115+enum Token {
116+ Num(f64),
117+ Str(String),
118+ Var(String),
119+ Word(String),
120+ Op(&'static str),
121+ LParen,
122+ RParen,
123+ Comma,
124+}
125+
126+impl fmt::Display for Token {
127+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128+ match self {
129+ Token::Num(n) => write!(f, "`{}`", n),
130+ Token::Str(s) => write!(f, "`\"{}\"`", s),
131+ Token::Var(v) => write!(f, "`${}`", v),
132+ Token::Word(w) => write!(f, "`{}`", w),
133+ Token::Op(op) => write!(f, "`{}`", op),
134+ Token::LParen => f.write_str("`(`"),
135+ Token::RParen => f.write_str("`)`"),
136+ Token::Comma => f.write_str("`,`"),
137+ }
138+ }
139+}
140+
141+const OPERATORS: &[&str] = &[
142+ "..", "==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%",
143+];
144+
145+/// Read `src` into tokens, each with its byte offset (plus `base`).
146+/// Variable names end as in text (see `interp.rs`), and `${...}` is a
147+/// variable (`${name}`) or a parenthesized expression.
148+fn tokenize(
149+ src: &str,
150+ scope: &dyn Scope,
151+ base: usize,
152+ tokens: &mut Vec<(Token, usize)>,
153+) -> Result<(), Error> {
154+ let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
155+ let mut i = 0;
156+ while let Some(c) = src[i..].chars().next() {
157+ let at = base + i;
158+ if c.is_whitespace() {
159+ i += c.len_utf8();
160+ } else if c == '(' {
161+ tokens.push((Token::LParen, at));
162+ i += 1;
163+ } else if c == ')' {
164+ tokens.push((Token::RParen, at));
165+ i += 1;
166+ } else if c == ',' {
167+ tokens.push((Token::Comma, at));
168+ i += 1;
169+ } else if c == '"' {
170+ let start = i + 1;
171+ let end = src[start..]
172+ .find('"')
173+ .map(|p| start + p)
174+ .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
175+ tokens.push((Token::Str(src[start..end].to_string()), at));
176+ i = end + 1;
177+ } else if c == '$' {
178+ match interp::reference(&src[i + 1..], scope) {
179+ Some((Reference::Var(path), len)) => {
180+ tokens.push((Token::Var(path), at));
181+ i += 1 + len;
182+ }
183+ Some((Reference::Expr(inner), len)) => {
184+ tokens.push((Token::LParen, at));
185+ tokenize(inner, scope, at + 2, tokens)?;
186+ tokens.push((Token::RParen, at + len));
187+ i += 1 + len;
188+ }
189+ None => {
190+ let n = interp::custom_property_len(&src[i + 1..]);
191+ return Err(Error::Invalid(match n {
192+ 0 => format!("`$` without a variable name in `{}`", src.trim()),
193+ n => interp::custom_property_message(&src[i + 1..i + 1 + n]),
194+ }));
195+ }
196+ }
197+ } else if let Some(op) = OPERATORS
198+ .iter()
199+ .find(|op| src[i..].starts_with(**op))
200+ // A `-` followed by a letter starts a word (`-webkit-box`);
201+ // inside words (`ease-in`) it is consumed by the word itself.
202+ .filter(|op| **op != "-" || !src[i + 1..].starts_with(char::is_alphabetic))
203+ {
204+ tokens.push((Token::Op(op), at));
205+ i += op.len();
206+ } else if is_word_char(c) {
207+ let start = i;
208+ // A word ends at `..`, so `1..5` is a range
209+ while let Some(c) = src[i..]
210+ .chars()
211+ .next()
212+ .filter(|&c| is_word_char(c) && !src[i..].starts_with(".."))
213+ {
214+ i += c.len_utf8();
215+ }
216+ let word = &src[start..i];
217+ tokens.push((
218+ match word.parse::<f64>() {
219+ Ok(n) if !word.ends_with('.') => Token::Num(n),
220+ _ => Token::Word(word.to_string()),
221+ },
222+ at,
223+ ));
224+ } else {
225+ return Err(Error::Invalid(format!(
226+ "unexpected `{}` in `{}`",
227+ c,
228+ src.trim()
229+ )));
230+ }
231+ }
232+ Ok(())
233+}
234+
235+struct Parser<'a> {
236+ tokens: &'a [(Token, usize)],
237+ pos: usize,
238+ scope: &'a dyn Scope,
239+ /// Parsing a part whose value isn't needed (the branch `if()` doesn't
240+ /// take, the side of `and`/`or` that doesn't decide): it is read and
241+ /// checked for syntax, not evaluated.
242+ skip: bool,
243+}
244+
245+/// The value a skipped part stands for; nothing reads it.
246+const SKIPPED: Value = Value::Bool(false);
247+
248+impl Parser<'_> {
249+ fn peek(&self) -> Option<&Token> {
250+ self.tokens.get(self.pos).map(|(token, _)| token)
251+ }
252+
253+ fn eat_word(&mut self, word: &str) -> bool {
254+ if matches!(self.peek(), Some(Token::Word(w)) if w == word) {
255+ self.pos += 1;
256+ true
257+ } else {
258+ false
259+ }
260+ }
261+
262+ fn eat_op(&mut self, ops: &[&'static str]) -> Option<&'static str> {
263+ match self.peek() {
264+ Some(Token::Op(op)) if ops.contains(op) => {
265+ let op = *op;
266+ self.pos += 1;
267+ Some(op)
268+ }
269+ _ => None,
270+ }
271+ }
272+
273+ fn expect(&mut self, token: Token) -> Result<(), Error> {
274+ match self.peek() {
275+ Some(t) if *t == token => {
276+ self.pos += 1;
277+ Ok(())
278+ }
279+ Some(t) => Err(format!("expected {} but found {}", token, t).into()),
280+ None => Err(format!("expected {} at the end", token).into()),
281+ }
282+ }
283+
284+ /// Parse with `f`, skipping evaluation when `skip` (or when already
285+ /// skipping).
286+ fn skipping(
287+ &mut self,
288+ skip: bool,
289+ f: impl FnOnce(&mut Self) -> Result<Value, Error>,
290+ ) -> Result<Value, Error> {
291+ let outer = self.skip;
292+ self.skip |= skip;
293+ let value = f(self);
294+ self.skip = outer;
295+ value
296+ }
297+
298+ fn expr(&mut self) -> Result<Value, Error> {
299+ let mut left = self.and()?;
300+ while self.eat_word("or") {
301+ let decided = left.truthy();
302+ let right = self.skipping(decided, Self::and)?;
303+ left = Value::Bool(decided || right.truthy());
304+ }
305+ Ok(left)
306+ }
307+
308+ fn and(&mut self) -> Result<Value, Error> {
309+ let mut left = self.not()?;
310+ while self.eat_word("and") {
311+ let decided = !left.truthy();
312+ let right = self.skipping(decided, Self::not)?;
313+ left = Value::Bool(!decided && right.truthy());
314+ }
315+ Ok(left)
316+ }
317+
318+ fn not(&mut self) -> Result<Value, Error> {
319+ if self.eat_word("not") {
320+ return Ok(Value::Bool(!self.not()?.truthy()));
321+ }
322+ self.compare()
323+ }
324+
325+ fn compare(&mut self) -> Result<Value, Error> {
326+ let left = self.range()?;
327+ let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
328+ return Ok(left);
329+ };
330+ let right = self.range()?;
331+ if self.skip {
332+ return Ok(SKIPPED);
333+ }
334+ Ok(Value::Bool(left.compare(op, &right)?))
335+ }
336+
337+ /// `A..B` or `A..B step N`: the whole numbers from A to B.
338+ fn range(&mut self) -> Result<Value, Error> {
339+ let start = self.sum()?;
340+ if self.eat_op(&[".."]).is_none() {
341+ return Ok(start);
342+ }
343+ let end = self.sum()?;
344+ let step = match self.eat_word("step") {
345+ true => Some(self.sum()?),
346+ false => None,
347+ };
348+ if self.skip {
349+ return Ok(SKIPPED);
350+ }
351+ let number = |value: &Value| {
352+ value.as_num().ok_or_else(|| {
353+ Error::Invalid(format!(
354+ "a range goes from one number to another, not from {} (`{}`)",
355+ value.describe(),
356+ value
357+ ))
358+ })
359+ };
360+ let step = match &step {
361+ Some(step) => number(step)?,
362+ None => 1.0,
363+ };
364+ Ok(crate::value::range(number(&start)?, number(&end)?, step)?)
365+ }
366+
367+ fn sum(&mut self) -> Result<Value, Error> {
368+ let mut left = self.product()?;
369+ while let Some(op) = self.eat_op(&["+", "-"]) {
370+ let right = self.product()?;
371+ left = self.arithmetic(op, &left, &right)?;
372+ }
373+ Ok(left)
374+ }
375+
376+ fn product(&mut self) -> Result<Value, Error> {
377+ let mut left = self.unary()?;
378+ while let Some(op) = self.eat_op(&["*", "/", "%"]) {
379+ let right = self.unary()?;
380+ left = self.arithmetic(op, &left, &right)?;
381+ }
382+ Ok(left)
383+ }
384+
385+ fn unary(&mut self) -> Result<Value, Error> {
386+ if self.eat_op(&["-"]).is_some() {
387+ let value = self.unary()?;
388+ if self.skip {
389+ return Ok(SKIPPED);
390+ }
391+ return value
392+ .as_num()
393+ .map(|n| Value::Num(-n))
394+ .ok_or_else(|| Error::Invalid(format!("cannot negate `{}`", value)));
395+ }
396+ self.primary()
397+ }
398+
399+ fn primary(&mut self) -> Result<Value, Error> {
400+ let (token, at) = self
401+ .tokens
402+ .get(self.pos)
403+ .cloned()
404+ .ok_or_else(|| "expected a value at the end".to_string())?;
405+ self.pos += 1;
406+ match token {
407+ Token::Num(n) => Ok(Value::Num(n)),
408+ Token::Str(_) | Token::Var(_) if self.skip => Ok(SKIPPED),
409+ Token::Str(s) => {
410+ let (text, problems) = interp::interpolate(&s, self.scope);
411+ match problems.into_iter().next() {
412+ None => Ok(Value::Str(text)),
413+ Some(Problem::Undefined { name, offset }) => Err(Error::Undefined {
414+ name,
415+ offset: at + 1 + offset,
416+ }),
417+ Some(Problem::Invalid { message, .. } | Problem::Record { message, .. }) => {
418+ Err(Error::Invalid(message))
419+ }
420+ Some(Problem::CustomProperty { name, .. }) => {
421+ Err(Error::Invalid(interp::custom_property_message(&name)))
422+ }
423+ }
424+ }
425+ Token::Var(name) => {
426+ interp::resolve(&name, self.scope).ok_or(Error::Undefined { name, offset: at })
427+ }
428+ Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
429+ Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
430+ Token::Word(name) if name == "if" && self.peek() == Some(&Token::LParen) => {
431+ self.pos += 1;
432+ self.choice()
433+ }
434+ Token::Word(name) if self.peek() == Some(&Token::LParen) => {
435+ self.pos += 1;
436+ let mut args = Vec::new();
437+ if self.peek() != Some(&Token::RParen) {
438+ args.push(self.expr()?);
439+ while self.peek() == Some(&Token::Comma) {
440+ self.pos += 1;
441+ args.push(self.expr()?);
442+ }
443+ }
444+ self.expect(Token::RParen)?;
445+ check_call(&name, args.len())?;
446+ if self.skip {
447+ return Ok(SKIPPED);
448+ }
449+ Ok(call(&name, args)?)
450+ }
451+ Token::Word(w) => Ok(Value::Str(w)),
452+ Token::LParen => {
453+ let value = self.expr()?;
454+ self.expect(Token::RParen)?;
455+ Ok(value)
456+ }
457+ other => Err(format!("unexpected {}", other).into()),
458+ }
459+ }
460+}
461+
462+impl Parser<'_> {
463+ /// `if(cond, a, b)` after its `(`: the branch that `cond` picks is
464+ /// evaluated, the other only read. Without `b`, a false `cond` gives
465+ /// an empty value.
466+ fn choice(&mut self) -> Result<Value, Error> {
467+ let arguments =
468+ || Error::Invalid("if() takes 2 or 3 arguments: if(CONDITION, A, B)".into());
469+ let condition = self.expr()?;
470+ match self.peek() {
471+ Some(Token::RParen) | None => return Err(arguments()),
472+ _ => self.expect(Token::Comma)?,
473+ }
474+ let taken = condition.truthy();
475+ let then = self.skipping(!taken, Self::expr)?;
476+ let otherwise = if self.peek() == Some(&Token::Comma) {
477+ self.pos += 1;
478+ self.skipping(taken, Self::expr)?
479+ } else {
480+ Value::Str(String::new())
481+ };
482+ if self.peek() == Some(&Token::Comma) {
483+ return Err(arguments());
484+ }
485+ self.expect(Token::RParen)?;
486+ Ok(if taken { then } else { otherwise })
487+ }
488+
489+ fn arithmetic(&self, op: &str, left: &Value, right: &Value) -> Result<Value, Error> {
490+ if self.skip {
491+ return Ok(SKIPPED);
492+ }
493+ Ok(arithmetic(op, left, right)?)
494+ }
495+}
496+
497+fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
498+ let (Some(a), Some(b)) = (left.as_num(), right.as_num()) else {
499+ return Err(format!("`{} {} {}` needs numbers", left, op, right));
500+ };
501+ Ok(Value::Num(match op {
502+ "+" => a + b,
503+ "-" => a - b,
504+ "*" => a * b,
505+ "/" if b == 0.0 => return Err("division by zero".to_string()),
506+ "/" => a / b,
507+ "%" if b == 0.0 => return Err("division by zero".to_string()),
508+ "%" => a % b,
509+ _ => unreachable!(),
510+ }))
511+}
512+
513+/// The built-in functions (other than `if`) and how many arguments each
514+/// takes.
515+const FUNCTIONS: &[(&str, usize)] = &[
516+ ("contains", 2),
517+ ("starts-with", 2),
518+ ("ends-with", 2),
519+ ("uppercase", 1),
520+ ("lowercase", 1),
521+ ("capitalize", 1),
522+ ("trim", 1),
523+ ("length", 1),
524+ ("reverse", 1),
525+ ("truncate", 2),
526+ ("replace", 3),
527+ ("default", 2),
528+];
529+
530+/// Check that `name` is a built-in function and gets `count` arguments.
531+fn check_call(name: &str, count: usize) -> Result<(), String> {
532+ let Some(&(_, arity)) = FUNCTIONS.iter().find(|(n, _)| *n == name) else {
533+ return Err(format!("unknown function `{}()`", name));
534+ };
535+ if count == arity {
536+ Ok(())
537+ } else {
538+ Err(format!(
539+ "{}() takes {} argument{}",
540+ name,
541+ arity,
542+ if arity == 1 { "" } else { "s" }
543+ ))
544+ }
545+}
546+
547+/// Call a built-in function with the right number of arguments (see
548+/// [`check_call`]). The text functions take text (a number or `true` and
549+/// `false` count as their text); a list or a record is an error, except
550+/// for `length`, `contains`, `reverse` and `default`, which also work on
551+/// a list's items.
552+fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
553+ let text = |i: usize| -> Result<String, String> {
554+ match &args[i] {
555+ value @ Value::List(_) => Err(format!(
556+ "{}() takes text, but `{}` is {}: to write one text with commas in \
557+ it, quote it or write `\\,`",
558+ name,
559+ value,
560+ value.describe()
561+ )),
562+ Value::Record(_) => Err(format!(
563+ "{}() takes text, but argument {} is a record: use one of its fields",
564+ name,
565+ i + 1
566+ )),
567+ value => Ok(value.to_string()),
568+ }
569+ };
570+ let num = |i: usize| {
571+ args.get(i)
572+ .and_then(Value::as_num)
573+ .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
574+ };
575+ Ok(match name {
576+ "contains" => Value::Bool(match &args[0] {
577+ Value::List(list) => list.items.iter().any(|item| item.equals(&args[1])),
578+ _ => text(0)?.contains(&text(1)?),
579+ }),
580+ "starts-with" => Value::Bool(text(0)?.starts_with(&text(1)?)),
581+ "ends-with" => Value::Bool(text(0)?.ends_with(&text(1)?)),
582+ "uppercase" => Value::Str(text(0)?.to_uppercase()),
583+ "lowercase" => Value::Str(text(0)?.to_lowercase()),
584+ "capitalize" => {
585+ let s = text(0)?;
586+ let mut chars = s.chars();
587+ Value::Str(match chars.next() {
588+ Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
589+ None => String::new(),
590+ })
591+ }
592+ "trim" => Value::Str(text(0)?.trim().to_string()),
593+ "length" => Value::Num(match &args[0] {
594+ Value::List(list) => list.items.len(),
595+ Value::Record(fields) => fields.len(),
596+ _ => text(0)?.chars().count(),
597+ } as f64),
598+ "reverse" => match &args[0] {
599+ Value::List(list) => Value::list(list.items.iter().rev().cloned().collect()),
600+ _ => Value::Str(text(0)?.chars().rev().collect()),
601+ },
602+ "truncate" => {
603+ let (s, n) = (text(0)?, num(1)? as usize);
604+ Value::Str(if s.chars().count() > n {
605+ format!("{}...", s.chars().take(n).collect::<String>())
606+ } else {
607+ s
608+ })
609+ }
610+ "replace" => Value::Str(text(0)?.replace(&text(1)?, &text(2)?)),
611+ "default" => {
612+ let empty = match &args[0] {
613+ Value::List(list) => list.items.is_empty(),
614+ Value::Record(_) => false,
615+ value => value.to_string().is_empty(),
616+ };
617+ let mut args = args;
618+ args.swap_remove(usize::from(empty))
619+ }
620+ _ => return Err(format!("unknown function `{}()`", name)),
621+ })
622+}
623+
624+#[cfg(test)]
625+mod tests {
626+ use super::*;
627+
628+ use crate::interp::tests::{Map, record, text_list};
629+
630+ fn scope() -> Map {
631+ Map::new(&[
632+ ("count", "3"),
633+ ("name", "World"),
634+ ("theme", "dark"),
635+ ("tricky", "a == b"),
636+ ("empty", ""),
637+ ])
638+ .with("tags", text_list(&["rust", "web dev"]))
639+ .with("post", record(&[("title", "Hi")]))
640+ }
641+
642+ fn ev(src: &str) -> Value {
643+ eval(src, &scope()).unwrap()
644+ }
645+
646+ #[test]
647+ fn arithmetic_has_precedence() {
648+ assert_eq!(ev("2 + 3 * 4").to_string(), "14");
649+ assert_eq!(ev("(2 + 3) * 4").to_string(), "20");
650+ assert_eq!(ev("$count * 2 - 1").to_string(), "5");
651+ assert_eq!(ev("7 / 2").to_string(), "3.5");
652+ assert_eq!(ev("-$count").to_string(), "-3");
653+ }
654+
655+ #[test]
656+ fn comparisons_and_logic() {
657+ assert!(ev("$count > 2").truthy());
658+ assert!(ev("$theme == dark").truthy());
659+ assert!(ev("$theme != light and not $empty").truthy());
660+ assert!(!ev("$count < 2 or $empty").truthy());
661+ assert!(ev("contains($name, or)").truthy());
662+ assert!(ev("starts-with($name, Wo) and ends-with($name, ld)").truthy());
663+ assert!(ev("uppercase($name) == WORLD").truthy());
664+ assert!(ev("#3b82f6 != red").truthy());
665+ }
666+
667+ #[test]
668+ fn variable_values_are_never_reparsed() {
669+ // The value contains `==`, but it's just a string.
670+ assert!(ev("$tricky == \"a == b\"").truthy());
671+ assert!(ev("$tricky").truthy());
672+ }
673+
674+ #[test]
675+ fn strings_and_if() {
676+ assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
677+ assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
678+ assert_eq!(ev("${name}").to_string(), "World");
679+ assert_eq!(ev("${$count + 1} * 2").to_string(), "8");
680+ }
681+
682+ #[test]
683+ fn only_the_branch_taken_is_evaluated() {
684+ let zero = Map::new(&[("n", "0"), ("on", "true")]);
685+ let ev0 = |src: &str| eval(src, &zero).map(|v| v.to_string());
686+ assert_eq!(ev0("if($n != 0, 10 / $n, 0)"), Ok("0".into()));
687+ assert_eq!(ev0("if($n == 0, none, 10 / $n)"), Ok("none".into()));
688+ assert_eq!(ev0("if($on, 24)"), Ok("24".into()));
689+ assert_eq!(ev0("if(not $on, 24)"), Ok("".into()));
690+ assert_eq!(
691+ ev0("if($on, \"#10b981\", \"var(--muted)\")"),
692+ Ok("#10b981".into())
693+ );
694+ // and / or stop at the side that decides
695+ assert_eq!(ev0("$n != 0 and 10 / $n > 1"), Ok("false".into()));
696+ assert_eq!(ev0("$n == 0 or 10 / $n > 1"), Ok("true".into()));
697+ assert_eq!(ev0("$on or truncate(abc, x)"), Ok("true".into()));
698+ // The branch not taken is still read: its syntax, function names and
699+ // argument counts are checked; its variables and values are not
700+ assert_eq!(ev0("if($on, 1, $undefined)"), Ok("1".into()));
701+ assert!(eval("if($on, 1, (2)", &zero).is_err());
702+ assert!(eval("if($on, 1, nope(2))", &zero).is_err());
703+ assert!(eval("if($on, 1, uppercase(a, b))", &zero).is_err());
704+ // if() itself takes 2 or 3 arguments
705+ assert!(eval("if($on)", &zero).is_err());
706+ assert!(eval("if($on, 1, 2, 3)", &zero).is_err());
707+ // Something else after the condition is what is reported
708+ let error = eval("if($n.x, 1, 2)", &zero).unwrap_err().to_string();
709+ assert!(error.contains("`.x`"), "{}", error);
710+ // The taken branch still reports its errors
711+ assert!(eval("if($n == 0, 10 / $n, 0)", &zero).is_err());
712+ assert!(eval("$n == 0 and 10 / $n > 1", &zero).is_err());
713+ }
714+
715+ #[test]
716+ fn names_and_fields() {
717+ assert_eq!(ev("$post.title").to_string(), "Hi");
718+ // A field the record doesn't have is empty
719+ assert_eq!(ev("default($post.draft, no)").to_string(), "no");
720+ assert_eq!(
721+ eval("$missing == 1", &scope()),
722+ Err(Error::Undefined {
723+ name: "missing".into(),
724+ offset: 0
725+ })
726+ );
727+ assert_eq!(
728+ eval("\"a $nope\"", &scope()),
729+ Err(Error::Undefined {
730+ name: "nope".into(),
731+ offset: 3
732+ })
733+ );
734+ }
735+
736+ #[test]
737+ fn functions() {
738+ assert_eq!(ev("uppercase($name)").to_string(), "WORLD");
739+ assert_eq!(ev("length($name) + 1").to_string(), "6");
740+ assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
741+ assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
742+ assert_eq!(ev("default($empty, none)").to_string(), "none");
743+ }
744+
745+ #[test]
746+ fn lists() {
747+ assert_eq!(ev("length($tags)").to_string(), "2");
748+ assert!(ev("contains($tags, \"web dev\")").truthy());
749+ assert!(!ev("contains($tags, web)").truthy());
750+ assert_eq!(ev("$tags").to_string(), "rust, web dev");
751+ assert_eq!(ev("reverse($tags)").to_string(), "web dev, rust");
752+ assert_eq!(ev("default($tags, none)").to_string(), "rust, web dev");
753+ // Text functions don't guess what a list means
754+ let error = eval("uppercase($tags)", &scope()).unwrap_err().to_string();
755+ assert!(error.contains("a list of 2 items"), "{}", error);
756+ assert!(eval("truncate($post, 2)", &scope()).is_err());
757+ }
758+
759+ #[test]
760+ fn ranges() {
761+ assert_eq!(ev("1..5").to_string(), "1, 2, 3, 4, 5");
762+ assert_eq!(ev("reverse(1..5)").to_string(), "5, 4, 3, 2, 1");
763+ assert_eq!(ev("length(0..10 step 5)").to_string(), "3");
764+ assert_eq!(ev("$count..1").to_string(), "3, 2, 1");
765+ assert_eq!(ev("1..$count + 1").to_string(), "1, 2, 3, 4");
766+ assert!(ev("contains(1..3, 2)").truthy());
767+ assert!(eval("1..x", &scope()).is_err());
768+ assert!(eval("1..5 step 0", &scope()).is_err());
769+ }
770+
771+ #[test]
772+ fn truthiness_and_comparison_follow_the_types() {
773+ let zero = Map::new(&[("z", "0.0"), ("upper", "#FFF")]);
774+ assert!(!eval("$z", &zero).unwrap().truthy());
775+ assert!(eval("$z == 0", &zero).unwrap().truthy());
776+ assert!(!eval("$upper == #fff", &zero).unwrap().truthy());
777+ assert_eq!(ev("100 / 3").to_string(), "33.3333");
778+ assert!(eval("1..3 < 2", &scope()).is_err());
779+ }
780+
781+ #[test]
782+ fn errors_are_reported() {
783+ let none = Map::new(&[]);
784+ assert!(eval("dark * 2", &none).is_err());
785+ assert!(eval("1 +", &none).is_err());
786+ assert!(eval("(1", &none).is_err());
787+ assert!(eval("1 / 0", &none).is_err());
788+ assert!(eval("nope(1)", &none).is_err());
789+ assert!(eval("uppercase(a, b)", &none).is_err());
790+ // Colour maths is CSS's (`color-mix()`), and a custom property is
791+ // read with `var()`
792+ for gone in ["darken", "lighten", "alpha", "mix"] {
793+ let error = eval(&format!("{}(red, 10)", gone), &none).unwrap_err();
794+ assert!(error.to_string().contains("unknown function"), "{}", error);
795+ }
796+ let error = eval("$--brand == red", &none).unwrap_err().to_string();
797+ assert!(error.contains("`var(--brand)`"), "{}", error);
798+ let error = eval("\"a $--brand\"", &none).unwrap_err().to_string();
799+ assert!(error.contains("`var(--brand)`"), "{}", error);
800+ }
801+}
crates/htmlang-core/src/interp.rsnew file+651
@@ -0,0 +1,651 @@
1+//! Variables in htmlang strings: `$name`, `$record.field` and `${...}`.
2+//!
3+//! The syntax tree finds every slot first: a run of text, an attribute's
4+//! value, an element's argument, a `@let` value, a file path, the title of
5+//! `@page`, the value of `@meta`. Each slot is then interpolated here, once.
6+//! A reference fills its own slot, and what it inserts is never read again
7+//! as htmlang: a value can't become an attribute name, a whole attribute, a
8+//! comma between attributes, markup or another `$name`.
9+//!
10+//! - `$` followed by a letter or `_` starts a name, which goes on with
11+//! letters, digits, `_` and `-` (a `-` only when a name character follows
12+//! it).
13+//! - `$--name` is an error: a custom property is read one way, with
14+//! `var(--name)`, in the browser, so it follows a token redefined under
15+//! `dark:`. See [`custom_property_len`].
16+//! - `.field` continues the name only when the value is a record or a list
17+//! (`$post.title`, `$tags.0`). After `@let lang fr`, `$lang.json` is
18+//! `fr.json`, and `$a..$b` is two names.
19+//! - `${name}` is the same name, delimited (`${size}px`), and `${EXPR}`
20+//! inserts the value of an expression (see `expr.rs`).
21+//! - A `$` before anything else is text: `$5`, `$$`, a `$` at the end.
22+//! - An undefined name is an error. A field that a record doesn't have is
23+//! empty, and so are its fields (`$post.author.name` when `$post` has no
24+//! `author`), so optional fields of `@data` records work.
25+//! - Quoted text (`@let arrow "→ "`, `@card [quote "→ "]`) remembers its
26+//! quotes: a CSS value gets them (`content $arrow` is `content:"→ "`),
27+//! text and HTML attribute values don't. See [`Sink`].
28+
29+use crate::expr;
30+use crate::value::Value;
31+
32+/// The variables in scope, as the evaluator stores them.
33+pub trait Scope {
34+ /// The value of a variable, by its name (without fields).
35+ fn get(&self, name: &str) -> Option<Value>;
36+}
37+
38+/// Where a slot's value goes, which decides what quoted text inserts.
39+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40+pub enum Sink {
41+ /// Text, an HTML attribute value, a path, a parameter: quoted text
42+ /// inserts what it says, without the quotes.
43+ Text,
44+ /// A CSS value: quoted text inserts itself with its quotes, or, inside a
45+ /// quoted string of the value, what it says with `"` escaped as `\"`.
46+ Css,
47+}
48+
49+/// Whether a CSS value is inside a quoted string after `text`, when it
50+/// was (`inside`) before it. A backslash inside a string escapes the next
51+/// character.
52+fn css_string_after(text: &str, mut inside: bool) -> bool {
53+ let mut chars = text.chars();
54+ while let Some(c) = chars.next() {
55+ match c {
56+ '\\' if inside => {
57+ chars.next();
58+ }
59+ '"' => inside = !inside,
60+ _ => {}
61+ }
62+ }
63+ inside
64+}
65+
66+/// `text` as it goes inside a CSS string: `"` escaped as `\"`. A backslash
67+/// and the character after it are a CSS escape and stay as written
68+/// (`\201C`), but a backslash at the end is doubled, so it can't escape
69+/// the rest of the string (`a\` gives `a\\`).
70+pub fn css_string_body(text: &str) -> String {
71+ let mut out = String::with_capacity(text.len());
72+ let mut chars = text.chars();
73+ while let Some(c) = chars.next() {
74+ match c {
75+ '\\' => {
76+ out.push('\\');
77+ out.push(chars.next().unwrap_or('\\'));
78+ }
79+ '"' => out.push_str("\\\""),
80+ _ => out.push(c),
81+ }
82+ }
83+ out
84+}
85+
86+/// A problem found while interpolating, at a byte offset into the text.
87+#[derive(Debug, Clone, PartialEq)]
88+pub enum Problem {
89+ /// A name with no definition.
90+ Undefined { name: String, offset: usize },
91+ /// `$--name`: a custom property, which is read with `var(--name)`.
92+ CustomProperty { name: String, offset: usize },
93+ /// A `${...}` whose expression doesn't evaluate.
94+ Invalid { message: String, offset: usize },
95+ /// A record put where text goes, which it has none of: it would print
96+ /// nothing.
97+ Record { message: String, offset: usize },
98+}
99+
100+/// The problem with putting `value`, written `written`, where text goes:
101+/// a record has no text.
102+fn no_text(written: &str, value: &Value, offset: usize) -> Option<Problem> {
103+ let name = written.strip_prefix('$').map(|name| {
104+ name.strip_prefix('{')
105+ .and_then(|n| n.strip_suffix('}'))
106+ .map_or(name, str::trim)
107+ });
108+ let fields = match value {
109+ Value::Record(fields) => fields,
110+ // A list of records, which would print only what isn't a record
111+ Value::List(list)
112+ if list.written.is_none()
113+ && list
114+ .items
115+ .iter()
116+ .any(|item| matches!(item, Value::Record(_))) =>
117+ {
118+ let example = match (name, list.items.first()) {
119+ (Some(name), Some(Value::Record(fields))) if is_path(name) => {
120+ match fields.first() {
121+ Some((field, _)) => format!(", such as `${}.0.{}`", name, field),
122+ None => String::new(),
123+ }
124+ }
125+ _ => String::new(),
126+ };
127+ return Some(Problem::Record {
128+ message: format!(
129+ "'{}' is a list of records, which has no text of its own: loop over it \
130+ with @each, or write a field of one of its items{}",
131+ written, example
132+ ),
133+ offset,
134+ });
135+ }
136+ _ => return None,
137+ };
138+ let example = match (name, fields.first()) {
139+ (Some(name), Some((field, _))) if is_path(name) => {
140+ format!(", such as `${}.{}`", name, field)
141+ }
142+ _ => String::new(),
143+ };
144+ Some(Problem::Record {
145+ message: format!(
146+ "'{}' is a record, which has no text of its own: write one of its fields{}",
147+ written, example
148+ ),
149+ offset,
150+ })
151+}
152+
153+fn is_name_char(c: char) -> bool {
154+ c.is_alphanumeric() || c == '_'
155+}
156+
157+/// The length of a word at the start of `s` whose first character passes
158+/// `first`: name characters, with `-` between them.
159+fn word_len(s: &str, first: impl Fn(char) -> bool) -> usize {
160+ let Some(c) = s.chars().next().filter(|&c| first(c)) else {
161+ return 0;
162+ };
163+ let mut end = c.len_utf8();
164+ let mut i = end;
165+ while let Some(c) = s[i..].chars().next() {
166+ if is_name_char(c) {
167+ i += c.len_utf8();
168+ end = i;
169+ } else if c == '-' {
170+ let rest = &s[i..];
171+ let dashes = rest.len() - rest.trim_start_matches('-').len();
172+ if !rest[dashes..].starts_with(is_name_char) {
173+ break;
174+ }
175+ i += dashes;
176+ } else {
177+ break;
178+ }
179+ }
180+ end
181+}
182+
183+/// The length of the variable name at the start of `s` (the text after a
184+/// `$`), by syntax alone, or 0 when `s` doesn't start one.
185+pub fn name_len(s: &str) -> usize {
186+ word_len(s, |c| c.is_alphabetic() || c == '_')
187+}
188+
189+/// The length of the custom property's name `--name` at the start of `s`
190+/// (the text after a `$`), or 0. `$--name` is not a variable and not text
191+/// either: it is an error that says to write `var(--name)`.
192+pub fn custom_property_len(s: &str) -> usize {
193+ match s.strip_prefix("--") {
194+ Some(rest) => match word_len(rest, |c| c.is_alphanumeric() || c == '_') {
195+ 0 => 0,
196+ n => n + 2,
197+ },
198+ None => 0,
199+ }
200+}
201+
202+/// What is wrong with `$NAME`, where `name` is a custom property's name
203+/// (`--brand`).
204+pub fn custom_property_message(name: &str) -> String {
205+ format!(
206+ "`${}` is not a variable: a custom property is read with `var({})`",
207+ name, name
208+ )
209+}
210+
211+/// Whether `s` is a whole name path, `name` or `name.field.field`.
212+fn is_path(s: &str) -> bool {
213+ let n = name_len(s);
214+ if n == 0 || matches!(&s[..n], "true" | "false" | "not" | "and" | "or") {
215+ return false;
216+ }
217+ let mut rest = &s[n..];
218+ while let Some(field) = rest.strip_prefix('.') {
219+ let f = word_len(field, is_name_char);
220+ if f == 0 {
221+ return false;
222+ }
223+ rest = &field[f..];
224+ }
225+ rest.is_empty()
226+}
227+
228+/// What a name path leads to so far: a value, or a field that a record
229+/// (or an item that a list) doesn't have, which is empty, and so is every
230+/// field of it.
231+#[derive(Clone)]
232+enum Step {
233+ Value(Value),
234+ Missing,
235+}
236+
237+impl Step {
238+ /// The field `name` of this step, or `None` when it has no fields
239+ /// (text, a number, true or false).
240+ fn field(&self, name: &str) -> Option<Step> {
241+ let Step::Value(value) = self else {
242+ return Some(Step::Missing);
243+ };
244+ match value {
245+ Value::Record(fields) => Some(
246+ fields
247+ .iter()
248+ .find(|(key, _)| key == name)
249+ .map_or(Step::Missing, |(_, value)| Step::Value(value.clone())),
250+ ),
251+ Value::List(list) => Some(
252+ name.parse::<usize>()
253+ .ok()
254+ .and_then(|i| list.items.get(i).cloned())
255+ .map_or(Step::Missing, Step::Value),
256+ ),
257+ _ => None,
258+ }
259+ }
260+
261+ fn value(self) -> Value {
262+ match self {
263+ Step::Value(value) => value,
264+ Step::Missing => Value::empty(),
265+ }
266+ }
267+}
268+
269+/// The name path at the start of `s` (after a `$`) and its length: the
270+/// name, and each `.field` while the value so far is a record or a list,
271+/// or a field one of them doesn't have.
272+fn path_at(s: &str, scope: &dyn Scope) -> Option<(String, usize)> {
273+ let n = name_len(s);
274+ if n == 0 {
275+ return None;
276+ }
277+ let mut path = s[..n].to_string();
278+ let mut len = n;
279+ let mut step = scope.get(&path).map(Step::Value);
280+ while let Some(rest) = s[len..].strip_prefix('.') {
281+ let f = word_len(rest, is_name_char);
282+ let Some(field) = step
283+ .as_ref()
284+ .filter(|_| f > 0)
285+ .and_then(|v| v.field(&rest[..f]))
286+ else {
287+ break;
288+ };
289+ path = format!("{}.{}", path, &rest[..f]);
290+ len += 1 + f;
291+ step = Some(field);
292+ }
293+ Some((path, len))
294+}
295+
296+/// A reference, after its `$`.
297+#[derive(Debug, Clone, PartialEq)]
298+pub enum Reference<'a> {
299+ /// `$name`, `$name.field` or `${name}`: the name path.
300+ Var(String),
301+ /// `${EXPR}`: the expression.
302+ Expr(&'a str),
303+}
304+
305+/// The reference that `s` (the text after a `$`) starts with, and its
306+/// length. `None` when the `$` is text.
307+pub fn reference<'a>(s: &'a str, scope: &dyn Scope) -> Option<(Reference<'a>, usize)> {
308+ if s.starts_with('{') {
309+ let close = matching_brace(s)?;
310+ let inner = &s[1..close];
311+ let reference = if is_path(inner.trim()) {
312+ Reference::Var(inner.trim().to_string())
313+ } else {
314+ Reference::Expr(inner)
315+ };
316+ return Some((reference, close + 1));
317+ }
318+ let (path, len) = path_at(s, scope)?;
319+ Some((Reference::Var(path), len))
320+}
321+
322+/// The value of a name path, or `None` when it is undefined. A field that
323+/// a record doesn't have is empty, and so is any field of it.
324+pub fn resolve(path: &str, scope: &dyn Scope) -> Option<Value> {
325+ let mut parts = path.split('.');
326+ let mut step = Step::Value(scope.get(parts.next()?)?);
327+ for field in parts {
328+ step = step.field(field)?;
329+ }
330+ Some(step.value())
331+}
332+
333+/// Fill in the `$name`s and `${...}`s of one slot's text, for text (see
334+/// [`Sink::Text`]). Each one that can't be filled is left as written and
335+/// reported.
336+pub fn interpolate(text: &str, scope: &dyn Scope) -> (String, Vec<Problem>) {
337+ interpolate_for(text, scope, Sink::Text)
338+}
339+
340+/// [`interpolate`] for a slot whose value goes to `sink`.
341+pub fn interpolate_for(text: &str, scope: &dyn Scope, sink: Sink) -> (String, Vec<Problem>) {
342+ let mut problems = Vec::new();
343+ if !text.contains('$') {
344+ return (text.to_string(), problems);
345+ }
346+ let mut out = String::with_capacity(text.len());
347+ let mut pos = 0;
348+ // Whether the CSS value is inside a quoted string at `pos`
349+ let mut in_string = false;
350+ while let Some(found) = text[pos..].find('$') {
351+ let dollar = pos + found;
352+ out.push_str(&text[pos..dollar]);
353+ if sink == Sink::Css {
354+ in_string = css_string_after(&text[pos..dollar], in_string);
355+ }
356+ let after = &text[dollar + 1..];
357+ let Some((reference, len)) = reference(after, scope) else {
358+ let n = custom_property_len(after);
359+ if n > 0 {
360+ problems.push(Problem::CustomProperty {
361+ name: after[..n].to_string(),
362+ offset: dollar,
363+ });
364+ }
365+ out.push_str(&text[dollar..dollar + 1 + n]);
366+ pos = dollar + 1 + n;
367+ continue;
368+ };
369+ let written = &text[dollar..dollar + 1 + len];
370+ let insert = |value: String, quoted: Option<String>| match (sink, quoted) {
371+ (Sink::Css, Some(css)) if in_string => {
372+ // The string's body, without its quotes
373+ let body = css.strip_prefix('"').unwrap_or(&css);
374+ body.strip_suffix('"').unwrap_or(body).to_string()
375+ }
376+ (Sink::Css, Some(css)) => css,
377+ (Sink::Css, None) if in_string => css_string_body(&value),
378+ _ => value,
379+ };
380+ match reference {
381+ Reference::Var(path) => match resolve(&path, scope) {
382+ Some(value) => match no_text(written, &value, dollar) {
383+ Some(problem) => problems.push(problem),
384+ None => out.push_str(&insert(
385+ value.to_string(),
386+ value.quoted_css().map(str::to_string),
387+ )),
388+ },
389+ None => {
390+ problems.push(Problem::Undefined {
391+ name: path,
392+ offset: dollar,
393+ });
394+ out.push_str(written);
395+ }
396+ },
397+ Reference::Expr(source) => match expr::eval(source, scope) {
398+ Ok(value) => match no_text(written, &value, dollar) {
399+ Some(problem) => problems.push(problem),
400+ None => out.push_str(&insert(value.to_string(), None)),
401+ },
402+ Err(error) => {
403+ problems.push(match error {
404+ // At the `${`
405+ expr::Error::Invalid(message) => Problem::Invalid {
406+ message,
407+ offset: dollar,
408+ },
409+ // At the name, inside the braces
410+ error => error.at(dollar + 2),
411+ });
412+ out.push_str(written);
413+ }
414+ },
415+ }
416+ pos = dollar + 1 + len;
417+ }
418+ out.push_str(&text[pos..]);
419+ (out, problems)
420+}
421+
422+/// The names `text` refers to, by syntax alone: the first name of each
423+/// path, including those inside `${...}`.
424+pub fn names(text: &str) -> Vec<&str> {
425+ name_spans(text)
426+ .into_iter()
427+ .map(|span| &text[span])
428+ .collect()
429+}
430+
431+/// Where the names of [`names`] are in `text`, as byte ranges of the name
432+/// alone: after the `$` of `$name`, inside the braces of `${name}`.
433+pub fn name_spans(text: &str) -> Vec<std::ops::Range<usize>> {
434+ let mut found = Vec::new();
435+ collect_name_spans(text, 0, &mut found);
436+ found
437+}
438+
439+fn collect_name_spans(text: &str, base: usize, found: &mut Vec<std::ops::Range<usize>>) {
440+ let mut pos = 0;
441+ while let Some(i) = text[pos..].find('$') {
442+ let after = pos + i + 1;
443+ let rest = &text[after..];
444+ pos = after;
445+ // `\$` is a dollar sign (and `\\$` a backslash before a name)
446+ let backslashes = text[..after - 1].len() - text[..after - 1].trim_end_matches('\\').len();
447+ if backslashes % 2 == 1 {
448+ continue;
449+ }
450+ if rest.starts_with('{')
451+ && let Some(close) = matching_brace(rest)
452+ {
453+ let inner = &rest[1..close];
454+ let trimmed = inner.trim_start();
455+ if is_path(trimmed.trim_end()) {
456+ let start = base + after + 1 + inner.len() - trimmed.len();
457+ found.push(start..start + name_len(trimmed));
458+ } else {
459+ collect_name_spans(inner, base + after + 1, found);
460+ }
461+ pos = after + close + 1;
462+ continue;
463+ }
464+ let n = name_len(rest);
465+ if n > 0 {
466+ found.push(base + after..base + after + n);
467+ pos = after + n;
468+ }
469+ }
470+}
471+
472+/// Index of the `}` matching the `{` that `s` starts with (skipping
473+/// braces inside string literals).
474+pub(crate) fn matching_brace(s: &str) -> Option<usize> {
475+ let mut depth = 0;
476+ let mut in_string = false;
477+ for (i, c) in s.char_indices() {
478+ match c {
479+ '"' => in_string = !in_string,
480+ _ if in_string => {}
481+ '{' => depth += 1,
482+ '}' => {
483+ depth -= 1;
484+ if depth == 0 {
485+ return Some(i);
486+ }
487+ }
488+ _ => {}
489+ }
490+ }
491+ None
492+}
493+
494+#[cfg(test)]
495+pub(crate) mod tests {
496+ use super::*;
497+ use std::collections::HashMap;
498+
499+ /// Variables by name: text from `new`, any value from `with`.
500+ pub(crate) struct Map(pub HashMap<String, Value>);
501+
502+ impl Map {
503+ pub(crate) fn new(pairs: &[(&str, &str)]) -> Self {
504+ Map(pairs
505+ .iter()
506+ .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
507+ .collect())
508+ }
509+
510+ pub(crate) fn with(mut self, name: &str, value: Value) -> Self {
511+ self.0.insert(name.to_string(), value);
512+ self
513+ }
514+ }
515+
516+ impl Scope for Map {
517+ fn get(&self, name: &str) -> Option<Value> {
518+ self.0.get(name).cloned()
519+ }
520+ }
521+
522+ pub(crate) fn text_list(items: &[&str]) -> Value {
523+ Value::list(items.iter().map(|s| Value::Str(s.to_string())).collect())
524+ }
525+
526+ pub(crate) fn record(fields: &[(&str, &str)]) -> Value {
527+ Value::Record(std::rc::Rc::new(
528+ fields
529+ .iter()
530+ .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
531+ .collect(),
532+ ))
533+ }
534+
535+ fn vars() -> Map {
536+ Map::new(&[("lang", "fr"), ("n", "3"), ("n-1", "two")])
537+ .with("post", record(&[("title", "Hello")]))
538+ .with("tags", text_list(&["a", "b"]))
539+ }
540+
541+ fn fill(text: &str) -> String {
542+ interpolate(text, &vars()).0
543+ }
544+
545+ #[test]
546+ fn names_end_where_they_must() {
547+ assert_eq!(fill("locales/$lang.json"), "locales/fr.json");
548+ assert_eq!(fill("$post.title!"), "Hello!");
549+ assert_eq!(fill("$tags.1 and $tags"), "b and a, b");
550+ assert_eq!(fill("${lang}uage"), "fruage");
551+ assert_eq!(fill("$lang- $lang:"), "fr- fr:");
552+ assert_eq!(fill("$n-1"), "two");
553+ assert_eq!(fill("1..$n"), "1..3");
554+ // A name ends at `..`
555+ assert_eq!(fill("$n..$n $post.title..x"), "3..3 Hello..x");
556+ }
557+
558+ #[test]
559+ fn a_dollar_before_anything_else_is_text() {
560+ assert_eq!(fill("$5 and $$ and $"), "$5 and $$ and $");
561+ assert_eq!(fill("a $-x ${"), "a $-x ${");
562+ }
563+
564+ #[test]
565+ fn a_custom_property_is_not_a_variable() {
566+ let (out, problems) = interpolate("x $--brand-dark $-- $--1", &vars());
567+ assert_eq!(out, "x $--brand-dark $-- $--1");
568+ assert_eq!(
569+ problems,
570+ [
571+ Problem::CustomProperty {
572+ name: "--brand-dark".into(),
573+ offset: 2
574+ },
575+ Problem::CustomProperty {
576+ name: "--1".into(),
577+ offset: 20
578+ }
579+ ]
580+ );
581+ assert!(custom_property_message("--brand").contains("`var(--brand)`"));
582+ assert_eq!(names("$--e $f"), ["f"]);
583+ }
584+
585+ #[test]
586+ fn missing_fields_are_empty_and_undefined_names_are_errors() {
587+ assert_eq!(fill("[$post.summary]"), "[]");
588+ let (out, problems) = interpolate("x $nope y", &vars());
589+ assert_eq!(out, "x $nope y");
590+ assert_eq!(
591+ problems,
592+ [Problem::Undefined {
593+ name: "nope".into(),
594+ offset: 2
595+ }]
596+ );
597+ }
598+
599+ #[test]
600+ fn expressions() {
601+ assert_eq!(fill("${$n * 2}px"), "6px");
602+ let (_, problems) = interpolate("a ${$n + $m}", &vars());
603+ assert_eq!(
604+ problems,
605+ [Problem::Undefined {
606+ name: "m".into(),
607+ offset: 9
608+ }]
609+ );
610+ }
611+
612+ #[test]
613+ fn quoted_text_keeps_its_quotes_only_in_css() {
614+ let quoted = Value::Quoted(crate::ast::Quoted {
615+ text: r#"a"b"#.into(),
616+ css: r#""a\"b""#.into(),
617+ });
618+ let scope = Map::new(&[("n", r#"x"y"#)]).with("q", quoted);
619+ let fill = |text: &str, sink| interpolate_for(text, &scope, sink).0;
620+ assert_eq!(fill("$q", Sink::Text), r#"a"b"#);
621+ assert_eq!(fill("$q", Sink::Css), r#""a\"b""#);
622+ // Inside a CSS string, what it says, escaped as the string needs
623+ assert_eq!(
624+ fill(r#""($q) $n" $q"#, Sink::Css),
625+ r#""(a\"b) x\"y" "a\"b""#
626+ );
627+ assert_eq!(fill(r#""\" $q""#, Sink::Css), r#""\" a\"b""#);
628+ // What an unquoted value holds goes into a string that stays valid
629+ let scope = Map::new(&[("end", r"a\"), ("esc", r"\201C"), ("q", r#"x\"y"#)]);
630+ let fill = |text: &str| interpolate_for(text, &scope, Sink::Css).0;
631+ assert_eq!(fill(r#""$end""#), r#""a\\""#);
632+ assert_eq!(fill(r#""$esc""#), r#""\201C""#);
633+ assert_eq!(fill(r#""$q""#), r#""x\"y""#);
634+ }
635+
636+ #[test]
637+ fn inserted_values_are_never_read_again() {
638+ let scope = Map::new(&[("a", "$b"), ("b", "no")]);
639+ assert_eq!(interpolate("$a", &scope).0, "$b");
640+ }
641+
642+ #[test]
643+ fn names_by_syntax() {
644+ assert_eq!(names("$a.b ${c} ${upper($d)} $5 $--e"), ["a", "c", "d"]);
645+ assert_eq!(names(r"\$a \\$b \\\$c"), ["b"]);
646+ assert_eq!(
647+ name_spans("x ${ size }px ${$n + ${m}} $a- b"),
648+ [5..9, 17..18, 23..24, 28..29]
649+ );
650+ }
651+}
crates/htmlang-core/src/lib.rs+6
@@ -1,6 +1,12 @@
1pub mod ast;
1pub mod ast;
2pub mod codegen;
2pub mod codegen;
3+pub mod diagnostic;
4+pub mod expr;
5+pub mod interp;
3pub mod parser;
6pub mod parser;
7+pub mod syntax;
8+pub mod value;
9+pub mod vocab;
4
10
5#[cfg(test)]
11#[cfg(test)]
6mod codegen_tests;
12mod codegen_tests;
crates/htmlang-core/src/parser.rs+6194−4801

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

editors/vscode/README.md+86−27

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

examples/blog.hl+86−49

This diff is too big to show here.

examples/blog.html.map+1−1

This diff is too big to show here.

examples/demo.hl+276−303

This diff is too big to show here.

examples/demo.html+800−3

This diff is too big to show here.

examples/demo.html.map+1−1

This diff is too big to show here.

examples/docs.hl+132−62

This diff is too big to show here.

examples/docs.html.map+1−1

This diff is too big to show here.

examples/landing.hl+131−66

This diff is too big to show here.

examples/landing.html.map+1−1

This diff is too big to show here.

examples/posts.jsonnew file+27

This diff is too big to show here.

src/bin/htmlang_lsp/analysis.rs+1297−966

This diff is too big to show here.

src/bin/htmlang_lsp/completion.rs+1481−1124

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

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+34−273

This diff is too big to show here.

src/convert.rsdeleted−1434

This diff is too big to show here.

src/fmt.rs+401−408

This diff is too big to show here.

src/lib.rs+4−2

This diff is too big to show here.

src/main.rs+465−2895

This diff is too big to show here.

src/playground_default.hldeleted−330

This diff is too big to show here.

src/serve.rs+128−157

This diff is too big to show here.

src/upgrade.rsdeleted−626

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

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+2222−53

This diff is too big to show here.

tests/snapshots.rs+4522−1224

This diff is too big to show here.

tests/snapshots/accessibility.hl+5−5

This diff is too big to show here.

tests/snapshots/accessibility.html+1−1

This diff is too big to show here.

tests/snapshots/alignment.hl+1−1

This diff is too big to show here.

tests/snapshots/alignment.html+1−1

This diff is too big to show here.

tests/snapshots/anchor_positioning.html+1−1

This diff is too big to show here.

tests/snapshots/animate_shorthand.hl+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.hl+4−4

This diff is too big to show here.

tests/snapshots/arithmetic.html+1−1

This diff is too big to show here.

tests/snapshots/assert_directive.hldeleted−10

This diff is too big to show here.

tests/snapshots/assert_directive.htmldeleted−1

This diff is too big to show here.

tests/snapshots/attributes.hl+5−5

This diff is too big to show here.

tests/snapshots/attributes.html+1−1

This diff is too big to show here.

tests/snapshots/basic_elements.hl+2−2

This diff is too big to show here.

tests/snapshots/basic_elements.html+1−1

This diff is too big to show here.

tests/snapshots/breadcrumb.hldeleted−4

This diff is too big to show here.

tests/snapshots/breadcrumb.htmldeleted−1

This diff is too big to show here.

tests/snapshots/chain_operator.hl+1−1

This diff is too big to show here.

tests/snapshots/chain_operator.html+1−1

This diff is too big to show here.

tests/snapshots/clamp_css.hl+1−1

This diff is too big to show here.

tests/snapshots/clamp_css.html+1−1

This diff is too big to show here.

tests/snapshots/code_samples.hlnew file+28

This diff is too big to show here.

tests/snapshots/code_samples.htmlnew file+5

This diff is too big to show here.

tests/snapshots/color_scheme.hl+1−1

This diff is too big to show here.

tests/snapshots/color_scheme.html+1−1

This diff is too big to show here.

tests/snapshots/colour_variants.hlnew file+12

This diff is too big to show here.

tests/snapshots/colour_variants.htmlnew file+1

This diff is too big to show here.

tests/snapshots/compat_test.hl+1−1

This diff is too big to show here.

tests/snapshots/computed_let.hl+3−3

This diff is too big to show here.

tests/snapshots/computed_let.html+1−1

This diff is too big to show here.

tests/snapshots/conditionals.html+1−1

This diff is too big to show here.

tests/snapshots/container_queries.hl+2−2

This diff is too big to show here.

tests/snapshots/container_queries.html+1−1

This diff is too big to show here.

tests/snapshots/convert_basic.hl+1−1

This diff is too big to show here.

tests/snapshots/css_property.html+1−1

This diff is too big to show here.

tests/snapshots/css_shorthands.hl+7−10

This diff is too big to show here.

tests/snapshots/css_shorthands.html+1−1

This diff is too big to show here.

tests/snapshots/css_units.hl+3−3

This diff is too big to show here.

tests/snapshots/css_units.html+1−1

This diff is too big to show here.

tests/snapshots/css_vars.hl+5−5

This diff is too big to show here.

tests/snapshots/css_vars.html+1−1

This diff is too big to show here.

tests/snapshots/custom_properties.hlnew file+47

This diff is too big to show here.

tests/snapshots/custom_properties.htmlnew file+1

This diff is too big to show here.

tests/snapshots/dark_mode.hl+2−2

This diff is too big to show here.

tests/snapshots/dark_mode.html+1−1

This diff is too big to show here.

tests/snapshots/data_directive.hl+7−6

This diff is too big to show here.

tests/snapshots/data_directive.html+1−1

This diff is too big to show here.

tests/snapshots/direction_attr.html+1−1

This diff is too big to show here.

tests/snapshots/each_destructuring.hl+4−3

This diff is too big to show here.

tests/snapshots/each_destructuring.html+1−1

This diff is too big to show here.

tests/snapshots/each_else.hl+2−2

This diff is too big to show here.

tests/snapshots/each_else.html+1−1

This diff is too big to show here.

tests/snapshots/each_index.html+1−1

This diff is too big to show here.

tests/snapshots/each_range.html+1−1

This diff is too big to show here.

tests/snapshots/each_step.hl+4−4

This diff is too big to show here.

tests/snapshots/each_step.html+1−1

This diff is too big to show here.

tests/snapshots/element_prefixes.hlnew file+42

This diff is too big to show here.

tests/snapshots/element_prefixes.htmlnew file+3

This diff is too big to show here.

tests/snapshots/else_if.html+1−1

This diff is too big to show here.

tests/snapshots/env_directive.hl+1−1

This diff is too big to show here.

tests/snapshots/env_directive.html+1−1

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.hlnew file+19

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.htmlnew file+1

This diff is too big to show here.

tests/snapshots/extra_css.hl+3−3

This diff is too big to show here.

tests/snapshots/extra_css.html+1−1

This diff is too big to show here.

tests/snapshots/fn_defaults.hl+2−2

This diff is too big to show here.

tests/snapshots/fn_defaults.html+1−1

This diff is too big to show here.

tests/snapshots/form_elements.hl+9−9

This diff is too big to show here.

tests/snapshots/form_elements.html+1−1

This diff is too big to show here.

tests/snapshots/fragment.hl+1−1

This diff is too big to show here.

tests/snapshots/fragment.html+1−1

This diff is too big to show here.

tests/snapshots/function_calls.hlnew file+66

This diff is too big to show here.

tests/snapshots/function_calls.htmlnew file+1

This diff is too big to show here.

tests/snapshots/function_definitions.hlnew file+35

This diff is too big to show here.

tests/snapshots/function_definitions.htmlnew file+1

This diff is too big to show here.

tests/snapshots/functions.hl+5−5

This diff is too big to show here.

tests/snapshots/functions.html+1−1

This diff is too big to show here.

tests/snapshots/grid_areas.hl+1−1

This diff is too big to show here.

tests/snapshots/grid_areas.html+1−1

This diff is too big to show here.

tests/snapshots/has_pseudo.hl+2−2

This diff is too big to show here.

tests/snapshots/has_pseudo.html+1−1

This diff is too big to show here.

tests/snapshots/hidden_attr.hl+1−1

This diff is too big to show here.

tests/snapshots/hidden_attr.html+1−1

This diff is too big to show here.

tests/snapshots/html_elements.hlnew file+42

This diff is too big to show here.

tests/snapshots/html_elements.htmlnew file+1

This diff is too big to show here.

tests/snapshots/if_expr.hl+21−3

This diff is too big to show here.

tests/snapshots/if_expr.html+1−1

This diff is too big to show here.

tests/snapshots/implicit_el.hldeleted−7

This diff is too big to show here.

tests/snapshots/implicit_el.htmldeleted−1

This diff is too big to show here.

tests/snapshots/initial_letter.html+1−1

This diff is too big to show here.

tests/snapshots/inline_text.hl+2−2

This diff is too big to show here.

tests/snapshots/inline_text.html+1−1

This diff is too big to show here.

tests/snapshots/lang_directive.hl+1−2

This diff is too big to show here.

tests/snapshots/lang_directive.html+1−1

This diff is too big to show here.

tests/snapshots/layer_wrapping.html+1−1

This diff is too big to show here.

tests/snapshots/layout_direction.hlnew file+33

This diff is too big to show here.

tests/snapshots/layout_direction.htmlnew file+1

This diff is too big to show here.

tests/snapshots/layout_modes.hlnew file+44

This diff is too big to show here.

tests/snapshots/layout_modes.htmlnew file+2

This diff is too big to show here.

tests/snapshots/leading_argument.hlnew file+50

This diff is too big to show here.

tests/snapshots/leading_argument.htmlnew file+1

This diff is too big to show here.

tests/snapshots/list_elements.hl+11−11

This diff is too big to show here.

tests/snapshots/list_elements.html+1−1

This diff is too big to show here.

tests/snapshots/loops.html+1−1

This diff is too big to show here.

tests/snapshots/manifest_directive.hl+3−8

This diff is too big to show here.

tests/snapshots/manifest_directive.html+1−1

This diff is too big to show here.

tests/snapshots/markdown_block.hl+1−1

This diff is too big to show here.

tests/snapshots/markdown_block.html+1−1

This diff is too big to show here.

tests/snapshots/match_directive.hl+9−10

This diff is too big to show here.

tests/snapshots/match_directive.html+1−1

This diff is too big to show here.

tests/snapshots/media_elements.hl+1−1

This diff is too big to show here.

tests/snapshots/media_elements.html+1−1

This diff is too big to show here.

tests/snapshots/media_extended.html+1−1

This diff is too big to show here.

tests/snapshots/meta_head.hl+1−1

This diff is too big to show here.

tests/snapshots/meta_head.html+1−1

This diff is too big to show here.

tests/snapshots/mixin_spread.hl+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+7−7

This diff is too big to show here.

tests/snapshots/new_css_properties.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_2.hl+2−2

This diff is too big to show here.

tests/snapshots/new_css_properties_2.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_3.hl+2−2

This diff is too big to show here.

tests/snapshots/new_css_properties_3.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_4.hl+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_4.html+1−1

This diff is too big to show here.

tests/snapshots/new_directives.hl+18−9

This diff is too big to show here.

tests/snapshots/new_directives.html+8−5

This diff is too big to show here.

tests/snapshots/new_elements.hl+7−7

This diff is too big to show here.

tests/snapshots/new_elements.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_2.hl+10−10

This diff is too big to show here.

tests/snapshots/new_elements_2.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_3.hl+2−2

This diff is too big to show here.

tests/snapshots/new_elements_3.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_4.hl+4−13

This diff is too big to show here.

tests/snapshots/new_elements_4.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_5.hldeleted−19

This diff is too big to show here.

tests/snapshots/new_elements_5.htmldeleted−1

This diff is too big to show here.

tests/snapshots/new_elements_6.hl+3−3

This diff is too big to show here.

tests/snapshots/new_elements_6.html+1−1

This diff is too big to show here.

tests/snapshots/new_html_attrs.hl+5−5

This diff is too big to show here.

tests/snapshots/new_html_attrs.html+1−1

This diff is too big to show here.

tests/snapshots/new_pseudos.hl+2−2

This diff is too big to show here.

tests/snapshots/new_pseudos.html+1−1

This diff is too big to show here.

tests/snapshots/no_page.hl+1−1

This diff is too big to show here.

tests/snapshots/no_page.html+1−1

This diff is too big to show here.

tests/snapshots/nth_pseudo.hl+3−3

This diff is too big to show here.

tests/snapshots/nth_pseudo.html+1−1

This diff is too big to show here.

tests/snapshots/og_directive.hl+3−3

This diff is too big to show here.

tests/snapshots/og_directive.html+1−1

This diff is too big to show here.

tests/snapshots/one_css_path.hlnew file+14

This diff is too big to show here.

tests/snapshots/one_css_path.htmlnew file+4

This diff is too big to show here.

tests/snapshots/page_root.hlnew file+15

This diff is too big to show here.

tests/snapshots/page_root.htmlnew file+1

This diff is too big to show here.

tests/snapshots/pagination_each.hldeleted−3

This diff is too big to show here.

tests/snapshots/pagination_each.htmldeleted−1

This diff is too big to show here.

tests/snapshots/parameters.hlnew file+31

This diff is too big to show here.

tests/snapshots/parameters.htmlnew file+1

This diff is too big to show here.

tests/snapshots/popover_api.hl+2−2

This diff is too big to show here.

tests/snapshots/popover_api.html+1−1

This diff is too big to show here.

tests/snapshots/positional.html+1−1

This diff is too big to show here.

tests/snapshots/prefixes_compose.hlnew file+17

This diff is too big to show here.

tests/snapshots/prefixes_compose.htmlnew file+1

This diff is too big to show here.

tests/snapshots/pseudo_elements.hl+1−1

This diff is too big to show here.

tests/snapshots/pseudo_elements.html+1−1

This diff is too big to show here.

tests/snapshots/pseudo_states.html+1−1

This diff is too big to show here.

tests/snapshots/pseudo_states_extended.hl+5−5

This diff is too big to show here.

tests/snapshots/pseudo_states_extended.html+1−1

This diff is too big to show here.

tests/snapshots/px_rule.hlnew file+20

This diff is too big to show here.

tests/snapshots/px_rule.htmlnew file+1

This diff is too big to show here.

tests/snapshots/raw_html.hl+2−2

This diff is too big to show here.

tests/snapshots/raw_html.html+1−1

This diff is too big to show here.

tests/snapshots/repeat_directive.hl+2−2

This diff is too big to show here.

tests/snapshots/repeat_directive.html+1−1

This diff is too big to show here.

tests/snapshots/responsive.hl+1−1

This diff is too big to show here.

tests/snapshots/responsive.html+1−1

This diff is too big to show here.

tests/snapshots/responsive_images.hl+2−2

This diff is too big to show here.

tests/snapshots/responsive_images.html+1−1

This diff is too big to show here.

tests/snapshots/scope_css.hl+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.hl+1−1

This diff is too big to show here.

tests/snapshots/script_element.html+1−1

This diff is too big to show here.

tests/snapshots/scroll_driven_animations.hl+6−4

This diff is too big to show here.

tests/snapshots/scroll_driven_animations.html+1−1

This diff is too big to show here.

tests/snapshots/selection_pseudo.hl+1−1

This diff is too big to show here.

tests/snapshots/selection_pseudo.html+1−1

This diff is too big to show here.

tests/snapshots/semantic_elements.hl+4−4

This diff is too big to show here.

tests/snapshots/semantic_elements.html+1−1

This diff is too big to show here.

tests/snapshots/sizing.hl+1−1

This diff is too big to show here.

tests/snapshots/sizing.html+1−1

This diff is too big to show here.

tests/snapshots/slots_and_children.hlnew file+59

This diff is too big to show here.

tests/snapshots/slots_and_children.htmlnew file+1

This diff is too big to show here.

tests/snapshots/starting_style.hl+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+132−132

This diff is too big to show here.

tests/snapshots/stress_deeply_nested.html+1−1

This diff is too big to show here.

tests/snapshots/stress_large.hl+194−190

This diff is too big to show here.

tests/snapshots/stress_large.html+1−1

This diff is too big to show here.

tests/snapshots/string_interpolation.html+1−1

This diff is too big to show here.

tests/snapshots/style_block.hl+1−1

This diff is too big to show here.

tests/snapshots/style_block.html+1−1

This diff is too big to show here.

tests/snapshots/subgrid_css.html+1−1

This diff is too big to show here.

tests/snapshots/table_elements.html+1−1

This diff is too big to show here.

tests/snapshots/text_everywhere.hlnew file+41

This diff is too big to show here.

tests/snapshots/text_everywhere.htmlnew file+3

This diff is too big to show here.

tests/snapshots/translations_i18n.hl+3−8

This diff is too big to show here.

tests/snapshots/translations_i18n.html+1−1

This diff is too big to show here.

tests/snapshots/unless_directive.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.