on bbd9209 help sign in
.gitattributesdeleted−3
since splitsnapshots
unifiedsplit
@@ -1,3 +0,0 @@
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+5−4
@@ -40,12 +40,13 @@ 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: Assemble the playground
43+ - name: Build htmlang CLI
44+ run: cargo build --release --bin htmlang
45+
46+ - name: Generate playground HTML
44 run: |
47 run: |
45 mkdir -p dist
48 mkdir -p dist
46− cp crates/htmlang-wasm/playground/index.html dist/
49+ ./target/release/htmlang playground dist/index.html
47− cp target/wasm-pkg/htmlang_wasm.js target/wasm-pkg/htmlang_wasm_bg.wasm dist/
48− cp examples/demo.hl dist/
49
50
50 - name: Upload Pages artifact
51 - name: Upload Pages artifact
51 uses: actions/upload-pages-artifact@v3
52 uses: actions/upload-pages-artifact@v3
.gitignore−1
@@ -1,4 +1,3 @@
1/target
1/target
2*.html
2*.html
3!tests/snapshots/*.html
3!tests/snapshots/*.html
4−!crates/htmlang-wasm/playground/*.html
CONTRIBUTING.md+14−107
@@ -19,100 +19,20 @@ 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
22+ - `ast.rs` — the element kinds; every plain HTML element is one row in `TAGS`.
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,
23 - `vocab.rs` — the attribute vocabulary (htmlang attributes, CSS properties,
76− HTML attributes, `@page`'s own `favicon`), the pixel rule
24+ HTML attributes) and the state/media prefixes.
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
25+ - `expr.rs` — the expression language for conditions and computed values.
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
26 - `std.hl` — the standard library, written in htmlang and loaded before
97 every file.
27 every file.
98- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
28- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
99−- `src/` — CLI, dev server and formatter (`fmt.rs`, which prints the syntax
29+- `src/` — CLI, dev server, formatter, and HTML-to-hl converter.
100− tree back with its comments and blank lines).
101−- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`). Its
30+- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`).
102− completions come from the compiler's tables; hover and completion text live
103− in `docs.rs`.
104- `editors/vscode/` — VS Code extension.
31- `editors/vscode/` — VS Code extension.
105- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
32- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
106− These tests, and `tests/regressions.rs`, read the output with its
107− hashed classes numbered (`hl-a`, `hl-b`, ..., see `tests/common`), so a
108− snapshot changes when a style does, not when a hash does.
109- `tests/regressions.rs` — one test per fixed bug.
33- `tests/regressions.rs` — one test per fixed bug.
110−- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`,
34+- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`.
111− and every page in `examples/`: any diagnostic fails it.
35+- `examples/` — sample `.hl` files used as smoke tests and documentation.
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`).
116
36
117## Adding a feature
37## Adding a feature
118
38
@@ -121,34 +41,21 @@ Linux, macOS, and Windows. Your change should pass all three.
121 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
41 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
122 tests alongside the code.
42 tests alongside the code.
1232. Prefer the smallest mechanism: a component belongs in `std.hl`, an HTML
432. 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
44+ element is a row in `TAGS`, and a CSS property needs no code at all. Only
127− thread a feature through the parser and codegen when it needs to be, and
45+ thread a feature through the parser, codegen and LSP when it needs to be.
128− describe it in the LSP's `docs.rs`.
1293. Document it in `DESIGN.md` (the examples there are compiled by the tests).
463. Document it in `DESIGN.md` (the examples there are compiled by the tests).
130− If it's user-facing, also update `README.md`, and show it in
47+ If it's user-facing, also update `README.md`.
131− `examples/demo.hl` if the tour has a section it belongs in.
48+4. If it removes or renames syntax, add a rewrite to `src/upgrade.rs` and a
49+ hint to `REMOVED_SYNTAX` in the parser.
132−4. If it changes the CLI surface, update the `--help` output.
50+5. If it changes the CLI surface, update the `--help` output.
133
51
134## Style
52## Style
135
53
136- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
54- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
137- Keep `@` prefixes and bracket-attribute syntax consistent with existing
55- Keep `@` prefixes and bracket-attribute syntax consistent with existing
138− directives. A new `@foo` is a row in `ast::DIRECTIVES`; the parser, the
56+ directives. Add `KNOWN_DIRECTIVES` entries when you add a new `@foo`.
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`.
145- Diagnostics should include `line` and, when practical, `column` and a
57- Diagnostics should include `line` and, when practical, `column` and a
146 `source_line` excerpt. Use `Severity::Help` for suggestions, not `Warning`.
58 `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.
152- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
59- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
153 diagnostic so the compiler keeps going.
60 diagnostic so the compiler keeps going.
154
61
Cargo.lock+211
@@ -24,6 +24,28 @@ 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+
27[[package]]
49[[package]]
28name = "bitflags"
50name = "bitflags"
29version = "1.3.2"
51version = "1.3.2"
@@ -48,12 +70,33 @@ version = "1.11.1"
48source = "registry+https://github.com/rust-lang/crates.io-index"
70source = "registry+https://github.com/rust-lang/crates.io-index"
49checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
71checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
50
72
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+
51[[package]]
85[[package]]
52name = "cfg-if"
86name = "cfg-if"
53version = "1.0.4"
87version = "1.0.4"
54source = "registry+https://github.com/rust-lang/crates.io-index"
88source = "registry+https://github.com/rust-lang/crates.io-index"
55checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
89checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
56
90
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+
57[[package]]
100[[package]]
58name = "dashmap"
101name = "dashmap"
59version = "5.5.3"
102version = "5.5.3"
@@ -78,6 +121,12 @@ dependencies = [
78 "syn",
121 "syn",
79]
122]
80
123
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+
81[[package]]
130[[package]]
82name = "filetime"
131name = "filetime"
83version = "0.2.27"
132version = "0.2.27"
@@ -89,6 +138,12 @@ dependencies = [
89 "libredox",
138 "libredox",
90]
139]
91
140
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+
92[[package]]
147[[package]]
93name = "form_urlencoded"
148name = "form_urlencoded"
94version = "1.2.2"
149version = "1.2.2"
@@ -98,6 +153,12 @@ dependencies = [
98 "percent-encoding",
153 "percent-encoding",
99]
154]
100
155
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+
101[[package]]
162[[package]]
102name = "fsevent-sys"
163name = "fsevent-sys"
103version = "4.1.0"
164version = "4.1.0"
@@ -183,6 +244,29 @@ dependencies = [
183 "slab",
244 "slab",
184]
245]
185
246
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+
186[[package]]
270[[package]]
187name = "hashbrown"
271name = "hashbrown"
188version = "0.14.5"
272version = "0.14.5"
@@ -195,8 +279,10 @@ version = "0.1.0"
195dependencies = [
279dependencies = [
196 "htmlang-core",
280 "htmlang-core",
197 "notify",
281 "notify",
282+ "rustls-pemfile",
198 "serde_json",
283 "serde_json",
199 "tokio",
284 "tokio",
285+ "tokio-rustls",
200 "tower-lsp",
286 "tower-lsp",
201]
287]
202
288
@@ -356,6 +442,16 @@ version = "1.0.18"
356source = "registry+https://github.com/rust-lang/crates.io-index"
442source = "registry+https://github.com/rust-lang/crates.io-index"
357checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
443checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
358
444
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+
359[[package]]
455[[package]]
360name = "kqueue"
456name = "kqueue"
361version = "1.1.1"
457version = "1.1.1"
@@ -558,6 +654,12 @@ dependencies = [
558 "proc-macro2",
654 "proc-macro2",
559]
655]
560
656
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+
561[[package]]
663[[package]]
562name = "redox_syscall"
664name = "redox_syscall"
563version = "0.5.18"
665version = "0.5.18"
@@ -576,6 +678,66 @@ dependencies = [
576 "bitflags 2.11.1",
678 "bitflags 2.11.1",
577]
679]
578
680
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+
579[[package]]
741[[package]]
580name = "rustversion"
742name = "rustversion"
581version = "1.0.22"
743version = "1.0.22"
@@ -651,6 +813,12 @@ dependencies = [
651 "syn",
813 "syn",
652]
814]
653
815
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+
654[[package]]
822[[package]]
655name = "slab"
823name = "slab"
656version = "0.4.12"
824version = "0.4.12"
@@ -679,6 +847,12 @@ version = "1.2.1"
679source = "registry+https://github.com/rust-lang/crates.io-index"
847source = "registry+https://github.com/rust-lang/crates.io-index"
680checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
848checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
681
849
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+
682[[package]]
856[[package]]
683name = "syn"
857name = "syn"
684version = "2.0.117"
858version = "2.0.117"
@@ -737,6 +911,16 @@ dependencies = [
737 "syn",
911 "syn",
738]
912]
739
913
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+
740[[package]]
924[[package]]
741name = "tokio-util"
925name = "tokio-util"
742version = "0.7.18"
926version = "0.7.18"
@@ -847,6 +1031,12 @@ version = "1.0.24"
847source = "registry+https://github.com/rust-lang/crates.io-index"
1031source = "registry+https://github.com/rust-lang/crates.io-index"
848checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1032checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
849
1033
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+
850[[package]]
1040[[package]]
851name = "url"
1041name = "url"
852version = "2.5.8"
1042version = "2.5.8"
@@ -882,6 +1072,15 @@ version = "0.11.1+wasi-snapshot-preview1"
882source = "registry+https://github.com/rust-lang/crates.io-index"
1072source = "registry+https://github.com/rust-lang/crates.io-index"
883checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1073checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
884
1074
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+
885[[package]]
1084[[package]]
886name = "wasm-bindgen"
1085name = "wasm-bindgen"
887version = "0.2.114"
1086version = "0.2.114"
@@ -1024,6 +1223,12 @@ version = "0.52.6"
1024source = "registry+https://github.com/rust-lang/crates.io-index"
1223source = "registry+https://github.com/rust-lang/crates.io-index"
1025checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1224checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1026
1225
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+
1027[[package]]
1232[[package]]
1028name = "writeable"
1233name = "writeable"
1029version = "0.6.3"
1234version = "0.6.3"
@@ -1074,6 +1279,12 @@ dependencies = [
1074 "synstructure",
1279 "synstructure",
1075]
1280]
1076
1281
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+
1077[[package]]
1288[[package]]
1078name = "zerotrie"
1289name = "zerotrie"
1079version = "0.2.4"
1290version = "0.2.4"
Cargo.toml+2
@@ -13,8 +13,10 @@ 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"
16serde_json = "1"
17serde_json = "1"
17tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
18tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
19+tokio-rustls = { version = "0.26.4", features = ["ring"] }
18tower-lsp = "0.20"
20tower-lsp = "0.20"
19
21
20[profile.release]
22[profile.release]
DESIGN.md+302−1422
@@ -1,1410 +1,379 @@
1−# htmlang language reference
1+# htmlang (.hl)
2
2
3−htmlang is a small layout language that compiles to static HTML. A page is a
3+A minimalist layout language inspired by elm-ui that compiles to static HTML.
4−tree of elements written one per line, with indentation for nesting:
5
4
6−```
5+## Principles
7−@page Hello
6+
8−@el [width fill, max-width 640, center-x, padding 40, spacing 16]
7+- `@` means structure, bare lines mean content.
9− @h1 Hello
8+- Layout is explicit and compositional: every element declares its own layout
10− @paragraph
9+ role, and there is no CSS cascade to reason about.
11− This page was written in {@b htmlang}.
10+- Output is one self-contained HTML file with embedded CSS (flexbox), and no
12−```
11+ JavaScript unless you write some.
13
12
14Every example block in this file is compiled by the test suite
13Every example block in this file is compiled by the test suite
15(`tests/docs.rs`), so the examples stay correct.
14(`tests/docs.rs`), so the examples stay correct.
16
15
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−
45## Syntax
16## Syntax
46
17
47−### Elements
18+### Structure
48
19
49```
20```
50@element [attributes] argument
21@element [attributes] argument
51 children
22 children
52```
23```
53
24
54−Children are indented under their parent, with spaces or with tabs (a
25+Children are indented under their parent. Attributes are comma-separated inside
55−tab counts as one space, so a file that mixes them is a warning).
56−Attributes go inside `[...]`,
57−separated by commas, and the list may span several lines:
26+`[...]` and may span several lines:
58
27
59```
28```
60@el [
29@el [
61 padding 20,
30 padding 20,
62 background white,
31 background white,
63− border-radius 8,
32+ rounded 8,
64− box-shadow 0 2 4 rgba(0,0,0,0.1)
33+ box-shadow 0 2px 4px rgba(0,0,0,0.1)
65]
34]
66 Content
35 Content
67```
36```
68
37
69−An attribute can take one of three forms:
38+An attribute is either a **style**, written `key value` (`padding 20`,
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`,
39+`color red`), or an **HTML attribute**, written `key=value` (`id=main`,
74− `aria-label=Close`).
40+`type=email`, `aria-label=Close`). Boolean HTML attributes are written bare
75−- A bare word is a **flag**: a layout attribute such as `center-x`, a
41+(`required`, `disabled`, `open`). A comma inside `(...)` or `"..."` does not
76− boolean HTML attribute such as `required`, `disabled`, `hidden` or `open`,
42+split attributes, so a font stack is written `font "Inter, sans-serif"`.
77− or a `data-` or `hx-` attribute that has no value (`data-open`,
78− `hx-preserve`).
79−
80−A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else,
81−`\,` keeps a comma in the value:
82−
83−```
84−@el [transition opacity 0.3s\, transform 0.3s, font-family "Open Sans"\, sans-serif]
85−@el [hx-get=/news, hx-trigger=load\, every 60s]
86−```
87−
88−Right after a `key=value`, a name htmlang doesn't know is most likely the
89−rest of that value, which a comma ended: `[hx-trigger=load, every 60s]` is
90−an error (`split-value`) that shows the value written with `\,`.
91−
92−An attribute given twice, by a bundle and after it, by a function and its
93−call, or written twice, takes the later value. An HTML attribute is
94−written once, with that value.
95−
96−A variable fills an attribute's value (`padding $gap`, `alt=$title`), never
97−its name or a whole attribute: attributes come from a
98−[bundle](#definitions), written `[$card]`, and a condition chooses whole
99−attributes with [`if()`](#conditional-attributes).
100−
101−The attribute list belongs to the element name right before it. Anywhere
102−else, `[` is an ordinary character, so a line of text can contain one:
103−
104−```
105−@text Use [ to open a list
106−@paragraph Arrays look like [1, 2, 3].
107−```
108
43
109### Text
44### Text
110
45
111−A line that doesn't start with `@` is text. Text after an element's
46+Any line that doesn't start with `@` or `[` is text. Text after an element's
112−attributes is the element's content: its first line of text, read like any
47+attributes is its content, and `{...}` puts elements inside a line of text:
113−other. Inside a line of text, `{...}` holds an inline element, and
114−[escapes](#escapes) and [`$names`](#variables) work:
115
48
116```
49```
117@paragraph
50@paragraph
118− This is {@strong important}, and this is {@link https://example.com a link}.
51+ This is {@text [bold] important} and this is a {@link https://example.com link}.
119−@text [font-weight bold, font-size 24, color #333] Hello world
52+@text [bold, 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.
53@section [padding 8] Text after the attributes is content too.
124```
54```
125
55
126−How an element's lines combine is its [layout](#rows-columns-and-text)'s
127−business: in a row or column each line is a child; in a text element lines
128−flow. Text that `@if`, `@each`, `@fragment` or a function writes counts as
129−lines written in its place, so in a text element it flows with the rest,
130−joined with a space, and in a native element it is on lines of its own.
131−
132−The text of `@code` and `@textarea` is shown as written. On its line and
133−inline, a `{@...}` in it is text, while escapes and `$names` still work.
134−The lines indented under it are a [verbatim](#verbatim-bodies) sample:
135−nothing in them is htmlang (`@`, `$`, `{`, `\` and `--` are text), and
136−they are HTML-escaped with their line breaks and indentation kept. This
137−is how a page shows code, htmlang included. `@pre` is an ordinary element,
138−so `@pre > @code` is HTML's `<pre><code>`, and a function can wrap `@pre`
139−around a sample. A sample is text on the line or lines in the block, not
140−both.
141−
142−```
143−@paragraph
144− Write {@code {@link /docs docs}} for a link.
145−@pre [padding 16] > @code
146− @el [padding 40]
147− <b>shown as text</b>
148−@textarea [aria-label=Notes]
149− First line
150− Second line, indented
151−```
152−
153−A sample in the block has no `$names`, so a version number in it can't
154−come from a variable; a one-line sample on `@code`'s own line can
155−(`@code cargo install htmlang@$version`).
156−
157−### Escapes
158−
159−A backslash before one of these characters stands for the character itself.
160−The table is the same in every htmlang string: text, arguments, attribute
161−values, `@let`, `@page` and `@meta`.
162−
163−| Escape | Writes | For |
164−|---|---|---|
165−| `\@`, `\--` | `@`, `--` | a line of text that starts with `@` or `--` |
166−| `\$` | `$` | a `$` before a name, which would start a variable |
167−| `\{`, `\}` | `{`, `}` | braces that don't start or end an inline element |
168−| `\[`, `\]` | `[`, `]` | brackets that don't open or close an attribute list; a `'...'` in a value (`alert(']')`) doesn't hide one, so a list that seems to end inside one is reported (`unbalanced-quote`) |
169−| `\,` | `,` | a comma in an attribute's value |
170−| `\"` | `"` | a quote that doesn't start or end quoted text |
171−| `\\` | `\` | a backslash before one of these characters |
172−
173−A backslash before any other character is kept as written, so CSS's own
174−escapes and patterns pass through: `before:content "\201C"`,
175−`pattern=\d{3}`. A `$` that isn't followed by a letter or `_` is text
176−anyway: `$5`, `$$`.
177−
178−```
179−\@htmlang on social media
180−@let price 5
181−@paragraph \$price is $price dollars: {@mark \{braces\}}, \[brackets\] and a comma\, too
182−@input [type=text, pattern=\d{3}-\d{4}, title=Costs \$5]
183−```
184−
185### Comments
56### Comments
186
57
187−A line whose first word is `--` is a comment: `--` followed by a space, or
58+`--` at the start of a line begins a comment. Comments must be on their own
188−alone on its line. A comment must be on its own line, because `--` later in
189−a line is ordinary text. A comment may sit between the lines of an attribute
59+line: `--` later in a line is ordinary text.
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).
193
60
194```
61```
195-- this is a comment
62-- this is a comment
196@row [spacing 10]
63@row [spacing 10]
197− -- todo: more nav items
64+ -- todo: add 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
207```
65```
208
66
209−### Chains
67+### Shorthands
210
68
211−`>` puts elements that have one child each on one line. The last element in
69+A line starting with `[` is an anonymous `@el`. `>` chains single-child
212−the chain gets the indented children. A `>` is a chain only between two
70+elements on one line; the last element in the chain gets the indented children.
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).
218
71
219```
72```
73+[padding 20, background white]
74+ Hello
75+
220−@el [padding 16, background blue, border-radius 8] > @link https://example.com
76+@el [padding 16, background blue, rounded 8] > @link https://example.com
221 @text [color white] Get Started
77 @text [color white] Get Started
222```
78```
223
79
224−### Verbatim bodies
80+### Raw content
225
81
226−The bodies of `@raw`, `@style`, `@head`, `@markdown`, `@script`, `@code`
82+`@raw """ ... """` is pasted into the output verbatim.
227−and `@textarea` are foreign text, kept exactly as written: nothing in them
228−is parsed as htmlang, and a `--` line in them is not a comment. `@raw`
229−writes its HTML into the page, `@style` its CSS and `@head` its HTML into
230−the `<head>`; `@code` and `@textarea` show theirs as text. Which lines are
231−verbatim is decided by the element or directive on the line, also at the
232−end of a chain (`@pre > @code`, `@el > @script`).
233−
234−The header's line follows one rule. For `@raw`, `@style` and `@head`, text
235−on the line is a one-line body; for `@markdown` it is the file and for
236−`@script` the `src`. Text on the line and an indented body together are an
237−error. Only `@script` takes attributes: an attribute list on `@raw`,
238−`@style`, `@head` or `@markdown` is an error, so content that starts with
239−`[` (a CSS attribute selector) goes in the indented block.
240
83
241```
84```
242−@raw <hr class="fancy">
85+@raw """
243−@raw
244− <div class="custom-widget">
86+<div class="custom-widget"></div>
245− <span>Hand-written HTML</span>
87+"""
246− </div>
247−@style .note { color: gray; }
248```
88```
249
89
250−### Directives
90+## Elements
251
91
252−A directive is a built-in word that isn't an element. Each one takes a
92+Layout elements set up flexbox; the rest map to the HTML element of the same
253−fixed kind of argument and a fixed kind of body. `@page` is the one
93+name.
254−directive that is also an element: it stands for the page's root, `<body>`,
255−whose content is the rest of the file (see [Page and head](#page-and-head)).
256
94
257−| Directive | Argument | Body |
95+| Element | Output | Purpose |
258−|---|---|---|
259−| `@page` | attributes (styles for `<body>`, `key=value` for `<html>`) and a title | none |
260−| `@let` | a name and a value or bundle, or `@name` and parameters | a function's body |
261−| `@include` | a file | none |
262−| `@data` | a variable and a source | none |
263−| `@meta` | a name and a value | none |
264−| `@if`, `@else`, `@each` | a condition or a loop | htmlang |
265−| `@raw`, `@style`, `@head` | the rest of the line: a one-line body | verbatim, instead of the line |
266−| `@markdown` | a file | verbatim Markdown, instead of the file |
267−
268−An indented line under a directive that takes no body is an error, because
269−it would otherwise silently become a sibling.
270−
271−### Diagnostics
272−
273−The compiler checks the whole file, including code that doesn't run: the
274−branch of an `@if` or an `if()` that isn't taken, the body of a function that is never
275−called and the body of a loop over an empty list. Unknown elements,
276−functions, attributes and prefixes are reported there too. In code that
277−doesn't run, a call is checked against every function in the file and the
278−files it includes; where the code runs, a name is looked up where it is
279−written, so a function's body sees only what is defined above the function
280−(a call to a function defined further down is reported when the body
281−runs, and two functions can't call each other). A `$name` is checked only
282−where the code runs, since any of them may come from data: an undefined
283−variable in a branch that isn't taken, or in a function that is never
284−called, isn't reported.
285−
286−**Names are checked, and values are CSS's.** A misspelled element, prefix,
287−parameter or slot is an error, and so is an undefined variable. A CSS
288−property htmlang doesn't know, but whose name CSS could have, is written to
289−the CSS as it is, with a warning that suggests the closest known name
290−(`text-grow`, or `colr`, which asks "did you mean `color`?"). Values go to
291−the CSS as written, so the browser decides what `z-index auto` or `color
292−rebeccapurple` means; only what is wrong in any CSS is reported (see
293−[CSS properties](#css-properties)).
294−
295−**Nothing you write is left out silently.** An error means something
296−couldn't go into the page: a flag htmlang doesn't know, an attribute
297−`@fragment` has no element for, content for a function without
298−`@children`. A warning means it went into the page as written, but may not
299−be what you meant. A warning is reported once, even from a line that runs
300−many times in a loop or a function.
301−
302−Every diagnostic has a stable code, such as `unknown-element` or
303−`unused-variable`. The command line prints it as `error[unknown-element]`,
304−`--format json` has it in a `code` field, and the editor's quick fixes are
305−keyed on it.
306−
307−## Layout
308−
309−### Rows, columns and text
310−
311−Every element has one layout, which decides what the lines written inside
312−it are. **In a row or column each line is a child; in a text element lines
313−flow.**
314−
315−| Layout | Elements | What is inside it |
316|---|---|---|
96|---|---|---|
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`) |
97+| `@row` | div, flex row | Horizontal layout |
318−| row | `@row` | The same, side by side |
98+| `@column` | div, flex column | Vertical layout |
319−| grid | `@grid` | Each line of text is a cell |
99+| `@el` | div, flex column | Generic container |
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 |
100+| `@grid` | div, grid | Grid container (use `grid-cols`) |
321−| native | `@table`, `@caption`, `@select`, `@pre`, `@textarea`, `@video`, ... | HTML's own layout, which htmlang leaves alone |
101+| `@stack` | div, position relative | Children layered on top of each other |
322−| void | `@input`, `@hr`, `@br`, `@image`, `@source` | Nothing: it takes no content |
102+| `@in-front` / `@behind` | div, absolute | Overlay layers filling the parent (see below) |
323−
103+| `@text` | span | Styled inline text |
324−The table under [HTML elements](#html-elements) gives every element's layout. The
104+| `@paragraph` | p | Flowing text with inline elements |
325−page itself is a column too: `@page` is the [root element](#page-and-head),
105+| `@link url` | a | Link; text after the URL becomes its content |
326−`<body>`, so what is written at the top of a page stacks like the children
106+| `@image src` | img | Image |
327−of an `@el`, `height fill` there takes the rest of the window and
107+| `@h1` … `@h6` | h1 … h6 | Headings |
328−`center-y` centres in it.
108+
329−
109+Semantic containers, all laid out as columns: `@nav`, `@header`, `@footer`,
110+`@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@form`,
111+`@details` / `@summary`, `@dialog`, `@figure` / `@figcaption`, `@blockquote` /
112+`@cite`, `@fieldset` / `@legend`, `@noscript`.
113+
114+Content: `@list` / `@item` (`@list [ordered]` for `<ol>`), `@dl` / `@dt` /
115+`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
116+`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
117+`@canvas`, `@iframe src`, `@video src`, `@audio src`, `@picture` / `@source`,
118+`@script`, `@fragment` (children without a wrapper).
119+
120+Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
121+`@label`, `@datalist`. `@form URL` sets the form's `action`.
122+
330−```
123+```
331−@el [spacing 4]
124+@list [ordered]
332− First line
125+ @item First
333− Second line
126+ @item Second
334−@button [type=button]
127+@form [method=post] /subscribe
335− Save
128+ @label [for=email] Email
336− changes
129+ @input [type=email, name=email, id=email, required]
337−```
130+ @button [type=submit] Send
338−
131+@details [open]
339−The column shows two lines 4px apart; the button says "Save changes".
132+ @summary Question
340−
133+ @text The answer.
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
434−```
435−
436−Any other element keeps its HTML element. One whose HTML ends a paragraph,
437−such as `@section`, `@ul` or `@h2`, is a warning inside `@paragraph`
438−(`block-in-paragraph`): the browser would move it, and what follows it, out
439−of the `<p>`.
440−
441−`@link` is text too, so a card written inside a link is an inline `<span>`
442−that shrinks to its content. A clickable card is the link itself, laid out
443−as a column with CSS:
444−
445−```
446−@link [href=/post, display flex, flex-direction column, gap 8, padding 16, border 1 solid #e5e7eb]
447− @strong A post
448− @text Its summary.
449```
134```
450
135
451−### Overlays: `@in-front` and `@behind`
136+### `@in-front` / `@behind`
452
137
453−These layers work like elm-ui's `inFront` and `behind`. Their children fill
138+Overlay layers, like elm-ui's `inFront` and `behind`. Their children fill the
454−the parent's bounds, and the parent becomes a positioning context
139+parent's bounds; the parent automatically becomes a positioning context
455−(`position: relative; isolation: isolate`). An explicit `position` on the
140+(`position: relative; isolation: isolate`). An explicit `position` on the parent
456−parent takes precedence.
141+wins.
457
142
458```
143```
459@el [width 200, height 200, background blue]
144@el [width 200, height 200, background blue]
460 @text [color white] Main content
145 @text [color white] Main content
461 @in-front
146 @in-front
462− @text [color yellow, align-right, align-bottom] Painted on top
147+ @text [color yellow] Painted on top
463 @behind
148 @behind
464− @el [background red, height fill]
149+ @el [background red, width fill, height fill]
465```
150```
466
151
467### Standard library
152### Standard library
468
153
469−Two definitions are written in htmlang (`std.hl`) and are available in every
154+These components are written in htmlang (`std.hl`) and available in every file.
470−file. A `@let` of your own with the same name replaces them. `@spacer` takes
155+Use them like elements; your own `@let` with the same name takes precedence.
156+
157+| Component | Purpose |
158+|---|---|
159+| `@badge` | Small pill for counts and statuses |
160+| `@tag` | Label with slightly rounded corners |
161+| `@chip` | Outlined, fully rounded label |
162+| `@avatar` | Circular frame for an image or initials |
471−up the remaining space in a row or column. The `$truncate` bundle cuts text
163+| `@spacer` | Takes up the remaining space in a row or column |
472−off at one line with an ellipsis. You define any other components yourself
164+| `@tooltip [tip TEXT]` | Text that shows `TEXT` when hovered |
473−with `@let`.
165+| `@carousel` | Horizontally scrolling row that snaps to each child |
166+| `@breadcrumb` | Breadcrumb trail of `@item`s |
167+| `$skeleton`, `$truncate`, `$no-scrollbar` | Attribute bundles: loading placeholder, one-line ellipsis, hidden scrollbars |
474
168
475```
169```
476@row [spacing 8, align-items center]
170@row [spacing 8, align-items center]
477− @text Logo
171+ @badge [background #ef4444, color white] 3
172+ @chip Rust
478 @spacer
173 @spacer
479− @text [$truncate, max-width 200] A long title that gets cut off
174+ @tooltip [tip Opens in a new tab] Help
175+@breadcrumb
176+ @item > @link / Home
177+ @item Docs
178+@el [$skeleton, height 20, width fill]
480```
179```
481
180
482−## Style
181+## Attributes
483−
182+
484−### CSS properties
183+### Layout and sizing
485−
184+
486−Any attribute that isn't a layout attribute is a **CSS property**, with the
185+| Attribute | Effect |
487−same name and value as in CSS: `padding 20`, `font-weight bold`,
488−`border 1 solid #e5e7eb`, `border-radius 8`, `background red`,
489−`grid-template-areas "a b"`, `display none`.
490−
491−A quoted string in a value is CSS's own, so it keeps its quotes and its
492−commas: `font-family "Inter, sans-serif"` names one family called
493−"Inter, sans-serif", and the compiler warns about it. A font stack is
494−several values with commas between them, written `\,`:
495−`font-family Inter\, sans-serif`.
496−
497−Values mean what they mean in CSS, with one addition: **in a length
498−property, every bare number outside parentheses and quotes is pixels**. So
499−`padding 20` is `20px`, `border 1 solid red` is `1px solid red`,
500−`box-shadow 0 2 4 rgba(0,0,0,0.1)` is `0 2px 4px rgba(0,0,0,0.1)`, and
501−`margin 0 auto` is `0 auto` (a zero stays `0`). Numbers inside a function
502−belong to it (`rgb(255 128 0)`, `calc(100% - 20px)`), and values that have
503−a unit or are keywords pass through as written (`width 50%`, `max-width
504−min(100%, 800px)`). Every other property takes numbers as CSS does, so
505−`opacity 0.5`, `z-index 2`, `line-height 1.5`, `flex 1 1 240px`,
506−`grid-column 1 / 3`, `initial-letter 3` and `border-image-width 2` are
507−written as they are, and so are custom properties (`--cols 3`), which have
508−no type to go by.
509−
510−The length properties are these, and only these:
511−
512−| Group | Properties |
513|---|---|
186|---|---|
514−| Size | `width`, `height`, `min-*` and `max-*` of both, `block-size`, `inline-size` and their `min-`/`max-`, `flex-basis`, `column-width`, `contain-intrinsic-*` |
187+| `spacing N` | Gap between children |
515−| Space | `margin`, `padding`, `scroll-margin`, `scroll-padding` and every side of them, `gap`, `row-gap`, `column-gap`, `border-spacing` |
188+| `padding N` / `padding Y X` / `padding T R B L` | Padding (also `padding-x`, `padding-y`) |
516−| Position | `top`, `right`, `bottom`, `left`, `inset` and `inset-*`, `translate`, `transform-origin`, `perspective`, `perspective-origin`, `offset-position`, `offset-anchor`, `offset-distance` |
189+| `margin ...` | Margin, same forms as padding (also `margin-x`, `margin-y`) |
517−| Border | `border` and every `border-*` shorthand, width and radius (not `border-image-*`), `outline`, `outline-width`, `outline-offset`, `column-rule`, `column-rule-width` |
190+| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
518−| Shadow | `box-shadow`, `text-shadow` |
191+| `height fill` / `height N` / `height shrink` | Same for height |
519−| Grid | `grid-template-columns`, `grid-template-rows`, `grid-auto-columns`, `grid-auto-rows` |
192+| `center-x`, `center-y` | Center within the parent |
520−| Background | `background-position` (and `-x`, `-y`), `background-size`, `object-position`, `mask-position`, `mask-size` |
193+| `align-left`, `align-right`, `align-top`, `align-bottom` | Align within the parent |
521−| Text | `font-size`, `letter-spacing`, `word-spacing`, `text-indent`, `text-decoration-thickness`, `text-underline-offset`, `vertical-align`, `baseline-shift` |
194+| `wrap` | Let a row wrap |
522−| Other | `shape-margin`, `overflow-clip-margin`, `view-timeline-inset` |
195+| `grid-cols N`, `grid-rows N`, `col-span N`, `row-span N` | Grid layout |
523−
196+| `hidden` | `display: none` |
524−Shorthands that mix lengths with numbers of other kinds (`font`,
197+
525−`background`, `mask`, `grid`, `grid-template`, `offset`, `flex`) are not in
198+### Style
526−the table, so the lengths in them are written with their unit:
527−`font 700 16px/1.5 Inter`.
528−
529−Nothing else is added to a value, and a shorthand means what it means in
530−CSS: `outline 2 solid red` is `2px solid red`, while `outline 2 red` names
531−no line style, so CSS draws no outline. `container card / inline-size`
532−and `contain content` are CSS's own shorthands too, and a bare `contain` is
533−an error, like any style without a value. `line-clamp N` alone also adds
534−the `-webkit-box` declarations that browsers still need to cut text off
535−after N lines.
536−
537−A property htmlang doesn't know is written to the CSS as it is, so new CSS
538−(`text-grow per-line`) works before htmlang lists it; the
539−compiler warns and suggests the closest known name, in case it is a typo.
540−Vendor-prefixed properties (`-webkit-tap-highlight-color`) pass without a
541−warning. The value of a property htmlang doesn't know is written exactly as
542−it is, without pixels added. A style needs a value: `[padding]` is an error.
543
199
544−```
200+| Attribute | Effect |
545−@el [-webkit-tap-highlight-color transparent, -webkit-text-stroke 1px black]
201+|---|---|
546− Passed through as written
202+| `border N COLOR` (also `border-top` etc.) | Solid border |
547−```
203+| `rounded N` | Border radius |
204+| `bold`, `italic`, `underline` | Text style |
205+| `size N` | Font size |
206+| `font NAME` | Font family |
207+| `line-clamp N` | Cut text off after N lines |
548
208
549−A value is checked only for what is wrong in any CSS. A `;`, `{` or `}`
209+**Any standard CSS property** can also be used as an attribute, with the same
550−(outside a quoted string, and for `;` outside parentheses, as in CSS's own
210+name and value: `background red`, `opacity 0.5`, `cursor pointer`,
551−`if()`) or an unclosed quote or parenthesis would break out of the rule, and
211+`z-index 10`, `margin-top 16`, `grid-template-areas "a b"`, and so on.
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`.
570
212
571−```
213+**Units.** For lengths, a bare number is pixels (`padding 20` is `20px`).
572−@el [
214+Values with a unit, keywords and CSS functions are passed through: `width 50%`,
573− --gap 16px,
215+`margin 0 auto`, `max-width min(100%, 800px)`.
574− md:--gap 24px,
216+
575− --hue 200,
217+### HTML attributes
576− spacing var(--gap),
577− background hsl(var(--hue) 80% 95%)
578−]
579− @text One
580− @text Two
581−```
582
218
583−`@let --name value` declares a custom property for the whole page, on
219+Write HTML attributes as `key=value`: `id=main`, `class=note`, `href=/about`,
584−`:root` (see [CSS custom properties](#css-custom-properties));
220+`type=email`, `alt=Logo`, `target=_blank`, `aria-label=Close menu`,
585−`[--name value]` sets it on one element and everything inside it. So a
221+`data-id=42`. Any name works. Booleans are written bare: `required`,
586−design token is declared once with `@let`, and a theme redefines it where
222+`disabled`, `checked`, `open`, `sandbox`. A style and an HTML attribute may
587−it changes, once, rather than repeating a colour on every element that
223+share a name without clashing:
588−uses it:
589
224
590```
225```
591−@let --ink #0f172a
226+@image [width=800, width 200, alt=A photo] photo.jpg
592−@let --surface white
227+@select [size=4, size 18]
593−@let --subtle #f8fafc
228+ @option One
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```
229```
599
230
600−### Prefixes
231+### State and media prefixes
601
232
602−A prefix applies a style only in some condition:
233+Prefix a style attribute to apply it conditionally:
603
234
604| Prefix | Applies |
235| Prefix | Applies |
605|---|---|
236|---|---|
606−| `hover:`, `focus:`, `first-child:`, `checked:`, `user-invalid:`, `open:`, ... | When that CSS pseudo-class matches (`:hover`, `:first-child`, ...) |
237+| `hover:`, `active:`, `focus:`, `focus-visible:`, `focus-within:`, `disabled:`, `checked:`, `visited:`, `target:`, `valid:`, `invalid:`, `empty:`, `placeholder:`, `selection:` | In that state |
607−| `nth-child(odd):`, `not(.featured):`, `has(> img):`, `is(...):`, ... | When that pseudo-class, with its argument, matches |
238+| `first:`, `last:`, `odd:`, `even:`, `nth:EXPR:` | By position among siblings |
608−| `before:`, `after:`, `marker:`, `placeholder:`, `backdrop:`, ... | To that pseudo-element (`::before`, `::marker`, ...) |
609| `children:` | To each direct child |
239| `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)) |
240+| `before:`, `after:` | On the `::before` / `::after` pseudo-element (with `content`) |
241+| `has(SELECTOR):` | When the element contains a match |
611−| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640, 768, 1024, 1280, 1536px) |
242+| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640–1536px) |
612−| `cq-sm:` … `cq-2xl:` | From that container width up (the same widths, for an ancestor with `container-type inline-size`) |
243+| `cq-sm:` … `cq-2xl:` | Container queries |
613−| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Under that media condition |
244+| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Media conditions |
614−
615−The selector prefixes are CSS's own pseudo-classes and pseudo-elements,
616−under their CSS names, without the colons: `link`, `visited`,
617−`first-child`, `last-child`, `only-child`, `first-of-type`,
618−`last-of-type`, `only-of-type`, `empty`, `target`, `open`,
619−`popover-open`, `default`, `checked`, `indeterminate`,
620−`placeholder-shown`, `autofill`, `required`, `optional`, `valid`,
621−`invalid`, `user-valid`, `user-invalid`, `read-only`, `read-write`,
622−`focus-within`, `hover`, `focus`, `focus-visible`, `active`, `enabled`
623−and `disabled`; with an argument, `nth-child()`, `nth-last-child()`,
624−`nth-of-type()`, `nth-last-of-type()`, `not()`, `is()`, `where()` and
625−`has()`; and the pseudo-elements `before`, `after`, `marker`,
626−`placeholder`, `selection`, `backdrop`, `first-line`, `first-letter` and
627−`file-selector-button`, which htmlang writes with CSS's `::`. The argument
628−is CSS, written as it is between balanced parentheses, and it may hold
629−spaces and colons: the key ends at the first space outside parentheses, so
630−`has(> img):padding 0` and `is(:hover, :focus-visible):color red` are one
631−key each. `children:` is htmlang's one combinator word, and an element's
632−name with `@` (`@td:`) picks out the elements of that kind inside. A name
633−that isn't one of these (`hvoer:`, or `first:` for `first-child:`) is an
634−unknown prefix, an error, not a selector that silently never matches.
635−
636−A prefix goes on a style or a layout flag, and prefixes stack: the key
637−is a chain of prefixes, then the property, and the style applies where
638−all of them hold. The selector prefixes (every row above the widths)
639−read left to right: `hover:children:` styles the children of a hovered
640−element, `children:hover:` a hovered child. A pseudo-element is not an
641−element with states of its own, so it comes last among them:
642−`hover:before:color` is the `::before` of a hovered element, and
643−`before:hover:color` is an error that says so. The width, media and
644−container prefixes go anywhere in the chain, in any order, and each is
645−an `@media` or `@container` rule around the ones inside it:
646−`md:dark:padding 32` and `dark:md:padding 32` are the same style, from
647−768px up in dark mode.
648−
649−A prefix written before a `[group]` applies to each style in it, and one
650−written before a `$bundle` to each style the bundle holds, so styles that
651−share a condition are written under it once. A prefix before an `if()`
652−applies to the branch it picks. The group or bundle holds styles only:
653−an HTML attribute (`md:[id=x]`) or a flag that is not a style has no
654−states, and it is an error there, as it is with a prefix of its own
655−(`md:id=x`).
656−
657−`children:` styles each direct child, as the parent sees it. They take
658−effect only where the child sets nothing itself, since what an element
659−says about itself wins over what its parent says about it. They do win
660−over the defaults of the child's kind, so `children:display list-item`
661−brings back a list's markers and `children:margin-bottom 8` spaces
662−headings. The words
663−that place an element in its parent (`width fill`, `width shrink`,
664−`center-x`, `align-*`) are errors under `children:`, because there the
665−parent they measure against is this element: they go on the children
666−themselves, or the CSS they stand for does (`children:flex 1`).
667−
668−#### Styling the elements inside
669−
670−An element prefix, `@NAME:`, styles every element of that kind inside
671−this one, at any depth. `@table [@td:padding 8]` pads every cell of the
672−table, and `@article [@h2:font-size 20]` sizes every heading in the
673−article. It is how content that has no attributes of its own is styled:
674−the HTML `@markdown` writes, and the rows a loop writes.
675−
676−```
677−@let team Ada, Grace, Linus
678−@table [border-collapse collapse, width 100%, @th:[padding 8, text-align left], @td:padding 8, @tr:border-bottom 1 solid #e5e7eb]
679− @tr
680− @th Name
681− @th Team
682− @each $name in $team
683− @tr
684− @td $name
685− @td [padding 2] Platform
686−@article [spacing 12, @h2:font-size 20, @code:[padding 1 4, background #f1f5f9], @link:hover:color #2563eb]
687− @markdown
688− ## Notes
689−
690− Run `htmlang check` before you [publish](/publish).
691−```
692
245
693−- **The element has a tag of its own.** `@td:` is every `<td>`,
246+`@breakpoint name WIDTH` defines a custom responsive prefix.
694− `@paragraph:` every `<p>`, `@link:` every `<a>` and `@image:` every
695− `<img>`, the ones `@markdown` and `@raw` write included. An element that
696− shares its tag can't be picked out, so its prefix is an error: `@el`,
697− `@row`, `@grid`, `@in-front` and `@behind` all write `<div>`, and `@text`
698− writes the `<span>` that every line of text in a row or column is too.
699− So is a function's name (`@card:`), since a function writes whatever its
700− body says and has no tag of its own, and a name that isn't an element
701− (`@a:` suggests `@link:`).
702−- **It takes CSS properties.** htmlang's layout words (`spacing`, `wrap`,
703− `grid-cols`, `col-span`, `width fill`, `center-x`, `align-*`) compile
704− against each element's own layout and its parent's, which the prefix
705− doesn't know, so they are errors under it: write them on the elements,
706− or the CSS they stand for (`@li:margin-inline auto`). HTML attributes
707− have no states: `@td:colspan=2` is an error, like `md:colspan=2`.
708−- **It composes with the other prefixes, read left to right.** Selector
709− prefixes after it choose among the elements:
710− `@tr:nth-child(even):background #f8fafc` shades every other row, and
711− `@link:hover:color red` colours a hovered link. Those before it are
712− about the element itself: `hover:@link:color red` recolours its links
713− while it is hovered. Width, media and container prefixes go anywhere
714− (`md:@td:padding 12`). A `[group]` or a `$bundle` takes it like any
715− prefix: `@th:[padding 8, text-align left]`. A style has one element
716− prefix, and not `children:` as well: each says which elements the style
717− is for.
718−- **What an element says about itself wins.** A cell's own `[padding 2]`
719− wins over the table's `@td:padding 8`, and a parent's `children:` wins
720− over it too. The prefix wins over the defaults of the element's kind, so
721− `@main [@h2:margin-block-start 16]` spaces headings, which start with
722− `margin: 0`. Where two elements around one both style it, the nearest
723− wins, whatever the order they are written in: in an `@article
724− [@td:padding 16]` around a `@table [@td:padding 8]`, the table's cells
725− get 8.
726−- **It styles what is inside, not the element itself.** `@ul
727− [@ul:padding-inline-start 16]` indents the lists inside the list, not
728− the list itself.
729−
730−`children:` and an element prefix answer different questions:
731−`children:` is for the direct children, whatever they are, as the parent
732−sees them; `@td:` is for every cell inside, however deep. An element's
733−own attributes are for it alone.
734−
735−Each style under an element prefix compiles to a rule in CSS's `@scope`,
736−on the element's class, in the layer `hl-inside` (see [CSS](#css)):
737−`@scope (.hl-2k9xq0m) { :scope td { padding: 8px } }`. `@scope`'s proximity
738−rule is what makes the nearest element win. `@scope` is Baseline 2025,
739−supported by Chrome and Edge 118, Safari 17.4 and Firefox 146; an older
740−browser ignores these rules, so the elements inside keep the styles they
741−have without them.
742
247
743```
248```
744−@let card [padding 16, border 1 solid #e5e7eb, border-radius 8]
745−@let active true
746−@el [padding 16, background #3b82f6, hover:background #2563eb, md:[padding 32, font-size 20], dark:[background #1e3a8a, hover:background #1e40af]]
249+@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
747 @text [color white] Click me
250 @text [color white] Click me
748−@row [spacing 4, children:flex 1, hover:children:opacity 0.8]
251+@row [spacing 4, children:flex 1, nth:2n:background #f3f4f6]
749− @el [nth-child(odd):background #f3f4f6] A
252+ @el A
750− @el [nth-child(odd):background #f3f4f6] B
253+ @el B
751− @el [nth-child(odd):background #f3f4f6] C
752−@el [before:content "→ ", before:color red, hover:before:color blue]
254+@el [before:content "→ ", before:color red]
753 Item with an arrow
255 Item with an arrow
754−@section [spacing 16, md:$card, hover:if($active, [background #eef2ff, color #3730a3])]
755− A card from md up
756```
256```
757
257
758−The styles of one element follow a fixed order, whatever the order they
759−are written in: those without a prefix, then those with selector
760−prefixes only, then each width, media and container condition in the
761−order of the table (a stack like `md:dark:` after `dark:` on its own),
762−with its selector chains inside it. So where two styles both hold, the
763−one later in the table wins (`dark:` over `md:`, `lg:` over `md:`), and a
764−stack wins over each of its prefixes on its own (`md:dark:` over `dark:`
765−and `md:`). Selector chains follow CSS's usual order: `link:` and
766−`visited:`, the position and form states, then `hover:`, `focus:`,
767−`active:`, and `enabled:` and `disabled:` last. So a hovered row wins over
768−`nth-child(odd):`, and a pressed button's `active:` over its `focus:`.
769−
770### Conditional attributes
258### Conditional attributes
771
259
772−`if(CONDITION, A, B)`, written as a whole attribute, is `A` when the
260+`key if CONDITION` includes an attribute only when the condition holds;
773−condition holds and `B` otherwise. Each branch is an attribute, a `[group]`
261+`if(CONDITION, A, B)` picks a value.
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
262
778```
263```
779@let active true
264@let active true
780−@el [if($active, background blue, background gray), if($active, font-weight bold)]
265+@el [background if($active, blue, gray), bold if $active]
781 Conditionally styled
266 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.
892−```
893−
894−### The leading argument
895−
896−An element that points at a URL or a file takes it as the first word after
897−its attributes, its leading argument, which fills one HTML attribute:
898−
899−| Element | Leading argument |
900−|---|---|
901−| `@link`, `@area` | `href` |
902−| `@image`, `@script`, `@iframe`, `@video`, `@audio`, `@track`, `@embed` | `src` |
903−| `@source` | `srcset` directly inside `@picture`, `src` inside `@video` and `@audio` |
904−| `@form` | `action` |
905−| `@object` | `data` |
906−| `@optgroup` | `label` |
907−
908−The first word is taken before any `$name` in it is filled in, and it ends
909−at the first space that isn't inside `"..."` or `${...}`: `@link $url More`
910−and `@link /page/${$n + 1} Next` take the whole `$url` and
911−`/page/${$n + 1}`, and `@link "/my page" Open` takes `/my page`. After it,
912−the rest of the text is the element's content, read like any text; an
913−element without content (`@image`, `@source`, `@track`, `@embed`, `@area`)
914−takes nothing more, and a word after its argument is an error (write a
915−text alternative as `alt=...`). So is a word after an `@optgroup`'s label,
916−since it holds only the `@option` lines under it. A value with a space in
917−it is quoted, as such a label: `@optgroup "Citrus fruits"`. The first word is
918−always the argument, so on an element whose attribute is often left out
919−(`@form`, `@video`, `@audio`), text goes on the lines under it: `@form Sign
920−in` would make `Sign` the form's action. A first word that reads as text,
921−a capitalized word with nothing of a URL in it, is taken with a warning
922−(`text-as-url`): `@link [hx-get=/panel] Open panel` makes `Open` the link's
923−`href`. A relative URL like that one is written `./Open`.
924−
925−The attribute can be written as an attribute instead, and it means the same:
926−`@link [href=/about]` with its text on the lines under it, or
927−`@image [src=logo.svg, inline]`. Both at once, as in
928−`@link [href=/a] About`, is an error that says which word was taken as the
929−`href`.
930−
931−```
932−@nav [flex-direction row, spacing 16]
933− @link /docs The docs
934− @link [aria-current=page] /pricing Pricing
935−@image [alt=The team, width 320] "team photo.jpg"
936−@picture
937− @source [type=image/avif] hero.avif
938− @image [alt=The office] hero.jpg
939−@video [controls] intro.mp4
940− @track [kind=captions, srclang=en] intro.vtt
941− Your browser doesn't play this video.
942−```
943−
944−`@script` is an element like the others: its leading argument is its `src`,
945−any HTML attribute passes through (`@script [type=module, defer] app.js`),
946−and without a `src` its indented body is its JavaScript, kept exactly as
947−written. A `src` and a body together are an error, since the browser runs
948−only the file. It isn't shown, so a style on it is an error.
949−
950−```
951−@script [defer] analytics.js
952−@script
953− document.body.dataset.ready = "yes"
954```
267```
955
268
956−### HTML attributes
269+## Variables and functions
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
270
979−`@let` defines everything reusable. What it defines depends on its form:
271+`@let` defines everything reusable. What it defines depends on its shape:
980
272
981```
273```
982-- A value, used as $primary
274-- A value, used as $primary
983@let primary #3b82f6
275@let primary #3b82f6
984−-- A computed value: `=` makes the rest an expression
276+-- A computed value: `=` makes it an expression
985@let gap = 8 * 2
277@let gap = 8 * 2
986−-- Quoted text, with $variables filled in (see Variables for its quotes)
278+-- A quoted string, with $variables interpolated
987−@let greeting "Hello from $primary"
279+@let greeting "Hello $primary"
988-- An attribute bundle, used as [$card]
280-- An attribute bundle, used as [$card]
989−@let card [padding 20, background white, border-radius 8]
281+@let card [padding 20, background white, rounded 8]
990−-- A function: `@` before its name, its parameters in brackets, and a body
282+-- A function: a @let with an indented body
991−@let @panel [title]
283+@let panel $title $tone=neutral
992− @el [$card, spacing $gap]
284+ @el [$card]
993− @text [font-weight bold, color $primary] $title
285+ @text [bold] $title
994 @children
286 @children
995
287
996@panel [title Welcome]
288@panel [title Welcome]
997− $greeting
289+ Body text goes into @children.
998@el [$card, background #f9fafb]
290@el [$card, background #f9fafb]
999 Attributes after a bundle override it.
291 Attributes after a bundle override it.
1000```
292```
1001
293
1002−The line alone says what a `@let` defines: an `@` before the name makes a
294+A function is called like an element:
1003−function, a `[` after the name an attribute bundle, and anything else a
1004−value. A value or a bundle is named without `$` and used with it; a function
1005−is named with `@` and called with it. Only a function has a body, so an
1006−indented block under a value or a bundle is an error, and so are `@let $x`
1007−(write `@let x`) and a `@let` without a value.
1008
295
1009−Values, bundles and functions share one namespace, and one rule says where
296+- Its parameters are passed as attributes; parameters with `=default` may be
1010−a name is visible: **from the line that defines it to the end of its
297+ omitted.
1011−block**. A block is the file, the lines indented under an element, a branch
298+- Other attributes style its root element, so `@panel [title Hi, padding 40]`
1012−of `@if`, one repetition of `@each` or a function's body. A `@let` gives its
299+ works like styling a built-in element.
1013−name a new meaning in its block, whatever the name meant before, and hides
300+- Text after the attributes and indented children replace `@children`, and a
1014−what the name means around the block until the block ends:
301+ caller's `@slot name` block replaces `@slot name` (the slot's own children are
302+ the default).
303+- An `@style` block at the top of the body is scoped to the function: its rules
304+ only apply inside the function's output.
1015
305
1016```
306```
1017−@let gap 8
1018−@el [spacing $gap]
1019− @let gap 16
1020− @el [padding $gap] Sixteen
1021−@el [padding $gap] Eight
1022−```
1023−
1024−Parameters, `@each` variables and `@data` names follow the same rule, and
1025−each repetition of `@each` starts afresh, so a `@let` in one doesn't carry
1026−over to the next. A function's body sees its parameters and what is
1027−visible where the function is defined: not the names at a call, and not a
1028−definition further down, so a function means the same wherever it is
1029−called. `@let t.greeting Hello` gives the record `$t` the field `greeting`
1030−(making `$t` a record when it isn't defined), and `@let items.0 first`
1031−replaces the first item of the list `$items`; a field of any other value is
1032−an error.
1033−
1034−### Variables
1035−
1036−`$name` inserts a value wherever htmlang holds text: a line of text, an
1037−attribute's value, an element's argument, a `@let` value, a file path, the
1038−title of `@page` and the value of `@meta`. Each line is read first, and then
1039−`$name` fills the one place it is written in. What it inserts is never read
1040−again as htmlang, so a value can't become an attribute, an attribute's name,
1041−a comma between attributes, an inline element or another `$name`. Attributes
1042−come from a bundle:
1043−
1044−```
1045−@let link-style [color #2563eb, text-decoration underline]
1046−@let label Docs
1047−@link [$link-style, aria-label=$label] /docs Read the $label
1048−```
1049−
1050−A name starts with a letter or `_` and goes on with letters, digits, `_`
1051−and `-`. It ends at the first other character, so after `@let lang fr`,
1052−`$lang.json` is `fr.json`. A `.field` continues the name only when the value
1053−is a record or a list (`$post.title`, `$tags.0`). `${name}` is the same name
1054−with explicit ends, for text that follows it directly, and `${EXPR}` inserts
1055−the value of an [expression](#expressions). A `$` followed by anything else
1056−is text.
1057−
1058−```
1059−@let lang fr
1060−@let size 4
1061−@data $post {"title": "Hello"}
1062−@text locales/$lang.json, ${size}px, $post.title, $5
1063−```
1064−
1065−A name that isn't defined is an error. A field that a record doesn't have
1066−is empty, and so is any field of it, so optional fields of `@data` records
1067−work: `@if $post.draft` is false, `$post.author.name` is empty when there is
1068−no `author`, and `${default($post.tag, none)}` gives `none`.
1069−
1070−### Values
1071−
1072−A value is text, a number, `true` or `false`, a list or a record, and a
1073−name holds a whole value: a `@let`, a parameter, `@each` and `@data` bind
1074−one, and binding the name again replaces it. What a `@let` holds is what it
1075−says:
1076−
1077−```
1078−-- Text, as written
1079−@let size 16px
1080−-- Commas make a list, which prints as it was written
1081−@let fruits apple, banana, cherry
1082−-- Quoted, or with `\,`, text with commas in it is one text
1083−@let tagline "Fast, simple"
1084−@let motto small\, fast
1085−-- A range of whole numbers
1086−@let steps 1..9 step 4
1087−-- One `$name` or `${...}` keeps the type of what it holds
1088−@data $posts [{"title": "Hello"}, {"title": "Again"}]
1089−@let first $posts.0
1090−@let backwards ${reverse($fruits)}
1091−@text [font-size $size] $fruits: ${length($fruits)}. $tagline: ${length($tagline)}. $motto. $steps
1092−@text $first.title, $backwards
1093−```
1094−
1095−The last lines give `apple, banana, cherry: 3. Fast, simple: 12. small,
1096−fast. 1..9 step 4` and `Hello, cherry, banana, apple`. A value that is
1097−exactly one `$name` or `${...}` keeps its type wherever it goes: a `@let`, a
1098−function's parameter or default, the list of `@each`. So a record can be
1099−passed to a function whole (`@post-card [post $p]`), and a list stays a
1100−list.
1101−
1102−- Text written without quotes reads as a number when it is one (`3`,
1103− `0.5`), and as false when it is `false` or `0`: that is how htmlang writes
1104− numbers and flags (`@let gap 8`, `@card [dot false]`). Quoted text is
1105− always text.
1106−- **False** is `false`, empty text, `0` and an empty list (or record).
1107− Everything else is true, including `no` and quoted `"0"`.
1108−- `==` and `!=` compare numbers as numbers (`1.0 == 1`), lists and records
1109− item by item, and anything else as text, exactly (`#FFF` isn't `#fff`).
1110− `<`, `>`, `<=` and `>=` compare numbers as numbers and text by character
1111− order.
1112−- A computed number prints without float noise: whole numbers without
1113− decimals, others with at most four (`${100 / 3}` is `33.3333`).
1114−- A list prints as written when the source wrote it, and otherwise as its
1115− items joined with `, `. A record has no text of its own, so writing one
1116− (or a list of records) where text goes is an error: write one of its
1117− fields, or loop over the list with `@each`.
1118−- A `$list` in a comma list is one item, a list inside the list:
1119− `@let more $items, d` has two items. htmlang has no list concatenation.
1120−
1121−**Quoted text remembers that it was quoted.** A value written `"..."` (in a
1122−`@let`, an attribute or a function's argument) keeps its quotes in a CSS
1123−value, where CSS needs them, and loses them in text, in HTML attribute
1124−values and in expressions (`${length($x)}` counts no quotes). It stays
1125−quoted when it passes through another `@let` or a parameter. Inside a quoted string of a CSS value, it inserts
1126−what it says, without its own quotes:
1127−
1128−```
1129−@let arrow "→ "
1130−@let label "Next, please"
1131−@el [before:content $arrow, after:content " ($label)", aria-label=$label] $label
1132−```
1133−
1134−This gives `content:"→ "`, `content:" (Next, please)"`,
1135−`aria-label="Next, please"` and the text `Next, please`. A value written
1136−without quotes is inserted as written everywhere.
1137−
1138−### Functions
1139−
1140−A function's parameters are listed in brackets after its name and written
1141−the way a call passes them: a name alone (`title`) is a required parameter,
1142−and a name and a value (`tone #f9fafb`) is a parameter with that default.
1143−The body uses them as `$title` and `$tone`. `@let @name` without brackets
1144−takes no parameters.
1145−
1146−A default is filled in at each call that leaves its parameter out, like a
1147−value passed for it: it may hold spaces, quotes, escapes, `${...}` and
1148−`$variables`. It sees what the body sees: the names visible where the
1149−function is defined and the parameters declared before it
1150−(`[title, heading "About $title"]`). A default that uses a parameter
1151−declared after it is an error.
1152−
1153−A function is called like an element, wherever an element can be: on a
1154−line of its own, in a [chain](#chains) (`@el > @card [title Hi]`) or
1155−inline in text (`{@key Ctrl+K}`):
1156−
1157−- Its parameters are passed by name, as attributes: `name value`, or the
1158− name alone for `true`. A parameter with a default can be left out;
1159− leaving out one without a default is an error that names it. `=` writes
1160− an HTML attribute, so `title=Hi` for a parameter is an error too.
1161−- Every other attribute goes to the function's root element: a style, a
1162− flag, or a `key=value` HTML attribute such as `id=intro`. It is checked
1163− like an attribute written on that element, so `@panel [title Hi,
1164− padding 40]` works the same as styling a built-in element, and
1165− `[paddin 40]` gets the same warning. A name close to one of the
1166− parameters (`titel Hi`) is a misspelled parameter, which is an error.
1167− This needs the body to have a single root element: attributes on a call
1168− whose body has several roots, or is text, are an error. A `class=` adds
1169− to the root's own classes.
1170−- A parameter passed twice (in the call's list, or after a bundle that
1171− passes it) takes the later value, as any attribute does.
1172−- Text after the attributes and the indented lines are the call's
1173− content, and replace `@children` in the body. A `@slot NAME` block
1174− directly under the call (or under an `@if`, `@else` or `@each` there)
1175− fills the body's `@slot NAME` instead.
1176−- The lines indented under `@children` or `@slot NAME` in the body are its
1177− fallback: they are shown when a call passes no content, or no block for
1178− that slot.
1179−
1180−```
1181−@let @card [title, tone #f9fafb]
1182− @article [padding 16, spacing 8, background $tone, border-radius 8]
1183− @row [align-items center]
1184− @h3 $title
1185− @spacer
1186− @slot actions
1187− @children
1188− @slot footer
1189− @text [font-size 12, color gray] No footer
1190−
1191−@card [title Plain]
1192− Just children.
1193−@card [title Full, tone #eff6ff, padding 24]
1194− @slot actions
1195− @link /edit Edit
1196− The children go here.
1197− @slot footer
1198− @text Updated today
1199−```
1200−
1201−`@children` takes a fallback the same way:
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]
307+@let note $kind=info
1259 @style
308 @style
1260− & { border-left: 4px solid #3b82f6; }
1261 .title { font-weight: bold; }
309 .title { font-weight: bold; }
1262− @el [padding 12, border-radius 6, background #eff6ff]
310+ @el [padding 12, rounded 6, background #eff6ff]
1263 @text [class=title] $kind
311 @text [class=title] $kind
1264 @children
312 @children
1265
313
1266@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
314@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
1267```
315```
1268
316
1269−A call in text or in a chain whose body has several roots, or is text,
317+A multi-line string uses triple quotes; its indented lines are the value:
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
318
1273```
319```
1274−@let @intro
320+@let intro """
1275− htmlang is a layout language.
321+ First line
1276− It compiles to {@strong static HTML}.
322+ Second line
1277−@let @key
323+ """
1278− @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
324+@text $intro
1279− @children
1280−
1281−@paragraph
1282− @intro
1283−@paragraph
1284− In short: {@intro} Press {@key Ctrl+K} to search.
1285```
325```
1286
326
1287−A function may call itself, under a condition that stops it, so it can
327+`@let --name value` also emits a CSS custom property. `@theme` declares a group
1288−render nested data such as a menu. A parameter passed one `$name` that
328+of design tokens at once; each becomes both `$name` and `--name`:
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
329
1294```
330```
1295−@data $menu [
331+@theme
1296− {"label": "Guide", "children": [{"label": "Install"}, {"label": "Usage"}]},
332+ brand #3b82f6
1297− {"label": "Reference"}
333+ radius 8
1298−]
334+@el [background $brand, rounded $radius] Themed
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```
335```
1309
336
1310−The elements a function's body writes are the call's: a problem with
337+### Filters
1311−one of them, such as low contrast or `spacing` on an element that isn't a
338+
1312−row, column or grid, is reported at the call, once, and names the function. A
339+`$name|filter` transforms a value: `uppercase`, `lowercase`, `capitalize`,
1313−problem in the text of a body is reported on its own line, or at the call
340+`trim`, `length`, `reverse`, `truncate:N`, `replace:OLD:NEW`, `default:VALUE`,
1314−when the function comes from `std.hl` or another file.
341+and for colors `lighten:N`, `darken:N`, `alpha:N`, `mix:COLOR:N`.
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
342
1349```
343```
344+@let name htmlang
1350−@let --brand #3b82f6
345+@let base #3b82f6
1351−@let --radius 8px
346+@text [color $base|darken:10] $name|uppercase
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```
347```
1358
348
349+### Checks
350+
351+`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
352+warning. `@deprecated MESSAGE` before a function warns every caller.
353+
1359## Expressions
354## Expressions
1360
355
1361Conditions and computed values (`@let x = ...`) are expressions:
356Conditions and computed values (`@let x = ...`) are expressions:
1362
357
1363| | |
358| | |
1364|---|---|
359|---|---|
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 |
360+| Values | numbers, `"strings"` (with `$var` interpolation), `$variables` (with filters), `true`, `false`, and bare words (`dark`, `#fff`) as strings |
1366| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
361| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
1367−| Comparison | `== != < > <= >=`: numbers as numbers, text exactly, lists and records item by item (see [Values](#values)) |
362+| Text | `~` joins two values: `$first ~ " " ~ $last` |
363+| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains`, `starts-with`, `ends-with` |
1368−| Logic | `and`, `or`, `not`. `false`, empty text, `0` and an empty list are false |
364+| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
1369−| Choice | `if(CONDITION, A, B)`, where `B` may be left out (empty) |
365+| Choice | `if(CONDITION, A, B)` |
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
366
1395−```
367+Variables are looked up while evaluating, so a value containing `==` or spaces
1396−@let name htmlang
368+is still one value. An invalid expression is a compile error.
1397−@let size 12
1398−@text [font-size ${$size + 4}] ${uppercase($name)} has ${length($name)} letters
1399−```
1400
369
1401## Control flow
370## Control flow
1402
371
1403All control flow runs at compile time.
372All control flow runs at compile time.
1404
373
1405```
374```
375+@let items apple, banana, cherry
1406@let count 3
376@let count 3
1407−@let hidden false
1408
377
1409@if $count > 2 and not $hidden
378@if $count > 2 and not $hidden
1410 @text Many
379 @text Many
@@ -1412,208 +381,119 @@ All control flow runs at compile time.
1412 @text None
381 @text None
1413@else
382@else
1414 @text Few
383 @text Few
1415−```
1416−
1417−`@each $item in LIST` repeats its body for each item. Its variables are
1418−written with `$`, like `@data $name`. LIST is a value, read like the value
1419−of a `@let`: items written with commas (a quoted item or one with `\,` is
1420−one text), a range `A..B` (which counts down when its start is greater than
1421−its end, and takes `step N`), or one `$name` or `${...}` that holds a list,
1422−such as one loaded with `@data` or `${reverse(1..5)}`. A text is one item,
1423−and empty text none. Each item is bound whole, so a record keeps its fields.
1424−An optional second variable is the index, starting from 0. `@else` gives
1425−the content to show when the list is empty.
1426
384
1427−```
385+@each $item in $items
1428−@let fruits apple, banana, cherry
386+ @text $_index: $item
1429−@each $fruit, $i in $fruits
1430− @text ${$i + 1}. $fruit
1431@else
387@else
1432− @text No fruit.
388+ @text The list is empty.
1433
389
1434−@row [spacing 8]
1435− @each $n in 10..0 step 5
390+@each $i in 1..10 step 3
1436− @text $n
391+ @text $i
1437−```
1438
392
1439−## Data and files
393+@each $label, $url in Home /, About /about
394+ @link $url $label
1440
395
1441−| Directive | Effect |
396+@match $count
1442−|---|---|
397+ @case 3
1443−| `@data $name file.json` | Load a JSON file. Objects are records (`$name.key`) and arrays are lists |
398+ @text Three
1444−| `@data $name [...]` / `@data $name {...}` | Inline JSON, which may span several lines |
399+ @default
1445−| `@data $name dir/*.json` | A list with one record per file, in name order. `$item.file` is the file's name |
400+ @text Other
1446−| `@data $name env:NAME DEFAULT` | An environment variable, with an optional default |
401+```
1447−| `@include file.hl` | Insert another file: its content and its definitions. A file that holds only definitions outputs nothing (a [library](#page-layouts)) |
1448−| `@markdown` / `@markdown file.md` | Markdown (an indented body or a file), converted to HTML |
1449−| `@image [inline] file.svg` | Put the file inside the page: SVG as markup, other images as base64. The file is read next to the page, and one that can't be read is an error. The SVG's root gets `width` and `height` (lengths, not `fill` or `shrink`) and `color` or `fill` (as its `fill`) in its `style=`, after the file's own, where `var(--name)` works, and the HTML attributes (`class=`, `aria-label=`, ...); any other style, and `alt=`, is an error, since the markup has no generated class, and an element prefix (`@image:`) doesn't reach it. `inline` goes only on `@image`, without a prefix |
1450
402
1451−Records keep their values whole, even when a value contains spaces or
403+A range counts down when its start is greater than its end.
1452−commas:
1453
404
1454−```
1455−@data $team [
405+## Files and data
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
406
1467−### Page layouts
407+| Directive | Effect |
408+|---|---|
409+| `@include file.hl` | Insert another file here, content and definitions |
410+| `@import file.hl` | Take only its definitions (`@let`, bundles, functions) |
411+| `@extends layout.hl` | Render this page inside a layout (below) |
412+| `@data file.json` / `@data $prefix file.json` | Load JSON values as variables |
413+| `@fetch $prefix http://...` | Like `@data`, fetched at build time (http only) |
414+| `@collection $name "glob"` | List files matching a pattern |
415+| `@translations` | Per-locale strings, used as `$t.key` |
416+| `@env NAME default` | Read an environment variable |
417+| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
418+| `@svg file.svg` | Inline an SVG file |
1468
419
1469−A layout is an ordinary function in a file of its own. It marks where content
420+With `@extends`, the layout marks where content goes with `@slot name` (named
1470−goes with `@slot NAME` (named blocks) and `@children` (everything in the call
421+blocks) and `@children` (everything in the page outside `@slot` blocks):
1471−that is outside a `@slot` block), each with fallback content indented under
1472−it.
1473
422
1474```
423```
1475-- layout.hl
424-- layout.hl
1476−@let @layout
1477− @page [lang=en, background #f8fafc] My Site
425+@page My Site
1478− @el [width fill, max-width 800, center-x, spacing 24]
426+@column [max-width 800, center-x]
1479− @header
1480− @slot header
427+ @slot header
1481− @text Default header
428+ @text Default header
1482− @main
1483− @children
429+ @children
1484```
430```
1485
431
1486```
432```
1487-- page.hl
433-- page.hl
1488−@include layout.hl
434+@extends layout.hl
1489−@layout
1490− @slot header
435+@slot header
1491− @h1 About us
436+ @text About us
1492− @paragraph This fills @children.
437+@paragraph This fills @children.
1493```
438```
1494
439
1495−The layout holds the page's `@page`, whose attributes can come from its
1496−parameters (`@page [background $bg] $title`). A page has one `@page`, so a
1497−second one is an error (`duplicate-page`): the page's own next to its
1498−layout's, or a layout called twice. The same `@meta` tag twice is written
1499−once. A file that holds only `@let`s, such as `layout.hl`, is a library
1500−(it may also read `@data` and `@include` other libraries): its definitions
1501−aren't reported unused, in it or in a page that includes it and uses only
1502−some of them, and `htmlang build`, `serve` and `watch` don't build it into
1503−a page of its own. An included file's unused definitions are never
1504−reported in the file that includes it.
1505−
1506## Page and head
440## Page and head
1507
441
1508−`@page TITLE` makes the output a full HTML document. Without it, the output
442+`@page TITLE` produces a full HTML document; without it the output is a fragment.
1509−is a fragment, which has no `<head>`: `@meta` and `@head` in it are a
443+Its attributes set `lang`, `favicon`, `canonical` and `base`. `@meta NAME VALUE`
1510−warning. `--partial` writes a fragment of a page too, and warns that
1511−`@page`, `@meta` and `@head` are left out. The page is the root element, and `@page`'s attributes are
1512−checked like any element's:
1513−
1514−- Styles style `<body>`, prefixes included, so a page's colour, font and
1515− dark background go on `@page` and cover the whole window.
1516−- `key=value` attributes go on `<html>`: `lang=en`, `dir=rtl`, `class=x`.
1517−- `favicon FILE` is its one word of htmlang's own: the file is put into
1518− the page as its icon (or linked, when it can't be read).
1519−
1520−`<body>` is a column that fills the window (`display: flex;
1521−flex-direction: column; min-height: 100dvh`, from the reset), so the
1522−page's top-level elements are laid out like the children of an `@el`:
1523−they stack, even a line of text or an inline element such as `@text` or
1524−`@link`, and each takes the full width unless it says otherwise. A
1525−centred column at the top writes `width fill` like anywhere else (see
1526−[Rows, columns and text](#rows-columns-and-text)).
1527−
1528−`@meta NAME VALUE` adds a meta tag, and names that start with `og:` become
444+adds a meta tag (`og:` names become Open Graph `property` tags), and
1529−Open Graph `property` tags; a `@meta viewport` replaces the usual one.
445+`@font-face NAME URL`, `@manifest NAME` (with indented settings), `@json-ld`
1530−`@head` holds any other raw HTML for the `<head>`, such as a font link, a
446+(indented JSON) and `@head` (indented raw HTML) add the rest.
1531−canonical URL or JSON-LD, on its line or in its indented block.
1532
447
1533```
448```
1534−@page [lang=en, favicon favicon.png, color #1f2937, dark:background #0b1220, dark:color #e5e7eb] My Site
449+@page [lang en, favicon /favicon.png] My Site
1535@meta description A small site
450@meta description A small site
1536@meta og:title My Site
451@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
1544```
452```
1545
453
1546−Translations are a JSON file per locale: `@data $t locales/$lang.json`
1547−loads `locales/fr.json` when `$lang` is `fr`.
1548−
1549## CSS
454## CSS
1550
455
1551−`@style` holds raw CSS, including at-rules such as `@keyframes`,
456+- `@style` holds raw CSS, including at-rules such as `@scope`, `@property` and
1552−`@font-face`, `@property` and `@starting-style`, in its indented block or,
457+ `@starting-style`.
1553−for a single rule, on its line:
458+- `@keyframes name` takes htmlang attribute syntax (`from [opacity 0]`) or raw
459+ CSS.
460+- Generated rules live in `@layer htmlang` (after a `hl-reset` layer), so any
461+ CSS in `@style` or `@raw` overrides them.
1554
462
1555```
463```
464+@keyframes fade-in
465+ from [opacity 0]
466+ to [opacity 1]
1556@style
467@style
1557− @keyframes fade-in {
1558− from { opacity: 0; }
1559− to { opacity: 1; }
1560− }
1561 .note { color: gray; }
468 .note { color: gray; }
1562−@style .quiet { opacity: 0.6; }
1563@el [animation fade-in 0.3s ease, class=note] Fades in
469@el [animation fade-in 0.3s ease, class=note] Fades in
1564```
470```
1565
471
1566−Each element with styles gets a generated class, and elements with the
472+## Upgrading older files
1567−same styles share one. A class is named by its style: `hl-` and seven
473+
1568−characters of a hash of what it sets (`hl-2k9xq0m`), so one style has one
474+`htmlang upgrade [dir|file]` rewrites removed syntax; the compiler reports each
1569−name in every file. A fragment put into a page (`--partial` output that a
475+removed form with its replacement.
1570−server returns, or any HTML of one file put into another's page) means
476+
1571−the same there, and its rules restyle nothing of the page's: a class of
477+| Removed | Use instead |
1572−both is the same style. Two different styles of one file never share a
1573−name (the later one takes more characters), and `htmlang build` checks
1574−the whole site: two files whose different styles hash to one name are an
1575−error (`class-collision`), which one more attribute on either style
1576−fixes. A function's scoped `@style` is named by the function and a hash
1577−of the stylesheet, `hl-fn-card-1x0a9zq`. The `hl-` prefix is htmlang's:
1578−a class of your own that starts with it gets a warning, since the generated
1579−rules would apply to it too. Every style goes into the class under its own
1580−name, with its value as written (plus pixels, see
1581−[CSS properties](#css-properties)); only the layout words, and
1582−`line-clamp`'s fallback, write more than one declaration.
1583−
1584−An element's defaults go with its own class: its layout (a column is
1585−`display: flex`), the margins browsers give headings, paragraphs, lists,
1586−figures and blockquotes are 0, lists have no markers, `@fieldset` has a
1587−thin border and padding, `@code`, `@kbd` and `@pre` are monospace, `@link`
1588−has no underline and takes its parent's text colour, and `@image` is a
1589−block. They apply only to htmlang's own elements, so HTML that comes from
1590−`@markdown` or `@raw` keeps the browser's defaults: a Markdown list keeps
1591−its bullets and a Markdown link its underline.
1592−
1593−The generated rules live in three cascade layers, after a small reset in
1594−the layer `hl-reset`, and a later layer wins over an earlier one whatever
1595−the specificity:
1596−
1597−| Layer | Holds |
1598|---|---|
478|---|---|
1599−| `hl-reset` | The reset |
479+| `@fn`, `@define`, `@mixin`, `@component` | `@let` (function, bundle; `@style` in a function body is scoped) |
1600−| `hl-kind` | The defaults of each element's kind, as `:where(.hl-2k9xq0m)` |
480+| `@unless COND` | `@if not COND` |
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` |
481+| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
1602−| `htmlang` | Each element's own styles, and its parent's `children:` styles |
482+| `@switch` | `@match` |
1603−
483+| `@use "file" names` | `@import file` |
1604−So an element's own styles win over everything else htmlang writes for
484+| `@with $x as y` | `@let y $x` |
1605−it; its parent's `children:` styles and the element prefixes of the
485+| `@layout file` | `@extends file` |
1606−elements around it win over its defaults.
486+| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
1607−
487+| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
1608−A page's reset sets `box-sizing: border-box` everywhere and makes
488+| `@og KEY VALUE` | `@meta og:KEY VALUE` |
1609−`<body>` a column that fills the window. A fragment (a file
489+| `@debug`, `@log` | `@warn` |
1610−without `@page`, or `--partial` output) goes into a page it doesn't own, so
490+| `@defer` | Its content, directly |
1611−its reset touches only htmlang's own elements (those with an `hl-` class),
491+| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@column`, `@paragraph`, `@image`, `@item`, `@button`, `@list`, `@hr`, `@option` |
1612−which get `box-sizing: border-box`; no other element on the page is
492+| `type email`, `id main` (HTML attributes written as styles) | `type=email`, `id=main` |
1613−touched. In both, an element
493+| `skeleton`, `no-scrollbar`, `gradient A B` | `$skeleton`, `$no-scrollbar`, `background linear-gradient(A, B)` |
1614−htmlang lays out stays hidden while it has `hidden`, while an `@dialog` is
494+| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
1615−closed, and while a popover isn't showing, even though its generated
495+| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
1616−`display` would otherwise beat the browser's `display: none`. A page with
496+| `COND ? A : B` | `if(COND, A, B)` |
1617−links, buttons or form fields also gives htmlang's ones a focus outline.
497+| `...$bundle` | `$bundle` |
1618−CSS outside any layer takes precedence over every layer, so rules in
498+| `animate`, `inset-area` | `animation`, `position-area` |
1619−`@style` or `@raw` override the generated ones.
499+| `\|upper`, `\|lower`, `\|cap`, `\|len` | `\|uppercase`, `\|lowercase`, `\|capitalize`, `\|length` |
README.md+70−190
@@ -1,123 +1,35 @@
1# htmlang
1# htmlang
2
2
3−A small layout language that compiles to static HTML. The layout model comes
4−from [elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest/):
3+A minimalist layout language inspired by [elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest/) that compiles to static HTML.
5−rows and columns, spacing between children, and elements that fill or center
4+
6−themselves. Styling uses CSS properties under their CSS names, and every other
5+`@` means structure. Bare lines mean content. No CSS required.
7−element uses its HTML name.
6+
7+## Example
8
8
9```
9```
10−@page [lang=en, background #f8fafc] My Site
10+@page My Site
11−@let --brand #3b82f6
11+@let primary #3b82f6
12
12
13−@let @card [title]
13+@let card $title
14− @article [padding 20, spacing 8, border 1 solid #e5e7eb, border-radius 8, hover:border-color var(--brand)]
14+ @el [padding 20, background white, rounded 8, border 1 #e5e7eb, hover:border 1 $primary, transition all 0.15s ease]
15− @h3 $title
15+ @text [bold] $title
16 @children
16 @children
17
17
18−@el [width fill, max-width 800, center-x, padding 40, spacing 24]
18+@column [max-width 800, center-x, padding 40, spacing 20]
19− @h1 [font-size 32] Hello
19+ @text [bold, size 32] Hello
20+
20 @paragraph
21 @paragraph
21− Built with {@text [font-weight bold, color var(--brand)] htmlang}.
22+ Built with {@text [bold, color $primary] htmlang}.
23+
22− @row [spacing 16, wrap]
24+ @row [wrap, spacing 10]
23 @card [title Simple]
25 @card [title Simple]
24− Layout without writing a stylesheet.
26+ Write layouts without CSS
25− @card [title Static]
27+ @card [title Fast]
26− One HTML file, with its CSS inside.
28+ Compiles to a single HTML file
27```
29```
28
30
29−Each page compiles to one self-contained `.html` file. It has no JavaScript
31+This compiles to one self-contained `.html` file with flexbox layout and
30−unless you write some, and nothing is added that you didn't ask for.
32+generated CSS classes. No JavaScript, no external dependencies.
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.
121
33
122## Install
34## Install
123
35
@@ -128,118 +40,86 @@ cargo install --path .
128## Usage
40## Usage
129
41
130```
42```
131−htmlang page.hl # compile page.hl to page.html
43+htmlang page.hl # compile page.hl -> page.html
132htmlang page.hl -o out.html # choose the output file
44htmlang page.hl -o out.html # choose the output file
45+htmlang -w page.hl # recompile on change
133htmlang serve . # dev server with live reload
46htmlang serve . # dev server with live reload
134−htmlang build src -o dist # compile a whole site
135```
47```
136
48
137| Command | Purpose |
49| Command | Purpose |
138|---|---|
50|---|---|
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`) |
51+| `build <dir> [-o out] [--minify] [--compat] [--strict]` | Compile every `.hl` file under a directory |
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 |
52+| `serve [dir\|file] [-p PORT] [--open] [--https --cert C --key K]` | Dev server with live reload |
141| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
53| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
142| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
54| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
143| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
55| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
144−| `fmt <file.hl>...` | Format files in place |
56+| `fmt <file.hl>` | Format a file in place |
57+| `convert <file.html>` | Convert HTML to htmlang (prints to stdout) |
58+| `upgrade [dir\|file]` | Rewrite syntax from older versions of the language |
145| `lsp` | Run the language server over stdio |
59| `lsp` | Run the language server over stdio |
146
60
147−Compiling a file directly also takes `-w` / `--watch`, `--dev` (readable
61+Compiling directly also takes `--dev`, `--compat`, `--strict`, `--partial`,
148−output and source maps), `--strict`, `--partial` (a fragment, without the
149−document wrapper), `--format json`, `-s` / `--serve`, `-p` / `--port` and
62+`--format json`, `-s` / `--serve`, `-p` / `--port` and `--open`.
150−`--open`.
151
63
152−## A short tour
64+## Editor support
153
65
154−Layout attributes place an element. Everything else is CSS, and `key=value`
66+A VS Code extension with syntax highlighting and LSP integration is in
155−is an HTML attribute:
67+[`editors/vscode`](editors/vscode). The language server (`htmlang-lsp`) provides
68+diagnostics, completions, hover documentation, go to definition, rename and
69+formatting.
156
70
157−```
71+## Language tour
158−@row [spacing 12, align-items center, padding 12 20, border-bottom 1 solid #e5e7eb, id=toolbar]
72+
159− @text [font-weight 700] Acme
73+Elements are laid out with `@row`, `@column` and `@el`; `@text`, `@paragraph`,
160− @spacer
74+`@link` and `@image` hold content, and every other HTML element is available by
161− @input [type=search, placeholder=Search, aria-label=Search, width 240, padding 6 10]
75+name (`@nav`, `@section`, `@table`, `@input`, ...).
162− @button [type=submit, padding 6 14, hover:background #f3f4f6] Go
163−```
164
76
165−A prefix applies a style only in a state, from a screen width up, or under a
77+Attributes are styles, written `key value`, or HTML attributes, written
166−media condition. Prefixes stack, and a prefix before a `[group]` applies to
78+`key=value`:
167−each style in it:
168
79
169```
80```
170−@el [padding 16, md:[padding 32, font-size 20], background #3b82f6, hover:background #2563eb, dark:[background #1e3a8a, hover:background #1e40af]]
81+-- gap between children, padding, and an HTML id
171− @text [color white] Click me
82+@row [spacing 20, padding 16, id=toolbar]
83+ -- take the remaining space; center the text
84+ @el [width fill, text-align center] Search
85+ @input [type=search, placeholder=Find..., width 240]
172```
86```
173
87
174−An element prefix, `@td:`, styles every such element inside, so rows a
88+Any standard CSS property works as a style (`opacity 0.5`, `margin-top 16`).
175−loop writes and HTML from `@markdown` need no attributes of their own:
89+Prefixes apply a style conditionally:
176
90
177```
91```
178−@table [border-collapse collapse, @th:[padding 8, text-align left], @td:padding 8, @tr:nth-child(even):background #f8fafc]
92+@el [background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
179− @tr
93+ @text [color white] Click me
180− @th Name
181− @tr
182− @td Ada
183```
94```
184
95
185−`@let` defines values, bundles and functions, in one namespace. A function
96+`@let` defines values, computed values, attribute bundles and functions:
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.
192
97
193```
98```
194−@let --primary #3b82f6
99+@let primary #3b82f6
195@let gap = 8 * 2
100@let gap = 8 * 2
196−@let rounded [border-radius 8, overflow hidden]
101+@let card [padding 20, rounded 8]
197−
198−@let @cta [label, href #]
102+@let button $label
199− @link [$rounded, padding 10 16, background var(--primary), color white] $href
103+ @el [$card, background $primary]
200− $label
104+ @text [color white, bold] $label
201−@let @key
202− @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
203 @children
105 @children
204
106
205−@row [spacing $gap]
107+@button [label Click me, padding 12]
206− @cta [label Sign up]
207− @cta [label Learn more, href /about, background #64748b]
208−@paragraph
209− Press {@key Ctrl+K} to search.
210```
108```
211
109
212−Data, loops and conditions run at compile time:
110+Functions are used like elements: extra attributes (here `padding 12`) style
111+their root element. A small standard library (`@badge`, `@chip`, `@tooltip`,
112+`@carousel`, `@breadcrumb`, ...) is written the same way.
113+
114+Control flow runs at compile time:
213
115
214```
116```
215−@data $links [
117+@let items Home, About, Contact
216− {"label": "Home", "url": "/"},
217− {"label": "Blog", "url": "/blog"},
218− {"label": "About us", "url": "/about"}
219−]
220−@let current /blog
221−
222−@nav
223− @row [spacing 16]
118+@row [spacing 8]
224− @each $link in $links
119+ @each $item in $items
225− @link [if($link.url == $current, [font-weight bold, aria-current=page])] $link.url $link.label
120+ @if $item != About
121+ @link /$item|lowercase $item
226```
122```
227
123
228−The [`examples/`](examples) directory has complete pages (a landing page, a
124+See [DESIGN.md](DESIGN.md) for the full language: files and data (`@include`,
229−blog, a docs page, and a tour of the whole language). [DESIGN.md](DESIGN.md)
125+`@import`, `@extends`, `@data`), page metadata, filters, expressions and CSS.
230−is the language reference.
231−
232−## Editor support
233−
234−The VS Code extension in [`editors/vscode`](editors/vscode) provides syntax
235−highlighting, snippets, and the language server (`htmlang-lsp`). The server
236−gives diagnostics, completions, hover documentation, go to definition, rename
237−and formatting.
238−
239−The compiler, the formatter and the language server read a file with the
240−same parser, so the editor sees exactly what the compiler sees, relative
241−`@include` and `@data` paths included. Every diagnostic has a stable code
242−(`error[unknown-element]` on the command line, a `code` field in
243−`--format json`), and the whole file is checked, including branches and
244−functions that don't run (a `$name` there is checked only where the code
245−runs, since it may come from data).
crates/htmlang-core/src/ast.rs+123−624
@@ -3,33 +3,45 @@ 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− /// The page `@page` makes: without one, the output is a fragment.
6+ pub page_title: Option<String>,
7+ pub lang: Option<String>,
7− pub page: Option<Page>,
8+ pub favicon: Option<String>,
8 pub meta_tags: Vec<(String, String)>,
9 pub meta_tags: Vec<(String, String)>,
9 pub head_blocks: Vec<String>,
10 pub head_blocks: Vec<String>,
10 pub variables: HashMap<String, String>,
11 pub variables: HashMap<String, String>,
11 pub defines: HashMap<String, Vec<Attribute>>,
12 pub defines: HashMap<String, Vec<Attribute>>,
13+ pub keyframes: Vec<(String, String)>,
12 pub css_vars: Vec<(String, String)>,
14 pub css_vars: Vec<(String, String)>,
13 pub custom_css: Vec<String>,
15 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>,
17 pub og_tags: Vec<(String, String)>,
16 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>,
18 pub nodes: Vec<Node>,
25 pub nodes: Vec<Node>,
19}
26}
20
27
21−/// What `@page` says: the page is the root element. Its styles style
28+#[derive(Debug, Clone)]
22−/// `<body>`, which lays out the page's top-level elements as a column; its
29+pub struct ManifestConfig {
23−/// HTML attributes (`lang=en`, `dir=rtl`, `class=x`) go on `<html>`.
30+ pub name: String,
24−#[derive(Debug, Clone, Default)]
31+ pub short_name: Option<String>,
25−pub struct Page {
32+ pub start_url: String,
26− pub title: String,
33+ pub display: String,
27− /// `key=value` attributes, and bare boolean HTML attributes, for `<html>`
34+ pub background_color: Option<String>,
28− pub html_attrs: Vec<Attribute>,
35+ pub theme_color: Option<String>,
29− /// Styles, prefixed ones included, for `<body>`
36+ pub description: Option<String>,
30− pub styles: Vec<Attribute>,
37+ pub icons: Vec<(String, String)>, // (src, sizes)
31− /// `favicon FILE`, `@page`'s one word of htmlang's own
38+}
32− pub favicon: Option<String>,
39+
40+#[derive(Debug, Clone)]
41+pub struct PreloadHint {
42+ pub href: String,
43+ pub as_type: String, // "font", "image", "style", "script"
44+ pub crossorigin: bool,
33}
45}
34
46
35#[derive(Debug, Clone)]
47#[derive(Debug, Clone)]
@@ -49,52 +61,32 @@ pub enum TextSegment {
49pub struct Element {
61pub struct Element {
50 pub kind: ElementKind,
62 pub kind: ElementKind,
51 pub attrs: Vec<Attribute>,
63 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.
56 pub argument: Option<String>,
64 pub argument: Option<String>,
57 pub children: Vec<Node>,
65 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.
60 pub line_num: usize,
66 pub line_num: usize,
61− /// The function whose body wrote it, when it comes from a call.
62− pub function: Option<String>,
63}
67}
64
68
69+
65#[derive(Debug, Clone)]
70#[derive(Debug, Clone)]
66pub struct Attribute {
71pub struct Attribute {
67 pub key: String,
72 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>,
73 pub value: Option<String>,
71 /// Written `key=value`: an HTML attribute rather than a style.
74 /// Written `key=value`: an HTML attribute rather than a style.
72 pub html: bool,
75 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,
86}
76}
87
77
88#[derive(Debug, Clone, PartialEq)]
78#[derive(Debug, Clone, PartialEq)]
89pub enum ElementKind {
79pub enum ElementKind {
90 // Layout and text: the core of the language
80 // Layout and text: the core of the language
91 Row,
81 Row,
82+ Column,
92 El,
83 El,
93 Text,
84 Text,
94 Paragraph,
85 Paragraph,
95 Link,
86 Link,
96 Image,
87 Image,
97 // Elements generated in a special way
88 // Elements generated in a special way
89+ Script,
98 Fragment,
90 Fragment,
99 // Function bodies: placeholders for the caller's content
91 // Function bodies: placeholders for the caller's content
100 Children,
92 Children,
@@ -108,11 +100,13 @@ impl ElementKind {
108 pub fn name(&self) -> &str {
100 pub fn name(&self) -> &str {
109 match self {
101 match self {
110 ElementKind::Row => "row",
102 ElementKind::Row => "row",
103+ ElementKind::Column => "column",
111 ElementKind::El => "el",
104 ElementKind::El => "el",
112 ElementKind::Text => "text",
105 ElementKind::Text => "text",
113 ElementKind::Paragraph => "paragraph",
106 ElementKind::Paragraph => "paragraph",
114 ElementKind::Link => "link",
107 ElementKind::Link => "link",
115 ElementKind::Image => "image",
108 ElementKind::Image => "image",
109+ ElementKind::Script => "script",
116 ElementKind::Fragment => "fragment",
110 ElementKind::Fragment => "fragment",
117 ElementKind::Children => "children",
111 ElementKind::Children => "children",
118 ElementKind::Slot(_) => "slot",
112 ElementKind::Slot(_) => "slot",
@@ -127,86 +121,6 @@ impl ElementKind {
127 }
121 }
128 }
122 }
129
123
130− /// How the element lays out what is inside it. `@fragment`, `@children`
131− /// and `@slot` have no element of their own: their content lands in the
132− /// element around them and takes its layout.
133− pub fn layout(&self) -> Layout {
134− match self {
135− ElementKind::Row => Layout::Row,
136− ElementKind::El => Layout::Column,
137− ElementKind::Text | ElementKind::Paragraph | ElementKind::Link => Layout::Text,
138− ElementKind::Image => Layout::Void,
139− ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_) => Layout::Native,
140− ElementKind::Tag(spec) => spec.layout,
141− }
142− }
143−
144− /// The CSS every such element starts with, besides its layout's
145− /// `display` (browser margins reset, the list markers of a column, a
146− /// link's colour). It goes into the element's own generated class,
147− /// never onto a global element selector, so HTML from `@markdown` and
148− /// `@raw` keeps the browser's defaults.
149− pub fn css(&self) -> &'static str {
150− match self {
151− ElementKind::Paragraph => "margin:0;",
152− ElementKind::Link => "text-decoration:none;color:inherit;",
153− ElementKind::Image => "display:block;",
154− ElementKind::Tag(spec) => spec.css,
155− _ => "",
156− }
157− }
158−
159− /// What the first token after its attributes is: `@link`'s `href`,
160− /// `@image`'s `src`, or its row's [`TagArg`].
161− pub fn arg(&self) -> TagArg {
162− match self {
163− ElementKind::Link => TagArg::Attr("href"),
164− ElementKind::Image => TagArg::Attr("src"),
165− ElementKind::Tag(spec) => spec.arg,
166− _ => TagArg::Child,
167− }
168− }
169−
170− /// The HTML attribute its leading argument fills, when it has one;
171− /// `in_picture`: the element is directly inside `@picture`.
172− pub fn leading_attribute(&self, in_picture: bool) -> Option<&'static str> {
173− self.arg().attribute(in_picture)
174− }
175−
176− /// Whether its indented body is foreign text written into the page as
177− /// it is (`@script`). (`@code` and `@textarea` also have a verbatim
178− /// body, shown as text: see [`TagSpec::literal`].)
179− pub fn is_verbatim(&self) -> bool {
180− self.spec().is_some_and(|spec| spec.verbatim)
181− }
182−
183− /// The HTML element it writes (outside text, where htmlang's own
184− /// `<div>`s are `<span>`s): `None` for `@fragment`, `@children` and
185− /// `@slot`, which write none of their own.
186− pub fn tag(&self) -> Option<&'static str> {
187− match self {
188− ElementKind::Row | ElementKind::El => Some("div"),
189− ElementKind::Text => Some("span"),
190− ElementKind::Paragraph => Some("p"),
191− ElementKind::Link => Some("a"),
192− ElementKind::Image => Some("img"),
193− ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_) => None,
194− ElementKind::Tag(spec) => Some(spec.html),
195− }
196− }
197−
198− /// The HTML element only this element writes, which a selector can
199− /// pick out: `@td` is every `<td>`, `@paragraph` every `<p>`. `None`
200− /// for an element that shares its tag: `@el`, `@row`, `@grid`,
201− /// `@in-front` and `@behind` are all `<div>`, and `@text` is a
202− /// `<span>`, as is every line of text in a row or column. `@script`
203− /// isn't shown, so there is nothing to style.
204− pub fn own_tag(&self) -> Option<&'static str> {
205− let tag = self.tag()?;
206− let shared = matches!(tag, "div" | "span") || self.is_verbatim();
207− (!shared).then_some(tag)
208− }
209−
210 /// Is this the table element named `name` (e.g. `"main"`)?
124 /// Is this the table element named `name` (e.g. `"main"`)?
211 pub fn is_tag(&self, name: &str) -> bool {
125 pub fn is_tag(&self, name: &str) -> bool {
212 self.spec().is_some_and(|spec| spec.name == name)
126 self.spec().is_some_and(|spec| spec.name == name)
@@ -216,11 +130,13 @@ impl ElementKind {
216 pub fn from_name(name: &str) -> Option<ElementKind> {
130 pub fn from_name(name: &str) -> Option<ElementKind> {
217 Some(match name {
131 Some(match name {
218 "row" => ElementKind::Row,
132 "row" => ElementKind::Row,
133+ "column" => ElementKind::Column,
219 "el" => ElementKind::El,
134 "el" => ElementKind::El,
220 "text" => ElementKind::Text,
135 "text" => ElementKind::Text,
221 "paragraph" => ElementKind::Paragraph,
136 "paragraph" => ElementKind::Paragraph,
222 "link" => ElementKind::Link,
137 "link" => ElementKind::Link,
223 "image" => ElementKind::Image,
138 "image" => ElementKind::Image,
139+ "script" => ElementKind::Script,
224 "fragment" => ElementKind::Fragment,
140 "fragment" => ElementKind::Fragment,
225 "children" => ElementKind::Children,
141 "children" => ElementKind::Children,
226 "slot" => ElementKind::Slot(String::new()),
142 "slot" => ElementKind::Slot(String::new()),
@@ -231,15 +147,8 @@ impl ElementKind {
231 /// Every element name, for suggestions and completions.
147 /// Every element name, for suggestions and completions.
232 pub fn all_names() -> impl Iterator<Item = &'static str> {
148 pub fn all_names() -> impl Iterator<Item = &'static str> {
233 [
149 [
234− "row",
150+ "row", "column", "el", "text", "paragraph", "link", "image", "script", "fragment",
235− "el",
236− "text",
237− "paragraph",
238− "link",
239− "image",
240− "fragment",
241− "children",
151+ "children", "slot",
242− "slot",
243 ]
152 ]
244 .into_iter()
153 .into_iter()
245 .chain(TAGS.iter().map(|spec| spec.name))
154 .chain(TAGS.iter().map(|spec| spec.name))
@@ -249,90 +158,12 @@ impl ElementKind {
249/// What an element does with the text after its name and attributes.
158/// What an element does with the text after its name and attributes.
250#[derive(Debug, Clone, Copy, PartialEq)]
159#[derive(Debug, Clone, Copy, PartialEq)]
251pub enum TagArg {
160pub enum TagArg {
252− /// Its content: the first line of its text, parsed like any text
161+ /// Leading text content (`@section Hello`).
253− /// (`@h1 Hello {@text [color red] world}`). The element's layout
254− /// decides how it combines with the lines under it.
255 Child,
162 Child,
256− /// Its leading attribute: the first token fills this HTML attribute
163+ /// Printed as the element's text (`@item First`).
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`).
264− Source,
265−}
266−
267−impl TagArg {
268− /// The HTML attribute the leading argument fills; `in_picture`: the
269− /// element is directly inside `@picture`.
270− pub fn attribute(self, in_picture: bool) -> Option<&'static str> {
271− match self {
272− TagArg::Child => None,
273− TagArg::Attr(attr) => Some(attr),
274− TagArg::Source if in_picture => Some("srcset"),
275− TagArg::Source => Some("src"),
276− }
277− }
278−
279− /// The attributes the leading argument may fill, wherever the element
280− /// is: one, or `src` and `srcset` for `@source`.
281− pub fn attributes(self) -> Vec<&'static str> {
282− let mut out: Vec<&'static str> = [self.attribute(false), self.attribute(true)]
283− .into_iter()
284− .flatten()
285− .collect();
286− out.dedup();
287− out
288− }
289−}
290−
291−/// How an element lays out what is inside it: one value per element,
292−/// after elm-ui's layouts. It decides what its text lines are, whether
293−/// `spacing` works on it, and how its children's `width fill`, `center-x`
294−/// and `align-*` compile.
295−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296−pub enum Layout {
297− /// A flex column (`@el`, `@section`, `@ul`, `@li`): each text line is a
298− /// child of its own, and `spacing` is the gap between the children.
299− Column,
300− /// A flex row (`@row`): like a column, side by side.
301− Row,
302− /// A CSS grid (`@grid`): each text line is a cell.
303− Grid,
304− /// Flowing text (`@paragraph`, `@h1`, `@button`, `@td`): the argument,
305− /// the text lines and the children are one run, joined with spaces. It
306− /// has no gap for `spacing`, and a row, column or grid inside it is laid
307− /// out inline (`@el` becomes an inline-flex `<span>`).
308 Text,
164 Text,
309− /// HTML's own layout, which htmlang leaves alone: tables, `@select`,
165+ /// Emitted as this HTML attribute (`@iframe URL` sets `src`).
310− /// `@picture`, media and form controls. Text lines are separated by a
311− /// line break, which HTML shows as a space except in `@pre` and
312− /// `@textarea`.
313− Native,
314− /// No content at all (`@input`, `@hr`, `@image`).
315− Void,
316−}
317−
318−impl Layout {
319− /// A row, column or grid: an element that lays out its children, so
320− /// `spacing`, `wrap` and `grid-cols` work on it.
321− pub fn is_container(self) -> bool {
322− matches!(self, Layout::Column | Layout::Row | Layout::Grid)
323− }
324−
325− /// The name used in messages and the reference: `column`, `text`, ...
326− pub fn name(self) -> &'static str {
166+ Attr(&'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}
167}
337
168
338/// How an element compiles: the data behind [`ElementKind::Tag`].
169/// How an element compiles: the data behind [`ElementKind::Tag`].
@@ -342,19 +173,17 @@ pub struct TagSpec {
342 pub name: &'static str,
173 pub name: &'static str,
343 /// HTML element emitted.
174 /// HTML element emitted.
344 pub html: &'static str,
175 pub html: &'static str,
345− /// Default CSS besides the layout's `display` (margin reset, ...).
176+ /// Default CSS (flex column, margin reset, ...).
346 pub css: &'static str,
177 pub css: &'static str,
347 pub arg: TagArg,
178 pub arg: TagArg,
348− /// How it lays out its content.
179+ /// Bare text children are wrapped in `<span>` (flex containers).
349− pub layout: Layout,
180+ pub wraps_text: bool,
350− /// Its text is shown as written (`@code`, `@textarea`). On its line and
181+ /// Accepts layout attributes for its children (`spacing`, ...).
351− /// inline, a `{@...}` is text, not an inline element (escapes and
182+ pub container: bool,
352− /// `$names` still work). Its indented body is verbatim: nothing in it is
183+ /// Void element: no children, no closing tag (`<hr>`, `<input>`).
353− /// parsed, and it is shown HTML-escaped, its lines and indentation kept.
354− pub literal: bool,
184+ pub void: bool,
355− /// Its indented body is foreign text written into the page exactly as
185+ /// Children are joined with spaces, like a paragraph (headings).
356− /// it is (`@script`'s JavaScript): nothing in it is parsed as htmlang.
357− pub verbatim: bool,
186+ pub inline: bool,
358}
187}
359
188
360impl TagSpec {
189impl TagSpec {
@@ -363,413 +192,83 @@ impl TagSpec {
363 html: "",
192 html: "",
364 css: "",
193 css: "",
365 arg: TagArg::Child,
194 arg: TagArg::Child,
366− layout: Layout::Native,
195+ wraps_text: false,
367− literal: false,
196+ container: false,
197+ void: false,
368− verbatim: false,
198+ inline: false,
369 };
199 };
370}
200}
371
201
372/// Every element besides the core ones in [`ElementKind`].
202/// Every element besides the core ones in [`ElementKind`].
373−#[rustfmt::skip]
374pub static TAGS: &[TagSpec] = &[
203pub static TAGS: &[TagSpec] = &[
375− TagSpec { name: "nav", html: "nav", layout: Layout::Column, ..TagSpec::DEFAULT },
204+ TagSpec { name: "nav", html: "nav", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
376− TagSpec { name: "header", html: "header", layout: Layout::Column, ..TagSpec::DEFAULT },
205+ TagSpec { name: "header", html: "header", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
377− TagSpec { name: "footer", html: "footer", layout: Layout::Column, ..TagSpec::DEFAULT },
206+ TagSpec { name: "footer", html: "footer", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
378− TagSpec { name: "main", html: "main", layout: Layout::Column, ..TagSpec::DEFAULT },
207+ TagSpec { name: "main", html: "main", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
379− TagSpec { name: "section", html: "section", layout: Layout::Column, ..TagSpec::DEFAULT },
208+ TagSpec { name: "section", html: "section", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
380− TagSpec { name: "article", html: "article", layout: Layout::Column, ..TagSpec::DEFAULT },
209+ TagSpec { name: "article", html: "article", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
381− TagSpec { name: "aside", html: "aside", layout: Layout::Column, ..TagSpec::DEFAULT },
210+ TagSpec { name: "aside", html: "aside", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
382− TagSpec { name: "details", html: "details", layout: Layout::Column, ..TagSpec::DEFAULT },
211+ TagSpec { name: "details", html: "details", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
383− TagSpec { name: "dialog", html: "dialog", layout: Layout::Column, ..TagSpec::DEFAULT },
212+ TagSpec { name: "dialog", html: "dialog", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
384− TagSpec { name: "search", html: "search", layout: Layout::Column, ..TagSpec::DEFAULT },
213+ TagSpec { name: "search", html: "search", wraps_text: true, container: true, ..TagSpec::DEFAULT },
385− TagSpec { name: "address", html: "address", layout: Layout::Column, ..TagSpec::DEFAULT },
214+ TagSpec { name: "address", html: "address", wraps_text: true, container: true, ..TagSpec::DEFAULT },
386− TagSpec { name: "noscript", html: "noscript", layout: Layout::Column, ..TagSpec::DEFAULT },
215+ TagSpec { name: "noscript", html: "noscript", wraps_text: true, container: true, ..TagSpec::DEFAULT },
387− TagSpec { name: "form", html: "form", arg: TagArg::Attr("action"), layout: Layout::Column, ..TagSpec::DEFAULT },
216+ TagSpec { name: "form", html: "form", css: "display:flex;flex-direction:column;", arg: TagArg::Attr("action"), wraps_text: true, container: true, ..TagSpec::DEFAULT },
388− TagSpec { name: "figure", html: "figure", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
217+ TagSpec { name: "figure", html: "figure", css: "display:flex;flex-direction:column;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
389− TagSpec { name: "blockquote", html: "blockquote", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
218+ TagSpec { name: "blockquote", html: "blockquote", css: "display:flex;flex-direction:column;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
390− TagSpec { name: "fieldset", html: "fieldset", css: "border:1px solid currentColor;padding:8px;margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
219+ TagSpec { name: "fieldset", html: "fieldset", css: "display:flex;flex-direction:column;border:1px solid currentColor;padding:8px;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
391− TagSpec { name: "datalist", html: "datalist", layout: Layout::Native, ..TagSpec::DEFAULT },
220+ TagSpec { name: "datalist", html: "datalist", wraps_text: true, container: true, ..TagSpec::DEFAULT },
392− TagSpec { name: "ul", html: "ul", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
221+ TagSpec { name: "list", html: "ul", css: "margin:0;padding-left:0;list-style:none;", container: true, ..TagSpec::DEFAULT },
393− TagSpec { name: "ol", html: "ol", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
222+ TagSpec { name: "item", html: "li", css: "display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
394− TagSpec { name: "li", html: "li", layout: Layout::Column, ..TagSpec::DEFAULT },
223+ TagSpec { name: "dl", html: "dl", css: "margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
395− TagSpec { name: "dl", html: "dl", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
224+ TagSpec { name: "dt", html: "dt", arg: TagArg::Text, container: true, ..TagSpec::DEFAULT },
396− TagSpec { name: "dt", html: "dt", layout: Layout::Text, ..TagSpec::DEFAULT },
225+ TagSpec { name: "dd", html: "dd", css: "margin:0;display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
397− TagSpec { name: "dd", html: "dd", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
226+ TagSpec { name: "table", html: "table", ..TagSpec::DEFAULT },
398− TagSpec { name: "table", html: "table", layout: Layout::Native, ..TagSpec::DEFAULT },
227+ TagSpec { name: "thead", html: "thead", ..TagSpec::DEFAULT },
399− TagSpec { name: "thead", html: "thead", layout: Layout::Native, ..TagSpec::DEFAULT },
228+ TagSpec { name: "tbody", html: "tbody", ..TagSpec::DEFAULT },
400− TagSpec { name: "tbody", html: "tbody", layout: Layout::Native, ..TagSpec::DEFAULT },
229+ TagSpec { name: "tr", html: "tr", ..TagSpec::DEFAULT },
401− TagSpec { name: "tr", html: "tr", layout: Layout::Native, ..TagSpec::DEFAULT },
230+ TagSpec { name: "select", html: "select", ..TagSpec::DEFAULT },
402− TagSpec { name: "select", html: "select", layout: Layout::Native, ..TagSpec::DEFAULT },
231+ TagSpec { name: "picture", html: "picture", ..TagSpec::DEFAULT },
403− TagSpec { name: "picture", html: "picture", layout: Layout::Native, ..TagSpec::DEFAULT },
232+ TagSpec { name: "progress", html: "progress", ..TagSpec::DEFAULT },
404− TagSpec { name: "progress", html: "progress", layout: Layout::Native, ..TagSpec::DEFAULT },
233+ TagSpec { name: "meter", html: "meter", ..TagSpec::DEFAULT },
405− TagSpec { name: "meter", html: "meter", layout: Layout::Native, ..TagSpec::DEFAULT },
234+ TagSpec { name: "output", html: "output", ..TagSpec::DEFAULT },
406− TagSpec { name: "output", html: "output", layout: Layout::Native, ..TagSpec::DEFAULT },
235+ TagSpec { name: "canvas", html: "canvas", container: true, ..TagSpec::DEFAULT },
407− TagSpec { name: "canvas", html: "canvas", layout: Layout::Native, ..TagSpec::DEFAULT },
236+ TagSpec { name: "td", html: "td", arg: TagArg::Text, ..TagSpec::DEFAULT },
408− TagSpec { name: "td", html: "td", layout: Layout::Text, ..TagSpec::DEFAULT },
237+ TagSpec { name: "th", html: "th", arg: TagArg::Text, ..TagSpec::DEFAULT },
409− TagSpec { name: "th", html: "th", layout: Layout::Text, ..TagSpec::DEFAULT },
238+ TagSpec { name: "button", html: "button", arg: TagArg::Text, ..TagSpec::DEFAULT },
410− TagSpec { name: "button", html: "button", layout: Layout::Text, ..TagSpec::DEFAULT },
239+ TagSpec { name: "label", html: "label", arg: TagArg::Text, ..TagSpec::DEFAULT },
411− TagSpec { name: "label", html: "label", layout: Layout::Text, ..TagSpec::DEFAULT },
240+ TagSpec { name: "option", html: "option", arg: TagArg::Text, ..TagSpec::DEFAULT },
412− TagSpec { name: "option", html: "option", layout: Layout::Native, ..TagSpec::DEFAULT },
241+ TagSpec { name: "textarea", html: "textarea", arg: TagArg::Text, ..TagSpec::DEFAULT },
413− TagSpec { name: "textarea", html: "textarea", layout: Layout::Native, literal: true, ..TagSpec::DEFAULT },
242+ TagSpec { name: "summary", html: "summary", arg: TagArg::Text, ..TagSpec::DEFAULT },
414− TagSpec { name: "summary", html: "summary", layout: Layout::Text, ..TagSpec::DEFAULT },
243+ TagSpec { name: "cite", html: "cite", arg: TagArg::Text, ..TagSpec::DEFAULT },
415− TagSpec { name: "cite", html: "cite", layout: Layout::Text, ..TagSpec::DEFAULT },
244+ TagSpec { name: "figcaption", html: "figcaption", arg: TagArg::Text, ..TagSpec::DEFAULT },
416− TagSpec { name: "figcaption", html: "figcaption", layout: Layout::Text, ..TagSpec::DEFAULT },
245+ TagSpec { name: "legend", html: "legend", arg: TagArg::Text, ..TagSpec::DEFAULT },
417− TagSpec { name: "legend", html: "legend", layout: Layout::Text, ..TagSpec::DEFAULT },
246+ TagSpec { name: "time", html: "time", arg: TagArg::Text, ..TagSpec::DEFAULT },
418− TagSpec { name: "time", html: "time", layout: Layout::Text, ..TagSpec::DEFAULT },
247+ TagSpec { name: "mark", html: "mark", arg: TagArg::Text, ..TagSpec::DEFAULT },
419− TagSpec { name: "mark", html: "mark", layout: Layout::Text, ..TagSpec::DEFAULT },
248+ TagSpec { name: "abbr", html: "abbr", arg: TagArg::Text, ..TagSpec::DEFAULT },
420− TagSpec { name: "abbr", html: "abbr", layout: Layout::Text, ..TagSpec::DEFAULT },
249+ TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
421− TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, literal: true, ..TagSpec::DEFAULT },
250+ TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
422− TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, ..TagSpec::DEFAULT },
251+ TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", ..TagSpec::DEFAULT },
423− TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", layout: Layout::Native, ..TagSpec::DEFAULT },
252+ TagSpec { name: "h1", html: "h1", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
424− TagSpec { name: "h1", html: "h1", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
253+ TagSpec { name: "h2", html: "h2", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
425− TagSpec { name: "h2", html: "h2", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
254+ TagSpec { name: "h3", html: "h3", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
426− TagSpec { name: "h3", html: "h3", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
255+ TagSpec { name: "h4", html: "h4", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
427− TagSpec { name: "h4", html: "h4", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
256+ TagSpec { name: "h5", html: "h5", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
428− TagSpec { name: "h5", html: "h5", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
257+ TagSpec { name: "h6", html: "h6", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
429− TagSpec { name: "h6", html: "h6", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
258+ TagSpec { name: "input", html: "input", void: true, ..TagSpec::DEFAULT },
430− TagSpec { name: "input", html: "input", layout: Layout::Void, ..TagSpec::DEFAULT },
259+ TagSpec { name: "hr", html: "hr", void: true, ..TagSpec::DEFAULT },
431− TagSpec { name: "hr", html: "hr", layout: Layout::Void, ..TagSpec::DEFAULT },
260+ TagSpec { name: "source", html: "source", arg: TagArg::Attr("src"), void: true, ..TagSpec::DEFAULT },
432− TagSpec { name: "source", html: "source", arg: TagArg::Source, layout: Layout::Void, ..TagSpec::DEFAULT },
261+ TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
433− TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
262+ TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
434− TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
263+ TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), container: true, ..TagSpec::DEFAULT },
435− TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
264+ TagSpec { name: "grid", html: "div", css: "display:grid;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
436− TagSpec { name: "script", html: "script", arg: TagArg::Attr("src"), layout: Layout::Native, verbatim: true, ..TagSpec::DEFAULT },
265+ TagSpec { name: "stack", html: "div", css: "position:relative;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
437− // Flow containers
266+ TagSpec { name: "in-front", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;", container: true, ..TagSpec::DEFAULT },
438− TagSpec { name: "hgroup", html: "hgroup", layout: Layout::Column, ..TagSpec::DEFAULT },
267+ TagSpec { name: "behind", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;z-index:-1;", container: true, ..TagSpec::DEFAULT },
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];
268];
479
269
480−/// HTML elements htmlang writes under another name. The HTML name isn't an
270+/// Elements that print their argument as their text (`@text Hello`,
481−/// element: the unknown-element error for `@a` suggests `@link`. (Not a
271+/// `@item First`).
482−/// second spelling: only the suggestion reads this.)
272+pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
483−pub const HTML_NAMES_WRITTEN_OTHERWISE: &[(&str, &str)] = &[
273+ *kind == ElementKind::Text || kind.spec().is_some_and(|spec| spec.arg == TagArg::Text)
484− ("a", "link"),
485− ("img", "image"),
486− ("span", "text"),
487− ("p", "paragraph"),
488− ("div", "el"),
489−];
490−
491−/// Whether the element named `name` (without the `@`) shows its text as
492−/// written: `{@code {@link /x y}}` prints the braces, and its indented body
493−/// is verbatim text, HTML-escaped. The syntax tree reads this, so every
494−/// tool agrees on it.
495−pub fn has_literal_text(name: &str) -> bool {
496− ElementKind::from_name(name)
497− .and_then(|kind| kind.spec())
498− .is_some_and(|spec| spec.literal)
499−}
500−
501−/// How a directive reads the rest of its line.
502−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503−pub enum ArgGrammar {
504− /// Free text, taken as written (`@include file.hl`, `@meta name value`).
505− /// For a directive with a verbatim body it is the one-line form of the
506− /// body (`@raw <hr>`, `@style .a { color: red }`) or, for `@markdown`,
507− /// the file.
508− Text,
509− /// An optional `[attributes]` list, then text (`@page [lang=en] Title`).
510− AttrsText,
511− /// An expression (`@if $count > 2`).
512− Expression,
513− /// A name, then a value, `= EXPR`, `"text"`, `[attributes]` or the
514− /// parameters of a function (`@let`).
515− Definition,
516− /// Nothing, or `if EXPR` (`@else`, `@else if`).
517− Else,
518− /// `$item in LIST` or `$item, $index in LIST` (`@each`).
519− Loop,
520− /// `$name SOURCE`; inline JSON may span several lines (`@data`).
521− Data,
522−}
523−
524−/// What the lines indented under a directive or element are.
525−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526−pub enum BodyKind {
527− /// Nothing: indented lines are an error.
528− None,
529− /// htmlang: elements, text and directives.
530− Htmlang,
531− /// Foreign text (CSS, HTML, JavaScript, Markdown, a code sample), kept
532− /// exactly as written: nothing in it is parsed, and `--` is not a
533− /// comment. The text on the header's line is its one-line form (or a
534− /// file or URL: `@markdown notes.md`, `@script app.js`); the line and a
535− /// body together are an error. A verbatim directive takes no
536− /// attributes.
537− Verbatim,
538−}
539−
540−/// A directive: a `@name` that isn't an element. The syntax tree
541−/// (`syntax.rs`), the evaluator, the formatter and the editor tools all
542−/// read the directives' shapes from this table.
543−#[derive(Debug, PartialEq)]
544−pub struct DirectiveSpec {
545− /// Name in htmlang source, without the `@`.
546− pub name: &'static str,
547− pub args: ArgGrammar,
548− pub body: BodyKind,
549−}
550−
551−/// Every directive.
552−#[rustfmt::skip]
553−pub static DIRECTIVES: &[DirectiveSpec] = &[
554− DirectiveSpec { name: "page", args: ArgGrammar::AttrsText, body: BodyKind::None },
555− DirectiveSpec { name: "let", args: ArgGrammar::Definition, body: BodyKind::Htmlang },
556− DirectiveSpec { name: "include", args: ArgGrammar::Text, body: BodyKind::None },
557− DirectiveSpec { name: "data", args: ArgGrammar::Data, body: BodyKind::None },
558− DirectiveSpec { name: "meta", args: ArgGrammar::Text, body: BodyKind::None },
559− DirectiveSpec { name: "if", args: ArgGrammar::Expression, body: BodyKind::Htmlang },
560− DirectiveSpec { name: "else", args: ArgGrammar::Else, body: BodyKind::Htmlang },
561− DirectiveSpec { name: "each", args: ArgGrammar::Loop, body: BodyKind::Htmlang },
562− DirectiveSpec { name: "style", args: ArgGrammar::Text, body: BodyKind::Verbatim },
563− DirectiveSpec { name: "head", args: ArgGrammar::Text, body: BodyKind::Verbatim },
564− DirectiveSpec { name: "raw", args: ArgGrammar::Text, body: BodyKind::Verbatim },
565− DirectiveSpec { name: "markdown", args: ArgGrammar::Text, body: BodyKind::Verbatim },
566−];
567−
568−/// The directive named `name` (without the `@`).
569−pub fn directive(name: &str) -> Option<&'static DirectiveSpec> {
570− DIRECTIVES.iter().find(|d| d.name == name)
571−}
572−
573−/// What the lines indented under `@name` are: the directive's body kind,
574−/// or an element's (verbatim for a row that says so: `@script`, and
575−/// `@code` and `@textarea`, whose text is literal; htmlang for every other
576−/// element).
577−pub fn body_kind(name: &str) -> BodyKind {
578− let verbatim = |spec: &TagSpec| spec.verbatim || spec.literal;
579− match directive(name) {
580− Some(spec) => spec.body,
581− None if ElementKind::from_name(name)
582− .and_then(|kind| kind.spec())
583− .is_some_and(verbatim) =>
584− {
585− BodyKind::Verbatim
586− }
587− None => BodyKind::Htmlang,
588− }
589−}
590−
591−#[cfg(test)]
592−mod tests {
593− use super::{ElementKind, Layout};
594−
595− fn layout(name: &str) -> Layout {
596− ElementKind::from_name(name).unwrap().layout()
597− }
598−
599− #[test]
600− fn every_element_has_the_layout_it_is_documented_with() {
601− let text = [
602− "text",
603− "paragraph",
604− "link",
605− "h1",
606− "h2",
607− "h3",
608− "h4",
609− "h5",
610− "h6",
611− "button",
612− "td",
613− "th",
614− "label",
615− "summary",
616− "legend",
617− "figcaption",
618− "dt",
619− "cite",
620− "code",
621− "kbd",
622− "mark",
623− "abbr",
624− "time",
625− "b",
626− "i",
627− "strong",
628− "em",
629− "small",
630− "sub",
631− "sup",
632− "q",
633− "del",
634− ];
635− for name in text {
636− assert_eq!(layout(name), Layout::Text, "@{}", name);
637− }
638− let columns = [
639− "el", "li", "dd", "ul", "ol", "dl", "search", "address", "noscript", "section", "form",
640− "in-front", "behind", "hgroup", "menu",
641− ];
642− for name in columns {
643− assert_eq!(layout(name), Layout::Column, "@{}", name);
644− }
645− assert_eq!(layout("row"), Layout::Row);
646− assert_eq!(layout("grid"), Layout::Grid);
647− for name in [
648− "table", "tr", "select", "option", "picture", "video", "pre", "canvas", "caption",
649− "colgroup", "tfoot", "optgroup", "ruby", "rt", "object",
650− ] {
651− assert_eq!(layout(name), Layout::Native, "@{}", name);
652− }
653− for name in [
654− "image", "input", "hr", "source", "br", "wbr", "col", "track", "embed", "area",
655− ] {
656− assert_eq!(layout(name), Layout::Void, "@{}", name);
657− }
658− }
659−
660− #[test]
661− fn html_names_written_otherwise_are_not_elements() {
662− for (html, htmlang) in super::HTML_NAMES_WRITTEN_OTHERWISE {
663− assert!(ElementKind::from_name(html).is_none(), "@{}", html);
664− let kind = ElementKind::from_name(htmlang).unwrap();
665− let written = match kind {
666− ElementKind::El => "div",
667− ElementKind::Text => "span",
668− ElementKind::Paragraph => "p",
669− ElementKind::Link => "a",
670− ElementKind::Image => "img",
671− _ => panic!("@{} is not one of htmlang's own", htmlang),
672− };
673− assert_eq!(written, *html);
674− }
675− // No two rows share a name, and none is a directive's
676− let mut names: Vec<&str> = ElementKind::all_names().collect();
677− let count = names.len();
678− names.sort_unstable();
679− names.dedup();
680− assert_eq!(names.len(), count, "an element name is listed twice");
681− for name in names {
682− assert!(super::directive(name).is_none(), "@{}", name);
683− }
684− }
685−
686− #[test]
687− fn each_row_names_at_most_one_leading_attribute() {
688− let leading = |name: &str, in_picture: bool| {
689− ElementKind::from_name(name)
690− .unwrap()
691− .leading_attribute(in_picture)
692− };
693− for (name, attr) in [
694− ("link", "href"),
695− ("area", "href"),
696− ("image", "src"),
697− ("script", "src"),
698− ("iframe", "src"),
699− ("video", "src"),
700− ("audio", "src"),
701− ("track", "src"),
702− ("embed", "src"),
703− ("form", "action"),
704− ("object", "data"),
705− ("optgroup", "label"),
706− ] {
707− assert_eq!(leading(name, false), Some(attr), "@{}", name);
708− assert_eq!(leading(name, true), Some(attr), "@{}", name);
709− }
710− assert_eq!(leading("source", false), Some("src"));
711− assert_eq!(leading("source", true), Some("srcset"));
712− for name in ["el", "text", "h1", "button", "slot", "fragment", "picture"] {
713− assert_eq!(leading(name, false), None, "@{}", name);
714− }
715− // @script is an ordinary row whose body is kept as written
716− let script = ElementKind::from_name("script").unwrap();
717− assert!(script.is_verbatim() && script.spec().is_some());
718− assert_eq!(super::body_kind("script"), super::BodyKind::Verbatim);
719− assert_eq!(super::body_kind("el"), super::BodyKind::Htmlang);
720− // @code and @textarea: a verbatim body, shown as text; @pre is an
721− // ordinary element, so `@pre > @code` and wrapper functions work
722− for name in ["code", "textarea"] {
723− assert_eq!(
724− super::body_kind(name),
725− super::BodyKind::Verbatim,
726− "@{}",
727− name
728− );
729− assert!(super::has_literal_text(name), "@{}", name);
730− }
731− assert_eq!(super::body_kind("pre"), super::BodyKind::Htmlang);
732− // Every verbatim directive takes its line as its one-line body or file
733− for spec in super::DIRECTIVES {
734− if spec.body == super::BodyKind::Verbatim {
735− assert_eq!(spec.args, super::ArgGrammar::Text, "@{}", spec.name);
736− }
737− }
738− }
739−
740− #[test]
741− fn an_element_has_a_tag_of_its_own_when_no_other_element_writes_it() {
742− let own = |name: &str| ElementKind::from_name(name).unwrap().own_tag();
743− for shared in [
744− "el", "row", "grid", "in-front", "behind", "text", "script", "fragment",
745− ] {
746− assert_eq!(own(shared), None, "@{}", shared);
747− }
748− assert_eq!(own("td"), Some("td"));
749− assert_eq!(own("link"), Some("a"));
750− assert_eq!(own("paragraph"), Some("p"));
751− assert_eq!(own("image"), Some("img"));
752− // Every tag of its own is written by that one element
753− let names: Vec<&str> = ElementKind::all_names().collect();
754− for name in &names {
755− let Some(tag) = own(name) else { continue };
756− let writers: Vec<&&str> = names
757− .iter()
758− .filter(|other| ElementKind::from_name(other).unwrap().tag() == Some(tag))
759− .collect();
760− assert_eq!(writers, vec![name], "<{}>", tag);
761− }
762− }
763−
764− #[test]
765− fn a_layout_s_display_is_not_repeated_in_the_row_s_css() {
766− for spec in super::TAGS {
767− assert!(
768− !spec.css.contains("display"),
769− "@{}: {}",
770− spec.name,
771− spec.css
772− );
773− }
774− }
775}
274}
crates/htmlang-core/src/codegen.rs+2288−1587
@@ -1,8 +1,7 @@
1use std::collections::HashMap;
1use std::collections::HashMap;
2−use std::hash::Hash;
2+use std::hash::{Hash, Hasher};
3
3
4use crate::ast::*;
4use crate::ast::*;
5−use crate::vocab::with_px;
6
5
7// ---------------------------------------------------------------------------
6// ---------------------------------------------------------------------------
8// Style collector: deduplicates CSS and assigns class names
7// Style collector: deduplicates CSS and assigns class names
@@ -17,315 +16,56 @@ const BREAKPOINTS: &[(&str, &str)] = &[
17 ("2xl", "1536px"),
16 ("2xl", "1536px"),
18];
17];
19
18
20−/// The prefix of every generated class (`hl-2k9xq0m`), which keeps them
19+/// Generate short CSS class names: a..z, then aa..a9, ba..b9, ..., z9, then
21−/// out of the author's own class names.
20+/// aaa, ... The first character is always a letter; later ones are drawn from
22−pub const CLASS_PREFIX: &str = "hl-";
21+/// [a-z0-9]. The mapping is a bijection, so distinct indices never collide.
23−
22+pub(crate) fn short_class_name(idx: usize) -> String {
24−/// How many base-36 digits a class's name has after `hl-`: 7, about 36
23+ const REST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
25−/// bits of its style's hash, so a site with 10,000 different styles has
24+ // Find the name length: 26 names of length 1, 26*36 of length 2, ...
26−/// odds of about 6 in 10,000 that two of them share a name, which
25+ let mut n = idx;
27−/// `htmlang build` reports (`class-collision`). Within one file, a style
26+ let mut len = 1;
28−/// whose name is taken takes more digits.
27+ let mut count = 26usize;
29−pub const CLASS_DIGITS: usize = 7;
28+ while n >= count {
30−
29+ n -= count;
31−/// A stable hash of `text`: FNV-1a, then SplitMix64's finalizer, so every
30+ len += 1;
32−/// digit of a name depends on all of the text. It is the same on every
31+ count = count.saturating_mul(36);
33−/// platform and in every build, unlike `std`'s hasher.
32+ }
34−pub fn stable_hash(text: &str) -> u64 {
33+ let mut tail = Vec::with_capacity(len - 1);
35− let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
34+ for _ in 1..len {
36− for byte in text.bytes() {
35+ tail.push(REST[n % 36]);
37− hash ^= u64::from(byte);
36+ n /= 36;
38− hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
37+ }
39− }
38+ let mut name = String::with_capacity(len);
40− hash = (hash ^ (hash >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
39+ name.push((b'a' + n as u8) as char);
41− hash = (hash ^ (hash >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
40+ name.extend(tail.iter().rev().map(|&b| b as char));
42− hash ^ (hash >> 31)
41+ name
43−}
44−
45−/// `digits` base-36 digits of `hash`: `2k9xq0m`. With 13, all of it.
46−pub fn hash_digits(hash: u64, digits: usize) -> String {
47− const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
48− let mut n = hash;
49− (0..digits)
50− .map(|_| {
51− let digit = DIGITS[(n % 36) as usize] as char;
52− n /= 36;
53− digit
54− })
55− .collect()
56−}
57−
58−/// A class htmlang generated: its name, and the hash of the style it
59−/// stands for. One style has one name in every file, so a fragment's
60−/// classes mean the same in any page it goes into.
61−#[derive(Clone, Debug, PartialEq, Eq)]
62−pub struct Class {
63− pub name: String,
64− pub style: u64,
65−}
66−
67−/// When a style applies: the at-rule prefixes it is under (their ranks,
68−/// see [`crate::vocab::at_rule_rank`], sorted, so `dark:md:` and `md:dark:`
69−/// are one condition) and its selector prefixes, left to right as the
70−/// selector reads (`hover:children:` is `.a:hover > *`).
71−#[derive(Clone, Default, PartialEq, Eq, Hash, Debug)]
72−struct Condition {
73− at: Vec<usize>,
74− selector: Vec<String>,
75−}
76−
77−impl Condition {
78− /// The condition of an attribute's key, and the name after its
79− /// prefixes.
80− fn of(key: &str) -> (Condition, &str) {
81− let (prefixes, name) = crate::vocab::split_prefixes(key);
82− let mut condition = Condition::default();
83− for prefix in prefixes {
84− match crate::vocab::at_rule_rank(prefix) {
85− Some(rank) => condition.at.push(rank),
86− None => condition.selector.push(prefix.to_string()),
87− }
88− }
89− condition.at.sort_unstable();
90− condition.at.dedup();
91− (condition, name)
92− }
93−
94− /// Only the at-rule part: a direction under `hover:` is the one without
95− /// it.
96− fn at_only(at: &[usize]) -> Condition {
97− Condition {
98− at: at.to_vec(),
99− selector: Vec::new(),
100− }
101− }
102−
103− /// The styles go on the element's children (`children:`).
104− fn on_children(&self) -> bool {
105− self.selector.iter().any(|p| p == crate::vocab::CHILDREN)
106− }
107−
108− /// The styles go on the elements of one kind inside the element
109− /// (`@td:`): where the element prefix is in the selector chain.
110− fn inside(&self) -> Option<usize> {
111− self.selector
112− .iter()
113− .position(|p| crate::vocab::element_prefix(p).is_some())
114− }
115−
116− /// Whether this condition holds wherever `other` does: its at-rules
117− /// are implied by `other`'s, and it has no selector or `other`'s.
118− fn holds_at(&self, other: &Condition) -> bool {
119− (self.selector.is_empty() || self.selector == other.selector)
120− && self.at.iter().all(|&a| {
121− other
122− .at
123− .iter()
124− .any(|&b| crate::vocab::at_rule_implied(a, b))
125− })
126− }
127−
128− /// The order the rules of each condition are written in, which the
129− /// cascade follows: no prefix, then selectors, then each at-rule
130− /// block (see [`at_order`]), with its selectors inside it.
131− fn order(&self) -> (AtOrder, SelectorOrder) {
132− (at_order(&self.at), selector_order(&self.selector))
133− }
134−}
135−
136−/// Where a block of at-rules is written: after the rules without one; by
137−/// its last (innermost) prefix in the order of
138−/// [`crate::vocab::at_rule_prefixes`] (widths, media, container widths), so
139−/// `md:dark:` comes with `dark:`, after it; then by length.
140−type AtOrder = (bool, usize, usize, Vec<usize>);
141−
142−fn at_order(at: &[usize]) -> AtOrder {
143− (
144− !at.is_empty(),
145− at.last().copied().unwrap_or(0),
146− at.len(),
147− at.to_vec(),
148− )
149−}
150−
151−/// Selector chains in the order of [`crate::vocab::PSEUDOS`] (position,
152−/// then form state, then `hover:`, `focus:`, `active:`, `disabled:`), and
153−/// by their argument's text for the same pseudo-class; a chain before the
154−/// longer ones it starts. A chain through `children:` comes after the
155−/// others, ordered by what follows its last `children:` and then by what
156−/// comes before it: only that tail counts toward its specificity (see
157−/// [`selector`]), so `hover:children:` follows `children:` and wins over it
158−/// on hover.
159−type SelectorOrder = (bool, Vec<(usize, String)>, Vec<(usize, String)>);
160−
161−fn selector_order(selector: &[String]) -> SelectorOrder {
162− let table = crate::vocab::PSEUDOS;
163− let ranked = |chain: &[String]| -> Vec<(usize, String)> {
164− chain
165− .iter()
166− .map(|p| {
167− let name = crate::vocab::pseudo_name(p);
168− let rank = table
169− .iter()
170− .position(|(q, _)| *q == name)
171− .unwrap_or(table.len());
172− (rank, p.clone())
173− })
174− .collect()
175− };
176− match selector.iter().rposition(|p| p == crate::vocab::CHILDREN) {
177− None => (false, ranked(selector), Vec::new()),
178− Some(i) => (true, ranked(&selector[i + 1..]), ranked(&selector[..i])),
179− }
180−}
181−
182−/// The order of the chains through an element prefix (`@td:`, see
183−/// [`scoped_selector`]): by the prefixes before it, which select the root
184−/// of the scope and add nothing to the specificity, so `hover:@td:` comes
185−/// after `@td:` and wins on hover; then by the element; then by the
186−/// prefixes after it.
187−fn inside_order(chain: &[String]) -> (SelectorOrder, String, SelectorOrder) {
188− let at = chain
189− .iter()
190− .position(|p| crate::vocab::element_prefix(p).is_some())
191− .unwrap_or(chain.len());
192− (
193− selector_order(&chain[..at]),
194− chain.get(at).cloned().unwrap_or_default(),
195− selector_order(chain.get(at + 1..).unwrap_or_default()),
196− )
197−}
198−
199−/// The selector for `class` under a chain of selector prefixes, read left
200−/// to right. Everything before the last `children:` goes in `:where()`, so
201−/// a parent's `children:` styles have no specificity, and a child's own
202−/// attributes win over them: `children:hover:` is `:where(.a)>*:hover`,
203−/// `hover:children:` is `:where(.a:hover)>*`.
204−fn selector(class: &str, chain: &[String]) -> String {
205− let last_children = chain.iter().rposition(|p| p == crate::vocab::CHILDREN);
206− let mut out = format!(".{}", class);
207− for (i, prefix) in chain.iter().enumerate() {
208− if prefix == crate::vocab::CHILDREN {
209− if Some(i) == last_children {
210− out = format!(":where({})>*", out);
211− } else {
212− out.push_str(">*");
213− }
214− } else if let Some(pseudo) = crate::vocab::pseudo_selector(prefix) {
215− out.push_str(&pseudo);
216− }
217− }
218− out
219−}
220−
221−/// The rule for the elements an element prefix picks out inside the
222−/// element with the class `class` (see [`Condition::inside`]): the scope's
223−/// root, from the selector prefixes before it (`hover:@td:` is inside a
224−/// hovered element), and the selector for the elements, from those after
225−/// it (`@td:hover:` is a hovered cell). `:scope td` leaves out the root
226−/// itself, so `@ul [@ul:...]` styles the lists inside, not its own. `None`
227−/// when the name has no tag of its own (the parser reports it).
228−fn scoped_selector(class: &str, chain: &[String], at: usize) -> Option<(String, String)> {
229− let name = crate::vocab::element_prefix(&chain[at])?;
230− let tag = ElementKind::from_name(name)?.own_tag()?;
231− let root = selector(class, &chain[..at]);
232− let mut target = format!(":scope {}", tag);
233− for prefix in &chain[at + 1..] {
234− if let Some(pseudo) = crate::vocab::pseudo_selector(prefix) {
235− target.push_str(&pseudo);
236− }
237− }
238− Some((root, target))
239−}
240−
241−/// The `@media` or `@container` rule an at-rule prefix stands for, as
242−/// written in readable and in compact output.
243−fn at_rule(rank: usize, dev: bool) -> String {
244− let prefix = crate::vocab::at_rule_prefixes()
245− .nth(rank)
246− .unwrap_or_default();
247− let name = prefix.trim_end_matches(':');
248− let width = |name: &str| {
249− BREAKPOINTS
250− .iter()
251− .find(|(n, _)| *n == name)
252− .map_or("0", |(_, w)| *w)
253− };
254− let (kind, feature, value) = if let Some(size) = name.strip_prefix("cq-") {
255− ("@container", "min-width", width(size))
256− } else {
257− match name {
258− "print" => {
259− return "@media print".to_string();
260− }
261− "dark" => ("@media", "prefers-color-scheme", "dark"),
262− "motion-safe" => ("@media", "prefers-reduced-motion", "no-preference"),
263− "motion-reduce" => ("@media", "prefers-reduced-motion", "reduce"),
264− "landscape" | "portrait" => ("@media", "orientation", name),
265− _ => ("@media", "min-width", width(name)),
266− }
267− };
268− if dev {
269− format!("{} ({}: {})", kind, feature, value)
270− } else {
271− format!("{}({}:{})", kind, feature, value)
272− }
273−}
274−
275−/// A style as text, for its hash: the defaults of its element's kind, its
276−/// rules (the at-rules' ranks, the selector prefixes and the declarations
277−/// of each) and the rules keyed on it, each part ended by a character CSS
278−/// doesn't have.
279−fn style_text(defaults: &str, rules: &[(Condition, String)], distinct: &str) -> String {
280− let mut text = format!("{defaults}\u{1}");
281− for (condition, body) in rules {
282− for rank in &condition.at {
283− text.push_str(&format!("{rank}\u{2}"));
284− }
285− text.push('\u{3}');
286− for prefix in &condition.selector {
287− text.push_str(prefix);
288− text.push('\u{2}');
289− }
290− text.push('\u{3}');
291− text.push_str(body);
292− text.push('\u{1}');
293− }
294− text.push_str(distinct);
295− text
296}
42}
297
43
298struct StyleEntry {
44struct StyleEntry {
299 class_name: String,
45 class_name: String,
300− /// The hash of the style, which names the class
46+ base: String,
301− style: u64,
47+ /// (CSS selector suffix, css_rules) — e.g. (":hover", "color:red;")
302− /// The defaults of the element's kind (its layout, a heading's
48+ pseudo: Vec<(String, String)>,
303− /// `margin:0`, ...), less what its own attributes set. They are written
49+ /// Responsive overrides: (breakpoint_prefix, css)
304− /// as `:where(.hl-a)` in the layer `hl-kind`, so an element prefix
50+ responsive: Vec<(String, String)>,
305− /// around it (`@h2:`), its parent's `children:` styles and its own
51+ /// Dark mode overrides
306− /// attributes, all in later layers, win over them.
52+ dark: String,
307− defaults: String,
53+ /// Print overrides
308− /// The element's rules: under each condition, its declarations, in the
54+ print: String,
309− /// order they are written
55+ motion_safe: String,
310− rules: Vec<(Condition, String)>,
56+ motion_reduce: String,
311− /// What else tells two elements with the same CSS apart: the rules
57+ landscape: String,
312− /// keyed on this class (see [`StyleCollector::keyed`]). Not written.
58+ portrait: String,
313− distinct: String,
59+ /// Container query overrides: (breakpoint_prefix, css)
60+ container: Vec<(String, String)>,
314}
61}
315
62
316struct StyleCollector {
63struct StyleCollector {
317 entries: Vec<StyleEntry>,
64 entries: Vec<StyleEntry>,
318− /// Maps a style's hash to its entries (more than one only when two
65+ /// Maps a pre-hashed style signature to an index into `entries`.
319− /// styles hash alike, which the full comparison tells apart).
66+ /// Using u64 as the key keeps lookups allocation-free; on the rare case of
67+ /// a hash collision we fall back to a full equality check against the entry.
320 index: HashMap<u64, Vec<usize>>,
68 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)>,
329}
69}
330
70
331impl StyleCollector {
71impl StyleCollector {
@@ -333,282 +73,313 @@ impl StyleCollector {
333 StyleCollector {
73 StyleCollector {
334 entries: Vec::new(),
74 entries: Vec::new(),
335 index: HashMap::new(),
75 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);
347 }
76 }
348 }
77 }
349
78
350 /// Returns a class name for this style combination, or None if all empty.
79 /// Returns a class name for this style combination, or None if all empty.
80+ #[allow(clippy::too_many_arguments)]
351 fn get_class(
81 fn get_class(
352 &mut self,
82 &mut self,
353− defaults: String,
83+ base: String,
354− mut rules: Vec<(Condition, String)>,
84+ pseudo: Vec<(String, String)>,
355− distinct: String,
85+ responsive: Vec<(String, String)>,
86+ dark: String,
87+ print: String,
88+ motion_safe: String,
89+ motion_reduce: String,
90+ landscape: String,
91+ portrait: String,
92+ container: Vec<(String, String)>,
356 ) -> Option<String> {
93 ) -> 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+ {
359 return None;
105 return None;
360 }
106 }
361− rules.sort_by_cached_key(|(condition, _)| condition.order());
107+ use std::collections::hash_map::DefaultHasher;
362− let style = stable_hash(&style_text(&defaults, &rules, &distinct));
108+ let mut h = DefaultHasher::new();
363−
109+ base.hash(&mut h);
364− if let Some(indices) = self.index.get(&style) {
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) {
365 for &idx in indices {
122 for &idx in indices {
366 let e = &self.entries[idx];
123 let e = &self.entries[idx];
367− if e.defaults == defaults && e.rules == rules && e.distinct == distinct {
124+ if e.base == base
125+ && e.pseudo == pseudo
126+ && e.responsive == responsive
127+ && e.dark == dark
128+ && e.print == print
129+ && e.motion_safe == motion_safe
130+ && e.motion_reduce == motion_reduce
131+ && e.landscape == landscape
132+ && e.portrait == portrait
133+ && e.container == container
134+ {
368 return Some(e.class_name.clone());
135 return Some(e.class_name.clone());
369 }
136 }
370 }
137 }
371 }
138 }
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
375 let idx = self.entries.len();
139 let idx = self.entries.len();
376− let name = (CLASS_DIGITS..=13)
140+ let name = short_class_name(idx);
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());
381 self.entries.push(StyleEntry {
141 self.entries.push(StyleEntry {
382 class_name: name.clone(),
142 class_name: name.clone(),
383− style,
143+ base,
384− defaults,
144+ pseudo,
385− rules,
145+ responsive,
386− distinct,
146+ dark,
147+ print,
148+ motion_safe,
149+ motion_reduce,
150+ landscape,
151+ portrait,
152+ container,
387 });
153 });
388− self.index.entry(style).or_default().push(idx);
154+ self.index.entry(sig).or_default().push(idx);
389 Some(name)
155 Some(name)
390 }
156 }
391
157
392− /// The classes given, in the order they were
158+ /// All generated rules, wrapped in `@layer htmlang`.
393− fn classes(&self) -> impl Iterator<Item = Class> + '_ {
159+ fn to_css_formatted(&self, dev: bool) -> String {
394− self.entries.iter().map(|e| Class {
160+ let mut css = String::new();
395− name: e.class_name.clone(),
161+ let inner_indent = if dev { " " } else { "" };
396− style: e.style,
162+ css.push_str(if dev { "@layer htmlang {\n" } else { "@layer htmlang{" });
397− })
163+
398− }
164+ // Non-responsive rules. Base and pseudo rules are merged separately by
399−
165+ // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
400− /// All generated rules, in three layers (see [`LAYERS`]): the element
166+ // replaces two identical rules. Pseudo variants are grouped per selector
401− /// kinds' defaults in `hl-kind` (`:where(.hl-a)`), the rules for the
167+ // suffix (`:hover` with `:hover`, etc.) to keep each pseudo's cascade
402− /// elements an element prefix picks out inside an element (`@td:`), and
168+ // position independent of others.
403− /// a function's scoped `@style`, in `hl-inside`, and the elements' own
169+ let mut base_pairs: Vec<(&str, &str)> = Vec::with_capacity(self.entries.len());
404− /// rules in `htmlang`. They are written in a fixed order, so which rule
170+ for e in &self.entries {
405− /// wins never depends on where in the page an element is: the rules
171+ if !e.base.is_empty() {
406− /// without a prefix, then those under selector prefixes, then each block
172+ base_pairs.push((e.class_name.as_str(), e.base.as_str()));
407− /// of at-rules (see [`at_order`]), in which the rules keyed on a parent
408− /// come first, then the element's own, then its selectors (see
409− /// [`selector_order`]).
410− fn to_css_formatted(&self, dev: bool, scoped: &[String]) -> String {
411− // The defaults: `:where(.hl-a,.hl-b){...}`
412− let mut kind = String::new();
413− let mut defaults: Vec<(&str, Vec<&str>)> = Vec::new();
414− for e in self.entries.iter().filter(|e| !e.defaults.is_empty()) {
415− match defaults.iter_mut().find(|(body, _)| *body == e.defaults) {
416− Some((_, classes)) => classes.push(&e.class_name),
417− None => defaults.push((&e.defaults, vec![&e.class_name])),
418− }
419− }
420− for (body, classes) in defaults {
421− let classes: Vec<String> = classes.iter().map(|c| format!(".{}", c)).collect();
422− let selector = format!(":where({})", classes.join(","));
423− if dev {
424− kind.push_str(&format!(" {} {{{}}}\n", selector, body));
425− } else {
426− kind.push_str(&format!("{}{{{}}}", selector, body));
427 }
173 }
428 }
174 }
175+ emit_grouped_rules(&mut css, &base_pairs, "", inner_indent, dev);
429
176
430− let mut inside = self.blocks(true, dev);
177+ // Collect pseudo rules grouped by selector suffix, preserving the order
431− // A function's scoped `@style`, nested under its class
178+ // in which suffixes first appear across entries.
432− for block in scoped {
179+ let mut pseudo_order: Vec<&str> = Vec::new();
433− if dev {
180+ let mut pseudo_buckets: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
434− inside.push_str(block);
181+ for e in &self.entries {
435− inside.push('\n');
182+ for (selector, body) in &e.pseudo {
436− } else {
183+ if body.is_empty() {
437− inside.push_str(&compact_css(block));
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()));
438 }
194 }
439 }
195 }
440−
196+ for selector in &pseudo_order {
441− let mut css = String::new();
197+ if let Some(pairs) = pseudo_buckets.get(selector) {
442− for (name, rules) in [
198+ emit_grouped_rules(&mut css, pairs, selector, inner_indent, dev);
443− ("hl-kind", kind),
444− ("hl-inside", inside),
445− ("htmlang", self.blocks(false, dev)),
446− ] {
447− if rules.is_empty() {
448− continue;
449− }
450− if dev {
451− css.push_str(&format!("@layer {} {{\n{}}}\n", name, rules));
452− } else {
453− css.push_str(&format!("@layer {}{{{}}}", name, rules));
454 }
199 }
455 }
200 }
456− css
457− }
458
201
459− /// The rules of every block of at-rules, in order: those for the
202+ // Responsive rules grouped by breakpoint
460− /// elements inside (`inside`: rules under an element prefix, each in
203+ for &(bp_name, bp_width) in BREAKPOINTS {
461− /// an `@scope` on its element's class), or the elements' own.
204+ let mut bp_pairs: Vec<(&str, &str)> = Vec::new();
462− fn blocks(&self, inside: bool, dev: bool) -> String {
205+ for e in &self.entries {
463− let mut css = String::new();
206+ for (bp, rule_css) in &e.responsive {
464− let rules = || {
207+ if bp == bp_name && !rule_css.is_empty() {
465− self.entries.iter().flat_map(move |e| {
208+ bp_pairs.push((e.class_name.as_str(), rule_css.as_str()));
466− e.rules
467− .iter()
468− .filter(move |(c, _)| c.inside().is_some() == inside)
469− .map(move |(c, body)| (e, c, body))
470− })
471− };
472− let mut blocks: Vec<&[usize]> = vec![&[]];
473− let keyed = self.keyed.iter().filter(|_| !inside);
474− let every = rules()
475− .map(|(_, c, _)| c.at.as_slice())
476− .chain(keyed.map(|(at, _, _)| at.as_slice()));
477− for at in every {
478− if !blocks.contains(&at) {
479− blocks.push(at);
480− }
481− }
482− blocks.sort_by_key(|at| at_order(at));
483−
484− for at in blocks {
485− let depth = at.len();
486− // Readable output indents a rule inside `@layer` and inside
487− // each at-rule but the first
488− let indent = if dev {
489− " ".repeat(depth.max(1))
490− } else {
491− String::new()
492− };
493− let mut inner = String::new();
494− // Rules keyed on a parent's class come first: they have the
495− // specificity of one class, like a class rule, so the child's
496− // own rules in the block still win
497− if !inside {
498− let keyed: Vec<(&str, &str)> = self
499− .keyed
500− .iter()
501− .filter(|(a, _, _)| a.as_slice() == at)
502− .map(|(_, selector, body)| (selector.as_str(), body.as_str()))
503− .collect();
504− emit_selector_rules(&mut inner, &keyed, &indent, dev);
505− }
506− // The selector chains of this block, in order
507− let mut chains: Vec<&[String]> = Vec::new();
508− for (_, condition, _) in rules() {
509− if condition.at == at && !chains.contains(&condition.selector.as_slice()) {
510− chains.push(&condition.selector);
511− }
512− }
513− if inside {
514− chains.sort_by_cached_key(|chain| inside_order(chain));
515− // One `@scope` for each root, its rules in the order of
516− // the chains: rules for one element under two roots (`.a`
517− // and `.a:hover`) tie on specificity and proximity, so the
518− // later one wins
519− let mut scopes: Vec<(String, Vec<(String, &str)>)> = Vec::new();
520− for chain in chains {
521− let Some(element) = chain
522− .iter()
523− .position(|p| crate::vocab::element_prefix(p).is_some())
524− else {
525− continue;
526− };
527− for (e, _, body) in
528− rules().filter(|(_, c, _)| c.at == at && c.selector == chain)
529− {
530− let Some((root, target)) = scoped_selector(&e.class_name, chain, element)
531− else {
532− continue;
533− };
534− match scopes.iter_mut().find(|(r, _)| *r == root) {
535− Some((_, targets)) => targets.push((target, body)),
536− None => scopes.push((root, vec![(target, body)])),
537− }
538 }
209 }
539 }
210 }
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 }
211 }
568− // Each at-rule wraps the next, outermost first
212+ emit_media_block(
569− for (level, &rank) in at.iter().enumerate().rev() {
213+ &mut css,
570− let pad = if dev {
214+ &format!("@media (min-width: {})", bp_width),
571− " ".repeat(level)
215+ &format!("@media(min-width:{})", bp_width),
572− } else {
216+ &bp_pairs,
573− String::new()
217+ "",
574− };
218+ dev,
575− inner = if dev {
219+ );
576− format!("{pad}{} {{\n{inner}{pad}}}\n", at_rule(rank, true))
220+ }
577− } else {
221+
578− format!("{}{{{inner}}}", at_rule(rank, false))
222+ // Dark mode rules
579− };
223+ let dark_pairs: Vec<(&str, &str)> = self
224+ .entries
225+ .iter()
226+ .filter(|e| !e.dark.is_empty())
227+ .map(|e| (e.class_name.as_str(), e.dark.as_str()))
228+ .collect();
229+ emit_media_block(
230+ &mut css,
231+ "@media (prefers-color-scheme: dark)",
232+ "@media(prefers-color-scheme:dark)",
233+ &dark_pairs,
234+ "",
235+ dev,
236+ );
237+
238+ // Print rules
239+ let print_pairs: Vec<(&str, &str)> = self
240+ .entries
241+ .iter()
242+ .filter(|e| !e.print.is_empty())
243+ .map(|e| (e.class_name.as_str(), e.print.as_str()))
244+ .collect();
245+ emit_media_block(
246+ &mut css,
247+ "@media print",
248+ "@media print",
249+ &print_pairs,
250+ "",
251+ dev,
252+ );
253+
254+ // Motion safe rules
255+ let motion_safe_pairs: Vec<(&str, &str)> = self
256+ .entries
257+ .iter()
258+ .filter(|e| !e.motion_safe.is_empty())
259+ .map(|e| (e.class_name.as_str(), e.motion_safe.as_str()))
260+ .collect();
261+ emit_media_block(
262+ &mut css,
263+ "@media (prefers-reduced-motion: no-preference)",
264+ "@media(prefers-reduced-motion:no-preference)",
265+ &motion_safe_pairs,
266+ "",
267+ dev,
268+ );
269+
270+ // Motion reduce rules
271+ let motion_reduce_pairs: Vec<(&str, &str)> = self
272+ .entries
273+ .iter()
274+ .filter(|e| !e.motion_reduce.is_empty())
275+ .map(|e| (e.class_name.as_str(), e.motion_reduce.as_str()))
276+ .collect();
277+ emit_media_block(
278+ &mut css,
279+ "@media (prefers-reduced-motion: reduce)",
280+ "@media(prefers-reduced-motion:reduce)",
281+ &motion_reduce_pairs,
282+ "",
283+ dev,
284+ );
285+
286+ // Landscape rules
287+ let landscape_pairs: Vec<(&str, &str)> = self
288+ .entries
289+ .iter()
290+ .filter(|e| !e.landscape.is_empty())
291+ .map(|e| (e.class_name.as_str(), e.landscape.as_str()))
292+ .collect();
293+ emit_media_block(
294+ &mut css,
295+ "@media (orientation: landscape)",
296+ "@media(orientation:landscape)",
297+ &landscape_pairs,
298+ "",
299+ dev,
300+ );
301+
302+ // Portrait rules
303+ let portrait_pairs: Vec<(&str, &str)> = self
304+ .entries
305+ .iter()
306+ .filter(|e| !e.portrait.is_empty())
307+ .map(|e| (e.class_name.as_str(), e.portrait.as_str()))
308+ .collect();
309+ emit_media_block(
310+ &mut css,
311+ "@media (orientation: portrait)",
312+ "@media(orientation:portrait)",
313+ &portrait_pairs,
314+ "",
315+ dev,
316+ );
317+
318+ // Container query rules grouped by breakpoint
319+ for &(bp_name, bp_width) in BREAKPOINTS {
320+ let mut cq_pairs: Vec<(&str, &str)> = Vec::new();
321+ for e in &self.entries {
322+ for (bp, rule_css) in &e.container {
323+ if bp == bp_name && !rule_css.is_empty() {
324+ cq_pairs.push((e.class_name.as_str(), rule_css.as_str()));
325+ }
326+ }
580 }
327 }
581− css.push_str(&inner);
328+ emit_media_block(
329+ &mut css,
330+ &format!("@container (min-width: {})", bp_width),
331+ &format!("@container(min-width:{})", bp_width),
332+ &cq_pairs,
333+ "",
334+ dev,
335+ );
582 }
336 }
337+
338+ css.push_str(if dev { "}\n" } else { "}" });
583 css
339 css
584 }
340 }
585}
341}
586
342
587−/// Emit CSS rules from `(selector, body)` pairs, merging identical bodies
343+/// Emit CSS rules from `(class_name, body)` pairs, merging identical bodies
588/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
344/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
589/// of each distinct body determines ordering, so output stays deterministic
345/// of each distinct body determines ordering, so output stays deterministic
590−/// across runs. `indent` is prepended to each rule line.
346+/// across runs. `selector_suffix` is appended to each class (e.g. `":hover"`,
591−fn emit_grouped_rules(out: &mut String, pairs: &[(String, &str)], indent: &str, dev: bool) {
347+/// or `""` for plain class rules). `indent` is prepended to each rule line.
348+fn emit_grouped_rules(
349+ out: &mut String,
350+ pairs: &[(&str, &str)],
351+ selector_suffix: &str,
352+ indent: &str,
353+ dev: bool,
354+) {
592 if pairs.is_empty() {
355 if pairs.is_empty() {
593 return;
356 return;
594 }
357 }
595 // Group in first-occurrence order.
358 // Group in first-occurrence order.
596 let mut order: Vec<&str> = Vec::new();
359 let mut order: Vec<&str> = Vec::new();
597 let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
360 let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
598− for (selector, body) in pairs {
361+ for &(name, body) in pairs {
599 if body.is_empty() {
362 if body.is_empty() {
600 continue;
363 continue;
601 }
364 }
602 if !groups.contains_key(body) {
365 if !groups.contains_key(body) {
603 order.push(body);
366 order.push(body);
604 }
367 }
605− groups.entry(body).or_default().push(selector);
368+ groups.entry(body).or_default().push(name);
606 }
369 }
607 let sp = if dev { " " } else { "" };
370 let sp = if dev { " " } else { "" };
608 let nl = if dev { "\n" } else { "" };
371 let nl = if dev { "\n" } else { "" };
609 for body in &order {
372 for body in &order {
373+ let names = &groups[body];
610 out.push_str(indent);
374 out.push_str(indent);
611− out.push_str(&groups[body].join(","));
375+ for (i, n) in names.iter().enumerate() {
376+ if i > 0 {
377+ out.push(',');
378+ }
379+ out.push('.');
380+ out.push_str(n);
381+ out.push_str(selector_suffix);
382+ }
612 out.push_str(sp);
383 out.push_str(sp);
613 out.push('{');
384 out.push('{');
614 out.push_str(body);
385 out.push_str(body);
@@ -617,44 +388,37 @@ fn emit_grouped_rules(out: &mut String, pairs: &[(String, &str)], indent: &str,
617 }
388 }
618}
389}
619
390
620−/// Emit `(selector, body)` rules in their order, merging a run of rules
391+/// Emit an `@media` / `@container` block containing grouped class rules.
621−/// with the same body into one rule with a selector list (the order
392+/// Skips the block entirely if no non-empty bodies are present.
622−/// matters: two rules may apply to one element).
393+fn emit_media_block(
623−fn emit_selector_rules(out: &mut String, rules: &[(&str, &str)], indent: &str, dev: bool) {
394+ out: &mut String,
624− let mut i = 0;
395+ header_dev: &str,
625− while i < rules.len() {
396+ header_min: &str,
626− let body = rules[i].1;
397+ pairs: &[(&str, &str)],
627− let run = rules[i..].iter().take_while(|(_, b)| *b == body).count();
398+ selector_suffix: &str,
628− if !body.is_empty() {
399+ dev: bool,
629− let selectors: Vec<&str> = rules[i..i + run].iter().map(|(s, _)| *s).collect();
400+) {
630− out.push_str(indent);
401+ if pairs.iter().all(|(_, body)| body.is_empty()) {
631− out.push_str(&selectors.join(","));
402+ return;
632− if dev {
403+ }
633− out.push(' ');
404+ let mut inner = String::new();
634− }
405+ let inner_indent = if dev { " " } else { "" };
635− out.push('{');
406+ emit_grouped_rules(&mut inner, pairs, selector_suffix, inner_indent, dev);
636− out.push_str(body);
407+ if inner.is_empty() {
637− out.push('}');
408+ return;
638− if dev {
409+ }
639− out.push('\n');
410+ if dev {
640− }
411+ out.push_str(&format!("{} {{\n{}}}\n", header_dev, inner));
641− }
412+ } else {
642− i += run;
413+ out.push_str(&format!("{}{{{}}}", header_min, inner));
643 }
414 }
644}
415}
645
416
646struct GenContext {
417struct GenContext {
647 dev: bool,
418 dev: bool,
648 depth: usize,
419 depth: usize,
420+ image_count: usize,
649 has_interactive: bool,
421 has_interactive: bool,
650− /// Writing inside a text element, at any depth: htmlang's own `<div>`s
651− /// are `<span>`s there, which text can hold.
652− in_text: bool,
653− /// How the element whose children are being written lays them out
654− flow: Flow,
655− /// Writing the children of a `@picture`, where `@source`'s leading
656− /// argument is its `srcset`
657− in_picture: bool,
658}
422}
659
423
660impl GenContext {
424impl GenContext {
@@ -680,26 +444,23 @@ pub struct CodegenOptions {
680 pub dev: bool,
444 pub dev: bool,
681 pub partial: bool,
445 pub partial: bool,
682 pub minify: bool,
446 pub minify: bool,
447+ pub compat: bool,
683}
448}
684
449
685/// Generate HTML from a parsed document using the given options.
450/// Generate HTML from a parsed document using the given options.
686pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String {
451pub 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();
694 let mut html = if opts.partial {
452 let mut html = if opts.partial {
695− generate_partial_inner(doc, opts.dev, &mut classes)
453+ generate_partial_inner(doc, opts.dev)
696 } else {
454 } else {
697− generate_full_inner(doc, opts.dev, &mut classes)
455+ generate_full_inner(doc, opts.dev)
698 };
456 };
699 if opts.minify {
457 if opts.minify {
700 html = minify_html(&html);
458 html = minify_html(&html);
701 }
459 }
702− (html, classes)
460+ if opts.compat {
461+ html = add_vendor_prefixes(&html);
462+ }
463+ html
703}
464}
704
465
705pub fn generate(doc: &Document) -> String {
466pub fn generate(doc: &Document) -> String {
@@ -747,6 +508,38 @@ pub fn generate_minified(doc: &Document) -> String {
747 )
508 )
748}
509}
749
510
511+pub fn generate_compat(doc: &Document) -> String {
512+ generate_with(
513+ doc,
514+ &CodegenOptions {
515+ compat: true,
516+ ..Default::default()
517+ },
518+ )
519+}
520+
521+pub fn generate_dev_compat(doc: &Document) -> String {
522+ generate_with(
523+ doc,
524+ &CodegenOptions {
525+ dev: true,
526+ compat: true,
527+ ..Default::default()
528+ },
529+ )
530+}
531+
532+pub fn generate_minified_compat(doc: &Document) -> String {
533+ generate_with(
534+ doc,
535+ &CodegenOptions {
536+ minify: true,
537+ compat: true,
538+ ..Default::default()
539+ },
540+ )
541+}
542+
750fn minify_html(html: &str) -> String {
543fn minify_html(html: &str) -> String {
751 let mut result = String::with_capacity(html.len());
544 let mut result = String::with_capacity(html.len());
752 let mut in_pre = false;
545 let mut in_pre = false;
@@ -825,149 +618,563 @@ fn minify_html(html: &str) -> String {
825 result
618 result
826}
619}
827
620
828−fn generate_full_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
621+/// Add vendor prefixes to CSS within <style> tags for broader browser compatibility.
829− // Without `@page` the output is a fragment
622+fn add_vendor_prefixes(html: &str) -> String {
830− let Some(page) = &doc.page else {
623+ // Find CSS within <style>...</style> and add vendor prefixes
831− return generate_partial_inner(doc, dev, classes);
624+ let mut result = String::with_capacity(html.len() + 512);
832− };
625+ let mut rest = html;
626+ while let Some(start) = rest.find("<style>") {
627+ let after_tag = start + 7;
628+ result.push_str(&rest[..after_tag]);
629+ rest = &rest[after_tag..];
630+ if let Some(end) = rest.find("</style>") {
631+ let css = &rest[..end];
632+ result.push_str(&prefix_css(css));
633+ result.push_str("</style>");
634+ rest = &rest[end + 8..];
635+ } else {
636+ break;
637+ }
638+ }
639+ result.push_str(rest);
640+ result
641+}
642+
643+fn prefix_css(css: &str) -> String {
644+ let mut out = String::with_capacity(css.len() + 256);
645+ let mut i = 0;
646+ let bytes = css.as_bytes();
647+ while i < bytes.len() {
648+ // Find property declarations
649+ if let Some(pos) = css[i..].find('{') {
650+ let brace = i + pos;
651+ out.push_str(&css[i..=brace]);
652+ i = brace + 1;
653+ // Process declarations within this block
654+ if let Some(close) = css[i..].find('}') {
655+ let block = &css[i..i + close];
656+ out.push_str(&prefix_declarations(block));
657+ out.push('}');
658+ i = i + close + 1;
659+ }
660+ } else {
661+ out.push_str(&css[i..]);
662+ break;
663+ }
664+ }
665+ out
666+}
667+
668+fn prefix_declarations(block: &str) -> String {
669+ let mut out = String::with_capacity(block.len() + 128);
670+ for decl in block.split(';') {
671+ let decl = decl.trim();
672+ if decl.is_empty() {
673+ continue;
674+ }
675+ if let Some((prop, val)) = decl.split_once(':') {
676+ let prop = prop.trim();
677+ let val = val.trim();
678+ match prop {
679+ "backdrop-filter" => {
680+ out.push_str(&format!("-webkit-backdrop-filter:{};", val));
681+ out.push_str(&format!("backdrop-filter:{};", val));
682+ }
683+ "user-select" => {
684+ out.push_str(&format!("-webkit-user-select:{};", val));
685+ out.push_str(&format!("-moz-user-select:{};", val));
686+ out.push_str(&format!("user-select:{};", val));
687+ }
688+ "appearance" => {
689+ out.push_str(&format!("-webkit-appearance:{};", val));
690+ out.push_str(&format!("-moz-appearance:{};", val));
691+ out.push_str(&format!("appearance:{};", val));
692+ }
693+ "background-clip" if val.contains("text") => {
694+ out.push_str(&format!("-webkit-background-clip:{};", val));
695+ out.push_str(&format!("background-clip:{};", val));
696+ }
697+ "hyphens" => {
698+ out.push_str(&format!("-webkit-hyphens:{};", val));
699+ out.push_str(&format!("-ms-hyphens:{};", val));
700+ out.push_str(&format!("hyphens:{};", val));
701+ }
702+ "text-size-adjust" => {
703+ out.push_str(&format!("-webkit-text-size-adjust:{};", val));
704+ out.push_str(&format!("-ms-text-size-adjust:{};", val));
705+ out.push_str(&format!("text-size-adjust:{};", val));
706+ }
707+ "mask-image" | "mask-size" | "mask-repeat" | "mask-position" => {
708+ out.push_str(&format!("-webkit-{}:{};", prop, val));
709+ out.push_str(&format!("{}:{};", prop, val));
710+ }
711+ _ => {
712+ out.push_str(decl);
713+ out.push(';');
714+ }
715+ }
716+ } else {
717+ out.push_str(decl);
718+ out.push(';');
719+ }
720+ }
721+ out
722+}
723+
724+fn generate_full_inner(doc: &Document, dev: bool) -> String {
833 let mut styles = StyleCollector::new();
725 let mut styles = StyleCollector::new();
834 let mut ctx = GenContext {
726 let mut ctx = GenContext {
835 dev,
727 dev,
836 depth: 0,
728 depth: 0,
729+ image_count: 0,
837 has_interactive: false,
730 has_interactive: false,
838− in_text: false,
839− flow: Flow::default(),
840− in_picture: false,
841 };
731 };
842
732
843− // The page is the root element: `<body>` is a column (the reset makes
733+ // Check if document has @main for skip-to-content link
844− // it one, filling the viewport), `@page`'s styles are its class, and
734+ let has_main = has_tag(&doc.nodes, "main");
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;
866
735
867 let mut body = String::new();
736 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− );
875
737
876− let element_css = build_element_css(doc, &styles, dev);
738+ // Skip-to-content link for accessibility (only when @main exists)
877− let nl = if dev { "\n" } else { "" };
739+ if has_main {
878−
740+ if dev {
879− let mut meta_html = String::new();
741+ body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>\n");
880− // A `@meta viewport` of the page's own replaces the usual one
742+ } else {
881− if !doc.meta_tags.iter().any(|(name, _)| name == "viewport") {
743+ body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>");
882− meta_html.push_str(&format!(
744+ }
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);
918 }
745 }
919
746
920− // `@page`'s HTML attributes go on `<html>`
747+ for node in &doc.nodes {
921− let mut root_attrs = String::new();
748+ generate_node(node, None, &mut body, &mut styles, &mut ctx);
922− for attr in html_attrs(&page.html_attrs) {
923− push_html_attr(&mut root_attrs, attr);
924 }
749 }
925− let mut body_attrs = String::new();
926− emit_class_attr(&mut body_attrs, body_class.as_deref(), None);
927
750
928− // Focus-visible CSS for interactive elements (accessibility)
751+ // Collect external domains for DNS prefetch
929− let focus_visible_css = if ctx.has_interactive {
752+ let dns_prefetch_html = collect_dns_prefetch(&body, dev);
930− let focusable = ":where(a,button,input,select,textarea):focus-visible";
753+
931− if dev {
754+ let element_css = build_element_css(doc, &styles, dev);
932− format!(
755+
933− "{}{} {{ outline: 2px solid currentColor; outline-offset: 2px; }}\n",
756+ // Build meta tags string
934− OWN_ELEMENTS, focusable
757+ let meta_html = if doc.meta_tags.is_empty() {
935− )
936− } else {
937− format!(
938− "{}{}{{outline:2px solid currentColor;outline-offset:2px}}",
939− OWN_ELEMENTS, focusable
940− )
941− }
942− } else {
943 String::new()
758 String::new()
759+ } else {
760+ let mut m = String::new();
761+ for (name, content) in &doc.meta_tags {
762+ if dev {
763+ m.push_str(&format!(
764+ "<meta name=\"{}\" content=\"{}\">\n",
765+ html_escape(name),
766+ html_escape(content)
767+ ));
768+ } else {
769+ m.push_str(&format!(
770+ "<meta name=\"{}\" content=\"{}\">",
771+ html_escape(name),
772+ html_escape(content)
773+ ));
774+ }
775+ }
776+ m
944 };
777 };
945− let reset_css = reset_css(dev, true, &focus_visible_css);
946− classes.extend(styles.classes());
947−
948− format!(
949− "<!DOCTYPE html>{nl}<html{root_attrs}>{nl}<head>{nl}<meta charset=\"utf-8\">{nl}\
950− {meta_html}<style>{nl}{reset_css}{element_css}</style>{nl}</head>{nl}\
951− <body{body_attrs}>{nl}{body}</body>{nl}</html>{nl}",
952− )
953−}
954
778
955−/// Assemble every CSS block the document needs (font faces, custom
779+ // Build OG meta tags
956−/// properties, generated class rules, keyframes, and user CSS). Shared by
780+ let og_html = if doc.og_tags.is_empty() {
957−/// full-page and partial output so both emit the same styles.
781+ String::new()
958−fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
782+ } else {
959− let mut element_css = String::new();
783+ let mut o = String::new();
784+ for (property, content) in &doc.og_tags {
785+ if dev {
786+ o.push_str(&format!(
787+ "<meta property=\"og:{}\" content=\"{}\">\n",
788+ html_escape(property),
789+ html_escape(content)
790+ ));
791+ } else {
792+ o.push_str(&format!(
793+ "<meta property=\"og:{}\" content=\"{}\">",
794+ html_escape(property),
795+ html_escape(content)
796+ ));
797+ }
798+ }
799+ o
800+ };
960
801
961− // Collect the CSS custom properties declared with `@let --name` so they
802+ // Build head blocks string
962− // can be emitted in a single `:root` block below.
803+ let head_html = if doc.head_blocks.is_empty() {
963− let mut root_vars: Vec<(String, String)> = Vec::new();
804+ String::new()
964− for (name, value) in &doc.css_vars {
805+ } else {
965− root_vars.push((name.clone(), value.clone()));
806+ let mut h = String::new();
966− }
807+ for block in &doc.head_blocks {
808+ h.push_str(block);
809+ if dev {
810+ h.push('\n');
811+ }
812+ }
813+ h
814+ };
967
815
968− // Generated rules always go in htmlang's layers, so unlayered user CSS
816+ let lang_attr = match &doc.lang {
969− // (`@style`, `@raw`) overrides them regardless of specificity.
817+ Some(lang) => format!(" lang=\"{}\"", html_escape(lang)),
970− let styles_css = styles.to_css_formatted(dev, &doc.scoped_css);
818+ None => String::new(),
819+ };
820+
821+ let favicon_html = match &doc.favicon {
822+ Some(path) => {
823+ // Try to read and inline the favicon
824+ if let Ok(data) = std::fs::read(path) {
825+ let mime = if path.ends_with(".ico") {
826+ "image/x-icon"
827+ } else if path.ends_with(".png") {
828+ "image/png"
829+ } else if path.ends_with(".svg") {
830+ "image/svg+xml"
831+ } else {
832+ "image/x-icon"
833+ };
834+ let b64 = base64_encode(&data);
835+ if dev {
836+ format!(
837+ "<link rel=\"icon\" href=\"data:{};base64,{}\">\n",
838+ mime, b64
839+ )
840+ } else {
841+ format!("<link rel=\"icon\" href=\"data:{};base64,{}\">", mime, b64)
842+ }
843+ } else {
844+ // Fall back to href
845+ if dev {
846+ format!("<link rel=\"icon\" href=\"{}\">\n", html_escape(path))
847+ } else {
848+ format!("<link rel=\"icon\" href=\"{}\">", html_escape(path))
849+ }
850+ }
851+ }
852+ None => String::new(),
853+ };
854+
855+ // Canonical URL
856+ let canonical_html = match &doc.canonical {
857+ Some(url) => {
858+ if dev {
859+ format!("<link rel=\"canonical\" href=\"{}\">\n", html_escape(url))
860+ } else {
861+ format!("<link rel=\"canonical\" href=\"{}\">", html_escape(url))
862+ }
863+ }
864+ None => String::new(),
865+ };
866+
867+ // Base URL
868+ let base_html = match &doc.base_url {
869+ Some(url) => {
870+ if dev {
871+ format!("<base href=\"{}\">\n", html_escape(url))
872+ } else {
873+ format!("<base href=\"{}\">", html_escape(url))
874+ }
875+ }
876+ None => String::new(),
877+ };
878+
879+ // JSON-LD blocks
880+ let json_ld_html: String = doc
881+ .json_ld_blocks
882+ .iter()
883+ .map(|block| {
884+ if dev {
885+ format!(
886+ "<script type=\"application/ld+json\">\n{}\n</script>\n",
887+ block
888+ )
889+ } else {
890+ format!("<script type=\"application/ld+json\">{}</script>", block)
891+ }
892+ })
893+ .collect();
894+
895+ // Manifest link
896+ let manifest_html = if let Some(ref manifest) = doc.manifest {
897+ let mut json = String::from("{");
898+ json.push_str(&format!("\"name\":{},", json_str(&manifest.name)));
899+ if let Some(ref short) = manifest.short_name {
900+ json.push_str(&format!("\"short_name\":{},", json_str(short)));
901+ }
902+ json.push_str(&format!("\"start_url\":{},", json_str(&manifest.start_url)));
903+ json.push_str(&format!("\"display\":{}", json_str(&manifest.display)));
904+ if let Some(ref bg) = manifest.background_color {
905+ json.push_str(&format!(",\"background_color\":{}", json_str(bg)));
906+ }
907+ if let Some(ref tc) = manifest.theme_color {
908+ json.push_str(&format!(",\"theme_color\":{}", json_str(tc)));
909+ }
910+ if let Some(ref desc) = manifest.description {
911+ json.push_str(&format!(",\"description\":{}", json_str(desc)));
912+ }
913+ if !manifest.icons.is_empty() {
914+ json.push_str(",\"icons\":[");
915+ for (i, (src, sizes)) in manifest.icons.iter().enumerate() {
916+ if i > 0 {
917+ json.push(',');
918+ }
919+ json.push_str(&format!(
920+ "{{\"src\":{},\"sizes\":{},\"type\":\"image/png\"}}",
921+ json_str(src),
922+ json_str(sizes)
923+ ));
924+ }
925+ json.push(']');
926+ }
927+ json.push('}');
928+
929+ // Inline the manifest as a data URI
930+ let encoded = base64_encode(json.as_bytes());
931+ if dev {
932+ format!(
933+ "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">\n",
934+ encoded
935+ )
936+ } else {
937+ format!(
938+ "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">",
939+ encoded
940+ )
941+ }
942+ } else {
943+ String::new()
944+ };
945+
946+ // Preload hints (auto-detect fonts from @font-face)
947+ let mut preload_html = String::new();
948+ for (_, url) in &doc.font_faces {
949+ if dev {
950+ preload_html.push_str(&format!(
951+ "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
952+ url
953+ ));
954+ } else {
955+ preload_html.push_str(&format!(
956+ "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>",
957+ url
958+ ));
959+ }
960+ }
961+ // Explicit preload hints from the document
962+ for hint in &doc.preload_hints {
963+ if dev {
964+ preload_html.push_str(&format!(
965+ "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>\n",
966+ html_escape(&hint.href),
967+ hint.as_type,
968+ if hint.crossorigin { " crossorigin" } else { "" }
969+ ));
970+ } else {
971+ preload_html.push_str(&format!(
972+ "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>",
973+ html_escape(&hint.href),
974+ hint.as_type,
975+ if hint.crossorigin { " crossorigin" } else { "" }
976+ ));
977+ }
978+ }
979+
980+ // Auto theme-color meta from @theme primary token
981+ let theme_color_html = doc
982+ .theme_tokens
983+ .iter()
984+ .find(|(name, _)| name == "primary" || name == "theme-color")
985+ .map(|(_, value)| {
986+ if dev {
987+ format!(
988+ "<meta name=\"theme-color\" content=\"{}\">\n",
989+ html_escape(value)
990+ )
991+ } else {
992+ format!(
993+ "<meta name=\"theme-color\" content=\"{}\">",
994+ html_escape(value)
995+ )
996+ }
997+ })
998+ .unwrap_or_default();
999+
1000+ // Focus-visible CSS for interactive elements (accessibility)
1001+ let focus_visible_css = if ctx.has_interactive {
1002+ if dev {
1003+ "a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n"
1004+ } else {
1005+ "a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}"
1006+ }
1007+ } else {
1008+ ""
1009+ };
1010+
1011+ // Skip-to-content CSS (visually hidden but accessible)
1012+ let skip_link_css = if has_main {
1013+ if dev {
1014+ ".hl-skip { position: absolute; left: -9999px; top: auto; width: 1px; height: 1px; overflow: hidden; z-index: 9999; padding: 8px 16px; background: #000; color: #fff; text-decoration: none; font-size: 14px; }\n.hl-skip:focus { left: 8px; top: 8px; width: auto; height: auto; overflow: visible; }\n"
1015+ } else {
1016+ ".hl-skip{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;z-index:9999;padding:8px 16px;background:#000;color:#fff;text-decoration:none;font-size:14px}.hl-skip:focus{left:8px;top:8px;width:auto;height:auto;overflow:visible}"
1017+ }
1018+ } else {
1019+ ""
1020+ };
1021+
1022+ let reset_css = reset_css(dev, focus_visible_css, skip_link_css);
1023+
1024+ match &doc.page_title {
1025+ Some(title) => {
1026+ if dev {
1027+ format!(
1028+ "\
1029+<!DOCTYPE html>
1030+<html{lang_attr}>
1031+<head>
1032+<meta charset=\"utf-8\">
1033+<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
1034+<title>{title}</title>
1035+{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}\
1036+<style>
1037+{reset_css}{element_css}\
1038+</style>
1039+</head>
1040+<body>
1041+{body}\
1042+</body>
1043+</html>
1044+",
1045+ title = html_escape(title),
1046+ lang_attr = lang_attr,
1047+ theme_color_html = theme_color_html,
1048+ base_html = base_html,
1049+ canonical_html = canonical_html,
1050+ manifest_html = manifest_html,
1051+ preload_html = preload_html,
1052+ dns_prefetch_html = dns_prefetch_html,
1053+ meta_html = meta_html,
1054+ favicon_html = favicon_html,
1055+ json_ld_html = json_ld_html,
1056+ head_html = head_html,
1057+ og_html = og_html,
1058+ reset_css = reset_css,
1059+ element_css = element_css,
1060+ body = body,
1061+ )
1062+ } else {
1063+ format!(
1064+ "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}<style>{reset_css}{element_css}</style></head><body>{body}</body></html>",
1065+ title = html_escape(title),
1066+ lang_attr = lang_attr,
1067+ theme_color_html = theme_color_html,
1068+ base_html = base_html,
1069+ canonical_html = canonical_html,
1070+ manifest_html = manifest_html,
1071+ preload_html = preload_html,
1072+ dns_prefetch_html = dns_prefetch_html,
1073+ meta_html = meta_html,
1074+ og_html = og_html,
1075+ favicon_html = favicon_html,
1076+ json_ld_html = json_ld_html,
1077+ head_html = head_html,
1078+ reset_css = reset_css,
1079+ element_css = element_css,
1080+ body = body,
1081+ )
1082+ }
1083+ }
1084+ None => {
1085+ if element_css.is_empty() {
1086+ body
1087+ } else if dev {
1088+ format!("<style>\n{}</style>\n{}", element_css, body)
1089+ } else {
1090+ format!("<style>{}</style>{}", element_css, body)
1091+ }
1092+ }
1093+ }
1094+}
1095+
1096+/// Encode `s` as a JSON string literal (with surrounding quotes).
1097+pub(crate) fn json_str(s: &str) -> String {
1098+ let mut out = String::with_capacity(s.len() + 2);
1099+ out.push('"');
1100+ for c in s.chars() {
1101+ match c {
1102+ '"' => out.push_str("\\\""),
1103+ '\\' => out.push_str("\\\\"),
1104+ '\n' => out.push_str("\\n"),
1105+ '\r' => out.push_str("\\r"),
1106+ '\t' => out.push_str("\\t"),
1107+ c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1108+ c => out.push(c),
1109+ }
1110+ }
1111+ out.push('"');
1112+ out
1113+}
1114+
1115+/// Assemble every CSS block the document needs (font faces, custom
1116+/// properties, generated class rules, keyframes, and user CSS). Shared by
1117+/// full-page and partial output so both emit the same styles.
1118+fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
1119+ let mut element_css = String::new();
1120+
1121+ // @font-face rules, in declaration order
1122+ for (name, url) in &doc.font_faces {
1123+ let format_hint = if url.ends_with(".woff2") {
1124+ " format('woff2')"
1125+ } else if url.ends_with(".woff") {
1126+ " format('woff')"
1127+ } else if url.ends_with(".ttf") {
1128+ " format('truetype')"
1129+ } else if url.ends_with(".otf") {
1130+ " format('opentype')"
1131+ } else {
1132+ ""
1133+ };
1134+ if dev {
1135+ element_css.push_str(&format!(
1136+ "@font-face {{\n font-family: '{}';\n src: url('{}'){};\n font-display: swap;\n}}\n",
1137+ name, url, format_hint
1138+ ));
1139+ } else {
1140+ element_css.push_str(&format!(
1141+ "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
1142+ name, url, format_hint
1143+ ));
1144+ }
1145+ }
1146+
1147+ // Collect all CSS custom properties (explicit `@let --name` / `@theme`
1148+ // tokens, plus any auto-extracted repeats) so they can be emitted in a
1149+ // single `:root` block below.
1150+ let mut root_vars: Vec<(String, String)> = Vec::new();
1151+ for (name, value) in &doc.css_vars {
1152+ root_vars.push((name.clone(), value.clone()));
1153+ }
1154+ for (name, value) in &doc.theme_tokens {
1155+ let css_name = format!("--{}", name);
1156+ if !root_vars.iter().any(|(n, _)| *n == css_name) {
1157+ root_vars.push((css_name, value.clone()));
1158+ }
1159+ }
1160+
1161+ // Generated rules always go in `@layer htmlang`, so unlayered user CSS
1162+ // (`@style`, `@raw`) overrides them regardless of specificity.
1163+ let styles_css = styles.to_css_formatted(dev);
1164+ // Fold literal values declared via `@theme` / `@let --name` back into
1165+ // `var(--name)` references so the generated CSS actually uses the
1166+ // custom properties emitted in `:root`.
1167+ let styles_css = substitute_css_vars(&styles_css, &root_vars);
1168+ // Further compress the CSS by auto-extracting any remaining literal
1169+ // values that appear often enough for `var(--hN)` references to come out
1170+ // shorter overall. Disabled in dev mode to keep the CSS readable.
1171+ let styles_css = if dev {
1172+ styles_css
1173+ } else {
1174+ let (new_css, auto_vars) = auto_extract_repeats(&styles_css, &root_vars);
1175+ root_vars.extend(auto_vars);
1176+ new_css
1177+ };
971
1178
972 // Emit the :root block first so the cascade picks up the custom
1179 // Emit the :root block first so the cascade picks up the custom
973 // properties before the class rules consume them.
1180 // properties before the class rules consume them.
@@ -992,121 +1199,71 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
992
1199
993 element_css.push_str(&styles_css);
1200 element_css.push_str(&styles_css);
994
1201
1202+ // @keyframes
1203+ for (name, kf_body) in &doc.keyframes {
1204+ if dev {
1205+ element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
1206+ } else {
1207+ element_css.push_str(&format!("@keyframes {}{{{}}}", name, kf_body));
1208+ }
1209+ }
1210+
1211+ // Keyframes for the standard library's `$skeleton` bundle, when used
1212+ if element_css.contains("hl-skeleton") {
1213+ if dev {
1214+ element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
1215+ } else {
1216+ element_css.push_str("@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}");
1217+ }
1218+ }
1219+
995 // @style blocks (custom CSS)
1220 // @style blocks (custom CSS)
996 for block in &doc.custom_css {
1221 for block in &doc.custom_css {
997 if dev {
1222 if dev {
998 element_css.push_str(block);
1223 element_css.push_str(block);
999 element_css.push('\n');
1224 element_css.push('\n');
1000 } else {
1225 } else {
1001− element_css.push_str(&compact_css(block));
1226+ let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1227+ element_css.push_str(&minified);
1002 }
1228 }
1003 }
1229 }
1004
1230
1005 element_css
1231 element_css
1006}
1232}
1007
1233
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−
1036/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1234/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1037−/// every layer, so an unlayered rule here would override the generated
1235+/// every layer, so an unlayered `a{color:inherit}` would override
1038−/// ones. A page resets `box-sizing` everywhere and makes `<body>` a column
1236+/// `@link [color red]`.
1039−/// that fills the window; a fragment, which goes into a page it doesn't
1237+fn reset_css(dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
1040−/// own, touches only htmlang's own elements. In both, an element htmlang
1238+ let base = if dev {
1041−/// lays out stays hidden while it is `hidden`, a closed `<dialog>` or a
1239+ "*, *::before, *::after { box-sizing: border-box; }\nbody { margin: 0; font-family: system-ui, -apple-system, sans-serif; }\nimg { display: block; }\na { text-decoration: none; color: inherit; }\n"
1042−/// popover that isn't showing: its generated `display` would otherwise
1043−/// beat the browser's `display: none`.
1044−fn reset_css(dev: bool, page: bool, focus_visible_css: &str) -> String {
1045− let own = OWN_ELEMENTS;
1046− let mut rules = Vec::new();
1047− if page {
1048− rules.push(if dev {
1049− "*, *::before, *::after { box-sizing: border-box; }".to_string()
1050− } else {
1051− "*,*::before,*::after{box-sizing:border-box}".to_string()
1052− });
1053− rules.push(if dev {
1054− "body { margin: 0; font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; min-height: 100dvh; }".to_string()
1055− } else {
1056− "body{margin:0;font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;min-height:100dvh}".to_string()
1057− });
1058 } else {
1240 } else {
1059− rules.push(if dev {
1241+ "*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}"
1060− format!("{own}, {own}::before, {own}::after {{ box-sizing: border-box; }}")
1242+ };
1061− } else {
1243+ let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
1062− format!("{own},{own}::before,{own}::after{{box-sizing:border-box}}")
1063− });
1064− }
1065− let hidden = "[hidden]:not([hidden=\"until-found\"]),dialog:not([open]):not(:popover-open),\
1066− [popover]:not(:popover-open):not(dialog[open])";
1067− rules.push(if dev {
1068− format!("{own}:where({hidden}) {{ display: none !important; }}")
1069− } else {
1070− format!("{own}:where({hidden}){{display:none!important}}")
1071− });
1072− let mut rules = rules.join(if dev { "\n" } else { "" });
1073− if dev {
1074− rules.push('\n');
1075− }
1076− rules.push_str(focus_visible_css);
1077 if dev {
1244 if dev {
1078− format!(
1245+ 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− )
1083 } else {
1246 } else {
1084− format!("@layer {};@layer hl-reset{{{}}}", LAYERS.join(","), rules)
1247+ format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
1085 }
1248 }
1086}
1249}
1087
1250
1088/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1251/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1089−fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
1252+fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1090 let mut styles = StyleCollector::new();
1253 let mut styles = StyleCollector::new();
1091 let mut ctx = GenContext {
1254 let mut ctx = GenContext {
1092 dev,
1255 dev,
1093 depth: 0,
1256 depth: 0,
1257+ image_count: 0,
1094 has_interactive: false,
1258 has_interactive: false,
1095− in_text: false,
1096− flow: Flow::default(),
1097− in_picture: false,
1098 };
1259 };
1099 let mut body = String::new();
1260 let mut body = String::new();
1100
1261
1101− generate_children(&doc.nodes, None, &mut body, &mut styles, &mut ctx);
1262+ for node in &doc.nodes {
1102−
1263+ generate_node(node, None, &mut body, &mut styles, &mut ctx);
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 }
1264 }
1109− classes.extend(styles.classes());
1265+
1266+ let element_css = build_element_css(doc, &styles, dev);
1110
1267
1111 if element_css.is_empty() {
1268 if element_css.is_empty() {
1112 body
1269 body
@@ -1121,20 +1278,22 @@ fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -
1121// Node generation
1278// Node generation
1122// ---------------------------------------------------------------------------
1279// ---------------------------------------------------------------------------
1123
1280
1124−/// Write `node`, which is inside an element with the layout `parent`
1125−/// (`None` at the top of the page).
1126fn generate_node(
1281fn generate_node(
1127 node: &Node,
1282 node: &Node,
1128− parent: Option<Layout>,
1283+ parent_kind: Option<&ElementKind>,
1129 out: &mut String,
1284 out: &mut String,
1130 styles: &mut StyleCollector,
1285 styles: &mut StyleCollector,
1131 ctx: &mut GenContext,
1286 ctx: &mut GenContext,
1132) {
1287) {
1133 match node {
1288 match node {
1134− Node::Element(elem) => generate_element(elem, parent, out, styles, ctx),
1289+ Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
1135 Node::Text(segments) => {
1290 Node::Text(segments) => {
1136− // In a row, column or grid each line of text is a child
1291+ let needs_wrap = matches!(
1137− let needs_wrap = parent.is_some_and(Layout::is_container);
1292+ parent_kind,
1293+ Some(ElementKind::Row | ElementKind::Column | ElementKind::El)
1294+ ) || parent_kind
1295+ .and_then(ElementKind::spec)
1296+ .is_some_and(|spec| spec.wraps_text);
1138 if needs_wrap {
1297 if needs_wrap {
1139 out.push_str(&ctx.indent());
1298 out.push_str(&ctx.indent());
1140 out.push_str("<span>");
1299 out.push_str("<span>");
@@ -1153,156 +1312,73 @@ fn generate_node(
1153 }
1312 }
1154}
1313}
1155
1314
1156−/// What goes between two things written one after the other in an element
1157−/// with this layout (`None`: the top of the page): text flows, so its lines
1158−/// and children are joined with a space; in HTML's own layout, and at the
1159−/// top of the page, two lines of text are separated by a line break (a
1160−/// space, except where whitespace is kept, as in `@pre`).
1161−fn separator(layout: Option<Layout>, previous_is_text: bool, next: &Node) -> Option<char> {
1162− match layout {
1163− Some(Layout::Text) => Some(' '),
1164− Some(Layout::Native) | None if previous_is_text && matches!(next, Node::Text(_)) => {
1165− Some('\n')
1166− }
1167− _ => None,
1168− }
1169−}
1170−
1171−/// Write the children of an element (or of a `@fragment`, which has no
1172−/// element of its own) laid out as `layout` (`None`: the top of the page).
1173−/// The first line of an element's text is its first child.
1174−fn generate_children(
1175− children: &[Node],
1176− layout: Option<Layout>,
1177− out: &mut String,
1178− styles: &mut StyleCollector,
1179− ctx: &mut GenContext,
1180−) {
1181− generate_run(children, layout, &mut None, out, styles, ctx);
1182−}
1183−
1184−/// [`generate_children`], with `previous` saying whether what was written
1185−/// last was a line of text (`None`: nothing yet). A `@fragment`'s children
1186−/// are written as if they stood in its place, so its lines are separated
1187−/// from the lines around it like any other lines.
1188−fn generate_run(
1189− children: &[Node],
1190− layout: Option<Layout>,
1191− previous: &mut Option<bool>,
1192− out: &mut String,
1193− styles: &mut StyleCollector,
1194− ctx: &mut GenContext,
1195−) {
1196− for child in children {
1197− if let Node::Element(elem) = child
1198− && elem.kind == ElementKind::Fragment
1199− {
1200− generate_run(&elem.children, layout, previous, out, styles, ctx);
1201− continue;
1202− }
1203− let start = out.len();
1204− if let Some(previous_is_text) = *previous
1205− && let Some(sep) = separator(layout, previous_is_text, child)
1206− {
1207− out.push(sep);
1208− }
1209− let body = out.len();
1210− generate_node(child, layout, out, styles, ctx);
1211− // A child that writes nothing (an empty `@fragment`) takes no
1212− // separator either, so text never gets two spaces in a row
1213− if out.len() == body {
1214− out.truncate(start);
1215− continue;
1216− }
1217− *previous = Some(matches!(child, Node::Text(_)));
1218− }
1219−}
1220−
1221−/// Emit the element's leading argument as its attribute (`@iframe URL` →
1222−/// `src="URL"`, `@form /submit` → `action="/submit"`, `@link /a` →
1223−/// `href="/a"`), first, before the class. `@image [inline] photo.png` puts
1224−/// the file itself into the page.
1225−fn emit_argument_attr(out: &mut String, elem: &Element, in_picture: bool) {
1226− let (Some(attr), Some(value)) = (elem.kind.leading_attribute(in_picture), &elem.argument)
1227− else {
1228− return;
1229− };
1230− // `@image [inline]`'s file is already a `data:` URI (see the parser)
1231− let value = html_escape(value);
1232− out.push(' ');
1233− out.push_str(attr);
1234− out.push_str("=\"");
1235− out.push_str(&value);
1236− out.push('"');
1237−}
1238
1315
1239−/// A file's bytes as a `data:` URI, its type from its extension.
1316+/// Emit the argument of an element whose argument is an HTML attribute
1240−pub(crate) fn data_uri(path: &str, data: &[u8]) -> String {
1317+/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`).
1241− let mime = match path
1318+fn emit_argument_attr(out: &mut String, elem: &Element) {
1242− .rsplit('.')
1319+ if let Some(TagSpec {
1243− .next()
1320+ arg: TagArg::Attr(attr),
1244− .map(str::to_ascii_lowercase)
1321+ ..
1245− .as_deref()
1322+ }) = elem.kind.spec()
1323+ && let Some(value) = &elem.argument
1246 {
1324 {
1247− Some("png") => "image/png",
1325+ out.push(' ');
1248− Some("jpg" | "jpeg") => "image/jpeg",
1326+ out.push_str(attr);
1249− Some("gif") => "image/gif",
1327+ out.push_str("=\"");
1250− Some("webp") => "image/webp",
1328+ out.push_str(&html_escape(value));
1251− Some("avif") => "image/avif",
1329+ out.push('"');
1252− Some("svg") => "image/svg+xml",
1330+ }
1253− Some("ico") => "image/x-icon",
1254− _ => "application/octet-stream",
1255− };
1256− format!("data:{};base64,{}", mime, base64_encode(data))
1257}
1331}
1258
1332
1259/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1333/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1260−/// are emitted with the generated class) and flags like `required`.
1334+/// are emitted with the generated class) and bare booleans like `required`.
1261fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1335fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1262− for attr in html_attrs(attrs) {
1336+ for attr in attrs {
1263 let key = attr.key.as_str();
1337 let key = attr.key.as_str();
1264− if key != "id" && key != "class" {
1338+ if attr.html && key != "id" && key != "class" {
1265− push_html_attr(out, attr);
1339+ out.push(' ');
1340+ out.push_str(key);
1341+ out.push_str("=\"");
1342+ out.push_str(&html_escape(strip_string_quotes(
1343+ attr.value.as_deref().unwrap_or(""),
1344+ )));
1345+ out.push('"');
1346+ } else if !attr.html
1347+ && attr.value.is_none()
1348+ && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&key)
1349+ {
1350+ out.push(' ');
1351+ out.push_str(key);
1266 }
1352 }
1267 }
1353 }
1268}
1354}
1269
1355
1270−/// The HTML attributes of `attrs`, `key=value` ones and flags, each name
1356+/// If the value is wrapped in a single pair of matching quotes (e.g.
1271−/// once: the later one wins, as a style does. (Of two attributes with one
1357+/// `"Avatar"`), return the inner content. Quotes act as source-level
1272−/// name, the browser keeps the first, so a bundle's attribute or a
1358+/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute
1273−/// function's own would win over one written after it.)
1359+/// values. Multi-quoted values like `"h h" "s m"` are left unchanged —
1274−fn html_attrs(attrs: &[Attribute]) -> impl Iterator<Item = &Attribute> {
1360+/// those are real string tokens (used e.g. by CSS `grid-template-areas`).
1275− let is_html =
1361+fn strip_string_quotes(val: &str) -> &str {
1276− |a: &Attribute| a.html || (a.value.is_none() && crate::vocab::is_html_flag(&a.key));
1362+ let bytes = val.as_bytes();
1277− attrs.iter().enumerate().filter_map(move |(i, attr)| {
1363+ if bytes.len() < 2 {
1278− let later = attrs[i + 1..]
1364+ return val;
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 }
1365 }
1366+ let first = bytes[0];
1367+ let last = bytes[bytes.len() - 1];
1368+ if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1369+ let inner = &val[1..val.len() - 1];
1370+ if !inner.as_bytes().contains(&first) {
1371+ return inner;
1372+ }
1373+ }
1374+ val
1295}
1375}
1296
1376
1297// True if any direct child is `@in-front` or `@behind`. Such children render
1377// True if any direct child is `@in-front` or `@behind`. Such children render
1298// as absolutely positioned overlays, so the parent automatically becomes a
1378// as absolutely positioned overlays, so the parent automatically becomes a
1299// positioning context (position:relative + isolation:isolate).
1379// positioning context (position:relative + isolation:isolate).
1300fn has_overlay_children(elem: &Element) -> bool {
1380fn has_overlay_children(elem: &Element) -> bool {
1301− holds_overlays(&elem.children)
1381+ elem.children.iter().any(|child| {
1302−}
1303−
1304−fn holds_overlays(children: &[Node]) -> bool {
1305− children.iter().any(|child| {
1306 matches!(
1382 matches!(
1307 child,
1383 child,
1308 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
1384 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
@@ -1312,32 +1388,48 @@ fn holds_overlays(children: &[Node]) -> bool {
1312
1388
1313fn generate_element(
1389fn generate_element(
1314 elem: &Element,
1390 elem: &Element,
1315− parent: Option<Layout>,
1391+ parent_kind: Option<&ElementKind>,
1316 out: &mut String,
1392 out: &mut String,
1317 styles: &mut StyleCollector,
1393 styles: &mut StyleCollector,
1318 ctx: &mut GenContext,
1394 ctx: &mut GenContext,
1319) {
1395) {
1320 // Self-closing elements
1396 // Self-closing elements
1321− if elem.kind.layout() == Layout::Void {
1397+ if elem.kind == ElementKind::Image || elem.kind.spec().is_some_and(|spec| spec.void) {
1322− generate_self_closing(elem, parent, out, styles, ctx);
1398+ generate_self_closing(elem, parent_kind, out, styles, ctx);
1323 return;
1399 return;
1324 }
1400 }
1325− // A verbatim element (@script) writes its body as it is, with no HTML
1401+ // @script renders as <script> with raw body content (no HTML escaping)
1326− // escaping; it isn't shown, so it has no class (its styles are an error)
1402+ if elem.kind == ElementKind::Script {
1327− if elem.kind.is_verbatim() {
1328− let tag = elem.kind.spec().map_or("script", |spec| spec.html);
1329 out.push_str(&ctx.indent());
1403 out.push_str(&ctx.indent());
1330− out.push('<');
1404+ out.push_str("<script");
1331− out.push_str(tag);
1405+ // Pass through src, type, defer, async, etc.
1332− emit_argument_attr(out, elem, false);
1406+ for attr in &elem.attrs {
1333− let (id, class) = extract_id_class(&elem.attrs);
1407+ let key = attr.key.as_str();
1334− for (key, value) in [("id", id), ("class", class)] {
1408+ if matches!(
1335− if let Some(value) = value {
1409+ key,
1336− out.push_str(&format!(" {}=\"{}\"", key, html_escape(&value)));
1410+ "src"
1411+ | "type"
1412+ | "defer"
1413+ | "async"
1414+ | "crossorigin"
1415+ | "integrity"
1416+ | "nomodule"
1417+ | "id"
1418+ ) {
1419+ if let Some(val) = &attr.value {
1420+ out.push(' ');
1421+ out.push_str(key);
1422+ out.push_str("=\"");
1423+ out.push_str(&html_escape(val));
1424+ out.push('"');
1425+ } else {
1426+ out.push(' ');
1427+ out.push_str(key);
1428+ }
1337 }
1429 }
1338 }
1430 }
1339− emit_html_attrs(out, &elem.attrs);
1340 out.push('>');
1431 out.push('>');
1432+ // Children are raw JS code, not HTML
1341 for child in &elem.children {
1433 for child in &elem.children {
1342 match child {
1434 match child {
1343 Node::Text(segments) => {
1435 Node::Text(segments) => {
@@ -1351,9 +1443,7 @@ fn generate_element(
1351 _ => {}
1443 _ => {}
1352 }
1444 }
1353 }
1445 }
1354− out.push_str("</");
1446+ out.push_str("</script>");
1355− out.push_str(tag);
1356− out.push('>');
1357 out.push_str(ctx.nl());
1447 out.push_str(ctx.nl());
1358 return;
1448 return;
1359 }
1449 }
@@ -1364,26 +1454,26 @@ fn generate_element(
1364 return;
1454 return;
1365 }
1455 }
1366 if elem.kind == ElementKind::Fragment {
1456 if elem.kind == ElementKind::Fragment {
1367− // Render children without a wrapper element, as if they were
1457+ // Render children without a wrapper element
1368− // written where the fragment is
1458+ for child in &elem.children {
1369− generate_children(&elem.children, parent, out, styles, ctx);
1459+ generate_node(child, parent_kind, out, styles, ctx);
1460+ }
1370 return;
1461 return;
1371 }
1462 }
1372
1463
1373− let mut own = Flow::of(elem);
1374 let tag = match &elem.kind {
1464 let tag = match &elem.kind {
1375− ElementKind::Row | ElementKind::El => "div",
1465+ ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
1376 ElementKind::Text => "span",
1466 ElementKind::Text => "span",
1377 ElementKind::Paragraph => "p",
1467 ElementKind::Paragraph => "p",
1378 ElementKind::Link => "a",
1468 ElementKind::Link => "a",
1469+ ElementKind::Tag(spec)
1470+ if spec.name == "list" && elem.attrs.iter().any(|a| a.key == "ordered") =>
1471+ {
1472+ "ol"
1473+ }
1379 ElementKind::Tag(spec) => spec.html,
1474 ElementKind::Tag(spec) => spec.html,
1380 _ => "",
1475 _ => "",
1381 };
1476 };
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();
1477 let kind_label = elem.kind.name();
1388
1478
1389 // Track interactive elements for focus-visible CSS
1479 // Track interactive elements for focus-visible CSS
@@ -1394,8 +1484,14 @@ fn generate_element(
1394 }
1484 }
1395
1485
1396 // Compute CSS for each state and get a class name
1486 // Compute CSS for each state and get a class name
1397− let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1487+ let overlay_children = has_overlay_children(elem);
1398− own.class = gen_class.clone();
1488+ let gen_class = compute_class(
1489+ &elem.attrs,
1490+ &elem.kind,
1491+ parent_kind,
1492+ styles,
1493+ overlay_children,
1494+ );
1399 let (id, user_class) = extract_id_class(&elem.attrs);
1495 let (id, user_class) = extract_id_class(&elem.attrs);
1400
1496
1401 if ctx.dev && elem.line_num > 0 {
1497 if ctx.dev && elem.line_num > 0 {
@@ -1409,7 +1505,32 @@ fn generate_element(
1409 out.push('<');
1505 out.push('<');
1410 out.push_str(tag);
1506 out.push_str(tag);
1411
1507
1412− emit_argument_attr(out, elem, ctx.in_picture);
1508+ // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1509+ if elem.kind.is_tag("main") && id.is_none() {
1510+ out.push_str(" id=\"hl-main\"");
1511+ }
1512+
1513+ if elem.kind == ElementKind::Link
1514+ && let Some(url) = &elem.argument
1515+ {
1516+ out.push_str(" href=\"");
1517+ out.push_str(&html_escape(url));
1518+ out.push('"');
1519+ // Auto rel="noopener noreferrer" and target="_blank" for external links
1520+ let is_external = url.starts_with("http://") || url.starts_with("https://");
1521+ if is_external {
1522+ let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1523+ let has_target = elem.attrs.iter().any(|a| a.key == "target");
1524+ if !has_rel {
1525+ out.push_str(" rel=\"noopener noreferrer\"");
1526+ }
1527+ if !has_target {
1528+ out.push_str(" target=\"_blank\"");
1529+ }
1530+ }
1531+ }
1532+
1533+ emit_argument_attr(out, elem);
1413
1534
1414 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1535 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1415
1536
@@ -1430,27 +1551,28 @@ fn generate_element(
1430 }
1551 }
1431
1552
1432 out.push('>');
1553 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");
1438 out.push_str(ctx.nl());
1554 out.push_str(ctx.nl());
1439
1555
1440− let layout = elem.kind.layout();
1556+ // Inline text argument
1557+ if renders_argument_as_text(&elem.kind)
1558+ && let Some(text) = &elem.argument
1559+ {
1560+ out.push_str(&html_escape(text));
1561+ }
1562+
1563+ // Children
1441 ctx.depth += 1;
1564 ctx.depth += 1;
1442− let outer_in_text = ctx.in_text;
1565+ let is_paragraph = elem.kind == ElementKind::Paragraph
1443− ctx.in_text = in_text || layout == Layout::Text;
1566+ || elem.kind.spec().is_some_and(|spec| spec.inline);
1444− let outer_flow = std::mem::replace(&mut ctx.flow, own);
1567+ for (i, child) in elem.children.iter().enumerate() {
1445− let outer_picture = std::mem::replace(&mut ctx.in_picture, elem.kind.is_tag("picture"));
1568+ generate_node(child, Some(&elem.kind), out, styles, ctx);
1446− generate_children(&elem.children, Some(layout), out, styles, ctx);
1569+ if is_paragraph && i < elem.children.len() - 1 {
1447− ctx.in_picture = outer_picture;
1570+ out.push(' ');
1448− ctx.flow = outer_flow;
1571+ }
1449− ctx.in_text = outer_in_text;
1572+ }
1450 ctx.depth -= 1;
1573 ctx.depth -= 1;
1451
1574
1452 out.push_str(&ctx.indent());
1575 out.push_str(&ctx.indent());
1453− ctx.dev = outer_dev;
1454 out.push_str("</");
1576 out.push_str("</");
1455 out.push_str(tag);
1577 out.push_str(tag);
1456 out.push('>');
1578 out.push('>');
@@ -1459,12 +1581,12 @@ fn generate_element(
1459
1581
1460fn generate_self_closing(
1582fn generate_self_closing(
1461 elem: &Element,
1583 elem: &Element,
1462− parent: Option<Layout>,
1584+ parent_kind: Option<&ElementKind>,
1463 out: &mut String,
1585 out: &mut String,
1464 styles: &mut StyleCollector,
1586 styles: &mut StyleCollector,
1465 ctx: &mut GenContext,
1587 ctx: &mut GenContext,
1466) {
1588) {
1467− let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1589+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1468 let (id, user_class) = extract_id_class(&elem.attrs);
1590 let (id, user_class) = extract_id_class(&elem.attrs);
1469
1591
1470 let (tag, kind_label) = match &elem.kind {
1592 let (tag, kind_label) = match &elem.kind {
@@ -1483,7 +1605,44 @@ fn generate_self_closing(
1483 out.push_str(&ctx.indent());
1605 out.push_str(&ctx.indent());
1484 out.push('<');
1606 out.push('<');
1485 out.push_str(tag);
1607 out.push_str(tag);
1486− emit_argument_attr(out, elem, ctx.in_picture);
1608+ emit_argument_attr(out, elem);
1609+
1610+ // Image src (with optional non-SVG base64 inlining)
1611+ if elem.kind == ElementKind::Image {
1612+ let src = elem.argument.as_deref().unwrap_or("");
1613+ let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
1614+ if is_inline && !src.is_empty() && !src.ends_with(".svg") {
1615+ let mime = if src.ends_with(".png") {
1616+ "image/png"
1617+ } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
1618+ "image/jpeg"
1619+ } else if src.ends_with(".gif") {
1620+ "image/gif"
1621+ } else if src.ends_with(".webp") {
1622+ "image/webp"
1623+ } else if src.ends_with(".avif") {
1624+ "image/avif"
1625+ } else {
1626+ "application/octet-stream"
1627+ };
1628+ if let Ok(data) = std::fs::read(src) {
1629+ let b64 = base64_encode(&data);
1630+ out.push_str(" src=\"data:");
1631+ out.push_str(mime);
1632+ out.push_str(";base64,");
1633+ out.push_str(&b64);
1634+ out.push('"');
1635+ } else {
1636+ out.push_str(" src=\"");
1637+ out.push_str(&html_escape(src));
1638+ out.push('"');
1639+ }
1640+ } else {
1641+ out.push_str(" src=\"");
1642+ out.push_str(&html_escape(src));
1643+ out.push('"');
1644+ }
1645+ }
1487
1646
1488 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1647 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1489
1648
@@ -1503,6 +1662,93 @@ fn generate_self_closing(
1503 ));
1662 ));
1504 }
1663 }
1505
1664
1665+ // Image optimization: auto-add loading="lazy" and decoding="async"
1666+ if elem.kind == ElementKind::Image {
1667+ // SVG inlining: @image [inline] logo.svg
1668+ if elem.attrs.iter().any(|a| a.key == "inline")
1669+ && let Some(src) = &elem.argument
1670+ && src.ends_with(".svg")
1671+ && let Ok(svg_content) = std::fs::read_to_string(src)
1672+ {
1673+ // Close the tag we opened, then emit inline SVG instead
1674+ out.truncate(out.rfind('<').unwrap_or(0));
1675+ out.push_str(&ctx.indent());
1676+ out.push_str(svg_content.trim());
1677+ out.push_str(ctx.nl());
1678+ return;
1679+ }
1680+ // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
1681+ let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
1682+ if let Some(resp) = responsive_attr
1683+ && let Some(ref sizes_str) = resp.value
1684+ {
1685+ let widths: Vec<&str> = sizes_str.split_whitespace().collect();
1686+ if !widths.is_empty() {
1687+ let src = elem.argument.as_deref().unwrap_or("");
1688+ if !src.is_empty() {
1689+ // Generate srcset with width descriptors
1690+ // Convention: file-{width}.ext (e.g., photo-400.jpg)
1691+ let dot_pos = src.rfind('.').unwrap_or(src.len());
1692+ let base = &src[..dot_pos];
1693+ let ext = &src[dot_pos..];
1694+ let mut srcset_parts = Vec::new();
1695+ for w in &widths {
1696+ srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
1697+ }
1698+ out.push_str(" srcset=\"");
1699+ out.push_str(&srcset_parts.join(", "));
1700+ out.push('"');
1701+ // Generate sizes attribute
1702+ let max_width = widths.last().unwrap_or(&"100vw");
1703+ out.push_str(&format!(
1704+ " sizes=\"(max-width: {}px) 100vw, {}px\"",
1705+ max_width, max_width
1706+ ));
1707+ }
1708+ }
1709+ }
1710+
1711+ // Auto image dimensions: read local image file to inject width/height + aspect-ratio
1712+ let has_width = elem.attrs.iter().any(|a| a.key == "width");
1713+ let has_height = elem.attrs.iter().any(|a| a.key == "height");
1714+ if (!has_width || !has_height)
1715+ && let Some(ref src) = elem.argument
1716+ && !src.starts_with("http://")
1717+ && !src.starts_with("https://")
1718+ && !src.starts_with("data:")
1719+ && let Some((w, h)) = read_image_dimensions(src)
1720+ {
1721+ // Intrinsic size attributes only when neither dimension is set in
1722+ // CSS; with one CSS dimension, a leftover intrinsic attribute for
1723+ // the other would distort the image (200 wide but 1000 tall).
1724+ if !has_width && !has_height {
1725+ out.push_str(&format!(" width=\"{}\" height=\"{}\"", w, h));
1726+ }
1727+ // Auto aspect-ratio to prevent CLS
1728+ if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
1729+ out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
1730+ }
1731+ }
1732+
1733+ // Smart image loading: first 3 images get fetchpriority="high" (above the fold),
1734+ // subsequent images get loading="lazy" + decoding="async"
1735+ ctx.image_count += 1;
1736+ if ctx.image_count <= 3 {
1737+ // Above-the-fold: eager loading with high priority
1738+ if !elem.attrs.iter().any(|a| a.key == "fetchpriority") {
1739+ out.push_str(" fetchpriority=\"high\"");
1740+ }
1741+ } else {
1742+ // Below-the-fold: lazy loading
1743+ if !elem.attrs.iter().any(|a| a.key == "loading") {
1744+ out.push_str(" loading=\"lazy\"");
1745+ }
1746+ if !elem.attrs.iter().any(|a| a.key == "decoding") {
1747+ out.push_str(" decoding=\"async\"");
1748+ }
1749+ }
1750+ }
1751+
1506 out.push('>');
1752 out.push('>');
1507 out.push_str(ctx.nl());
1753 out.push_str(ctx.nl());
1508}
1754}
@@ -1518,10 +1764,7 @@ fn generate_text_segments(
1518 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1764 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1519 TextSegment::Inline(elem) => {
1765 TextSegment::Inline(elem) => {
1520 let mut buf = String::new();
1766 let mut buf = String::new();
1521− // An element inside a line of text is in text
1767+ generate_element(elem, None, &mut buf, styles, ctx);
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;
1525 out.push_str(buf.trim_end());
1768 out.push_str(buf.trim_end());
1526 }
1769 }
1527 }
1770 }
@@ -1534,97 +1777,135 @@ fn generate_text_segments(
1534
1777
1535fn compute_class(
1778fn compute_class(
1536 attrs: &[Attribute],
1779 attrs: &[Attribute],
1537− site: &Site,
1780+ kind: &ElementKind,
1781+ parent_kind: Option<&ElementKind>,
1538 styles: &mut StyleCollector,
1782 styles: &mut StyleCollector,
1539− distinct: String,
1783+ has_overlay_children: bool,
1540) -> Option<String> {
1784) -> Option<String> {
1541− // Every condition an attribute is under, and none
1785+ let base = attrs_to_css(attrs, "", kind, parent_kind, has_overlay_children);
1542− let mut conditions = vec![Condition::default()];
1786+
1543− for attr in attrs.iter().filter(|a| !a.html) {
1787+ // Collect pseudo-state overrides
1544− let (condition, _) = Condition::of(&attr.key);
1788+ let mut pseudo = Vec::new();
1545− if !conditions.contains(&condition) {
1789+ for &(prefix, selector) in crate::vocab::PSEUDO_PREFIXES {
1546− conditions.push(condition);
1790+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1791+ if !css.is_empty() {
1792+ pseudo.push((selector.to_string(), css));
1793+ }
1794+ }
1795+
1796+ // Collect nth:EXPR: dynamic pseudo selectors
1797+ let mut nth_prefixes: Vec<String> = Vec::new();
1798+ for attr in attrs {
1799+ if attr.key.starts_with("nth:") {
1800+ let rest = &attr.key[4..];
1801+ if let Some(colon_pos) = rest.find(':') {
1802+ let prefix = format!("nth:{}:", &rest[..colon_pos]);
1803+ if !nth_prefixes.contains(&prefix) {
1804+ nth_prefixes.push(prefix);
1805+ }
1806+ }
1807+ }
1808+ }
1809+ for prefix in &nth_prefixes {
1810+ let expr = &prefix[4..prefix.len() - 1];
1811+ let selector = format!(":nth-child({})", expr);
1812+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1813+ if !css.is_empty() {
1814+ pseudo.push((selector, css));
1547 }
1815 }
1548 }
1816 }
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−}
1572
1817
1573−/// The defaults of an element's kind where it is: an overlay's positioning
1818+ // Collect has(...): dynamic pseudo selectors
1574−/// context, its layout, and its kind's own CSS (a heading's `margin:0`).
1819+ let mut has_prefixes: Vec<String> = Vec::new();
1575−fn default_css(site: &Site) -> String {
1820+ for attr in attrs {
1576− let mut css = String::new();
1821+ if attr.key.starts_with("has(")
1577− // Elements with @in-front / @behind children become positioning
1822+ && let Some(close) = attr.key.find("):")
1578− // contexts
1823+ {
1579− if site.has_overlay_children {
1824+ let prefix = format!("{}:", &attr.key[..close + 1]);
1580− css.push_str("position:relative;isolation:isolate;");
1825+ if !has_prefixes.contains(&prefix) {
1826+ has_prefixes.push(prefix);
1827+ }
1828+ }
1581 }
1829 }
1582− if !site.root {
1830+ for prefix in &has_prefixes {
1583− css.push_str(layout_css(site.kind.layout(), site.inline));
1831+ let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
1832+ let selector = format!(":has({})", inner);
1833+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1834+ if !css.is_empty() {
1835+ pseudo.push((selector, css));
1836+ }
1584 }
1837 }
1585− css.push_str(site.kind.css());
1586− css
1587−}
1588
1838
1589−/// The declarations of a rule body, as (property, `property:value;`), for
1839+ // Collect responsive overrides
1590−/// bodies htmlang wrote: every declaration ends in `;`, and a `;` inside
1840+ let mut responsive = Vec::new();
1591−/// quotes or parentheses doesn't end one.
1841+ for &(bp_name, _) in BREAKPOINTS {
1592−fn declarations(css: &str) -> impl Iterator<Item = (&str, &str)> {
1842+ let prefix = format!("{}:", bp_name);
1593− let mut rest = css;
1843+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1594− std::iter::from_fn(move || {
1844+ if !css.is_empty() {
1595− if rest.is_empty() {
1845+ responsive.push((bp_name.to_string(), css));
1596− return None;
1597 }
1846 }
1598− let (mut depth, mut quote, mut escaped) = (0i32, None, false);
1847+ }
1599− let mut end = rest.len();
1848+
1600− for (i, c) in rest.char_indices() {
1849+ // Collect container query overrides
1601− match c {
1850+ let mut container = Vec::new();
1602− _ if escaped => escaped = false,
1851+ for &(bp_name, _) in BREAKPOINTS {
1603− '\\' => escaped = true,
1852+ let prefix = format!("cq-{}:", bp_name);
1604− c if Some(c) == quote => quote = None,
1853+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1605− _ if quote.is_some() => {}
1854+ if !css.is_empty() {
1606− '"' | '\'' => quote = Some(c),
1855+ container.push((bp_name.to_string(), css));
1607− '(' => depth += 1,
1608− ')' => depth -= 1,
1609− ';' if depth == 0 => {
1610− end = i + 1;
1611− break;
1612− }
1613− _ => {}
1614− }
1615 }
1856 }
1616− let (declaration, after) = rest.split_at(end);
1857+ }
1617− rest = after;
1858+
1618− let name = declaration.split(':').next().unwrap_or("").trim();
1859+ let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
1619− Some((name, declaration))
1860+ let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
1620− })
1861+ let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind, has_overlay_children);
1862+ let motion_reduce =
1863+ attrs_to_css(attrs, "motion-reduce:", kind, parent_kind, has_overlay_children);
1864+ let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
1865+ let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
1866+
1867+ // Dedupe: if a property is declared twice within a single rule, keep only
1868+ // the last occurrence (element-kind defaults are written before
1869+ // user attributes, so a user [list-style disc] correctly overrides the
1870+ // default list-style:none, and we don't need to ship both).
1871+ let base = dedupe_declarations(&base);
1872+ let pseudo: Vec<(String, String)> = pseudo
1873+ .into_iter()
1874+ .map(|(sel, css)| (sel, dedupe_declarations(&css)))
1875+ .collect();
1876+ let responsive: Vec<(String, String)> = responsive
1877+ .into_iter()
1878+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1879+ .collect();
1880+ let dark = dedupe_declarations(&dark);
1881+ let print = dedupe_declarations(&print);
1882+ let motion_safe = dedupe_declarations(&motion_safe);
1883+ let motion_reduce = dedupe_declarations(&motion_reduce);
1884+ let landscape = dedupe_declarations(&landscape);
1885+ let portrait = dedupe_declarations(&portrait);
1886+ let container: Vec<(String, String)> = container
1887+ .into_iter()
1888+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1889+ .collect();
1890+
1891+ styles.get_class(
1892+ base,
1893+ pseudo,
1894+ responsive,
1895+ dark,
1896+ print,
1897+ motion_safe,
1898+ motion_reduce,
1899+ landscape,
1900+ portrait,
1901+ container,
1902+ )
1621}
1903}
1622
1904
1623/// Dedupe CSS declarations within a single rule body: for any property
1905/// Dedupe CSS declarations within a single rule body: for any property
1624/// declared more than once, keep only the last occurrence. Unparseable
1906/// declared more than once, keep only the last occurrence. Unparseable
1625−/// segments (no `:`) are preserved as-is. Semicolons inside parentheses or
1907+/// segments (no `:`) are preserved as-is. Semicolons inside parentheses are
1626−/// quoted strings are treated as part of a value, not as declaration
1908+/// treated as part of a value, not as declaration separators.
1627−/// separators.
1628fn dedupe_declarations(css: &str) -> String {
1909fn dedupe_declarations(css: &str) -> String {
1629 if css.is_empty() {
1910 if css.is_empty() {
1630 return String::new();
1911 return String::new();
@@ -1639,26 +1920,8 @@ fn dedupe_declarations(css: &str) -> String {
1639 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1920 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1640 let mut current = String::new();
1921 let mut current = String::new();
1641 let mut depth: i32 = 0;
1922 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;
1646 for ch in css.chars() {
1923 for ch in css.chars() {
1647− if escaped {
1924+ if ch == '(' {
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 == '(' {
1662 depth += 1;
1925 depth += 1;
1663 current.push(ch);
1926 current.push(ch);
1664 } else if ch == ')' {
1927 } else if ch == ')' {
@@ -1740,553 +2003,579 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
1740// Attribute → CSS mapping
2003// Attribute → CSS mapping
1741// ---------------------------------------------------------------------------
2004// ---------------------------------------------------------------------------
1742
2005
1743−/// Where an element is written, which its CSS depends on.
2006+/// (htmlang prefix, CSS selector suffix)
1744−struct Site<'a> {
2007+/// `display:flex;flex-direction:column;` — base layout for `@el` and every
1745− kind: &'a ElementKind,
2008+/// semantic wrapper that behaves like a column.
1746− /// How its parent lays out its children: its `width fill`, `height
2009+const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
1747− /// fill` and `shrink` compile against it.
2010+
1748− parent: &'a Flow,
2011+
1749− /// It has `@in-front` / `@behind` children.
2012+
2013+fn attrs_to_css(
2014+ attrs: &[Attribute],
2015+ state_prefix: &str,
2016+ kind: &ElementKind,
2017+ parent_kind: Option<&ElementKind>,
1750 has_overlay_children: bool,
2018 has_overlay_children: bool,
1751− /// A row, column or grid inside text, laid out inline.
2019+) -> String {
1752− inline: bool,
2020+ let mut css = String::new();
1753− /// The page's `<body>`, which the reset already makes a column.
1754− root: bool,
1755−}
1756
2021
1757−impl Site<'_> {
2022+ // Base element styles only for the default (non-state) pass
1758− /// Where an element in a parent with the layout `layout` and the flow
2023+ if state_prefix.is_empty() {
1759− /// `parent` is.
2024+ // Elements with @in-front / @behind children become positioning
1760− fn new<'a>(elem: &'a Element, layout: Option<Layout>, parent: &'a Flow) -> Site<'a> {
2025+ // contexts. Pushed before user attrs so an explicit `position` wins
1761− Site {
2026+ // via dedupe (only the last declaration of a property is kept).
1762− kind: &elem.kind,
2027+ if has_overlay_children {
1763− parent,
2028+ css.push_str("position:relative;isolation:isolate;");
1764− has_overlay_children: has_overlay_children(elem),
2029+ }
1765− inline: layout == Some(Layout::Text) && elem.kind.layout().is_container(),
2030+ match kind {
1766− root: false,
2031+ ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2032+ ElementKind::Column | ElementKind::El => css.push_str(FLEX_COLUMN),
2033+ ElementKind::Paragraph => css.push_str("margin:0;"),
2034+ ElementKind::Tag(spec) => css.push_str(spec.css),
2035+ _ => {}
1767 }
2036 }
1768 }
2037 }
1769−}
1770−
1771−// ---------------------------------------------------------------------------
1772−// Direction: what a child's `fill` and `shrink` compile against
1773−// ---------------------------------------------------------------------------
1774
2038
1775−/// The way a flex row or column lays out its children.
2039+ for attr in attrs {
1776−#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2040+ if attr.html {
1777−enum Axis {
2041+ continue;
1778− Row,
2042+ }
1779− Column,
2043+ // Determine the effective key for this pass
1780−}
2044+ let effective_key = if state_prefix.is_empty() {
2045+ if crate::vocab::is_prefixed(&attr.key) {
2046+ continue;
2047+ }
2048+ attr.key.as_str()
2049+ } else {
2050+ match attr.key.strip_prefix(state_prefix) {
2051+ Some(k) => k,
2052+ None => continue,
2053+ }
2054+ };
1781
2055
1782−impl Axis {
2056+ let val = attr.value.as_deref();
1783− /// The direction a `flex-direction` or `flex-flow` value sets, if it
1784− /// names one (not `var(--dir)`, which only the browser knows).
1785− fn of_value(value: &str) -> Option<Axis> {
1786− value.split_whitespace().find_map(|word| match word {
1787− "row" | "row-reverse" => Some(Axis::Row),
1788− "column" | "column-reverse" => Some(Axis::Column),
1789− _ => None,
1790− })
1791− }
1792−}
1793
2057
1794−/// How an element lays out its children, as its CSS sets it: the
2058+ match effective_key {
1795−/// direction its children's `width`/`height` `fill` and `shrink` compile
2059+ // Layout
1796−/// against. That is the direction of `@row` or of a column, or the one its
2060+ "spacing" | "gap" => {
1797−/// own `flex-direction` (or `flex-flow`) sets, with or without media or
2061+ if let Some(v) = val {
1798−/// container prefixes. Like elm-ui's `.r > .wf`, the rules for a direction
2062+ push_css(&mut css, "gap", &css_px(v));
1799−/// set under a prefix are keyed on the element's class: `@media (...) {
2063+ }
1800−/// :where(.a)>.b {...} }`.
2064+ }
1801−#[derive(Clone, Default)]
2065+ "padding" => {
1802−struct Flow {
2066+ if let Some(v) = val {
1803− /// Without a prefix; `None` when the element isn't a flex row or column
2067+ push_css(&mut css, "padding", &css_px_multi(v));
1804− /// (a grid, text, HTML's own layout, the top of the page)
2068+ }
1805− base: Option<Axis>,
2069+ }
1806− /// The direction under each chain of at-rule prefixes that sets one, in
2070+ "padding-x" => {
1807− /// the order their blocks are written
2071+ if let Some(v) = val {
1808− changes: Vec<(Vec<usize>, Axis)>,
2072+ // Logical property covers both inline sides in one
1809− /// The element's generated class, which the rules for the changes are
2073+ // declaration; for symmetric values this is visually
1810− /// keyed on
2074+ // identical to padding-left/right in LTR and RTL.
1811− class: Option<String>,
2075+ push_css(&mut css, "padding-inline", &css_px(v));
1812−}
2076+ }
2077+ }
2078+ "padding-y" => {
2079+ if let Some(v) = val {
2080+ push_css(&mut css, "padding-block", &css_px(v));
2081+ }
2082+ }
2083+ "padding-top" => {
2084+ if let Some(v) = val {
2085+ push_css(&mut css, "padding-top", &css_px(v));
2086+ }
2087+ }
2088+ "padding-bottom" => {
2089+ if let Some(v) = val {
2090+ push_css(&mut css, "padding-bottom", &css_px(v));
2091+ }
2092+ }
2093+ "padding-left" => {
2094+ if let Some(v) = val {
2095+ push_css(&mut css, "padding-left", &css_px(v));
2096+ }
2097+ }
2098+ "padding-right" => {
2099+ if let Some(v) = val {
2100+ push_css(&mut css, "padding-right", &css_px(v));
2101+ }
2102+ }
1813
2103
1814−impl Flow {
2104+ // Sizing
1815− fn of(elem: &Element) -> Flow {
2105+ "width" => {
1816− let mut base = match elem.kind.layout() {
2106+ if let Some(v) = val {
1817− Layout::Row => Axis::Row,
2107+ match v {
1818− Layout::Column => Axis::Column,
2108+ "fill" => match parent_kind {
1819− _ => return Flow::default(),
2109+ Some(ElementKind::Row) => {
1820− };
2110+ push_css(&mut css, "flex", "1");
1821− let mut changes: Vec<(Vec<usize>, Axis)> = Vec::new();
2111+ push_css(&mut css, "min-width", "0");
1822− for attr in elem.attrs.iter().filter(|a| !a.html) {
2112+ }
1823− let (condition, name) = Condition::of(&attr.key);
2113+ _ => push_css(&mut css, "width", "100%"),
1824− if !matches!(name, "flex-direction" | "flex-flow") || !condition.selector.is_empty() {
2114+ },
1825− continue;
2115+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
2116+ _ => push_css(&mut css, "width", &css_px(v)),
2117+ }
2118+ }
1826 }
2119 }
1827− let Some(axis) = attr.value.as_deref().and_then(Axis::of_value) else {
2120+ "height" => {
1828− continue;
2121+ if let Some(v) = val {
1829− };
2122+ match v {
1830− if condition.at.is_empty() {
2123+ "fill" => match parent_kind {
1831− base = axis;
2124+ Some(ElementKind::Column) => {
1832− } else {
2125+ push_css(&mut css, "flex", "1");
1833− // The later one wins, as in the CSS
2126+ push_css(&mut css, "min-height", "0");
1834− changes.retain(|(at, _)| *at != condition.at);
2127+ }
1835− changes.push((condition.at, axis));
2128+ _ => push_css(&mut css, "height", "100%"),
2129+ },
2130+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
2131+ _ => push_css(&mut css, "height", &css_px(v)),
2132+ }
2133+ }
2134+ }
2135+ "min-width" => {
2136+ if let Some(v) = val {
2137+ push_css(&mut css, "min-width", &css_px(v));
2138+ }
2139+ }
2140+ "max-width" => {
2141+ if let Some(v) = val {
2142+ push_css(&mut css, "max-width", &css_px(v));
2143+ }
2144+ }
2145+ "min-height" => {
2146+ if let Some(v) = val {
2147+ push_css(&mut css, "min-height", &css_px(v));
2148+ }
2149+ }
2150+ "max-height" => {
2151+ if let Some(v) = val {
2152+ push_css(&mut css, "max-height", &css_px(v));
2153+ }
1836 }
2154 }
1837− }
1838− changes.sort_by_key(|(at, _)| at_order(at));
1839− Flow {
1840− base: Some(base),
1841− changes,
1842− class: None,
1843− }
1844− }
1845
2155
1846− /// The direction under the at-rules `at`: the latest one set among
2156+ // Alignment
1847− /// the conditions that hold there (`md:` holds at `lg:`), else the one
2157+ "center-x" => match parent_kind {
1848− /// without a prefix.
2158+ Some(ElementKind::Column) | Some(ElementKind::El) => {
1849− fn at(&self, at: &[usize]) -> Option<Axis> {
2159+ push_css(&mut css, "align-self", "center");
1850− let base = self.base?;
2160+ }
1851− let here = Condition::at_only(at);
2161+ _ => {
1852− let changed = self
2162+ push_css(&mut css, "margin-left", "auto");
1853− .changes
2163+ push_css(&mut css, "margin-right", "auto");
1854− .iter()
2164+ }
1855− .rev()
2165+ },
1856− .find(|(chain, _)| Condition::at_only(chain).holds_at(&here))
2166+ "center-y" => match parent_kind {
1857− .map(|&(_, axis)| axis);
2167+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
1858− Some(changed.unwrap_or(base))
2168+ _ => {
1859− }
2169+ push_css(&mut css, "margin-top", "auto");
1860−}
2170+ push_css(&mut css, "margin-bottom", "auto");
2171+ }
2172+ },
2173+ "align-left" => match parent_kind {
2174+ Some(ElementKind::Column) | Some(ElementKind::El) => {
2175+ push_css(&mut css, "align-self", "flex-start");
2176+ }
2177+ _ => push_css(&mut css, "margin-right", "auto"),
2178+ },
2179+ "align-right" => match parent_kind {
2180+ Some(ElementKind::Column) | Some(ElementKind::El) => {
2181+ push_css(&mut css, "align-self", "flex-end");
2182+ }
2183+ _ => push_css(&mut css, "margin-left", "auto"),
2184+ },
2185+ "align-top" => match parent_kind {
2186+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
2187+ _ => push_css(&mut css, "margin-bottom", "auto"),
2188+ },
2189+ "align-bottom" => match parent_kind {
2190+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
2191+ _ => push_css(&mut css, "margin-top", "auto"),
2192+ },
2193+
2194+ // Style
2195+ "border" => {
2196+ if let Some(v) = val {
2197+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
2198+ if parts.len() == 2 {
2199+ push_css(
2200+ &mut css,
2201+ "border",
2202+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
2203+ );
2204+ } else {
2205+ push_css(
2206+ &mut css,
2207+ "border",
2208+ &format!("{} solid currentColor", css_px(parts[0])),
2209+ );
2210+ }
2211+ }
2212+ }
2213+ "border-top" | "border-bottom" | "border-left" | "border-right" => {
2214+ if let Some(v) = val {
2215+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
2216+ if parts.len() == 2 {
2217+ push_css(
2218+ &mut css,
2219+ effective_key,
2220+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
2221+ );
2222+ } else {
2223+ push_css(
2224+ &mut css,
2225+ effective_key,
2226+ &format!("{} solid currentColor", css_px(parts[0])),
2227+ );
2228+ }
2229+ }
2230+ }
2231+ "rounded" => {
2232+ if let Some(v) = val {
2233+ push_css(&mut css, "border-radius", &css_px(v));
2234+ }
2235+ }
2236+ "bold" => push_css(&mut css, "font-weight", "bold"),
2237+ "italic" => push_css(&mut css, "font-style", "italic"),
2238+ "underline" => push_css(&mut css, "text-decoration", "underline"),
2239+ "size" => {
2240+ if let Some(v) = val {
2241+ push_css(&mut css, "font-size", &css_px(v));
2242+ }
2243+ }
2244+ "font" => {
2245+ if let Some(v) = val {
2246+ // `font "Inter, sans-serif"` quotes a whole font stack so
2247+ // its commas don't split the attribute list.
2248+ let v = match v.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
2249+ Some(stack) if stack.contains(',') => stack,
2250+ _ => v,
2251+ };
2252+ push_css(&mut css, "font-family", v);
2253+ }
2254+ }
1861
2255
1862−/// `width` or `height`.
2256+ // Typography
1863−#[derive(Clone, Copy, PartialEq, Eq)]
2257+ "line-height" => {
1864−enum Dim {
2258+ if let Some(v) = val {
1865− Width,
2259+ push_css(&mut css, "line-height", &css_line_height(v));
1866− Height,
2260+ }
1867−}
2261+ }
2262+ "letter-spacing" => {
2263+ if let Some(v) = val {
2264+ push_css(&mut css, "letter-spacing", &css_px(v));
2265+ }
2266+ }
1868
2267
1869−/// What a `width` or `height` says.
2268+ // Overflow & positioning
1870−#[derive(Clone, Copy, PartialEq, Eq)]
2269+ "top" => {
1871−enum Size {
2270+ if let Some(v) = val {
1872− Fill,
2271+ push_css(&mut css, "top", &css_px(v));
1873− Shrink,
2272+ }
1874− /// A size of its own (`width 300`)
2273+ }
1875− Set,
2274+ "right" => {
1876−}
2275+ if let Some(v) = val {
2276+ push_css(&mut css, "right", &css_px(v));
2277+ }
2278+ }
2279+ "bottom" => {
2280+ if let Some(v) = val {
2281+ push_css(&mut css, "bottom", &css_px(v));
2282+ }
2283+ }
2284+ "left" => {
2285+ if let Some(v) = val {
2286+ push_css(&mut css, "left", &css_px(v));
2287+ }
2288+ }
1877
2289
1878−impl Size {
2290+ // Display & visibility
1879− fn of(value: &str) -> Size {
2291+ "hidden" => push_css(&mut css, "display", "none"),
1880− match value.trim() {
1881− "fill" => Size::Fill,
1882− "shrink" => Size::Shrink,
1883− _ => Size::Set,
1884− }
1885− }
1886−}
1887
2292
1888−/// The declarations `fill` and `shrink` make in a parent laid out along
2293+ // Effects
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
2294
1907−/// Every property `fill` and `shrink` of `dim` can set, with its initial
2295+ // Flow
1908−/// value (`flex` covers `flex-shrink`).
2296+ "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
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
2297
1924−/// What an element's attributes under some prefixes say about its size.
2298+ // Grid
1925−#[derive(Default)]
2299+ "grid" => {
1926−struct Sizes {
2300+ push_css(&mut css, "display", "grid");
1927− /// The last `width` and `height`
2301+ }
1928− width: Option<Size>,
2302+ "grid-cols" => {
1929− height: Option<Size>,
2303+ if let Some(v) = val {
1930− /// A `fill` or `shrink` for the width / height is among them (a later
2304+ if let Ok(n) = v.parse::<u32>() {
1931− /// size may replace it)
2305+ push_css(
1932− width_word: bool,
2306+ &mut css,
1933− height_word: bool,
2307+ "grid-template-columns",
1934− /// It writes `flex`, one of its longhands (`flex-grow`, `flex-shrink`,
2308+ &format!("repeat({},1fr)", n),
1935− /// `flex-basis`), `min-width` or `min-height` itself
2309+ );
1936− flex: bool,
2310+ } else {
1937− flex_grow: bool,
2311+ push_css(&mut css, "grid-template-columns", v);
1938− flex_shrink: bool,
2312+ }
1939− flex_basis: bool,
2313+ }
1940− min_width: bool,
2314+ }
1941− min_height: bool,
2315+ "grid-rows" => {
1942−}
2316+ if let Some(v) = val {
2317+ if let Ok(n) = v.parse::<u32>() {
2318+ push_css(
2319+ &mut css,
2320+ "grid-template-rows",
2321+ &format!("repeat({},1fr)", n),
2322+ );
2323+ } else {
2324+ push_css(&mut css, "grid-template-rows", v);
2325+ }
2326+ }
2327+ }
2328+ "col-span" => {
2329+ if let Some(v) = val {
2330+ push_css(&mut css, "grid-column", &format!("span {}", v));
2331+ }
2332+ }
2333+ "row-span" => {
2334+ if let Some(v) = val {
2335+ push_css(&mut css, "grid-row", &format!("span {}", v));
2336+ }
2337+ }
1943
2338
1944−impl Sizes {
2339+ // CSS containment for rendering performance
1945− /// Read the attributes under the conditions that hold wherever `at`
2340+ "contain" => {
1946− /// does, a later condition over an earlier one.
2341+ if let Some(v) = val {
1947− fn of(attrs: &[Attribute], at: &Condition) -> Sizes {
2342+ push_css(&mut css, "contain", v);
1948− let mut sizes = Sizes::default();
2343+ } else {
1949− let mut read: Vec<(Condition, &str, &str)> = Vec::new();
2344+ push_css(&mut css, "contain", "layout style paint");
1950− for attr in attrs.iter().filter(|a| !a.html) {
2345+ }
1951− let (condition, name) = Condition::of(&attr.key);
1952− let Some(value) = attr.value.as_deref().filter(|v| !v.trim().is_empty()) else {
1953− continue;
1954− };
1955− if condition.holds_at(at) {
1956− read.push((condition, name, value));
1957− }
1958− }
1959− read.sort_by_cached_key(|(condition, _, _)| condition.order());
1960− for (_, name, value) in read {
1961− match name {
1962− "width" => {
1963− let size = Size::of(value);
1964− sizes.width = Some(size);
1965− sizes.width_word |= size != Size::Set;
1966− }
1967− "height" => {
1968− let size = Size::of(value);
1969− sizes.height = Some(size);
1970− sizes.height_word |= size != Size::Set;
1971− }
1972− "flex" => sizes.flex = true,
1973− "flex-grow" => sizes.flex_grow = true,
1974− "flex-shrink" => sizes.flex_shrink = true,
1975− "flex-basis" => sizes.flex_basis = true,
1976− "min-width" => sizes.min_width = true,
1977− "min-height" => sizes.min_height = true,
1978− _ => {}
1979 }
2346 }
1980− }
2347+ "content-visibility" => {
1981− sizes
2348+ if let Some(v) = val {
1982− }
2349+ push_css(&mut css, "content-visibility", v);
1983−
2350+ } else {
1984− /// The element writes `property` itself: a layout word leaves it alone.
2351+ push_css(&mut css, "content-visibility", "auto");
1985− fn writes(&self, property: &str) -> bool {
2352+ }
1986− match property {
2353+ }
1987− "flex" => self.flex,
2354+
1988− "flex-grow" => self.flex || self.flex_grow,
2355+ // Outline (like border but doesn't affect layout)
1989− "flex-shrink" => self.flex || self.flex_shrink,
2356+ "outline" => {
1990− "flex-basis" => self.flex || self.flex_basis,
2357+ if let Some(v) = val {
1991− "min-width" => self.min_width,
2358+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
1992− "min-height" => self.min_height,
2359+ if parts.len() == 2 {
1993− "width" => self.width == Some(Size::Set),
2360+ push_css(
1994− "height" => self.height == Some(Size::Set),
2361+ &mut css,
1995− _ => false,
2362+ "outline",
1996− }
2363+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
1997− }
2364+ );
1998−
2365+ } else {
1999− /// Write a layout word's `property: value`, unless the element writes
2366+ push_css(
2000− /// the property itself. When it writes some of `flex`'s longhands, a
2367+ &mut css,
2001− /// `flex` is written as the others (`flex-shrink 0, width fill` still
2368+ "outline",
2002− /// grows).
2369+ &format!("{} solid currentColor", css_px(parts[0])),
2003− fn push(&self, css: &mut String, property: &str, value: &str) {
2370+ );
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 }
2371 }
2023 }
2372 }
2024− return;
2025 }
2373 }
2026− }
2027− if !self.writes(property) {
2028− push_css(css, property, value);
2029− }
2030− }
2031−}
2032
2374
2033−/// The rule that gives an element's `fill` and `shrink`, as its attributes
2375+ // Logical properties (i18n-aware)
2034−/// under the at-rules `at` set them, their meaning in a parent laid out
2376+ "padding-inline" => {
2035−/// along `axis`. Every other property a layout word could have set is put
2377+ if let Some(v) = val {
2036−/// back to its initial value, so the rule is right whichever earlier rule
2378+ push_css(&mut css, "padding-inline", &css_px_multi(v));
2037−/// it overrides. Properties the element writes itself are left alone.
2379+ }
2038−fn sizing_rule(attrs: &[Attribute], at: &[usize], axis: Axis) -> String {
2380+ }
2039− let sizes = Sizes::of(attrs, &Condition::at_only(at));
2381+ "padding-block" => {
2040− let mut sets: Vec<(&str, &str)> = Vec::new();
2382+ if let Some(v) = val {
2041− let mut resets: Vec<(&str, &str)> = Vec::new();
2383+ push_css(&mut css, "padding-block", &css_px_multi(v));
2042− for (dim, size, word) in [
2384+ }
2043− (Dim::Width, sizes.width, sizes.width_word),
2385+ }
2044− (Dim::Height, sizes.height, sizes.height_word),
2386+ "margin-inline" => {
2045− ] {
2387+ if let Some(v) = val {
2046− if !word {
2388+ push_css(&mut css, "margin-inline", &css_px_multi(v));
2047− continue;
2389+ }
2048− }
2390+ }
2049− if let Some(size) = size {
2391+ "margin-block" => {
2050− sets.extend_from_slice(sizing(dim, size, Some(axis)));
2392+ if let Some(v) = val {
2051− }
2393+ push_css(&mut css, "margin-block", &css_px_multi(v));
2052− resets.extend_from_slice(&sizing_properties(dim));
2394+ }
2053− }
2395+ }
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
2396
2069−/// The rules an element's `fill` and `shrink` need under each chain of
2397+ // Logical property start/end variants
2070−/// at-rules where the direction of `flow` (its parent's) changes: `(at,
2398+ "padding-inline-start"
2071−/// body)`.
2399+ | "padding-inline-end"
2072−fn sizing_changes(attrs: &[Attribute], flow: &Flow) -> Vec<(Vec<usize>, String)> {
2400+ | "padding-block-start"
2073− flow.changes
2401+ | "padding-block-end"
2074− .iter()
2402+ | "margin-inline-start"
2075− .map(|(at, axis)| (at.clone(), sizing_rule(attrs, at, *axis)))
2403+ | "margin-inline-end"
2076− .filter(|(_, body)| !body.is_empty())
2404+ | "margin-block-start"
2077− .collect()
2405+ | "margin-block-end" => {
2078−}
2406+ if let Some(v) = val {
2407+ push_css(&mut css, effective_key, &css_px(v));
2408+ }
2409+ }
2079
2410
2080−/// An element's generated class, with the rules keyed on its parent's
2411+ // Logical inset
2081−/// class that its `fill` and `shrink` need where the parent's direction
2412+ "inset-inline" | "inset-block" => {
2082−/// changes under a prefix (see [`Flow`]): `:where(.a)>.b`, one class of
2413+ if let Some(v) = val {
2083−/// specificity like any class rule.
2414+ push_css(&mut css, effective_key, &css_px_multi(v));
2084−fn element_class(elem: &Element, site: &Site, styles: &mut StyleCollector) -> Option<String> {
2415+ }
2085− let as_child = match site.parent.class {
2416+ }
2086− Some(_) => sizing_changes(&elem.attrs, site.parent),
2417+ "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
2087− None => Vec::new(),
2418+ if let Some(v) = val {
2088− };
2419+ push_css(&mut css, effective_key, &css_px(v));
2089− // Two elements with the same CSS but different rules keyed on them
2420+ }
2090− // need two classes
2421+ }
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
2422
2104−/// The `display` of a layout (nothing for text, native and void elements,
2423+ // Logical border
2105−/// which keep HTML's own).
2424+ "border-inline"
2106−fn layout_css(layout: Layout, inline: bool) -> &'static str {
2425+ | "border-block"
2107− match (layout, inline) {
2426+ | "border-inline-start"
2108− (Layout::Column, false) => "display:flex;flex-direction:column;",
2427+ | "border-inline-end"
2109− (Layout::Column, true) => "display:inline-flex;flex-direction:column;",
2428+ | "border-block-start"
2110− (Layout::Row, false) => "display:flex;flex-direction:row;",
2429+ | "border-block-end" => {
2111− (Layout::Row, true) => "display:inline-flex;flex-direction:row;",
2430+ if let Some(v) = val {
2112− (Layout::Grid, false) => "display:grid;",
2431+ push_css(&mut css, effective_key, v);
2113− (Layout::Grid, true) => "display:inline-grid;",
2432+ }
2114− _ => "",
2433+ }
2115− }
2116−}
2117
2434
2118−/// The declarations of the attributes under `condition` (the defaults of
2435+ // Logical border-radius
2119−/// the element's kind are [`default_css`]'s).
2436+ "border-start-start-radius"
2120−fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> String {
2437+ | "border-start-end-radius"
2121− let mut css = String::new();
2438+ | "border-end-start-radius"
2122− // The auto margins of `center-x`, `align-left`, ..., written at the end
2439+ | "border-end-end-radius" => {
2123− let mut aligned = String::new();
2440+ if let Some(v) = val {
2124− let kind = site.kind;
2441+ push_css(&mut css, effective_key, &css_px(v));
2125− // Under `children:` the styles go on the children, whose parent is this
2442+ }
2126− // element: htmlang's words that place an element in its parent are an
2443+ }
2127− // error there (the parser reports them), and left out
2128− let on_children = condition.on_children();
2129− let inside = condition.inside().is_some();
2130− // The direction `fill` and `shrink` compile against
2131− let axis = site.parent.at(&condition.at);
2132−
2133− // htmlang's words for laying out children (`spacing`, `wrap`,
2134− // `grid-cols`) mean nothing on an element that doesn't lay out its
2135− // children, which the parser reports; they are left out. Under
2136− // `children:` they go on the children, whose layout isn't known here.
2137− let lays_out_children = kind.layout().is_container() || on_children;
2138− fn under<'k>(key: &'k str, condition: &Condition) -> Option<&'k str> {
2139− let (c, name) = Condition::of(key);
2140− (c == *condition).then_some(name)
2141− }
2142−
2143− for (index, attr) in attrs.iter().enumerate() {
2144− if attr.html {
2145− continue;
2146− }
2147− let Some(effective_key) = under(&attr.key, condition) else {
2148− continue;
2149− };
2150− if on_children && crate::vocab::places_in_parent(effective_key, attr.value.as_deref()) {
2151− continue;
2152− }
2153− // Under an element prefix (`@td:`) the styles go on elements whose
2154− // layout and parent aren't known here: htmlang's own words are an
2155− // error there (the parser reports them), and left out
2156− if inside && crate::vocab::is_htmlang_word(effective_key, attr.value.as_deref()) {
2157− continue;
2158− }
2159
2444
2160− let val = attr.value.as_deref();
2445+ // Logical scroll margins & padding
2161− // A style whose value came out empty (a field a record doesn't
2446+ "scroll-margin-inline"
2162− // have, `${if()}` without its other branch) is left out
2447+ | "scroll-margin-block"
2163− if val.is_some_and(|v| v.trim().is_empty()) {
2448+ | "scroll-padding-inline"
2164− continue;
2449+ | "scroll-padding-block" => {
2165− }
2450+ if let Some(v) = val {
2451+ push_css(&mut css, effective_key, &css_px_multi(v));
2452+ }
2453+ }
2166
2454
2167− match effective_key {
2455+ // Logical sizing
2168− // htmlang's own words: they lay out children or place the
2456+ "inline-size" | "block-size" | "min-inline-size" | "max-inline-size"
2169− // element in its parent, and mean more than one CSS property
2457+ | "min-block-size" | "max-block-size" => {
2170− "spacing" if !lays_out_children => {}
2171− "spacing" => {
2172 if let Some(v) = val {
2458 if let Some(v) = val {
2173− push_css(&mut css, "gap", &with_px("gap", v));
2459+ push_css(&mut css, effective_key, &css_px(v));
2174 }
2460 }
2175 }
2461 }
2176
2462
2177− // Sizing: `fill` and `shrink` compile against the parent's
2463+ // Margin
2178− // direction. Only the last `width` (`height`) counts, as for
2464+ "margin" => {
2179− // any style written twice.
2465+ if let Some(v) = val {
2180− "width" | "height" if val.is_some_and(|v| Size::of(v) != Size::Set) => {
2466+ push_css(&mut css, "margin", &css_px_multi(v));
2181− let later = attrs[index + 1..].iter().any(|a| {
2182− !a.html
2183− && under(&a.key, condition) == Some(effective_key)
2184− && a.value.as_deref().is_some_and(|v| !v.trim().is_empty())
2185− });
2186− if later {
2187− continue;
2188 }
2467 }
2189− let dim = if effective_key == "width" {
2468+ }
2190− Dim::Width
2469+ "margin-x" => {
2191− } else {
2470+ if let Some(v) = val {
2192− Dim::Height
2471+ push_css(&mut css, "margin-inline", &css_px(v));
2193− };
2472+ }
2194− let size = Size::of(val.unwrap_or_default());
2473+ }
2195− // What the element writes itself, which a layout word
2474+ "margin-y" => {
2196− // leaves alone
2475+ if let Some(v) = val {
2197− let sizes = Sizes::of(attrs, condition);
2476+ push_css(&mut css, "margin-block", &css_px(v));
2198− for &(property, value) in sizing(dim, size, axis) {
2477+ }
2199− sizes.push(&mut css, property, value);
2478+ }
2200− }
2479+
2201− }
2480+ // Container queries
2202−
2481+ "container" => {
2203− // A size of its own under a prefix, where a `fill` or
2482+ push_css(&mut css, "container-type", "inline-size");
2204− // `shrink` that sets `flex` (along a flex parent's direction)
2483+ }
2205− // holds too: the word's `flex` and minimum are put back, so the
2484+
2206− // size is the one the element gets (`width fill, lg:width 200`
2485+ // Inset (shorthand for top/right/bottom/left)
2207− // in a row)
2486+ "inset" => {
2208− "width" | "height"
2487+ if let Some(v) = val {
2209− if *condition != Condition::default()
2488+ push_css(&mut css, "inset", &css_px(v));
2210− && attrs.iter().any(|a| {
2211− let (c, name) = Condition::of(&a.key);
2212− let along = match site.parent.at(&c.at) {
2213− Some(Axis::Row) => name == "width",
2214− Some(Axis::Column) => name == "height",
2215− None => false,
2216− };
2217− !a.html
2218− && name == effective_key
2219− && along
2220− && c != *condition
2221− && c.holds_at(condition)
2222− && a.value
2223− .as_deref()
2224− .is_some_and(|v| !v.trim().is_empty() && Size::of(v) != Size::Set)
2225− }) =>
2226− {
2227− let dim = if effective_key == "width" {
2228− Dim::Width
2229− } else {
2230− Dim::Height
2231− };
2232− let sizes = Sizes::of(attrs, condition);
2233− for (property, value) in sizing_properties(dim) {
2234− if property != effective_key {
2235− sizes.push(&mut css, property, value);
2236− }
2237 }
2489 }
2490+ }
2491+
2492+ // Table
2493+ "border-spacing" => {
2238 if let Some(v) = val {
2494 if let Some(v) = val {
2239− push_css(&mut css, effective_key, &with_px(effective_key, v));
2495+ push_css(&mut css, "border-spacing", &css_px(v));
2240 }
2496 }
2241 }
2497 }
2242
2498
2243− // Alignment: auto margins, which work along either direction.
2499+ // Text decoration
2244− // They are written last, so a `margin` on the same element
2500+ "text-decoration-thickness" => {
2245− // keeps its other sides (`center-x, margin 20`)
2501+ if let Some(v) = val {
2246− "center-x" => {
2502+ push_css(&mut css, "text-decoration-thickness", &css_px(v));
2247− push_css(&mut aligned, "margin-left", "auto");
2503+ }
2248− push_css(&mut aligned, "margin-right", "auto");
2249 }
2504 }
2250− "center-y" => {
2505+ "text-underline-offset" => {
2251− push_css(&mut aligned, "margin-top", "auto");
2506+ if let Some(v) = val {
2252− push_css(&mut aligned, "margin-bottom", "auto");
2507+ push_css(&mut css, "text-underline-offset", &css_px(v));
2508+ }
2253 }
2509 }
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
2510
2259− "wrap" if lays_out_children => push_css(&mut css, "flex-wrap", "wrap"),
2511+ // Multi-column
2260− "wrap" => {}
2512+ "column-width" => {
2513+ if let Some(v) = val {
2514+ push_css(&mut css, "column-width", &css_px(v));
2515+ }
2516+ }
2261
2517
2262− // Grid
2518+ // New CSS properties
2263− "grid-cols" | "grid-rows" if !lays_out_children => {}
2519+ "column-gap" => {
2264− "grid-cols" | "grid-rows" => {
2265 if let Some(v) = val {
2520 if let Some(v) = val {
2266− let property = if effective_key == "grid-cols" {
2521+ push_css(&mut css, "column-gap", &css_px(v));
2267− "grid-template-columns"
2522+ }
2268− } else {
2523+ }
2269− "grid-template-rows"
2524+ "text-indent" => {
2270− };
2525+ if let Some(v) = val {
2271− match v.parse::<u32>() {
2526+ push_css(&mut css, "text-indent", &css_px(v));
2272− Ok(n) => push_css(&mut css, property, &format!("repeat({},1fr)", n)),
2527+ }
2273− Err(_) => push_css(&mut css, property, &with_px(property, v)),
2528+ }
2274− }
2529+ "flex-basis" => {
2530+ if let Some(v) = val {
2531+ push_css(&mut css, "flex-basis", &css_px(v));
2532+ }
2533+ }
2534+ "scroll-margin" => {
2535+ if let Some(v) = val {
2536+ push_css(&mut css, "scroll-margin", &css_px(v));
2537+ }
2538+ }
2539+ "scroll-margin-top"
2540+ | "scroll-margin-bottom"
2541+ | "scroll-margin-left"
2542+ | "scroll-margin-right" => {
2543+ if let Some(v) = val {
2544+ push_css(&mut css, effective_key, &css_px(v));
2545+ }
2546+ }
2547+ "scroll-padding" => {
2548+ if let Some(v) = val {
2549+ push_css(&mut css, "scroll-padding", &css_px(v));
2275 }
2550 }
2276 }
2551 }
2277− "col-span" | "row-span" => {
2552+ "scroll-padding-top"
2553+ | "scroll-padding-bottom"
2554+ | "scroll-padding-left"
2555+ | "scroll-padding-right" => {
2278 if let Some(v) = val {
2556 if let Some(v) = val {
2279− let property = if effective_key == "col-span" {
2557+ push_css(&mut css, effective_key, &css_px(v));
2280− "grid-column"
2558+ }
2559+ }
2560+
2561+ "content" => {
2562+ if let Some(v) = val {
2563+ // Wrap in quotes if not already quoted and not a CSS keyword
2564+ if v.starts_with('"')
2565+ || v.starts_with('\'')
2566+ || v == "none"
2567+ || v == "normal"
2568+ || v.starts_with("attr(")
2569+ || v.starts_with("counter(")
2570+ {
2571+ push_css(&mut css, "content", v);
2281 } else {
2572 } else {
2282− "grid-row"
2573+ push_css(&mut css, "content", &format!("\"{}\"", v));
2283− };
2574+ }
2284− push_css(&mut css, property, &format!("span {}", v));
2285 }
2575 }
2286 }
2576 }
2287
2577
2288− // CSS's `line-clamp` has no support yet without its
2578+ // --- CSS Shorthands ---
2289− // `-webkit-` fallback, which needs a box to clamp
2290 "line-clamp" => {
2579 "line-clamp" => {
2291 if let Some(v) = val {
2580 if let Some(v) = val {
2292 push_css(&mut css, "display", "-webkit-box");
2581 push_css(&mut css, "display", "-webkit-box");
@@ -2296,14 +2585,15 @@ fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> Stri
2296 }
2585 }
2297 }
2586 }
2298
2587
2299− // Every other name is a CSS property (standard, custom,
2588+ // Any other standard CSS property is copied through, with `px`
2300− // vendor-prefixed, or one htmlang doesn't know, which the
2589+ // added to bare numbers where the property takes a length.
2301− // parser warned about), written under its own name with the
2590+ key if crate::vocab::is_css_property(key) => {
2302− // value as written, except for `px` after a bare number where
2303− // the property takes a length
2304− key if crate::vocab::is_property_name(key) => {
2305 if let Some(v) = val {
2591 if let Some(v) = val {
2306− push_css(&mut css, key, &with_px(key, v));
2592+ if crate::vocab::is_length_property(key) {
2593+ push_css(&mut css, key, &css_px_multi(v));
2594+ } else {
2595+ push_css(&mut css, key, v);
2596+ }
2307 }
2597 }
2308 }
2598 }
2309
2599
@@ -2311,7 +2601,6 @@ fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> Stri
2311 }
2601 }
2312 }
2602 }
2313
2603
2314− css.push_str(&aligned);
2315 css
2604 css
2316}
2605}
2317
2606
@@ -2322,6 +2611,258 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
2322 css.push(';');
2611 css.push(';');
2323}
2612}
2324
2613
2614+/// Known CSS units — if a value ends with one, skip appending `px`.
2615+/// Format a length value: a bare number gets `px` appended; anything else
2616+/// (values with units, keywords like `auto`, functions like `calc(...)`) is
2617+/// passed through unchanged.
2618+fn css_px(value: &str) -> String {
2619+ let v = value.trim();
2620+ if v == "0" {
2621+ return "0".to_string();
2622+ }
2623+ let is_bare_number = !v.is_empty()
2624+ && v.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
2625+ && v.parse::<f64>().is_ok();
2626+ if is_bare_number {
2627+ format!("{}px", v)
2628+ } else {
2629+ v.to_string()
2630+ }
2631+}
2632+
2633+/// Rewrite literal values that match a declared CSS custom property (from
2634+/// `@theme` or `@let --name value`) to `var(--name)` references. Matches are
2635+/// anchored to CSS value boundaries so e.g. `#3b82f6` inside a longer hex or
2636+/// inside an identifier is not replaced. The `:root` block is emitted before
2637+/// this call runs, so its declarations are not affected.
2638+fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
2639+ if css.is_empty() || vars.is_empty() {
2640+ return css.to_string();
2641+ }
2642+ // Prefer longer values first so that if two vars share a prefix, the
2643+ // longer (more specific) match wins.
2644+ let mut pairs: Vec<(&str, String)> = vars
2645+ .iter()
2646+ .filter(|(name, value)| name.starts_with("--") && !value.is_empty())
2647+ .map(|(name, value)| (value.as_str(), format!("var({})", name)))
2648+ .collect();
2649+ pairs.sort_by_key(|(v, _)| std::cmp::Reverse(v.len()));
2650+ if pairs.is_empty() {
2651+ return css.to_string();
2652+ }
2653+
2654+ let is_boundary_before = |b: Option<u8>| match b {
2655+ None => true,
2656+ Some(c) => matches!(c, b':' | b' ' | b',' | b'(' | b';' | b'{' | b'\n' | b'\t'),
2657+ };
2658+ let is_boundary_after = |b: Option<u8>| match b {
2659+ None => true,
2660+ Some(c) => matches!(c, b';' | b'}' | b',' | b' ' | b')' | b'\n' | b'\t'),
2661+ };
2662+
2663+ let bytes = css.as_bytes();
2664+ let mut out = String::with_capacity(css.len());
2665+ let mut i = 0;
2666+ let mut prev: Option<u8> = None;
2667+ // Only declaration values are rewritten — never selectors or at-rule
2668+ // preludes such as `@media (min-width:768px)`. `blocks` records, for
2669+ // each open `{`, whether it holds declarations (a style rule) or nested
2670+ // rules (an at-rule like `@media` / `@layer`).
2671+ let mut blocks: Vec<bool> = Vec::new();
2672+ let mut prelude_start = 0;
2673+ let mut in_value = false;
2674+ while i < bytes.len() {
2675+ match bytes[i] {
2676+ b'{' => {
2677+ let prelude = css[prelude_start..i].trim_start();
2678+ blocks.push(!prelude.starts_with('@'));
2679+ in_value = false;
2680+ prelude_start = i + 1;
2681+ }
2682+ b'}' => {
2683+ blocks.pop();
2684+ in_value = false;
2685+ prelude_start = i + 1;
2686+ }
2687+ b';' => {
2688+ in_value = false;
2689+ prelude_start = i + 1;
2690+ }
2691+ b':' if blocks.last() == Some(&true) => in_value = true,
2692+ _ => {}
2693+ }
2694+ if in_value && is_boundary_before(prev) {
2695+ let mut matched = false;
2696+ for (val, repl) in &pairs {
2697+ let vb = val.as_bytes();
2698+ if i + vb.len() <= bytes.len() && &bytes[i..i + vb.len()] == vb {
2699+ let next = bytes.get(i + vb.len()).copied();
2700+ if is_boundary_after(next) {
2701+ out.push_str(repl);
2702+ prev = repl.as_bytes().last().copied();
2703+ i += vb.len();
2704+ matched = true;
2705+ break;
2706+ }
2707+ }
2708+ }
2709+ if matched {
2710+ continue;
2711+ }
2712+ }
2713+ // Advance by one UTF-8 code point.
2714+ let start = i;
2715+ i += 1;
2716+ while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
2717+ i += 1;
2718+ }
2719+ out.push_str(&css[start..i]);
2720+ prev = bytes.get(i - 1).copied();
2721+ }
2722+ out
2723+}
2724+
2725+/// Scan generated CSS for literal property values that repeat often enough
2726+/// that substituting them with an auto-named custom property would reduce
2727+/// total byte count. Returns the rewritten CSS and any new vars that should
2728+/// be appended to `:root`.
2729+///
2730+/// The algorithm only promotes a value if it strictly saves bytes after
2731+/// accounting for the `--xN:value;` declaration overhead plus the
2732+/// `var(--xN)` reference cost at each call site. Values that are already
2733+/// `var(...)` references or contain `var(` calls are skipped so we never
2734+/// nest references. Names avoid collisions with `existing_vars`.
2735+fn auto_extract_repeats(
2736+ css: &str,
2737+ existing_vars: &[(String, String)],
2738+) -> (String, Vec<(String, String)>) {
2739+ if css.is_empty() {
2740+ return (css.to_string(), Vec::new());
2741+ }
2742+ // Count occurrences of each distinct property value (the text between
2743+ // `:` and the next `;` or `}` at the top level of a declaration block,
2744+ // ignoring balanced parentheses so that e.g. `rgba(...)` stays intact).
2745+ let bytes = css.as_bytes();
2746+ let mut counts: HashMap<String, usize> = HashMap::new();
2747+ let mut depth: i32 = 0;
2748+ let mut i = 0;
2749+ while i < bytes.len() {
2750+ match bytes[i] {
2751+ b'{' => {
2752+ depth += 1;
2753+ i += 1;
2754+ continue;
2755+ }
2756+ b'}' => {
2757+ if depth > 0 {
2758+ depth -= 1;
2759+ }
2760+ i += 1;
2761+ continue;
2762+ }
2763+ _ => {}
2764+ }
2765+ if depth > 0 && bytes[i] == b':' {
2766+ // Skip past `:` then walk to the terminator.
2767+ let start = i + 1;
2768+ let mut end = start;
2769+ let mut pdepth = 0i32;
2770+ while end < bytes.len() {
2771+ match bytes[end] {
2772+ b'(' => pdepth += 1,
2773+ b')' if pdepth > 0 => pdepth -= 1,
2774+ b';' | b'}' if pdepth == 0 => break,
2775+ b'{' if pdepth == 0 => break,
2776+ _ => {}
2777+ }
2778+ end += 1;
2779+ }
2780+ let val = css[start..end].trim();
2781+ if !val.is_empty() && !val.starts_with("var(") && !val.contains("var(") {
2782+ *counts.entry(val.to_string()).or_default() += 1;
2783+ }
2784+ i = end;
2785+ continue;
2786+ }
2787+ i += 1;
2788+ }
2789+
2790+ // Decide which values are worth extracting. A value of length L appearing
2791+ // N times costs N*L bytes inline; promoting it costs (L + 6) for the
2792+ // `--hK:V;` declaration plus N * ref_len for the call sites. We estimate
2793+ // ref_len optimistically as `var(--h0)` (9 bytes) and fall back to 10 for
2794+ // two-digit indices, which only affects very large extraction counts.
2795+ let existing: std::collections::HashSet<&str> =
2796+ existing_vars.iter().map(|(n, _)| n.as_str()).collect();
2797+ let mut candidates: Vec<(String, usize)> =
2798+ counts.into_iter().filter(|(_, n)| *n >= 2).collect();
2799+ // Stable, deterministic ordering — longest values first, tiebreak by text.
2800+ candidates.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
2801+
2802+ let mut extracted: Vec<(String, String)> = Vec::new();
2803+ let mut next_idx: usize = 0;
2804+ for (value, n) in candidates {
2805+ let l = value.len();
2806+ // Allocate the next unused `--hN` name.
2807+ let (name, ref_len) = loop {
2808+ let candidate = format!("--h{}", next_idx);
2809+ next_idx += 1;
2810+ let rl = candidate.len() + 6; // `var(` + name + `)`
2811+ if !existing.contains(candidate.as_str())
2812+ && !extracted.iter().any(|(n, _)| *n == candidate)
2813+ {
2814+ break (candidate, rl);
2815+ }
2816+ };
2817+ // Skip if promotion would not strictly reduce byte count.
2818+ // Before: n * l bytes. After: (l + name.len() + 2) for the :root
2819+ // declaration (`<name>:<value>;`) plus n * ref_len for the sites.
2820+ let decl_overhead = l + name.len() + 2;
2821+ let before = n * l;
2822+ let after = decl_overhead + n * ref_len;
2823+ if after < before {
2824+ extracted.push((name, value));
2825+ } else {
2826+ // Rewind the index so the next value can reuse this slot.
2827+ next_idx -= 1;
2828+ }
2829+ }
2830+
2831+ if extracted.is_empty() {
2832+ return (css.to_string(), Vec::new());
2833+ }
2834+ let new_css = substitute_css_vars(css, &extracted);
2835+ (new_css, extracted)
2836+}
2837+
2838+/// Format a `line-height` value. CSS accepts either a unitless multiplier
2839+/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
2840+/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which
2841+/// CSS interprets as 24× font-size — almost never what anyone wants). Treat
2842+/// integers ≥ 2 as pixel lengths; anything with a decimal, an existing unit,
2843+/// or the value `0`/`1` passes through unchanged.
2844+fn css_line_height(value: &str) -> String {
2845+ let v = value.trim();
2846+ if v == "0" || v == "1" {
2847+ return v.to_string();
2848+ }
2849+ // Plain integer — treat as pixel length. Decimals, units, keywords and
2850+ // functions pass through.
2851+ if !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()) {
2852+ return format!("{}px", v);
2853+ }
2854+ v.to_string()
2855+}
2856+
2857+/// Format multiple space-separated values, each getting px if needed.
2858+fn css_px_multi(value: &str) -> String {
2859+ value
2860+ .split_whitespace()
2861+ .map(css_px)
2862+ .collect::<Vec<_>>()
2863+ .join(" ")
2864+}
2865+
2325fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2866fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2326 let mut id = None;
2867 let mut id = None;
2327 let mut class = None;
2868 let mut class = None;
@@ -2335,6 +2876,153 @@ fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2335 (id, class)
2876 (id, class)
2336}
2877}
2337
2878
2879+/// Read image dimensions from a local file by parsing the header bytes.
2880+/// Supports PNG, JPEG, GIF, WebP, AVIF, and SVG.
2881+fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
2882+ // SVG: parse as text for viewBox/width/height attributes
2883+ if path.ends_with(".svg") || path.ends_with(".SVG") {
2884+ let text = std::fs::read_to_string(path).ok()?;
2885+ return read_svg_dimensions(&text);
2886+ }
2887+
2888+ let data = std::fs::read(path).ok()?;
2889+ if data.len() < 12 {
2890+ return None;
2891+ }
2892+
2893+ // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
2894+ if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") {
2895+ let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
2896+ let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
2897+ return Some((w, h));
2898+ }
2899+
2900+ // GIF: "GIF87a" or "GIF89a", width/height at bytes 6-9 (little-endian)
2901+ if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
2902+ let w = u16::from_le_bytes([data[6], data[7]]) as u32;
2903+ let h = u16::from_le_bytes([data[8], data[9]]) as u32;
2904+ return Some((w, h));
2905+ }
2906+
2907+ // JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2)
2908+ if data.starts_with(b"\xff\xd8") {
2909+ let mut i = 2;
2910+ while i + 9 < data.len() {
2911+ if data[i] != 0xFF {
2912+ i += 1;
2913+ continue;
2914+ }
2915+ let marker = data[i + 1];
2916+ if marker == 0xC0 || marker == 0xC2 {
2917+ let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
2918+ let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
2919+ return Some((w, h));
2920+ }
2921+ if i + 3 < data.len() {
2922+ let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2923+ i += 2 + len;
2924+ } else {
2925+ break;
2926+ }
2927+ }
2928+ }
2929+
2930+ // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
2931+ if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
2932+ if &data[12..16] == b"VP8 " && data.len() >= 30 {
2933+ let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
2934+ let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
2935+ return Some((w, h));
2936+ }
2937+ if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
2938+ let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
2939+ let w = (bits & 0x3FFF) + 1;
2940+ let h = ((bits >> 14) & 0x3FFF) + 1;
2941+ return Some((w, h));
2942+ }
2943+ }
2944+
2945+ // AVIF: ISOBMFF container with "ftyp" box containing "avif"/"avis" brand,
2946+ // then "ispe" box with width/height
2947+ if data.len() >= 12 && &data[4..8] == b"ftyp" {
2948+ let brand = &data[8..12];
2949+ if brand == b"avif" || brand == b"avis" || brand == b"mif1" {
2950+ return read_avif_dimensions(&data);
2951+ }
2952+ }
2953+
2954+ None
2955+}
2956+
2957+/// Parse SVG viewBox or width/height attributes to get dimensions.
2958+fn read_svg_dimensions(text: &str) -> Option<(u32, u32)> {
2959+ // Try viewBox first: viewBox="minX minY width height"
2960+ if let Some(vb_start) = text.find("viewBox=\"") {
2961+ let rest = &text[vb_start + 9..];
2962+ if let Some(end) = rest.find('"') {
2963+ let parts: Vec<&str> = rest[..end].split_whitespace().collect();
2964+ if parts.len() == 4
2965+ && let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
2966+ && w > 0.0
2967+ && h > 0.0
2968+ {
2969+ return Some((w.round() as u32, h.round() as u32));
2970+ }
2971+ }
2972+ }
2973+ // Fall back to width/height attributes on <svg>
2974+ let svg_tag = text.find("<svg")?;
2975+ let tag_end = text[svg_tag..].find('>')? + svg_tag;
2976+ let tag = &text[svg_tag..tag_end];
2977+ let w = extract_svg_attr(tag, "width")?;
2978+ let h = extract_svg_attr(tag, "height")?;
2979+ Some((w, h))
2980+}
2981+
2982+fn extract_svg_attr(tag: &str, attr: &str) -> Option<u32> {
2983+ let needle = format!("{}=\"", attr);
2984+ let start = tag.find(&needle)? + needle.len();
2985+ let rest = &tag[start..];
2986+ let end = rest.find('"')?;
2987+ let val = rest[..end].trim_end_matches("px");
2988+ val.parse::<f64>().ok().map(|v| v.round() as u32)
2989+}
2990+
2991+/// Parse AVIF (ISOBMFF) container to find ispe box with image dimensions.
2992+fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
2993+ // Walk ISOBMFF boxes looking for "ispe" (image spatial extents)
2994+ let mut i = 0;
2995+ while i + 8 <= data.len() {
2996+ let box_size =
2997+ u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
2998+ let box_type = &data[i + 4..i + 8];
2999+ if box_size < 8 {
3000+ break;
3001+ }
3002+ let box_end = (i + box_size).min(data.len());
3003+ // ispe box: 4 bytes version/flags + 4 bytes width + 4 bytes height
3004+ if box_type == b"ispe" && box_end >= i + 20 {
3005+ let w = u32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
3006+ let h = u32::from_be_bytes([data[i + 16], data[i + 17], data[i + 18], data[i + 19]]);
3007+ return Some((w, h));
3008+ }
3009+ // Recurse into container boxes (meta, iprp, ipco)
3010+ if matches!(
3011+ box_type,
3012+ b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia"
3013+ ) {
3014+ let header_size = if box_type == b"meta" { 12 } else { 8 };
3015+ if i + header_size < box_end
3016+ && let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end])
3017+ {
3018+ return Some(dims);
3019+ }
3020+ }
3021+ i = box_end;
3022+ }
3023+ None
3024+}
3025+
2338fn html_escape(s: &str) -> String {
3026fn html_escape(s: &str) -> String {
2339 s.replace('&', "&amp;")
3027 s.replace('&', "&amp;")
2340 .replace('<', "&lt;")
3028 .replace('<', "&lt;")
@@ -2450,30 +3138,43 @@ fn vlq_encode(value: i64, out: &mut String) {
2450 }
3138 }
2451}
3139}
2452
3140
2453−#[cfg(test)]
3141+
2454−mod tests {
3142+/// Does the tree contain the table element `name` (e.g. `"main"`)?
2455− use super::*;
3143+fn has_tag(nodes: &[Node], name: &str) -> bool {
2456−
3144+ nodes.iter().any(|node| match node {
2457− #[test]
3145+ Node::Element(elem) => elem.kind.is_tag(name) || has_tag(&elem.children, name),
2458− fn a_name_is_a_stable_hash_of_the_style() {
3146+ _ => false,
2459− // The same in every build and on every platform
3147+ })
2460− assert_eq!(stable_hash(""), 0xf52a_15e9_a9b5_e89b);
3148+}
2461− assert_eq!(hash_digits(35, 3), "z00");
3149+
2462− assert_eq!(hash_digits(u64::MAX, 13), "fsgs46211e5w3");
3150+/// Collect unique external domains from generated HTML for DNS prefetch hints.
2463− }
3151+fn collect_dns_prefetch(html: &str, dev: bool) -> String {
2464−
3152+ let mut domains = Vec::new();
2465− #[test]
3153+ let mut rest = html;
2466− fn a_name_taken_in_the_file_takes_more_digits() {
3154+ while let Some(pos) = rest.find("https://") {
2467− // These two styles' hashes end in the same 7 digits
3155+ let start = pos + 8; // skip "https://"
2468− let mut styles = StyleCollector::new();
3156+ rest = &rest[start..];
2469− let first = styles.get_class("width:129555px;".into(), Vec::new(), String::new());
3157+ let end = rest
2470− let second = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
3158+ .find(['/', '"', '\'', ' ', '>', ')'])
2471− let (first, second) = (first.unwrap(), second.unwrap());
3159+ .unwrap_or(rest.len());
2472− assert_eq!(first.len(), 3 + CLASS_DIGITS, "{first}");
3160+ let domain = &rest[..end];
2473− assert_eq!(second.len(), 3 + CLASS_DIGITS + 1, "{second}");
3161+ if !domain.is_empty() && domain.contains('.') && !domains.contains(&domain.to_string()) {
2474− assert!(second.starts_with(&first), "{first} {second}");
3162+ domains.push(domain.to_string());
2475− // The same style gets the name it got before
3163+ }
2476− let again = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
2477− assert_eq!(again.as_deref(), Some(second.as_str()));
2478 }
3164 }
3165+ let mut out = String::new();
3166+ for domain in &domains {
3167+ if dev {
3168+ out.push_str(&format!(
3169+ "<link rel=\"dns-prefetch\" href=\"//{}\">\n",
3170+ domain
3171+ ));
3172+ } else {
3173+ out.push_str(&format!(
3174+ "<link rel=\"dns-prefetch\" href=\"//{}\">",
3175+ domain
3176+ ));
3177+ }
3178+ }
3179+ out
2479}
3180}
crates/htmlang-core/src/codegen_tests.rs+14−33
@@ -1,8 +1,6 @@
1#[cfg(test)]
1#[cfg(test)]
2mod tests {
2mod tests {
3− use crate::codegen::{
3+ use crate::codegen::{generate, generate_dev, generate_partial, json_str, short_class_name};
4− CodegenOptions, generate, generate_dev, generate_partial, generate_with_classes,
5− };
6 use crate::parser::parse;
4 use crate::parser::parse;
7
5
8 fn compile(src: &str) -> String {
6 fn compile(src: &str) -> String {
@@ -49,42 +47,19 @@ mod tests {
49
47
50 #[test]
48 #[test]
51 fn dev_mode_is_deterministic_across_runs() {
49 fn dev_mode_is_deterministic_across_runs() {
52− let src =
50+ let src = "@row [spacing 10]\n @text [bold] a\n @text [italic] b\n";
53− "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
54 let r1 = parse(src);
51 let r1 = parse(src);
55 let r2 = parse(src);
52 let r2 = parse(src);
56 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
53 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
57 }
54 }
58
55
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−
68 #[test]
56 #[test]
69− fn a_style_has_one_name_in_every_file() {
57+ fn short_class_name_is_stable() {
70− // A fragment's classes mean in a page what they mean in the
58+ assert_eq!(short_class_name(0), "a");
71− // fragment, whatever else either holds
59+ assert_eq!(short_class_name(25), "z");
72− let page = classes("@el [padding 10, background red] a\n@el [gap 4] b\n");
60+ // Higher indexes must produce unique, stable names.
73− let fragment = classes("@row [margin 2] x\n@el [padding 10, background red] y\n");
61+ let names: std::collections::HashSet<_> = (0..50_000).map(short_class_name).collect();
74− assert_eq!(page[0], fragment[1]);
62+ assert_eq!(names.len(), 50_000, "short_class_name collisions");
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− }
88 }
63 }
89
64
90 #[test]
65 #[test]
@@ -118,4 +93,10 @@ mod tests {
118 "duplicate CSS rule emitted ({count} times) in:\n{out}"
93 "duplicate CSS rule emitted ({count} times) in:\n{out}"
119 );
94 );
120 }
95 }
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+ }
121}
102}
crates/htmlang-core/src/diagnostic.rsdeleted−245
@@ -1,245 +0,0 @@
1−//! Diagnostics: what the compiler reports about a source file.
2−//!
3−//! Every diagnostic has a stable `code` (see [`code`]), so tools such as the
4−//! language server key their quick fixes on the code rather than on the
5−//! wording of the message. `subject` and `suggestion` carry the name the
6−//! diagnostic is about and the name to use instead, when there is one.
7−
8−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9−pub enum Severity {
10− Error,
11− Warning,
12− Info,
13− Help,
14−}
15−
16−#[derive(Debug, Clone)]
17−pub struct Diagnostic {
18− /// Stable identifier of the kind of problem, e.g. `unknown-element`.
19− pub code: &'static str,
20− pub line: usize,
21− pub column: Option<usize>,
22− pub message: String,
23− pub severity: Severity,
24− pub source_line: Option<Box<str>>,
25− /// The name the diagnostic is about, as written (`@nosuch` without the
26− /// `@`, an attribute key, a definition's name).
27− pub subject: Option<Box<str>>,
28− /// The name to write instead of `subject` ("did you mean ...?").
29− pub suggestion: Option<Box<str>>,
30−}
31−
32−impl Diagnostic {
33− pub fn new(code: &'static str, severity: Severity, line: usize, message: String) -> Self {
34− Diagnostic {
35− code,
36− line,
37− column: None,
38− message,
39− severity,
40− source_line: None,
41− subject: None,
42− suggestion: None,
43− }
44− }
45−
46− pub fn error(code: &'static str, line: usize, message: String) -> Self {
47− Self::new(code, Severity::Error, line, message)
48− }
49−
50− pub fn warning(code: &'static str, line: usize, message: String) -> Self {
51− Self::new(code, Severity::Warning, line, message)
52− }
53−
54− pub fn column(mut self, column: usize) -> Self {
55− self.column = Some(column);
56− self
57− }
58−
59− pub fn source(mut self, source: impl Into<String>) -> Self {
60− self.source_line = Some(source.into().into_boxed_str());
61− self
62− }
63−
64− pub fn subject(mut self, subject: impl Into<String>) -> Self {
65− self.subject = Some(subject.into().into_boxed_str());
66− self
67− }
68−
69− pub fn suggest(mut self, suggestion: Option<impl Into<String>>) -> Self {
70− self.suggestion = suggestion.map(|s| s.into().into_boxed_str());
71− self
72− }
73−}
74−
75−/// The diagnostic codes. A code names a kind of problem and never changes,
76−/// even when a message is reworded.
77−pub mod code {
78− // Syntax: found while reading the file, before anything runs
79− pub const UNCLOSED_BRACKET: &str = "unclosed-bracket";
80− /// A `]` that ends an attribute list inside what reads as a `'...'`
81− /// string in a value (`hx-on:click=alert(']')`).
82− pub const UNBALANCED_QUOTE: &str = "unbalanced-quote";
83− pub const UNEXPECTED_BODY: &str = "unexpected-body";
84− pub const UNEXPECTED_ARGUMENT: &str = "unexpected-argument";
85− pub const MISSING_ARGUMENT: &str = "missing-argument";
86− pub const INVALID_LOOP: &str = "invalid-loop";
87− /// A `@let` whose name, parameters or body don't fit its kind.
88− pub const INVALID_DEFINITION: &str = "invalid-definition";
89− pub const STRAY_ELSE: &str = "stray-else";
90− /// Indentation with tabs and spaces mixed, which nests lines in ways
91− /// the eye doesn't see.
92− pub const MIXED_INDENTATION: &str = "mixed-indentation";
93−
94− // Names
95− pub const UNKNOWN_ELEMENT: &str = "unknown-element";
96− /// An attribute name htmlang doesn't know: a warning when CSS could
97− /// have the name (it is written to the CSS as it is), an error otherwise.
98− pub const UNKNOWN_ATTRIBUTE: &str = "unknown-attribute";
99− /// A misspelled prefix (`hovr:color red`).
100− pub const UNKNOWN_PREFIX: &str = "unknown-prefix";
101− /// A prefix the attribute can't take: a second one (`md:hover:`), or
102− /// one on an HTML attribute (`hover:required`).
103− pub const INVALID_PREFIX: &str = "invalid-prefix";
104− /// A style written without its value (`[padding]`).
105− pub const MISSING_VALUE: &str = "missing-value";
106− pub const HTML_ATTRIBUTE_FORM: &str = "html-attribute-form";
107− /// A second `@page`: a page has one root, so one `@page`.
108− pub const DUPLICATE_PAGE: &str = "duplicate-page";
109− pub const UNDEFINED_VARIABLE: &str = "undefined-variable";
110− /// An attribute, or an attribute's name, made from a variable.
111− pub const ATTRIBUTE_FROM_VARIABLE: &str = "attribute-from-variable";
112− pub const DUPLICATE_ATTRIBUTE: &str = "duplicate-attribute";
113−
114− // Values
115− /// A value that can't be written into the page's CSS (an error), or
116− /// one that can't mean what it says (a warning).
117− pub const INVALID_VALUE: &str = "invalid-value";
118− pub const INVALID_COLOR: &str = "invalid-color";
119− pub const INVALID_EXPRESSION: &str = "invalid-expression";
120− pub const INVALID_JSON: &str = "invalid-json";
121− /// A name htmlang doesn't know right after a `key=value`: the rest of
122− /// the value, which a comma ended (`hx-trigger=load, every 2s`).
123− pub const SPLIT_VALUE: &str = "split-value";
124− /// A leading argument taken as a URL that reads as text (`@link
125− /// [hx-get=/x] Open panel`, whose href is `Open`).
126− pub const TEXT_AS_URL: &str = "text-as-url";
127−
128− // Definitions and functions
129− pub const UNUSED_VARIABLE: &str = "unused-variable";
130− pub const UNUSED_BUNDLE: &str = "unused-bundle";
131− pub const UNUSED_FUNCTION: &str = "unused-function";
132− pub const RECURSIVE_CALL: &str = "recursive-call";
133− pub const NO_SINGLE_ROOT: &str = "no-single-root";
134− /// A call that leaves out a parameter without a default.
135− pub const MISSING_PARAMETER: &str = "missing-parameter";
136− /// A parameter passed `name=value`: parameters are written `name value`.
137− pub const PARAMETER_FORM: &str = "parameter-form";
138− /// A function named like a built-in element (which it replaces) or a
139− /// directive (which it can't replace).
140− pub const SHADOWS_BUILT_IN: &str = "shadows-built-in";
141− /// A `@slot NAME` block at a call whose function has no such slot.
142− pub const UNKNOWN_SLOT: &str = "unknown-slot";
143− /// Content passed to a function whose body has no `@children`.
144− pub const UNEXPECTED_CONTENT: &str = "unexpected-content";
145− /// A `@slot` or `@children` where it marks or fills nothing: outside
146− /// a function's body, inside an element at a call, or inline in text.
147− pub const MISPLACED_SLOT: &str = "misplaced-slot";
148− /// A slot name that isn't one name (`@slot my footer`).
149− pub const INVALID_SLOT_NAME: &str = "invalid-slot-name";
150−
151− // Files
152− pub const UNREADABLE_FILE: &str = "unreadable-file";
153− pub const CIRCULAR_INCLUDE: &str = "circular-include";
154− /// Two files of one build whose different styles got one class name:
155− /// a name is a hash of its style (`htmlang build` checks).
156− pub const CLASS_COLLISION: &str = "class-collision";
157− pub const UNSET_ENVIRONMENT: &str = "unset-environment";
158−
159− // Context: an attribute or element where it has no effect
160− /// An attribute the element ignores (a warning), or a layout word such
161− /// as `spacing` on an element that doesn't lay out its children (an
162− /// error: it is left out).
163− pub const NO_EFFECT: &str = "no-effect";
164− /// An element whose HTML ends a `<p>`, written inside a `@paragraph`.
165− pub const BLOCK_IN_PARAGRAPH: &str = "block-in-paragraph";
166−
167− // Accessibility
168− pub const MISSING_ALT: &str = "missing-alt";
169− pub const MISSING_INPUT_TYPE: &str = "missing-input-type";
170− pub const MISSING_LINK_TEXT: &str = "missing-link-text";
171− pub const MISSING_LABEL: &str = "missing-label";
172− pub const MISSING_TITLE: &str = "missing-title";
173− pub const MISSING_BUTTON_TEXT: &str = "missing-button-text";
174− pub const MISSING_CAPTIONS: &str = "missing-captions";
175− pub const LOW_CONTRAST: &str = "low-contrast";
176− pub const POSITIVE_TABINDEX: &str = "positive-tabindex";
177−
178− // `htmlang lint`
179− pub const DEEP_NESTING: &str = "deep-nesting";
180− pub const EMPTY_CONTAINER: &str = "empty-container";
181− pub const MISSING_BUTTON_TYPE: &str = "missing-button-type";
182−
183− /// A bug in the compiler itself.
184− pub const INTERNAL: &str = "internal";
185−
186− /// Every code, for tools and tests.
187− pub const ALL: &[&str] = &[
188− UNCLOSED_BRACKET,
189− UNBALANCED_QUOTE,
190− UNEXPECTED_BODY,
191− UNEXPECTED_ARGUMENT,
192− MISSING_ARGUMENT,
193− INVALID_LOOP,
194− INVALID_DEFINITION,
195− STRAY_ELSE,
196− MIXED_INDENTATION,
197− UNKNOWN_ELEMENT,
198− UNKNOWN_ATTRIBUTE,
199− UNKNOWN_PREFIX,
200− INVALID_PREFIX,
201− MISSING_VALUE,
202− HTML_ATTRIBUTE_FORM,
203− DUPLICATE_PAGE,
204− UNDEFINED_VARIABLE,
205− ATTRIBUTE_FROM_VARIABLE,
206− DUPLICATE_ATTRIBUTE,
207− INVALID_VALUE,
208− INVALID_COLOR,
209− INVALID_EXPRESSION,
210− INVALID_JSON,
211− SPLIT_VALUE,
212− TEXT_AS_URL,
213− UNUSED_VARIABLE,
214− UNUSED_BUNDLE,
215− UNUSED_FUNCTION,
216− RECURSIVE_CALL,
217− NO_SINGLE_ROOT,
218− MISSING_PARAMETER,
219− PARAMETER_FORM,
220− SHADOWS_BUILT_IN,
221− UNKNOWN_SLOT,
222− UNEXPECTED_CONTENT,
223− MISPLACED_SLOT,
224− INVALID_SLOT_NAME,
225− UNREADABLE_FILE,
226− CIRCULAR_INCLUDE,
227− CLASS_COLLISION,
228− UNSET_ENVIRONMENT,
229− NO_EFFECT,
230− BLOCK_IN_PARAGRAPH,
231− MISSING_ALT,
232− MISSING_INPUT_TYPE,
233− MISSING_LINK_TEXT,
234− MISSING_LABEL,
235− MISSING_TITLE,
236− MISSING_BUTTON_TEXT,
237− MISSING_CAPTIONS,
238− LOW_CONTRAST,
239− POSITIVE_TABINDEX,
240− DEEP_NESTING,
241− EMPTY_CONTAINER,
242− MISSING_BUTTON_TYPE,
243− INTERNAL,
244− ];
245−}
crates/htmlang-core/src/expr.rs+182−532
@@ -1,113 +1,82 @@
1//! Expressions: conditions (`@if $count > 2 and not $hidden`), computed
1//! Expressions: conditions (`@if $count > 2 and not $hidden`), computed
2−//! values (`@let gap = $base * 2`) and `${...}` in text and values
2+//! values (`@let gap = $base * 2`) and `if(cond, a, b)`.
3−//! (`${if($featured, 24, 0)}`).
4//!
3//!
5//! Expressions are tokenized and evaluated as a whole. Variables are looked
4//! 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
5//! 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.
6//! happens to contain `==` or spaces can't change how an expression parses.
8//!
7//!
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
8//! ```text
15//! expr := or
9//! expr := or
16//! or := and ("or" and)*
10//! or := and ("or" and)*
17//! and := not ("and" not)*
11//! and := not ("and" not)*
18//! not := "not" not | compare
12//! not := "not" not | compare
19−//! compare := range (("==" | "!=" | "<" | ">" | "<=" | ">=") range)?
13+//! compare := concat (("==" | "!=" | "<" | ">" | "<=" | ">=" | "contains"
20−//! range := sum (".." sum ("step" sum)?)?
14+//! | "starts-with" | "ends-with") concat)?
15+//! concat := sum ("~" sum)*
21//! sum := product (("+" | "-") product)*
16//! sum := product (("+" | "-") product)*
22//! product := unary (("*" | "/" | "%") unary)*
17//! product := unary (("*" | "/" | "%") unary)*
23//! unary := "-" unary | primary
18//! unary := "-" unary | primary
24//! primary := NUMBER | "STRING" | $VAR | WORD | true | false
19//! primary := NUMBER | "STRING" | $VAR | WORD | true | false
25−//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
20+//! | if(expr, expr, expr) | "(" expr ")"
26//! ```
21//! ```
27//!
22//!
28−//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is text. Values are
23+//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string.
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
24
45use std::fmt;
25use std::fmt;
46
26
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)]
27#[derive(Debug, Clone, PartialEq)]
53−pub enum Error {
28+pub enum Value {
54− /// `$name` has no definition; `offset` is where the `$` is.
29+ Num(f64),
55− Undefined { name: String, offset: usize },
30+ Str(String),
56− /// Anything else: a syntax error, a type error, division by zero.
31+ Bool(bool),
57− Invalid(String),
58}
32}
59
33
60−impl Error {
34+impl Value {
61− /// This error as a problem of a text in which the expression starts
35+ /// Empty strings, `false`, `0` and `"false"` / `"0"` are false.
62− /// at `base`.
36+ pub fn truthy(&self) -> bool {
63− pub(crate) fn at(self, base: usize) -> Problem {
64 match self {
37 match self {
65− Error::Undefined { name, offset } => Problem::Undefined {
38+ Value::Bool(b) => *b,
66− name,
39+ Value::Num(n) => *n != 0.0,
67− offset: base + offset,
40+ Value::Str(s) => !s.is_empty() && s != "false" && s != "0",
68− },
69− Error::Invalid(message) => Problem::Invalid {
70− message,
71− offset: base,
72− },
73 }
41 }
74 }
42 }
75−}
76
43
77−impl From<String> for Error {
44+ fn as_num(&self) -> Option<f64> {
78− fn from(message: String) -> Self {
45+ match self {
79− Error::Invalid(message)
46+ Value::Num(n) => Some(*n),
47+ Value::Str(s) => s.trim().parse().ok(),
48+ Value::Bool(_) => None,
49+ }
80 }
50 }
81}
51}
82
52
83−impl fmt::Display for Error {
53+impl fmt::Display for Value {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
55 match self {
86− Error::Undefined { name, .. } => write!(f, "undefined variable '${}'", name),
56+ Value::Num(n) if n.fract() == 0.0 && n.abs() < 1e15 => write!(f, "{}", *n as i64),
87− Error::Invalid(message) => f.write_str(message),
57+ Value::Num(n) => write!(f, "{}", n),
58+ Value::Str(s) => f.write_str(s),
59+ Value::Bool(b) => write!(f, "{}", b),
88 }
60 }
89 }
61 }
90}
62}
91
63
92−/// Evaluate `src` with the variables of `scope`. An undefined variable is
64+/// Resolves a variable reference: the text after `$`, including any
93−/// an error; a field that a record doesn't have is empty.
65+/// filters (`name|uppercase`). Returns `None` for undefined variables.
94−pub fn eval(src: &str, scope: &dyn Scope) -> Result<Value, Error> {
66+pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<String>;
95− let mut tokens = Vec::new();
67+
96− tokenize(src, scope, 0, &mut tokens)?;
68+/// Evaluate `src`. Undefined variables are empty strings.
69+pub fn eval(src: &str, resolve: Resolver) -> Result<Value, String> {
70+ let tokens = tokenize(src)?;
97 let mut parser = Parser {
71 let mut parser = Parser {
98 tokens: &tokens,
72 tokens: &tokens,
99 pos: 0,
73 pos: 0,
100− scope,
74+ resolve,
101− skip: false,
102 };
75 };
103 let value = parser.expr()?;
76 let value = parser.expr()?;
104 match parser.tokens.get(parser.pos) {
77 match parser.tokens.get(parser.pos) {
105 None => Ok(value),
78 None => Ok(value),
106− Some((tok, _)) => Err(Error::Invalid(format!(
79+ Some(tok) => Err(format!("unexpected {} in `{}`", tok, src.trim())),
107− "unexpected {} in `{}`",
108− tok,
109− src.trim()
110− ))),
111 }
80 }
112}
81}
113
82
@@ -138,116 +107,85 @@ impl fmt::Display for Token {
138 }
107 }
139}
108}
140
109
141−const OPERATORS: &[&str] = &[
110+const OPERATORS: &[&str] = &["==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%", "~"];
142− "..", "==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%",
111+
143−];
112+fn tokenize(src: &str) -> Result<Vec<Token>, String> {
144−
113+ let chars: Vec<char> = src.chars().collect();
145−/// Read `src` into tokens, each with its byte offset (plus `base`).
114+ let mut tokens = Vec::new();
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;
115 let mut i = 0;
156− while let Some(c) = src[i..].chars().next() {
116+ let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
157− let at = base + i;
117+ while i < chars.len() {
118+ let c = chars[i];
158 if c.is_whitespace() {
119 if c.is_whitespace() {
159− i += c.len_utf8();
120+ i += 1;
160 } else if c == '(' {
121 } else if c == '(' {
161− tokens.push((Token::LParen, at));
122+ tokens.push(Token::LParen);
162 i += 1;
123 i += 1;
163 } else if c == ')' {
124 } else if c == ')' {
164− tokens.push((Token::RParen, at));
125+ tokens.push(Token::RParen);
165 i += 1;
126 i += 1;
166 } else if c == ',' {
127 } else if c == ',' {
167− tokens.push((Token::Comma, at));
128+ tokens.push(Token::Comma);
168 i += 1;
129 i += 1;
169 } else if c == '"' {
130 } else if c == '"' {
170 let start = i + 1;
131 let start = i + 1;
171− let end = src[start..]
132+ let end = chars[start..]
172− .find('"')
133+ .iter()
134+ .position(|&c| c == '"')
173 .map(|p| start + p)
135 .map(|p| start + p)
174 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
136 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
175− tokens.push((Token::Str(src[start..end].to_string()), at));
137+ tokens.push(Token::Str(chars[start..end].iter().collect()));
176 i = end + 1;
138 i = end + 1;
177 } else if c == '$' {
139 } else if c == '$' {
178− match interp::reference(&src[i + 1..], scope) {
140+ // $name, with optional |filter:args chains
179− Some((Reference::Var(path), len)) => {
141+ let start = i + 1;
180− tokens.push((Token::Var(path), at));
142+ let mut end = start;
181− i += 1 + len;
143+ while end < chars.len()
182− }
144+ && !chars[end].is_whitespace()
183− Some((Reference::Expr(inner), len)) => {
145+ && !matches!(chars[end], '(' | ')' | ',' | '"')
184− tokens.push((Token::LParen, at));
146+ {
185− tokenize(inner, scope, at + 2, tokens)?;
147+ end += 1;
186− tokens.push((Token::RParen, at + len));
148+ }
187− i += 1 + len;
149+ if end == start {
188− }
150+ return Err(format!("`$` without a variable name in `{}`", src.trim()));
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 }
151 }
152+ tokens.push(Token::Var(chars[start..end].iter().collect()));
153+ i = end;
197 } else if let Some(op) = OPERATORS
154 } else if let Some(op) = OPERATORS
198 .iter()
155 .iter()
199− .find(|op| src[i..].starts_with(**op))
156+ .find(|op| chars[i..].iter().take(op.len()).copied().eq(op.chars()))
200 // A `-` followed by a letter starts a word (`-webkit-box`);
157 // A `-` followed by a letter starts a word (`-webkit-box`);
201 // inside words (`ease-in`) it is consumed by the word itself.
158 // inside words (`ease-in`) it is consumed by the word itself.
202− .filter(|op| **op != "-" || !src[i + 1..].starts_with(char::is_alphabetic))
159+ .filter(|op| **op != "-" || !chars.get(i + 1).is_some_and(|c| c.is_alphabetic()))
203 {
160 {
204− tokens.push((Token::Op(op), at));
161+ tokens.push(Token::Op(op));
205 i += op.len();
162 i += op.len();
206 } else if is_word_char(c) {
163 } else if is_word_char(c) {
207 let start = i;
164 let start = i;
208− // A word ends at `..`, so `1..5` is a range
165+ while i < chars.len() && is_word_char(chars[i]) {
209− while let Some(c) = src[i..]
166+ i += 1;
210− .chars()
211− .next()
212− .filter(|&c| is_word_char(c) && !src[i..].starts_with(".."))
213− {
214− i += c.len_utf8();
215 }
167 }
216− let word = &src[start..i];
168+ let word: String = chars[start..i].iter().collect();
217− tokens.push((
169+ tokens.push(match word.parse::<f64>() {
218− match word.parse::<f64>() {
170+ Ok(n) if !word.ends_with('.') => Token::Num(n),
219− Ok(n) if !word.ends_with('.') => Token::Num(n),
171+ _ => Token::Word(word),
220− _ => Token::Word(word.to_string()),
172+ });
221− },
222− at,
223− ));
224 } else {
173 } else {
225− return Err(Error::Invalid(format!(
174+ return Err(format!("unexpected `{}` in `{}`", c, src.trim()));
226− "unexpected `{}` in `{}`",
227− c,
228− src.trim()
229− )));
230 }
175 }
231 }
176 }
232− Ok(())
177+ Ok(tokens)
233}
178}
234
179
235struct Parser<'a> {
180struct Parser<'a> {
236− tokens: &'a [(Token, usize)],
181+ tokens: &'a [Token],
237 pos: usize,
182 pos: usize,
238− scope: &'a dyn Scope,
183+ resolve: Resolver<'a>,
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}
184}
244
185
245−/// The value a skipped part stands for; nothing reads it.
246−const SKIPPED: Value = Value::Bool(false);
247−
248impl Parser<'_> {
186impl Parser<'_> {
249 fn peek(&self) -> Option<&Token> {
187 fn peek(&self) -> Option<&Token> {
250− self.tokens.get(self.pos).map(|(token, _)| token)
188+ self.tokens.get(self.pos)
251 }
189 }
252
190
253 fn eat_word(&mut self, word: &str) -> bool {
191 fn eat_word(&mut self, word: &str) -> bool {
@@ -270,183 +208,137 @@ impl Parser<'_> {
270 }
208 }
271 }
209 }
272
210
273− fn expect(&mut self, token: Token) -> Result<(), Error> {
211+ fn expect(&mut self, token: Token) -> Result<(), String> {
274 match self.peek() {
212 match self.peek() {
275 Some(t) if *t == token => {
213 Some(t) if *t == token => {
276 self.pos += 1;
214 self.pos += 1;
277 Ok(())
215 Ok(())
278 }
216 }
279− Some(t) => Err(format!("expected {} but found {}", token, t).into()),
217+ Some(t) => Err(format!("expected {} but found {}", token, t)),
280− None => Err(format!("expected {} at the end", token).into()),
218+ None => Err(format!("expected {} at the end", token)),
281 }
219 }
282 }
220 }
283
221
284− /// Parse with `f`, skipping evaluation when `skip` (or when already
222+ fn expr(&mut self) -> Result<Value, String> {
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()?;
223 let mut left = self.and()?;
300 while self.eat_word("or") {
224 while self.eat_word("or") {
301− let decided = left.truthy();
225+ let right = self.and()?;
302− let right = self.skipping(decided, Self::and)?;
226+ left = Value::Bool(left.truthy() || right.truthy());
303− left = Value::Bool(decided || right.truthy());
304 }
227 }
305 Ok(left)
228 Ok(left)
306 }
229 }
307
230
308− fn and(&mut self) -> Result<Value, Error> {
231+ fn and(&mut self) -> Result<Value, String> {
309 let mut left = self.not()?;
232 let mut left = self.not()?;
310 while self.eat_word("and") {
233 while self.eat_word("and") {
311− let decided = !left.truthy();
234+ let right = self.not()?;
312− let right = self.skipping(decided, Self::not)?;
235+ left = Value::Bool(left.truthy() && right.truthy());
313− left = Value::Bool(!decided && right.truthy());
314 }
236 }
315 Ok(left)
237 Ok(left)
316 }
238 }
317
239
318− fn not(&mut self) -> Result<Value, Error> {
240+ fn not(&mut self) -> Result<Value, String> {
319 if self.eat_word("not") {
241 if self.eat_word("not") {
320 return Ok(Value::Bool(!self.not()?.truthy()));
242 return Ok(Value::Bool(!self.not()?.truthy()));
321 }
243 }
322 self.compare()
244 self.compare()
323 }
245 }
324
246
325− fn compare(&mut self) -> Result<Value, Error> {
247+ fn compare(&mut self) -> Result<Value, String> {
326− let left = self.range()?;
248+ let left = self.concat()?;
327− let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
249+ let op = if let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) {
250+ op
251+ } else if self.eat_word("contains") {
252+ "contains"
253+ } else if self.eat_word("starts-with") {
254+ "starts-with"
255+ } else if self.eat_word("ends-with") {
256+ "ends-with"
257+ } else {
328 return Ok(left);
258 return Ok(left);
329 };
259 };
330− let right = self.range()?;
260+ let right = self.concat()?;
331− if self.skip {
261+ let numbers = left.as_num().zip(right.as_num());
332− return Ok(SKIPPED);
262+ let (l, r) = (left.to_string(), right.to_string());
333− }
263+ Ok(Value::Bool(match (op, numbers) {
334− Ok(Value::Bool(left.compare(op, &right)?))
264+ ("==", Some((a, b))) => a == b,
335− }
265+ ("!=", Some((a, b))) => a != b,
336−
266+ ("<", Some((a, b))) => a < b,
337− /// `A..B` or `A..B step N`: the whole numbers from A to B.
267+ (">", Some((a, b))) => a > b,
338− fn range(&mut self) -> Result<Value, Error> {
268+ ("<=", Some((a, b))) => a <= b,
339− let start = self.sum()?;
269+ (">=", Some((a, b))) => a >= b,
340− if self.eat_op(&[".."]).is_none() {
270+ ("==", None) => l == r,
341− return Ok(start);
271+ ("!=", None) => l != r,
272+ ("<", None) => l < r,
273+ (">", None) => l > r,
274+ ("<=", None) => l <= r,
275+ (">=", None) => l >= r,
276+ ("contains", _) => l.contains(&r),
277+ ("starts-with", _) => l.starts_with(&r),
278+ ("ends-with", _) => l.ends_with(&r),
279+ _ => unreachable!(),
280+ }))
281+ }
282+
283+ fn concat(&mut self) -> Result<Value, String> {
284+ let mut left = self.sum()?;
285+ while self.eat_op(&["~"]).is_some() {
286+ let right = self.sum()?;
287+ left = Value::Str(format!("{}{}", left, right));
342 }
288 }
343− let end = self.sum()?;
289+ Ok(left)
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 }
290 }
366
291
367− fn sum(&mut self) -> Result<Value, Error> {
292+ fn sum(&mut self) -> Result<Value, String> {
368 let mut left = self.product()?;
293 let mut left = self.product()?;
369 while let Some(op) = self.eat_op(&["+", "-"]) {
294 while let Some(op) = self.eat_op(&["+", "-"]) {
370 let right = self.product()?;
295 let right = self.product()?;
371− left = self.arithmetic(op, &left, &right)?;
296+ left = arithmetic(op, &left, &right)?;
372 }
297 }
373 Ok(left)
298 Ok(left)
374 }
299 }
375
300
376− fn product(&mut self) -> Result<Value, Error> {
301+ fn product(&mut self) -> Result<Value, String> {
377 let mut left = self.unary()?;
302 let mut left = self.unary()?;
378 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
303 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
379 let right = self.unary()?;
304 let right = self.unary()?;
380− left = self.arithmetic(op, &left, &right)?;
305+ left = arithmetic(op, &left, &right)?;
381 }
306 }
382 Ok(left)
307 Ok(left)
383 }
308 }
384
309
385− fn unary(&mut self) -> Result<Value, Error> {
310+ fn unary(&mut self) -> Result<Value, String> {
386 if self.eat_op(&["-"]).is_some() {
311 if self.eat_op(&["-"]).is_some() {
387 let value = self.unary()?;
312 let value = self.unary()?;
388− if self.skip {
389− return Ok(SKIPPED);
390− }
391 return value
313 return value
392 .as_num()
314 .as_num()
393 .map(|n| Value::Num(-n))
315 .map(|n| Value::Num(-n))
394− .ok_or_else(|| Error::Invalid(format!("cannot negate `{}`", value)));
316+ .ok_or_else(|| format!("cannot negate `{}`", value));
395 }
317 }
396 self.primary()
318 self.primary()
397 }
319 }
398
320
399− fn primary(&mut self) -> Result<Value, Error> {
321+ fn primary(&mut self) -> Result<Value, String> {
400− let (token, at) = self
322+ let token = self
401− .tokens
323+ .peek()
402− .get(self.pos)
403 .cloned()
324 .cloned()
404 .ok_or_else(|| "expected a value at the end".to_string())?;
325 .ok_or_else(|| "expected a value at the end".to_string())?;
405 self.pos += 1;
326 self.pos += 1;
406 match token {
327 match token {
407 Token::Num(n) => Ok(Value::Num(n)),
328 Token::Num(n) => Ok(Value::Num(n)),
408− Token::Str(_) | Token::Var(_) if self.skip => Ok(SKIPPED),
329+ Token::Str(s) => Ok(Value::Str(interpolate(&s, self.resolve))),
409− Token::Str(s) => {
330+ Token::Var(name) => Ok(Value::Str((self.resolve)(&name).unwrap_or_default())),
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)),
331 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
429 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
332 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
430− Token::Word(name) if name == "if" && self.peek() == Some(&Token::LParen) => {
333+ Token::Word(w) if w == "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;
334 self.pos += 1;
436− let mut args = Vec::new();
335+ let condition = self.expr()?;
437− if self.peek() != Some(&Token::RParen) {
336+ self.expect(Token::Comma)?;
438− args.push(self.expr()?);
337+ let then = self.expr()?;
439− while self.peek() == Some(&Token::Comma) {
338+ self.expect(Token::Comma)?;
440− self.pos += 1;
339+ let otherwise = self.expr()?;
441− args.push(self.expr()?);
442− }
443− }
444 self.expect(Token::RParen)?;
340 self.expect(Token::RParen)?;
445− check_call(&name, args.len())?;
341+ Ok(if condition.truthy() { then } else { otherwise })
446− if self.skip {
447− return Ok(SKIPPED);
448− }
449− Ok(call(&name, args)?)
450 }
342 }
451 Token::Word(w) => Ok(Value::Str(w)),
343 Token::Word(w) => Ok(Value::Str(w)),
452 Token::LParen => {
344 Token::LParen => {
@@ -454,43 +346,8 @@ impl Parser<'_> {
454 self.expect(Token::RParen)?;
346 self.expect(Token::RParen)?;
455 Ok(value)
347 Ok(value)
456 }
348 }
457− other => Err(format!("unexpected {}", other).into()),
349+ other => Err(format!("unexpected {}", other)),
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 }
350 }
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 }
351 }
495}
352}
496
353
@@ -510,137 +367,42 @@ fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
510 }))
367 }))
511}
368}
512
369
513−/// The built-in functions (other than `if`) and how many arguments each
370+/// Replace `$name` references inside a string literal.
514−/// takes.
371+fn interpolate(s: &str, resolve: Resolver) -> String {
515−const FUNCTIONS: &[(&str, usize)] = &[
372+ let mut out = String::with_capacity(s.len());
516− ("contains", 2),
373+ let mut rest = s;
517− ("starts-with", 2),
374+ while let Some(pos) = rest.find('$') {
518− ("ends-with", 2),
375+ out.push_str(&rest[..pos]);
519− ("uppercase", 1),
376+ let after = &rest[pos + 1..];
520− ("lowercase", 1),
377+ let end = after
521− ("capitalize", 1),
378+ .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '|' | ':')))
522− ("trim", 1),
379+ .unwrap_or(after.len());
523− ("length", 1),
380+ if end == 0 {
524− ("reverse", 1),
381+ out.push('$');
525− ("truncate", 2),
382+ } else {
526− ("replace", 3),
383+ out.push_str(&resolve(&after[..end]).unwrap_or_default());
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 }
384 }
620− _ => return Err(format!("unknown function `{}()`", name)),
385+ rest = &after[end..];
621− })
386+ }
387+ out.push_str(rest);
388+ out
622}
389}
623
390
624#[cfg(test)]
391#[cfg(test)]
625mod tests {
392mod tests {
626 use super::*;
393 use super::*;
627
394
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 {
395 fn ev(src: &str) -> Value {
643− eval(src, &scope()).unwrap()
396+ let resolve = |name: &str| match name {
397+ "count" => Some("3".to_string()),
398+ "name" => Some("World".to_string()),
399+ "theme" => Some("dark".to_string()),
400+ "tricky" => Some("a == b".to_string()),
401+ "empty" => Some(String::new()),
402+ "name|uppercase" => Some("WORLD".to_string()),
403+ _ => None,
404+ };
405+ eval(src, &resolve).unwrap()
644 }
406 }
645
407
646 #[test]
408 #[test]
@@ -658,9 +420,8 @@ mod tests {
658 assert!(ev("$theme == dark").truthy());
420 assert!(ev("$theme == dark").truthy());
659 assert!(ev("$theme != light and not $empty").truthy());
421 assert!(ev("$theme != light and not $empty").truthy());
660 assert!(!ev("$count < 2 or $empty").truthy());
422 assert!(!ev("$count < 2 or $empty").truthy());
661− assert!(ev("contains($name, or)").truthy());
423+ assert!(ev("$name contains or").truthy());
662− assert!(ev("starts-with($name, Wo) and ends-with($name, ld)").truthy());
424+ assert!(ev("$name|uppercase == WORLD").truthy());
663− assert!(ev("uppercase($name) == WORLD").truthy());
664 assert!(ev("#3b82f6 != red").truthy());
425 assert!(ev("#3b82f6 != red").truthy());
665 }
426 }
666
427
@@ -673,129 +434,18 @@ mod tests {
673
434
674 #[test]
435 #[test]
675 fn strings_and_if() {
436 fn strings_and_if() {
437+ assert_eq!(ev("\"Hello \" ~ $name").to_string(), "Hello World");
676 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
438 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
677 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
439 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
678− assert_eq!(ev("${name}").to_string(), "World");
440+ assert_eq!(ev("$missing").to_string(), "");
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 }
441 }
780
442
781 #[test]
443 #[test]
782 fn errors_are_reported() {
444 fn errors_are_reported() {
783− let none = Map::new(&[]);
445+ let none = |_: &str| None;
784 assert!(eval("dark * 2", &none).is_err());
446 assert!(eval("dark * 2", &none).is_err());
785 assert!(eval("1 +", &none).is_err());
447 assert!(eval("1 +", &none).is_err());
786 assert!(eval("(1", &none).is_err());
448 assert!(eval("(1", &none).is_err());
787 assert!(eval("1 / 0", &none).is_err());
449 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 }
450 }
801}
451}
crates/htmlang-core/src/interp.rsdeleted−651
@@ -1,651 +0,0 @@
1−//! Variables in htmlang strings: `$name`, `$record.field` and `${...}`.
2−//!
3−//! The syntax tree finds every slot first: a run of text, an attribute's
4−//! value, an element's argument, a `@let` value, a file path, the title of
5−//! `@page`, the value of `@meta`. Each slot is then interpolated here, once.
6−//! A reference fills its own slot, and what it inserts is never read again
7−//! as htmlang: a value can't become an attribute name, a whole attribute, a
8−//! comma between attributes, markup or another `$name`.
9−//!
10−//! - `$` followed by a letter or `_` starts a name, which goes on with
11−//! letters, digits, `_` and `-` (a `-` only when a name character follows
12−//! it).
13−//! - `$--name` is an error: a custom property is read one way, with
14−//! `var(--name)`, in the browser, so it follows a token redefined under
15−//! `dark:`. See [`custom_property_len`].
16−//! - `.field` continues the name only when the value is a record or a list
17−//! (`$post.title`, `$tags.0`). After `@let lang fr`, `$lang.json` is
18−//! `fr.json`, and `$a..$b` is two names.
19−//! - `${name}` is the same name, delimited (`${size}px`), and `${EXPR}`
20−//! inserts the value of an expression (see `expr.rs`).
21−//! - A `$` before anything else is text: `$5`, `$$`, a `$` at the end.
22−//! - An undefined name is an error. A field that a record doesn't have is
23−//! empty, and so are its fields (`$post.author.name` when `$post` has no
24−//! `author`), so optional fields of `@data` records work.
25−//! - Quoted text (`@let arrow "→ "`, `@card [quote "→ "]`) remembers its
26−//! quotes: a CSS value gets them (`content $arrow` is `content:"→ "`),
27−//! text and HTML attribute values don't. See [`Sink`].
28−
29−use crate::expr;
30−use crate::value::Value;
31−
32−/// The variables in scope, as the evaluator stores them.
33−pub trait Scope {
34− /// The value of a variable, by its name (without fields).
35− fn get(&self, name: &str) -> Option<Value>;
36−}
37−
38−/// Where a slot's value goes, which decides what quoted text inserts.
39−#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40−pub enum Sink {
41− /// Text, an HTML attribute value, a path, a parameter: quoted text
42− /// inserts what it says, without the quotes.
43− Text,
44− /// A CSS value: quoted text inserts itself with its quotes, or, inside a
45− /// quoted string of the value, what it says with `"` escaped as `\"`.
46− Css,
47−}
48−
49−/// Whether a CSS value is inside a quoted string after `text`, when it
50−/// was (`inside`) before it. A backslash inside a string escapes the next
51−/// character.
52−fn css_string_after(text: &str, mut inside: bool) -> bool {
53− let mut chars = text.chars();
54− while let Some(c) = chars.next() {
55− match c {
56− '\\' if inside => {
57− chars.next();
58− }
59− '"' => inside = !inside,
60− _ => {}
61− }
62− }
63− inside
64−}
65−
66−/// `text` as it goes inside a CSS string: `"` escaped as `\"`. A backslash
67−/// and the character after it are a CSS escape and stay as written
68−/// (`\201C`), but a backslash at the end is doubled, so it can't escape
69−/// the rest of the string (`a\` gives `a\\`).
70−pub fn css_string_body(text: &str) -> String {
71− let mut out = String::with_capacity(text.len());
72− let mut chars = text.chars();
73− while let Some(c) = chars.next() {
74− match c {
75− '\\' => {
76− out.push('\\');
77− out.push(chars.next().unwrap_or('\\'));
78− }
79− '"' => out.push_str("\\\""),
80− _ => out.push(c),
81− }
82− }
83− out
84−}
85−
86−/// A problem found while interpolating, at a byte offset into the text.
87−#[derive(Debug, Clone, PartialEq)]
88−pub enum Problem {
89− /// A name with no definition.
90− Undefined { name: String, offset: usize },
91− /// `$--name`: a custom property, which is read with `var(--name)`.
92− CustomProperty { name: String, offset: usize },
93− /// A `${...}` whose expression doesn't evaluate.
94− Invalid { message: String, offset: usize },
95− /// A record put where text goes, which it has none of: it would print
96− /// nothing.
97− Record { message: String, offset: usize },
98−}
99−
100−/// The problem with putting `value`, written `written`, where text goes:
101−/// a record has no text.
102−fn no_text(written: &str, value: &Value, offset: usize) -> Option<Problem> {
103− let name = written.strip_prefix('$').map(|name| {
104− name.strip_prefix('{')
105− .and_then(|n| n.strip_suffix('}'))
106− .map_or(name, str::trim)
107− });
108− let fields = match value {
109− Value::Record(fields) => fields,
110− // A list of records, which would print only what isn't a record
111− Value::List(list)
112− if list.written.is_none()
113− && list
114− .items
115− .iter()
116− .any(|item| matches!(item, Value::Record(_))) =>
117− {
118− let example = match (name, list.items.first()) {
119− (Some(name), Some(Value::Record(fields))) if is_path(name) => {
120− match fields.first() {
121− Some((field, _)) => format!(", such as `${}.0.{}`", name, field),
122− None => String::new(),
123− }
124− }
125− _ => String::new(),
126− };
127− return Some(Problem::Record {
128− message: format!(
129− "'{}' is a list of records, which has no text of its own: loop over it \
130− with @each, or write a field of one of its items{}",
131− written, example
132− ),
133− offset,
134− });
135− }
136− _ => return None,
137− };
138− let example = match (name, fields.first()) {
139− (Some(name), Some((field, _))) if is_path(name) => {
140− format!(", such as `${}.{}`", name, field)
141− }
142− _ => String::new(),
143− };
144− Some(Problem::Record {
145− message: format!(
146− "'{}' is a record, which has no text of its own: write one of its fields{}",
147− written, example
148− ),
149− offset,
150− })
151−}
152−
153−fn is_name_char(c: char) -> bool {
154− c.is_alphanumeric() || c == '_'
155−}
156−
157−/// The length of a word at the start of `s` whose first character passes
158−/// `first`: name characters, with `-` between them.
159−fn word_len(s: &str, first: impl Fn(char) -> bool) -> usize {
160− let Some(c) = s.chars().next().filter(|&c| first(c)) else {
161− return 0;
162− };
163− let mut end = c.len_utf8();
164− let mut i = end;
165− while let Some(c) = s[i..].chars().next() {
166− if is_name_char(c) {
167− i += c.len_utf8();
168− end = i;
169− } else if c == '-' {
170− let rest = &s[i..];
171− let dashes = rest.len() - rest.trim_start_matches('-').len();
172− if !rest[dashes..].starts_with(is_name_char) {
173− break;
174− }
175− i += dashes;
176− } else {
177− break;
178− }
179− }
180− end
181−}
182−
183−/// The length of the variable name at the start of `s` (the text after a
184−/// `$`), by syntax alone, or 0 when `s` doesn't start one.
185−pub fn name_len(s: &str) -> usize {
186− word_len(s, |c| c.is_alphabetic() || c == '_')
187−}
188−
189−/// The length of the custom property's name `--name` at the start of `s`
190−/// (the text after a `$`), or 0. `$--name` is not a variable and not text
191−/// either: it is an error that says to write `var(--name)`.
192−pub fn custom_property_len(s: &str) -> usize {
193− match s.strip_prefix("--") {
194− Some(rest) => match word_len(rest, |c| c.is_alphanumeric() || c == '_') {
195− 0 => 0,
196− n => n + 2,
197− },
198− None => 0,
199− }
200−}
201−
202−/// What is wrong with `$NAME`, where `name` is a custom property's name
203−/// (`--brand`).
204−pub fn custom_property_message(name: &str) -> String {
205− format!(
206− "`${}` is not a variable: a custom property is read with `var({})`",
207− name, name
208− )
209−}
210−
211−/// Whether `s` is a whole name path, `name` or `name.field.field`.
212−fn is_path(s: &str) -> bool {
213− let n = name_len(s);
214− if n == 0 || matches!(&s[..n], "true" | "false" | "not" | "and" | "or") {
215− return false;
216− }
217− let mut rest = &s[n..];
218− while let Some(field) = rest.strip_prefix('.') {
219− let f = word_len(field, is_name_char);
220− if f == 0 {
221− return false;
222− }
223− rest = &field[f..];
224− }
225− rest.is_empty()
226−}
227−
228−/// What a name path leads to so far: a value, or a field that a record
229−/// (or an item that a list) doesn't have, which is empty, and so is every
230−/// field of it.
231−#[derive(Clone)]
232−enum Step {
233− Value(Value),
234− Missing,
235−}
236−
237−impl Step {
238− /// The field `name` of this step, or `None` when it has no fields
239− /// (text, a number, true or false).
240− fn field(&self, name: &str) -> Option<Step> {
241− let Step::Value(value) = self else {
242− return Some(Step::Missing);
243− };
244− match value {
245− Value::Record(fields) => Some(
246− fields
247− .iter()
248− .find(|(key, _)| key == name)
249− .map_or(Step::Missing, |(_, value)| Step::Value(value.clone())),
250− ),
251− Value::List(list) => Some(
252− name.parse::<usize>()
253− .ok()
254− .and_then(|i| list.items.get(i).cloned())
255− .map_or(Step::Missing, Step::Value),
256− ),
257− _ => None,
258− }
259− }
260−
261− fn value(self) -> Value {
262− match self {
263− Step::Value(value) => value,
264− Step::Missing => Value::empty(),
265− }
266− }
267−}
268−
269−/// The name path at the start of `s` (after a `$`) and its length: the
270−/// name, and each `.field` while the value so far is a record or a list,
271−/// or a field one of them doesn't have.
272−fn path_at(s: &str, scope: &dyn Scope) -> Option<(String, usize)> {
273− let n = name_len(s);
274− if n == 0 {
275− return None;
276− }
277− let mut path = s[..n].to_string();
278− let mut len = n;
279− let mut step = scope.get(&path).map(Step::Value);
280− while let Some(rest) = s[len..].strip_prefix('.') {
281− let f = word_len(rest, is_name_char);
282− let Some(field) = step
283− .as_ref()
284− .filter(|_| f > 0)
285− .and_then(|v| v.field(&rest[..f]))
286− else {
287− break;
288− };
289− path = format!("{}.{}", path, &rest[..f]);
290− len += 1 + f;
291− step = Some(field);
292− }
293− Some((path, len))
294−}
295−
296−/// A reference, after its `$`.
297−#[derive(Debug, Clone, PartialEq)]
298−pub enum Reference<'a> {
299− /// `$name`, `$name.field` or `${name}`: the name path.
300− Var(String),
301− /// `${EXPR}`: the expression.
302− Expr(&'a str),
303−}
304−
305−/// The reference that `s` (the text after a `$`) starts with, and its
306−/// length. `None` when the `$` is text.
307−pub fn reference<'a>(s: &'a str, scope: &dyn Scope) -> Option<(Reference<'a>, usize)> {
308− if s.starts_with('{') {
309− let close = matching_brace(s)?;
310− let inner = &s[1..close];
311− let reference = if is_path(inner.trim()) {
312− Reference::Var(inner.trim().to_string())
313− } else {
314− Reference::Expr(inner)
315− };
316− return Some((reference, close + 1));
317− }
318− let (path, len) = path_at(s, scope)?;
319− Some((Reference::Var(path), len))
320−}
321−
322−/// The value of a name path, or `None` when it is undefined. A field that
323−/// a record doesn't have is empty, and so is any field of it.
324−pub fn resolve(path: &str, scope: &dyn Scope) -> Option<Value> {
325− let mut parts = path.split('.');
326− let mut step = Step::Value(scope.get(parts.next()?)?);
327− for field in parts {
328− step = step.field(field)?;
329− }
330− Some(step.value())
331−}
332−
333−/// Fill in the `$name`s and `${...}`s of one slot's text, for text (see
334−/// [`Sink::Text`]). Each one that can't be filled is left as written and
335−/// reported.
336−pub fn interpolate(text: &str, scope: &dyn Scope) -> (String, Vec<Problem>) {
337− interpolate_for(text, scope, Sink::Text)
338−}
339−
340−/// [`interpolate`] for a slot whose value goes to `sink`.
341−pub fn interpolate_for(text: &str, scope: &dyn Scope, sink: Sink) -> (String, Vec<Problem>) {
342− let mut problems = Vec::new();
343− if !text.contains('$') {
344− return (text.to_string(), problems);
345− }
346− let mut out = String::with_capacity(text.len());
347− let mut pos = 0;
348− // Whether the CSS value is inside a quoted string at `pos`
349− let mut in_string = false;
350− while let Some(found) = text[pos..].find('$') {
351− let dollar = pos + found;
352− out.push_str(&text[pos..dollar]);
353− if sink == Sink::Css {
354− in_string = css_string_after(&text[pos..dollar], in_string);
355− }
356− let after = &text[dollar + 1..];
357− let Some((reference, len)) = reference(after, scope) else {
358− let n = custom_property_len(after);
359− if n > 0 {
360− problems.push(Problem::CustomProperty {
361− name: after[..n].to_string(),
362− offset: dollar,
363− });
364− }
365− out.push_str(&text[dollar..dollar + 1 + n]);
366− pos = dollar + 1 + n;
367− continue;
368− };
369− let written = &text[dollar..dollar + 1 + len];
370− let insert = |value: String, quoted: Option<String>| match (sink, quoted) {
371− (Sink::Css, Some(css)) if in_string => {
372− // The string's body, without its quotes
373− let body = css.strip_prefix('"').unwrap_or(&css);
374− body.strip_suffix('"').unwrap_or(body).to_string()
375− }
376− (Sink::Css, Some(css)) => css,
377− (Sink::Css, None) if in_string => css_string_body(&value),
378− _ => value,
379− };
380− match reference {
381− Reference::Var(path) => match resolve(&path, scope) {
382− Some(value) => match no_text(written, &value, dollar) {
383− Some(problem) => problems.push(problem),
384− None => out.push_str(&insert(
385− value.to_string(),
386− value.quoted_css().map(str::to_string),
387− )),
388− },
389− None => {
390− problems.push(Problem::Undefined {
391− name: path,
392− offset: dollar,
393− });
394− out.push_str(written);
395− }
396− },
397− Reference::Expr(source) => match expr::eval(source, scope) {
398− Ok(value) => match no_text(written, &value, dollar) {
399− Some(problem) => problems.push(problem),
400− None => out.push_str(&insert(value.to_string(), None)),
401− },
402− Err(error) => {
403− problems.push(match error {
404− // At the `${`
405− expr::Error::Invalid(message) => Problem::Invalid {
406− message,
407− offset: dollar,
408− },
409− // At the name, inside the braces
410− error => error.at(dollar + 2),
411− });
412− out.push_str(written);
413− }
414− },
415− }
416− pos = dollar + 1 + len;
417− }
418− out.push_str(&text[pos..]);
419− (out, problems)
420−}
421−
422−/// The names `text` refers to, by syntax alone: the first name of each
423−/// path, including those inside `${...}`.
424−pub fn names(text: &str) -> Vec<&str> {
425− name_spans(text)
426− .into_iter()
427− .map(|span| &text[span])
428− .collect()
429−}
430−
431−/// Where the names of [`names`] are in `text`, as byte ranges of the name
432−/// alone: after the `$` of `$name`, inside the braces of `${name}`.
433−pub fn name_spans(text: &str) -> Vec<std::ops::Range<usize>> {
434− let mut found = Vec::new();
435− collect_name_spans(text, 0, &mut found);
436− found
437−}
438−
439−fn collect_name_spans(text: &str, base: usize, found: &mut Vec<std::ops::Range<usize>>) {
440− let mut pos = 0;
441− while let Some(i) = text[pos..].find('$') {
442− let after = pos + i + 1;
443− let rest = &text[after..];
444− pos = after;
445− // `\$` is a dollar sign (and `\\$` a backslash before a name)
446− let backslashes = text[..after - 1].len() - text[..after - 1].trim_end_matches('\\').len();
447− if backslashes % 2 == 1 {
448− continue;
449− }
450− if rest.starts_with('{')
451− && let Some(close) = matching_brace(rest)
452− {
453− let inner = &rest[1..close];
454− let trimmed = inner.trim_start();
455− if is_path(trimmed.trim_end()) {
456− let start = base + after + 1 + inner.len() - trimmed.len();
457− found.push(start..start + name_len(trimmed));
458− } else {
459− collect_name_spans(inner, base + after + 1, found);
460− }
461− pos = after + close + 1;
462− continue;
463− }
464− let n = name_len(rest);
465− if n > 0 {
466− found.push(base + after..base + after + n);
467− pos = after + n;
468− }
469− }
470−}
471−
472−/// Index of the `}` matching the `{` that `s` starts with (skipping
473−/// braces inside string literals).
474−pub(crate) fn matching_brace(s: &str) -> Option<usize> {
475− let mut depth = 0;
476− let mut in_string = false;
477− for (i, c) in s.char_indices() {
478− match c {
479− '"' => in_string = !in_string,
480− _ if in_string => {}
481− '{' => depth += 1,
482− '}' => {
483− depth -= 1;
484− if depth == 0 {
485− return Some(i);
486− }
487− }
488− _ => {}
489− }
490− }
491− None
492−}
493−
494−#[cfg(test)]
495−pub(crate) mod tests {
496− use super::*;
497− use std::collections::HashMap;
498−
499− /// Variables by name: text from `new`, any value from `with`.
500− pub(crate) struct Map(pub HashMap<String, Value>);
501−
502− impl Map {
503− pub(crate) fn new(pairs: &[(&str, &str)]) -> Self {
504− Map(pairs
505− .iter()
506− .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
507− .collect())
508− }
509−
510− pub(crate) fn with(mut self, name: &str, value: Value) -> Self {
511− self.0.insert(name.to_string(), value);
512− self
513− }
514− }
515−
516− impl Scope for Map {
517− fn get(&self, name: &str) -> Option<Value> {
518− self.0.get(name).cloned()
519− }
520− }
521−
522− pub(crate) fn text_list(items: &[&str]) -> Value {
523− Value::list(items.iter().map(|s| Value::Str(s.to_string())).collect())
524− }
525−
526− pub(crate) fn record(fields: &[(&str, &str)]) -> Value {
527− Value::Record(std::rc::Rc::new(
528− fields
529− .iter()
530− .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
531− .collect(),
532− ))
533− }
534−
535− fn vars() -> Map {
536− Map::new(&[("lang", "fr"), ("n", "3"), ("n-1", "two")])
537− .with("post", record(&[("title", "Hello")]))
538− .with("tags", text_list(&["a", "b"]))
539− }
540−
541− fn fill(text: &str) -> String {
542− interpolate(text, &vars()).0
543− }
544−
545− #[test]
546− fn names_end_where_they_must() {
547− assert_eq!(fill("locales/$lang.json"), "locales/fr.json");
548− assert_eq!(fill("$post.title!"), "Hello!");
549− assert_eq!(fill("$tags.1 and $tags"), "b and a, b");
550− assert_eq!(fill("${lang}uage"), "fruage");
551− assert_eq!(fill("$lang- $lang:"), "fr- fr:");
552− assert_eq!(fill("$n-1"), "two");
553− assert_eq!(fill("1..$n"), "1..3");
554− // A name ends at `..`
555− assert_eq!(fill("$n..$n $post.title..x"), "3..3 Hello..x");
556− }
557−
558− #[test]
559− fn a_dollar_before_anything_else_is_text() {
560− assert_eq!(fill("$5 and $$ and $"), "$5 and $$ and $");
561− assert_eq!(fill("a $-x ${"), "a $-x ${");
562− }
563−
564− #[test]
565− fn a_custom_property_is_not_a_variable() {
566− let (out, problems) = interpolate("x $--brand-dark $-- $--1", &vars());
567− assert_eq!(out, "x $--brand-dark $-- $--1");
568− assert_eq!(
569− problems,
570− [
571− Problem::CustomProperty {
572− name: "--brand-dark".into(),
573− offset: 2
574− },
575− Problem::CustomProperty {
576− name: "--1".into(),
577− offset: 20
578− }
579− ]
580− );
581− assert!(custom_property_message("--brand").contains("`var(--brand)`"));
582− assert_eq!(names("$--e $f"), ["f"]);
583− }
584−
585− #[test]
586− fn missing_fields_are_empty_and_undefined_names_are_errors() {
587− assert_eq!(fill("[$post.summary]"), "[]");
588− let (out, problems) = interpolate("x $nope y", &vars());
589− assert_eq!(out, "x $nope y");
590− assert_eq!(
591− problems,
592− [Problem::Undefined {
593− name: "nope".into(),
594− offset: 2
595− }]
596− );
597− }
598−
599− #[test]
600− fn expressions() {
601− assert_eq!(fill("${$n * 2}px"), "6px");
602− let (_, problems) = interpolate("a ${$n + $m}", &vars());
603− assert_eq!(
604− problems,
605− [Problem::Undefined {
606− name: "m".into(),
607− offset: 9
608− }]
609− );
610− }
611−
612− #[test]
613− fn quoted_text_keeps_its_quotes_only_in_css() {
614− let quoted = Value::Quoted(crate::ast::Quoted {
615− text: r#"a"b"#.into(),
616− css: r#""a\"b""#.into(),
617− });
618− let scope = Map::new(&[("n", r#"x"y"#)]).with("q", quoted);
619− let fill = |text: &str, sink| interpolate_for(text, &scope, sink).0;
620− assert_eq!(fill("$q", Sink::Text), r#"a"b"#);
621− assert_eq!(fill("$q", Sink::Css), r#""a\"b""#);
622− // Inside a CSS string, what it says, escaped as the string needs
623− assert_eq!(
624− fill(r#""($q) $n" $q"#, Sink::Css),
625− r#""(a\"b) x\"y" "a\"b""#
626− );
627− assert_eq!(fill(r#""\" $q""#, Sink::Css), r#""\" a\"b""#);
628− // What an unquoted value holds goes into a string that stays valid
629− let scope = Map::new(&[("end", r"a\"), ("esc", r"\201C"), ("q", r#"x\"y"#)]);
630− let fill = |text: &str| interpolate_for(text, &scope, Sink::Css).0;
631− assert_eq!(fill(r#""$end""#), r#""a\\""#);
632− assert_eq!(fill(r#""$esc""#), r#""\201C""#);
633− assert_eq!(fill(r#""$q""#), r#""x\"y""#);
634− }
635−
636− #[test]
637− fn inserted_values_are_never_read_again() {
638− let scope = Map::new(&[("a", "$b"), ("b", "no")]);
639− assert_eq!(interpolate("$a", &scope).0, "$b");
640− }
641−
642− #[test]
643− fn names_by_syntax() {
644− assert_eq!(names("$a.b ${c} ${upper($d)} $5 $--e"), ["a", "c", "d"]);
645− assert_eq!(names(r"\$a \\$b \\\$c"), ["b"]);
646− assert_eq!(
647− name_spans("x ${ size }px ${$n + ${m}} $a- b"),
648− [5..9, 17..18, 23..24, 28..29]
649− );
650− }
651−}
crates/htmlang-core/src/lib.rs−4
@@ -1,11 +1,7 @@
1pub mod ast;
1pub mod ast;
2pub mod codegen;
2pub mod codegen;
3−pub mod diagnostic;
4pub mod expr;
3pub mod expr;
5−pub mod interp;
6pub mod parser;
4pub mod parser;
7−pub mod syntax;
8−pub mod value;
9pub mod vocab;
5pub mod vocab;
10
6
11#[cfg(test)]
7#[cfg(test)]
crates/htmlang-core/src/parser.rs+3665−6205

This diff is too big to show here.

crates/htmlang-core/src/std.hl+46−2

This diff is too big to show here.

crates/htmlang-core/src/syntax.rsdeleted−2943

This diff is too big to show here.

crates/htmlang-core/src/value.rsdeleted−406

This diff is too big to show here.

crates/htmlang-core/src/vocab.rs+157−777

This diff is too big to show here.

crates/htmlang-wasm/playground/index.htmldeleted−113

This diff is too big to show here.

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

This diff is too big to show here.

editors/vscode/README.md+27−86

This diff is too big to show here.

editors/vscode/snippets/htmlang.json+184−254

This diff is too big to show here.

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

This diff is too big to show here.

examples/blog.hl+48−86

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

This diff is too big to show here.

examples/demo.html+3−800

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

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

This diff is too big to show here.

examples/landing.html.map+1−1

This diff is too big to show here.

examples/posts.jsondeleted−27

This diff is too big to show here.

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

This diff is too big to show here.

src/bin/htmlang_lsp/completion.rs+1112−1493

This diff is too big to show here.

src/bin/htmlang_lsp/docs.rsdeleted−616

This diff is too big to show here.

src/bin/htmlang_lsp/hover.rs+927−343

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

src/bin/htmlang_lsp/tree.rsdeleted−222

This diff is too big to show here.

src/cli.rs+10−6

This diff is too big to show here.

src/convert.rsnew file+1425

This diff is too big to show here.

src/fmt.rs+408−401

This diff is too big to show here.

src/lib.rs+2−3

This diff is too big to show here.

src/main.rs+549−228

This diff is too big to show here.

src/serve.rs+157−128

This diff is too big to show here.

src/upgrade.rsnew file+1143

This diff is too big to show here.

tests/common/mod.rsdeleted−140

This diff is too big to show here.

tests/docs.rs+19−116

This diff is too big to show here.

tests/editor.rsdeleted−157

This diff is too big to show here.

tests/fmt.rsdeleted−55

This diff is too big to show here.

tests/regressions.rs+60−2144

This diff is too big to show here.

tests/snapshots.rs+1149−4421

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

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+5−9

This diff is too big to show here.

tests/snapshots/animations.html+1−1

This diff is too big to show here.

tests/snapshots/arithmetic.html+1−1

This diff is too big to show here.

tests/snapshots/assert_directive.hlnew file+10

This diff is too big to show here.

tests/snapshots/assert_directive.htmlnew file+1

This diff is too big to show here.

tests/snapshots/attributes.hl+3−3

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.hlnew file+4

This diff is too big to show here.

tests/snapshots/breadcrumb.htmlnew file+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.hldeleted−28

This diff is too big to show here.

tests/snapshots/code_samples.htmldeleted−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.hldeleted−12

This diff is too big to show here.

tests/snapshots/colour_variants.htmldeleted−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−4

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.hldeleted−47

This diff is too big to show here.

tests/snapshots/custom_properties.htmldeleted−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+6−7

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

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.hldeleted−42

This diff is too big to show here.

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

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.hldeleted−19

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.htmldeleted−1

This diff is too big to show here.

tests/snapshots/extra_css.hl+3−3

This diff is too big to show here.

tests/snapshots/extra_css.html+1−1

This diff is too big to show here.

tests/snapshots/fn_defaults.hl+2−2

This diff is too big to show here.

tests/snapshots/fn_defaults.html+1−1

This diff is too big to show here.

tests/snapshots/form_elements.hl+1−1

This diff is too big to show here.

tests/snapshots/form_elements.html+1−1

This diff is too big to show here.

tests/snapshots/fragment.hl+1−1

This diff is too big to show here.

tests/snapshots/fragment.html+1−1

This diff is too big to show here.

tests/snapshots/function_calls.hldeleted−66

This diff is too big to show here.

tests/snapshots/function_calls.htmldeleted−1

This diff is too big to show here.

tests/snapshots/function_definitions.hldeleted−35

This diff is too big to show here.

tests/snapshots/function_definitions.htmldeleted−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.hldeleted−42

This diff is too big to show here.

tests/snapshots/html_elements.htmldeleted−1

This diff is too big to show here.

tests/snapshots/if_expr.hl+3−21

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.hlnew file+7

This diff is too big to show here.

tests/snapshots/implicit_el.htmlnew file+1

This diff is too big to show here.

tests/snapshots/initial_letter.html+1−1

This diff is too big to show here.

tests/snapshots/inline_text.hl+2−2

This diff is too big to show here.

tests/snapshots/inline_text.html+1−1

This diff is too big to show here.

tests/snapshots/lang_directive.hl+1−1

This diff is too big to show here.

tests/snapshots/lang_directive.html+1−1

This diff is too big to show here.

tests/snapshots/layer_wrapping.html+1−1

This diff is too big to show here.

tests/snapshots/layout_direction.hldeleted−33

This diff is too big to show here.

tests/snapshots/layout_direction.htmldeleted−1

This diff is too big to show here.

tests/snapshots/layout_modes.hldeleted−44

This diff is too big to show here.

tests/snapshots/layout_modes.htmldeleted−2

This diff is too big to show here.

tests/snapshots/leading_argument.hldeleted−50

This diff is too big to show here.

tests/snapshots/leading_argument.htmldeleted−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+8−3

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

This diff is too big to show here.

tests/snapshots/match_directive.html+1−1

This diff is too big to show here.

tests/snapshots/media_elements.html+1−1

This diff is too big to show here.

tests/snapshots/media_extended.html+1−1

This diff is too big to show here.

tests/snapshots/meta_head.hl+1−1

This diff is too big to show here.

tests/snapshots/meta_head.html+1−1

This diff is too big to show here.

tests/snapshots/mixin_spread.hl+3−3

This diff is too big to show here.

tests/snapshots/mixin_spread.html+1−1

This diff is too big to show here.

tests/snapshots/named_slots.hl+2−2

This diff is too big to show here.

tests/snapshots/named_slots.html+1−1

This diff is too big to show here.

tests/snapshots/names_and_values.hldeleted−18

This diff is too big to show here.

tests/snapshots/names_and_values.htmldeleted−1

This diff is too big to show here.

tests/snapshots/new_css.hl+1−1

This diff is too big to show here.

tests/snapshots/new_css.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_attrs.hl+1−1

This diff is too big to show here.

tests/snapshots/new_css_attrs.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties.hl+4−4

This diff is too big to show here.

tests/snapshots/new_css_properties.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_2.hl+2−2

This diff is too big to show here.

tests/snapshots/new_css_properties_2.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_3.hl+2−2

This diff is too big to show here.

tests/snapshots/new_css_properties_3.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_4.hl+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_4.html+1−1

This diff is too big to show here.

tests/snapshots/new_directives.hl+8−19

This diff is too big to show here.

tests/snapshots/new_directives.html+5−8

This diff is too big to show here.

tests/snapshots/new_elements.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_2.hl+1−1

This diff is too big to show here.

tests/snapshots/new_elements_2.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_3.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_4.hl+13−4

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.hlnew file+19

This diff is too big to show here.

tests/snapshots/new_elements_5.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_elements_6.hl+1−1

This diff is too big to show here.

tests/snapshots/new_elements_6.html+1−1

This diff is too big to show here.

tests/snapshots/new_html_attrs.html+1−1

This diff is too big to show here.

tests/snapshots/new_pseudos.hl+2−2

This diff is too big to show here.

tests/snapshots/new_pseudos.html+1−1

This diff is too big to show here.

tests/snapshots/no_page.hl+1−1

This diff is too big to show here.

tests/snapshots/no_page.html+1−1

This diff is too big to show here.

tests/snapshots/nth_pseudo.hl+3−3

This diff is too big to show here.

tests/snapshots/nth_pseudo.html+1−1

This diff is too big to show here.

tests/snapshots/og_directive.html+1−1

This diff is too big to show here.

tests/snapshots/one_css_path.hldeleted−14

This diff is too big to show here.

tests/snapshots/one_css_path.htmldeleted−4

This diff is too big to show here.

tests/snapshots/page_root.hldeleted−15

This diff is too big to show here.

tests/snapshots/page_root.htmldeleted−1

This diff is too big to show here.

tests/snapshots/pagination_each.hlnew file+3

This diff is too big to show here.

tests/snapshots/pagination_each.htmlnew file+1

This diff is too big to show here.

tests/snapshots/parameters.hldeleted−31

This diff is too big to show here.

tests/snapshots/parameters.htmldeleted−1

This diff is too big to show here.

tests/snapshots/popover_api.hl+1−1

This diff is too big to show here.

tests/snapshots/popover_api.html+1−1

This diff is too big to show here.

tests/snapshots/positional.html+1−1

This diff is too big to show here.

tests/snapshots/prefixes_compose.hldeleted−17

This diff is too big to show here.

tests/snapshots/prefixes_compose.htmldeleted−1

This diff is too big to show here.

tests/snapshots/pseudo_elements.hl+1−1

This diff is too big to show here.

tests/snapshots/pseudo_elements.html+1−1

This diff is too big to show here.

tests/snapshots/pseudo_states.html+1−1

This diff is too big to show here.

tests/snapshots/pseudo_states_extended.hl+3−3

This diff is too big to show here.

tests/snapshots/pseudo_states_extended.html+1−1

This diff is too big to show here.

tests/snapshots/px_rule.hldeleted−20

This diff is too big to show here.

tests/snapshots/px_rule.htmldeleted−1

This diff is too big to show here.

tests/snapshots/raw_html.hl+2−2

This diff is too big to show here.

tests/snapshots/raw_html.html+1−1

This diff is too big to show here.

tests/snapshots/repeat_directive.hl+2−2

This diff is too big to show here.

tests/snapshots/repeat_directive.html+1−1

This diff is too big to show here.

tests/snapshots/responsive.hl+1−1

This diff is too big to show here.

tests/snapshots/responsive.html+1−1

This diff is too big to show here.

tests/snapshots/responsive_images.hl+1−1

This diff is too big to show here.

tests/snapshots/responsive_images.html+1−1

This diff is too big to show here.

tests/snapshots/scope_css.hl+1−1

This diff is too big to show here.

tests/snapshots/scope_css.html+1−1

This diff is too big to show here.

tests/snapshots/script_element.html+1−1

This diff is too big to show here.

tests/snapshots/scroll_driven_animations.hl+4−6

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.hldeleted−59

This diff is too big to show here.

tests/snapshots/slots_and_children.htmldeleted−1

This diff is too big to show here.

tests/snapshots/starting_style.hl+1−1

This diff is too big to show here.

tests/snapshots/starting_style.html+1−1

This diff is too big to show here.

tests/snapshots/stress_deeply_nested.hl+129−129

This diff is too big to show here.

tests/snapshots/stress_deeply_nested.html+1−1

This diff is too big to show here.

tests/snapshots/stress_large.hl+190−194

This diff is too big to show here.

tests/snapshots/stress_large.html+1−1

This diff is too big to show here.

tests/snapshots/string_interpolation.html+1−1

This diff is too big to show here.

tests/snapshots/style_block.html+1−1

This diff is too big to show here.

tests/snapshots/subgrid_css.html+1−1

This diff is too big to show here.

tests/snapshots/table_elements.html+1−1

This diff is too big to show here.

tests/snapshots/text_everywhere.hldeleted−41

This diff is too big to show here.

tests/snapshots/text_everywhere.htmldeleted−3

This diff is too big to show here.

tests/snapshots/translations_i18n.hl+1−1

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.hldeleted−62

This diff is too big to show here.

tests/snapshots/values_and_scope.htmldeleted−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.hldeleted−18

This diff is too big to show here.

tests/snapshots/variable_slots.htmldeleted−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.