on 620e060 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+294−1422
@@ -1,1360 +1,339 @@
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−
693−- **The element has a tag of its own.** `@td:` is every `<td>`,
694− `@paragraph:` every `<p>`, `@link:` every `<a>` and `@image:` every
695− `<img>`, the ones `@markdown` and `@raw` write included. An element that
696− shares its tag can't be picked out, so its prefix is an error: `@el`,
697− `@row`, `@grid`, `@in-front` and `@behind` all write `<div>`, and `@text`
698− writes the `<span>` that every line of text in a row or column is too.
699− So is a function's name (`@card:`), since a function writes whatever its
700− body says and has no tag of its own, and a name that isn't an element
701− (`@a:` suggests `@link:`).
702−- **It takes CSS properties.** htmlang's layout words (`spacing`, `wrap`,
703− `grid-cols`, `col-span`, `width fill`, `center-x`, `align-*`) compile
704− against each element's own layout and its parent's, which the prefix
705− doesn't know, so they are errors under it: write them on the elements,
706− or the CSS they stand for (`@li:margin-inline auto`). HTML attributes
707− have no states: `@td:colspan=2` is an error, like `md:colspan=2`.
708−- **It composes with the other prefixes, read left to right.** Selector
709− prefixes after it choose among the elements:
710− `@tr:nth-child(even):background #f8fafc` shades every other row, and
711− `@link:hover:color red` colours a hovered link. Those before it are
712− about the element itself: `hover:@link:color red` recolours its links
713− while it is hovered. Width, media and container prefixes go anywhere
714− (`md:@td:padding 12`). A `[group]` or a `$bundle` takes it like any
715− prefix: `@th:[padding 8, text-align left]`. A style has one element
716− prefix, and not `children:` as well: each says which elements the style
717− is for.
718−- **What an element says about itself wins.** A cell's own `[padding 2]`
719− wins over the table's `@td:padding 8`, and a parent's `children:` wins
720− over it too. The prefix wins over the defaults of the element's kind, so
721− `@main [@h2:margin-block-start 16]` spaces headings, which start with
722− `margin: 0`. Where two elements around one both style it, the nearest
723− wins, whatever the order they are written in: in an `@article
724− [@td:padding 16]` around a `@table [@td:padding 8]`, the table's cells
725− get 8.
726−- **It styles what is inside, not the element itself.** `@ul
727− [@ul:padding-inline-start 16]` indents the lists inside the list, not
728− the list itself.
729−
730−`children:` and an element prefix answer different questions:
731−`children:` is for the direct children, whatever they are, as the parent
732−sees them; `@td:` is for every cell inside, however deep. An element's
733−own attributes are for it alone.
734−
735−Each style under an element prefix compiles to a rule in CSS's `@scope`,
736−on the element's class, in the layer `hl-inside` (see [CSS](#css)):
737−`@scope (.hl-2k9xq0m) { :scope td { padding: 8px } }`. `@scope`'s proximity
738−rule is what makes the nearest element win. `@scope` is Baseline 2025,
739−supported by Chrome and Edge 118, Safari 17.4 and Firefox 146; an older
740−browser ignores these rules, so the elements inside keep the styles they
741−have without them.
742
245
743```
246```
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]]
247+@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
747 @text [color white] Click me
248 @text [color white] Click me
748−@row [spacing 4, children:flex 1, hover:children:opacity 0.8]
249+@row [spacing 4, children:flex 1, nth:2n:background #f3f4f6]
749− @el [nth-child(odd):background #f3f4f6] A
250+ @el A
750− @el [nth-child(odd):background #f3f4f6] B
251+ @el B
751− @el [nth-child(odd):background #f3f4f6] C
752−@el [before:content "→ ", before:color red, hover:before:color blue]
252+@el [before:content "→ ", before:color red]
753 Item with an arrow
253 Item with an arrow
754−@section [spacing 16, md:$card, hover:if($active, [background #eef2ff, color #3730a3])]
755− A card from md up
756```
254```
757
255
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
256### Conditional attributes
771
257
772−`if(CONDITION, A, B)`, written as a whole attribute, is `A` when the
258+`key if CONDITION` includes an attribute only when the condition holds;
773−condition holds and `B` otherwise. Each branch is an attribute, a `[group]`
259+`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
260
778```
261```
779@let active true
262@let active true
780−@el [if($active, background blue, background gray), if($active, font-weight bold)]
263+@el [background if($active, blue, gray), bold if $active]
781 Conditionally styled
264 Conditionally styled
782−@link [if($active, [background #eef2ff, font-weight 600, aria-current=page])] /docs Docs
783```
265```
784
266
785−A value that depends on a condition is an [expression](#expressions),
267+## Variables and functions
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
268
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−```
955−
956−### HTML attributes
957−
958−HTML attributes are written `key=value`: `id=main`, `class=note`,
959−`href=/about`, `type=email`, `alt=Logo`, `target=_blank`,
960−`aria-label=Close menu`, `data-id=42`, `hx-get=/items`. Any name works,
961−one with a colon in it included (`xml:lang=en`, `hx-on:click=open()`):
962−before an `=` a colon is part of the name, not a prefix. Boolean attributes
963−are written bare: `required`, `disabled`, `checked`, `hidden`, `open`,
964−`popover`, and so are `data-` and `hx-` attributes that have no value
965−(`hx-preserve`). An HTML attribute written like a style (`type email`,
966−`hx-get /items`) is an error that shows the `key=value` form. A style
967−and an HTML attribute can share a name, because the `=` tells them apart:
968−
969−```
970−@image [width=800, width 200, alt=A photo] photo.jpg
971−@label
972− Size
973− @select [size=4, font-size 18]
974− @option One
975−```
976−
977−## Definitions
978−
979−`@let` defines everything reusable. What it defines depends on its form:
269+`@let` defines everything reusable. What it defines depends on its shape:
980
270
981```
271```
982-- A value, used as $primary
272-- A value, used as $primary
983@let primary #3b82f6
273@let primary #3b82f6
984−-- A computed value: `=` makes the rest an expression
274+-- A computed value: `=` makes it an expression
985@let gap = 8 * 2
275@let gap = 8 * 2
986−-- Quoted text, with $variables filled in (see Variables for its quotes)
276+-- A quoted string, with $variables interpolated
987−@let greeting "Hello from $primary"
277+@let greeting "Hello $primary"
988-- An attribute bundle, used as [$card]
278-- An attribute bundle, used as [$card]
989−@let card [padding 20, background white, border-radius 8]
279+@let card [padding 20, background white, rounded 8]
990−-- A function: `@` before its name, its parameters in brackets, and a body
280+-- A function: a @let with an indented body
991−@let @panel [title]
281+@let panel $title $tone=neutral
992− @el [$card, spacing $gap]
282+ @el [$card]
993− @text [font-weight bold, color $primary] $title
283+ @text [bold] $title
994 @children
284 @children
995
285
996@panel [title Welcome]
286@panel [title Welcome]
997− $greeting
287+ Body text goes into @children.
998@el [$card, background #f9fafb]
288@el [$card, background #f9fafb]
999 Attributes after a bundle override it.
289 Attributes after a bundle override it.
1000```
290```
1001
291
1002−The line alone says what a `@let` defines: an `@` before the name makes a
1003−function, a `[` after the name an attribute bundle, and anything else a
1004−value. A value or a bundle is named without `$` and used with it; a function
1005−is named with `@` and called with it. Only a function has a body, so an
1006−indented block under a value or a bundle is an error, and so are `@let $x`
1007−(write `@let x`) and a `@let` without a value.
1008−
1009−Values, bundles and functions share one namespace, and one rule says where
1010−a name is visible: **from the line that defines it to the end of its
1011−block**. A block is the file, the lines indented under an element, a branch
1012−of `@if`, one repetition of `@each` or a function's body. A `@let` gives its
1013−name a new meaning in its block, whatever the name meant before, and hides
1014−what the name means around the block until the block ends:
1015−
1016−```
1017−@let gap 8
1018−@el [spacing $gap]
1019− @let gap 16
1020− @el [padding $gap] Sixteen
1021−@el [padding $gap] Eight
1022−```
1023−
1024−Parameters, `@each` variables and `@data` names follow the same rule, and
1025−each repetition of `@each` starts afresh, so a `@let` in one doesn't carry
1026−over to the next. A function's body sees its parameters and what is
1027−visible where the function is defined: not the names at a call, and not a
1028−definition further down, so a function means the same wherever it is
1029−called. `@let t.greeting Hello` gives the record `$t` the field `greeting`
1030−(making `$t` a record when it isn't defined), and `@let items.0 first`
1031−replaces the first item of the list `$items`; a field of any other value is
1032−an error.
1033−
1034−### Variables
1035−
1036−`$name` inserts a value wherever htmlang holds text: a line of text, an
1037−attribute's value, an element's argument, a `@let` value, a file path, the
1038−title of `@page` and the value of `@meta`. Each line is read first, and then
1039−`$name` fills the one place it is written in. What it inserts is never read
1040−again as htmlang, so a value can't become an attribute, an attribute's name,
1041−a comma between attributes, an inline element or another `$name`. Attributes
1042−come from a bundle:
1043−
1044−```
1045−@let link-style [color #2563eb, text-decoration underline]
1046−@let label Docs
1047−@link [$link-style, aria-label=$label] /docs Read the $label
1048−```
1049−
1050−A name starts with a letter or `_` and goes on with letters, digits, `_`
1051−and `-`. It ends at the first other character, so after `@let lang fr`,
1052−`$lang.json` is `fr.json`. A `.field` continues the name only when the value
1053−is a record or a list (`$post.title`, `$tags.0`). `${name}` is the same name
1054−with explicit ends, for text that follows it directly, and `${EXPR}` inserts
1055−the value of an [expression](#expressions). A `$` followed by anything else
1056−is text.
1057−
1058−```
1059−@let lang fr
1060−@let size 4
1061−@data $post {"title": "Hello"}
1062−@text locales/$lang.json, ${size}px, $post.title, $5
1063−```
1064−
1065−A name that isn't defined is an error. A field that a record doesn't have
1066−is empty, and so is any field of it, so optional fields of `@data` records
1067−work: `@if $post.draft` is false, `$post.author.name` is empty when there is
1068−no `author`, and `${default($post.tag, none)}` gives `none`.
1069−
1070−### Values
1071−
1072−A value is text, a number, `true` or `false`, a list or a record, and a
1073−name holds a whole value: a `@let`, a parameter, `@each` and `@data` bind
1074−one, and binding the name again replaces it. What a `@let` holds is what it
1075−says:
1076−
1077−```
1078−-- Text, as written
1079−@let size 16px
1080−-- Commas make a list, which prints as it was written
1081−@let fruits apple, banana, cherry
1082−-- Quoted, or with `\,`, text with commas in it is one text
1083−@let tagline "Fast, simple"
1084−@let motto small\, fast
1085−-- A range of whole numbers
1086−@let steps 1..9 step 4
1087−-- One `$name` or `${...}` keeps the type of what it holds
1088−@data $posts [{"title": "Hello"}, {"title": "Again"}]
1089−@let first $posts.0
1090−@let backwards ${reverse($fruits)}
1091−@text [font-size $size] $fruits: ${length($fruits)}. $tagline: ${length($tagline)}. $motto. $steps
1092−@text $first.title, $backwards
1093−```
1094−
1095−The last lines give `apple, banana, cherry: 3. Fast, simple: 12. small,
1096−fast. 1..9 step 4` and `Hello, cherry, banana, apple`. A value that is
1097−exactly one `$name` or `${...}` keeps its type wherever it goes: a `@let`, a
1098−function's parameter or default, the list of `@each`. So a record can be
1099−passed to a function whole (`@post-card [post $p]`), and a list stays a
1100−list.
1101−
1102−- Text written without quotes reads as a number when it is one (`3`,
1103− `0.5`), and as false when it is `false` or `0`: that is how htmlang writes
1104− numbers and flags (`@let gap 8`, `@card [dot false]`). Quoted text is
1105− always text.
1106−- **False** is `false`, empty text, `0` and an empty list (or record).
1107− Everything else is true, including `no` and quoted `"0"`.
1108−- `==` and `!=` compare numbers as numbers (`1.0 == 1`), lists and records
1109− item by item, and anything else as text, exactly (`#FFF` isn't `#fff`).
1110− `<`, `>`, `<=` and `>=` compare numbers as numbers and text by character
1111− order.
1112−- A computed number prints without float noise: whole numbers without
1113− decimals, others with at most four (`${100 / 3}` is `33.3333`).
1114−- A list prints as written when the source wrote it, and otherwise as its
1115− items joined with `, `. A record has no text of its own, so writing one
1116− (or a list of records) where text goes is an error: write one of its
1117− fields, or loop over the list with `@each`.
1118−- A `$list` in a comma list is one item, a list inside the list:
1119− `@let more $items, d` has two items. htmlang has no list concatenation.
1120−
1121−**Quoted text remembers that it was quoted.** A value written `"..."` (in a
1122−`@let`, an attribute or a function's argument) keeps its quotes in a CSS
1123−value, where CSS needs them, and loses them in text, in HTML attribute
1124−values and in expressions (`${length($x)}` counts no quotes). It stays
1125−quoted when it passes through another `@let` or a parameter. Inside a quoted string of a CSS value, it inserts
1126−what it says, without its own quotes:
1127−
1128−```
1129−@let arrow "→ "
1130−@let label "Next, please"
1131−@el [before:content $arrow, after:content " ($label)", aria-label=$label] $label
1132−```
1133−
1134−This gives `content:"→ "`, `content:" (Next, please)"`,
1135−`aria-label="Next, please"` and the text `Next, please`. A value written
1136−without quotes is inserted as written everywhere.
1137−
1138−### Functions
1139−
1140−A function's parameters are listed in brackets after its name and written
1141−the way a call passes them: a name alone (`title`) is a required parameter,
1142−and a name and a value (`tone #f9fafb`) is a parameter with that default.
1143−The body uses them as `$title` and `$tone`. `@let @name` without brackets
1144−takes no parameters.
1145−
1146−A default is filled in at each call that leaves its parameter out, like a
1147−value passed for it: it may hold spaces, quotes, escapes, `${...}` and
1148−`$variables`. It sees what the body sees: the names visible where the
1149−function is defined and the parameters declared before it
1150−(`[title, heading "About $title"]`). A default that uses a parameter
1151−declared after it is an error.
1152−
1153−A function is called like an element, wherever an element can be: on a
292+A function is called like an element:
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
293
1203−```
294+- Its parameters are passed as attributes; parameters with `=default` may be
1204−@let @notice
295+ omitted.
1205− @el [padding 12, background #fef3c7]
296+- Other attributes style its root element, so `@panel [title Hi, padding 40]`
1206− @children
297+ works like styling a built-in element.
1207− @text Nothing to report.
298+- Text after the attributes and indented children replace `@children`, and a
299+ caller's `@slot name` block replaces `@slot name` (the slot's own children are
300+ the default).
301+- An `@style` block at the top of the body is scoped to the function: its rules
302+ only apply inside the function's output.
1208
303
1209−@notice
1210−@notice Deploys are paused today.
1211```
304```
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]
305+@let note $kind=info
1259 @style
306 @style
1260− & { border-left: 4px solid #3b82f6; }
1261 .title { font-weight: bold; }
307 .title { font-weight: bold; }
1262− @el [padding 12, border-radius 6, background #eff6ff]
308+ @el [padding 12, rounded 6, background #eff6ff]
1263 @text [class=title] $kind
309 @text [class=title] $kind
1264 @children
310 @children
1265
311
1266@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
312@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
1267```
313```
1268
314
1269−A call in text or in a chain whose body has several roots, or is text,
315+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
316
1273```
317```
1274−@let @intro
318+@let intro """
1275− htmlang is a layout language.
319+ First line
1276− It compiles to {@strong static HTML}.
320+ Second line
1277−@let @key
321+ """
1278− @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
322+@text $intro
1279− @children
1280−
1281−@paragraph
1282− @intro
1283−@paragraph
1284− In short: {@intro} Press {@key Ctrl+K} to search.
1285```
323```
1286
324
1287−A function may call itself, under a condition that stops it, so it can
325+`@let --name value` also emits a CSS custom property, used as `$--name` and
1288−render nested data such as a menu. A parameter passed one `$name` that
326+as `var(--name)` in the generated CSS:
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
327
1294```
328```
1295−@data $menu [
329+@let --brand #3b82f6
1296− {"label": "Guide", "children": [{"label": "Install"}, {"label": "Usage"}]},
330+@el [background $--brand] Themed
1297− {"label": "Reference"}
1298−]
1299−@let @tree [items]
1300− @ul [padding-left 16]
1301− @each $item in $items
1302− @li
1303− @text $item.label
1304− @if $item.children
1305− @tree [items $item.children]
1306−
1307−@tree [items $menu]
1308```
331```
1309
332
1310−The elements a function's body writes are the call's: a problem with
333+### Checks
1311−one of them, such as low contrast or `spacing` on an element that isn't a
1312−row, column or grid, is reported at the call, once, and names the function. A
1313−problem in the text of a body is reported on its own line, or at the call
1314−when the function comes from `std.hl` or another file.
1315−
1316−Naming a function after a built-in element or a directive is a warning:
1317−`@let @button` replaces `@button` in every call after it, and a function
1318−named `@if` could never be called. Inside its own body `@button` is the
1319−function too, so a function that wraps the element it is named after,
1320−outside an `@if`, is an error: give it another name.
1321−
1322−### CSS custom properties
1323−
1324−`@let --name value` declares the CSS custom property `--name` on `:root`,
1325−for the whole page. Its value is written as it is, with no pixels added,
1326−so a length takes its unit (`@let --radius 8px`). A token is read one way,
1327−`var(--name)`, in the browser, so an element (`[dark:--name value]`, see
1328−[Custom properties](#custom-properties)) or a stylesheet can still
1329−redefine it. It is not a variable: `$--name` is an error that says to
1330−write `var(--name)`. Since `:root` is one place for the whole page, a
1331−`@let --name` goes at the top level of the file, once: inside an element,
1332−an `@if`, a loop or a function's body it is an error (set the property on an
1333−element with `[--name value]` instead), and declaring it again warns.
1334−
1335−A colour variant of a token is CSS too, so it follows the token wherever
1336−it is redefined: `color-mix()` mixes two colours, and a relative colour
1337−(`rgb(from ...)`) keeps a colour's channels except those you write (`/ 0.5`
1338−for its alpha). `in srgb` mixes the red, green and blue channels, as most
1339−colour tools do; `in oklab` gives steps that look more even, a good choice
1340−for new tokens.
1341−
1342−| For | Write |
1343−|---|---|
1344−| A darker colour | `color-mix(in srgb, var(--brand), black 10%)` |
1345−| A lighter colour | `color-mix(in srgb, var(--brand), white 10%)` |
1346−| A transparent colour | `rgb(from var(--brand) r g b / 0.5)` |
1347−| Two colours mixed | `color-mix(in srgb, var(--brand), #f43f5e 50%)` |
1348
334
1349−```
335+`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
1350−@let --brand #3b82f6
336+warning.
1351−@let --radius 8px
1352−@page [dark:--brand #60a5fa] Themed
1353−@el [background var(--brand), border-radius var(--radius), border 1 solid color-mix(in srgb, var(--brand), black 10%)] Themed
1354−@row
1355− @each $shade in 0, 10, 20, 30
1356− @el [background color-mix(in srgb, var(--brand), black $shade%)] $shade%
1357−```
1358
337
1359## Expressions
338## Expressions
1360
339
@@ -1362,40 +341,25 @@ Conditions and computed values (`@let x = ...`) are expressions:
1362
341
1363| | |
342| | |
1364|---|---|
343|---|---|
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 |
344+| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
1366| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
345| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
1367−| Comparison | `== != < > <= >=`: numbers as numbers, text exactly, lists and records item by item (see [Values](#values)) |
346+| Text | `~` joins two values: `$first ~ " " ~ $last` |
347+| 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 |
348+| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
1369−| Choice | `if(CONDITION, A, B)`, where `B` may be left out (empty) |
349+| 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) |
350+| Text functions | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)`, `reverse(s)`, `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` |
1372−
351+| Color functions | `lighten(c, pct)`, `darken(c, pct)`, `alpha(c, a)`, `mix(c1, c2, pct)` |
1373−Colour maths is CSS's, not an expression's: see
352+
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
353+Variables are looked up while evaluating, so a value containing `==` or spaces
1380−spaces is still one value. An invalid expression is a compile error, and so
354+is still one value. An invalid expression is a compile error.
1381−is an undefined variable. A text function given a list is an error too,
355+
1382−instead of a guess: `uppercase()` of `Fast, simple` (a list, because of its
356+In text and attribute values, `$name` inserts a variable and `${EXPR}` inserts
1383−comma) says so, and the text is written `"Fast, simple"` or `Fast\, simple`.
357+the value of any expression:
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
358
1395```
359```
1396@let name htmlang
360@let name htmlang
1397−@let size 12
361+@let base #3b82f6
1398−@text [font-size ${$size + 4}] ${uppercase($name)} has ${length($name)} letters
362+@text [color ${darken($base, 10)}] ${uppercase($name)} has ${length($name)} letters
1399```
363```
1400
364
1401## Control flow
365## Control flow
@@ -1403,8 +367,8 @@ expression (see [Variables](#variables)):
1403All control flow runs at compile time.
367All control flow runs at compile time.
1404
368
1405```
369```
370+@let items apple, banana, cherry
1406@let count 3
371@let count 3
1407−@let hidden false
1408
372
1409@if $count > 2 and not $hidden
373@if $count > 2 and not $hidden
1410 @text Many
374 @text Many
@@ -1412,208 +376,116 @@ All control flow runs at compile time.
1412 @text None
376 @text None
1413@else
377@else
1414 @text Few
378 @text Few
1415−```
1416
379
1417−`@each $item in LIST` repeats its body for each item. Its variables are
380+@each $item in $items
1418−written with `$`, like `@data $name`. LIST is a value, read like the value
381+ @text $_index: $item
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−
1427−```
1428−@let fruits apple, banana, cherry
1429−@each $fruit, $i in $fruits
1430− @text ${$i + 1}. $fruit
1431@else
382@else
1432− @text No fruit.
383+ @text The list is empty.
384+
385+@each $i in 1..10 step 3
386+ @text $i
1433
387
1434−@row [spacing 8]
388+@each $label, $url in Home /, About /about
1435− @each $n in 10..0 step 5
389+ @link $url $label
1436− @text $n
1437```
390```
1438
391
1439−## Data and files
392+A range counts down when its start is greater than its end.
393+
394+## Files and data
1440
395
1441| Directive | Effect |
396| Directive | Effect |
1442|---|---|
397|---|---|
1443−| `@data $name file.json` | Load a JSON file. Objects are records (`$name.key`) and arrays are lists |
398+| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
1444−| `@data $name [...]` / `@data $name {...}` | Inline JSON, which may span several lines |
399+| `@include lib.hl as ui` | Take only its definitions, named `ui.name` |
400+| `@extends layout.hl` | Render this page inside a layout (below) |
401+| `@data file.json` / `@data $name file.json` | Load JSON values as variables (`$name.key`) |
1445−| `@data $name dir/*.json` | A list with one record per file, in name order. `$item.file` is the file's name |
402+| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
1446−| `@data $name env:NAME DEFAULT` | An environment variable, with an optional default |
403+| `@data $name env:NAME [default]` | Read an environment variable |
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 |
404+| `@markdown` / `@markdown file.md` | Markdown, 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 |
405+| `@svg file.svg` | Inline an SVG file |
1450−
1451−Records keep their values whole, even when a value contains spaces or
1452−commas:
1453−
1454−```
1455−@data $team [
1456− {"name": "Ada", "role": "Engineering, research"},
1457− {"name": "Grace", "role": "Design"}
1458−]
1459−@ul [spacing 4]
1460− @each $person in $team
1461− @li
1462− @text [font-weight bold] $person.name
1463− @text $person.role
1464−@text ${length($team)} people
1465−```
1466−
1467−### Page layouts
1468
406
1469−A layout is an ordinary function in a file of its own. It marks where content
407+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
408+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
409
1474```
410```
1475-- layout.hl
411-- layout.hl
1476−@let @layout
1477− @page [lang=en, background #f8fafc] My Site
412+@page My Site
1478− @el [width fill, max-width 800, center-x, spacing 24]
413+@column [max-width 800, center-x]
1479− @header
1480− @slot header
414+ @slot header
1481− @text Default header
415+ @text Default header
1482− @main
1483− @children
416+ @children
1484```
417```
1485
418
1486```
419```
1487-- page.hl
420-- page.hl
1488−@include layout.hl
421+@extends layout.hl
1489−@layout
1490− @slot header
422+@slot header
1491− @h1 About us
423+ @text About us
1492− @paragraph This fills @children.
424+@paragraph This fills @children.
1493```
425```
1494
426
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
427## Page and head
1507
428
1508−`@page TITLE` makes the output a full HTML document. Without it, the output
429+`@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
430+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
431+adds a meta tag (`og:` names become Open Graph `property` tags), and
1529−Open Graph `property` tags; a `@meta viewport` replaces the usual one.
1530−`@head` holds any other raw HTML for the `<head>`, such as a font link, a
432+`@head` holds any other raw HTML for the `<head>` (fonts, JSON-LD, a manifest
1531−canonical URL or JSON-LD, on its line or in its indented block.
433+link). Translations are a JSON file per locale: `@data $t locales/$lang.json`.
1532
434
1533```
435```
1534−@page [lang=en, favicon favicon.png, color #1f2937, dark:background #0b1220, dark:color #e5e7eb] My Site
436+@page [lang en, favicon /favicon.png] My Site
1535@meta description A small site
437@meta description A small site
1536@meta og:title My Site
438@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```
439```
1545
440
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
441## CSS
1550
442
1551−`@style` holds raw CSS, including at-rules such as `@keyframes`,
443+- `@style` holds raw CSS, including at-rules such as `@scope`, `@property` and
1552−`@font-face`, `@property` and `@starting-style`, in its indented block or,
444+ `@starting-style`.
1553−for a single rule, on its line:
445+- `@keyframes name` takes htmlang attribute syntax (`from [opacity 0]`) or raw
446+ CSS.
447+- Generated rules live in `@layer htmlang` (after a `hl-reset` layer), so any
448+ CSS in `@style` or `@raw` overrides them.
1554
449
1555```
450```
451+@keyframes fade-in
452+ from [opacity 0]
453+ to [opacity 1]
1556@style
454@style
1557− @keyframes fade-in {
1558− from { opacity: 0; }
1559− to { opacity: 1; }
1560− }
1561 .note { color: gray; }
455 .note { color: gray; }
1562−@style .quiet { opacity: 0.6; }
1563@el [animation fade-in 0.3s ease, class=note] Fades in
456@el [animation fade-in 0.3s ease, class=note] Fades in
1564```
457```
1565
458
1566−Each element with styles gets a generated class, and elements with the
459+## Upgrading older files
1567−same styles share one. A class is named by its style: `hl-` and seven
460+
1568−characters of a hash of what it sets (`hl-2k9xq0m`), so one style has one
461+`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
462+removed form with its replacement.
1570−server returns, or any HTML of one file put into another's page) means
463+
1571−the same there, and its rules restyle nothing of the page's: a class of
464+| 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|---|---|
465|---|---|
1599−| `hl-reset` | The reset |
466+| `@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)` |
467+| `@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` |
468+| `@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 |
469+| `@switch`, `@match` | `@if $x == a` / `@else if` / `@else` |
1603−
470+| `@use "file" names`, `@import file` | `@include file` |
1604−So an element's own styles win over everything else htmlang writes for
471+| `@collection`, `@env`, `@translations`, `@fetch` | `@data $name SOURCE` (glob, `env:NAME`, or a JSON file per locale) |
1605−it; its parent's `children:` styles and the element prefixes of the
472+| `@theme` | `@let --name value` lines |
1606−elements around it win over its defaults.
473+| `@json-ld`, `@font-face`, `@manifest` | Raw HTML in `@head`, CSS in `@style` |
1607−
474+| `@breakpoint`, `@deprecated` | A media query in `@style`; nothing |
1608−A page's reset sets `box-sizing: border-box` everywhere and makes
475+| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
1609−`<body>` a column that fills the window. A fragment (a file
476+| `@with $x as y` | `@let y $x` |
1610−without `@page`, or `--partial` output) goes into a page it doesn't own, so
477+| `@layout file` | `@extends file` |
1611−its reset touches only htmlang's own elements (those with an `hl-` class),
478+| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
1612−which get `box-sizing: border-box`; no other element on the page is
479+| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
1613−touched. In both, an element
480+| `@og KEY VALUE` | `@meta og:KEY VALUE` |
1614−htmlang lays out stays hidden while it has `hidden`, while an `@dialog` is
481+| `@debug`, `@log` | `@warn` |
1615−closed, and while a popover isn't showing, even though its generated
482+| `@defer` | Its content, directly |
1616−`display` would otherwise beat the browser's `display: none`. A page with
483+| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@column`, `@paragraph`, `@image`, `@item`, `@button`, `@list`, `@hr`, `@option` |
1617−links, buttons or form fields also gives htmlang's ones a focus outline.
484+| `type email`, `id main` (HTML attributes written as styles) | `type=email`, `id=main` |
1618−CSS outside any layer takes precedence over every layer, so rules in
485+| `skeleton`, `no-scrollbar`, `gradient A B` | `$skeleton`, `$no-scrollbar`, `background linear-gradient(A, B)` |
1619−`@style` or `@raw` override the generated ones.
486+| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
487+| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
488+| `COND ? A : B` | `if(COND, A, B)` |
489+| `...$bundle` | `$bundle` |
490+| `animate`, `inset-area` | `animation`, `position-area` |
491+| `$x\|uppercase`, `$c\|darken:10` (filters) | `${uppercase($x)}`, `${darken($c, 10)}` |
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] [--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`, `--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 /${lowercase($item)} $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+`@extends`, `@data`), page metadata, 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+106−624
@@ -3,33 +3,28 @@ 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 canonical: Option<String>,
18+ pub base_url: Option<String>,
19+ pub preload_hints: Vec<PreloadHint>,
18 pub nodes: Vec<Node>,
20 pub nodes: Vec<Node>,
19}
21}
20
22
21−/// What `@page` says: the page is the root element. Its styles style
22−/// `<body>`, which lays out the page's top-level elements as a column; its
23−/// HTML attributes (`lang=en`, `dir=rtl`, `class=x`) go on `<html>`.
24−#[derive(Debug, Clone, Default)]
23+#[derive(Debug, Clone)]
25−pub struct Page {
24+pub struct PreloadHint {
26− pub title: String,
25+ pub href: String,
27− /// `key=value` attributes, and bare boolean HTML attributes, for `<html>`
26+ pub as_type: String, // "font", "image", "style", "script"
28− pub html_attrs: Vec<Attribute>,
27+ pub crossorigin: bool,
29− /// Styles, prefixed ones included, for `<body>`
30− pub styles: Vec<Attribute>,
31− /// `favicon FILE`, `@page`'s one word of htmlang's own
32− pub favicon: Option<String>,
33}
28}
34
29
35#[derive(Debug, Clone)]
30#[derive(Debug, Clone)]
@@ -49,52 +44,32 @@ pub enum TextSegment {
49pub struct Element {
44pub struct Element {
50 pub kind: ElementKind,
45 pub kind: ElementKind,
51 pub attrs: Vec<Attribute>,
46 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>,
47 pub argument: Option<String>,
57 pub children: Vec<Node>,
48 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,
49 pub line_num: usize,
61− /// The function whose body wrote it, when it comes from a call.
62− pub function: Option<String>,
63}
50}
64
51
52+
65#[derive(Debug, Clone)]
53#[derive(Debug, Clone)]
66pub struct Attribute {
54pub struct Attribute {
67 pub key: String,
55 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>,
56 pub value: Option<String>,
71 /// Written `key=value`: an HTML attribute rather than a style.
57 /// Written `key=value`: an HTML attribute rather than a style.
72 pub html: bool,
58 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}
59}
87
60
88#[derive(Debug, Clone, PartialEq)]
61#[derive(Debug, Clone, PartialEq)]
89pub enum ElementKind {
62pub enum ElementKind {
90 // Layout and text: the core of the language
63 // Layout and text: the core of the language
91 Row,
64 Row,
65+ Column,
92 El,
66 El,
93 Text,
67 Text,
94 Paragraph,
68 Paragraph,
95 Link,
69 Link,
96 Image,
70 Image,
97 // Elements generated in a special way
71 // Elements generated in a special way
72+ Script,
98 Fragment,
73 Fragment,
99 // Function bodies: placeholders for the caller's content
74 // Function bodies: placeholders for the caller's content
100 Children,
75 Children,
@@ -108,11 +83,13 @@ impl ElementKind {
108 pub fn name(&self) -> &str {
83 pub fn name(&self) -> &str {
109 match self {
84 match self {
110 ElementKind::Row => "row",
85 ElementKind::Row => "row",
86+ ElementKind::Column => "column",
111 ElementKind::El => "el",
87 ElementKind::El => "el",
112 ElementKind::Text => "text",
88 ElementKind::Text => "text",
113 ElementKind::Paragraph => "paragraph",
89 ElementKind::Paragraph => "paragraph",
114 ElementKind::Link => "link",
90 ElementKind::Link => "link",
115 ElementKind::Image => "image",
91 ElementKind::Image => "image",
92+ ElementKind::Script => "script",
116 ElementKind::Fragment => "fragment",
93 ElementKind::Fragment => "fragment",
117 ElementKind::Children => "children",
94 ElementKind::Children => "children",
118 ElementKind::Slot(_) => "slot",
95 ElementKind::Slot(_) => "slot",
@@ -127,86 +104,6 @@ impl ElementKind {
127 }
104 }
128 }
105 }
129
106
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"`)?
107 /// Is this the table element named `name` (e.g. `"main"`)?
211 pub fn is_tag(&self, name: &str) -> bool {
108 pub fn is_tag(&self, name: &str) -> bool {
212 self.spec().is_some_and(|spec| spec.name == name)
109 self.spec().is_some_and(|spec| spec.name == name)
@@ -216,11 +113,13 @@ impl ElementKind {
216 pub fn from_name(name: &str) -> Option<ElementKind> {
113 pub fn from_name(name: &str) -> Option<ElementKind> {
217 Some(match name {
114 Some(match name {
218 "row" => ElementKind::Row,
115 "row" => ElementKind::Row,
116+ "column" => ElementKind::Column,
219 "el" => ElementKind::El,
117 "el" => ElementKind::El,
220 "text" => ElementKind::Text,
118 "text" => ElementKind::Text,
221 "paragraph" => ElementKind::Paragraph,
119 "paragraph" => ElementKind::Paragraph,
222 "link" => ElementKind::Link,
120 "link" => ElementKind::Link,
223 "image" => ElementKind::Image,
121 "image" => ElementKind::Image,
122+ "script" => ElementKind::Script,
224 "fragment" => ElementKind::Fragment,
123 "fragment" => ElementKind::Fragment,
225 "children" => ElementKind::Children,
124 "children" => ElementKind::Children,
226 "slot" => ElementKind::Slot(String::new()),
125 "slot" => ElementKind::Slot(String::new()),
@@ -231,15 +130,8 @@ impl ElementKind {
231 /// Every element name, for suggestions and completions.
130 /// Every element name, for suggestions and completions.
232 pub fn all_names() -> impl Iterator<Item = &'static str> {
131 pub fn all_names() -> impl Iterator<Item = &'static str> {
233 [
132 [
234− "row",
133+ "row", "column", "el", "text", "paragraph", "link", "image", "script", "fragment",
235− "el",
236− "text",
237− "paragraph",
238− "link",
239− "image",
240− "fragment",
241− "children",
134+ "children", "slot",
242− "slot",
243 ]
135 ]
244 .into_iter()
136 .into_iter()
245 .chain(TAGS.iter().map(|spec| spec.name))
137 .chain(TAGS.iter().map(|spec| spec.name))
@@ -249,90 +141,12 @@ impl ElementKind {
249/// What an element does with the text after its name and attributes.
141/// What an element does with the text after its name and attributes.
250#[derive(Debug, Clone, Copy, PartialEq)]
142#[derive(Debug, Clone, Copy, PartialEq)]
251pub enum TagArg {
143pub enum TagArg {
252− /// Its content: the first line of its text, parsed like any text
144+ /// 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,
145 Child,
256− /// Its leading attribute: the first token fills this HTML attribute
146+ /// 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,
147 Text,
309− /// HTML's own layout, which htmlang leaves alone: tables, `@select`,
148+ /// 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 {
149+ 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}
150}
337
151
338/// How an element compiles: the data behind [`ElementKind::Tag`].
152/// How an element compiles: the data behind [`ElementKind::Tag`].
@@ -342,19 +156,17 @@ pub struct TagSpec {
342 pub name: &'static str,
156 pub name: &'static str,
343 /// HTML element emitted.
157 /// HTML element emitted.
344 pub html: &'static str,
158 pub html: &'static str,
345− /// Default CSS besides the layout's `display` (margin reset, ...).
159+ /// Default CSS (flex column, margin reset, ...).
346 pub css: &'static str,
160 pub css: &'static str,
347 pub arg: TagArg,
161 pub arg: TagArg,
348− /// How it lays out its content.
162+ /// Bare text children are wrapped in `<span>` (flex containers).
349− pub layout: Layout,
163+ pub wraps_text: bool,
350− /// Its text is shown as written (`@code`, `@textarea`). On its line and
164+ /// Accepts layout attributes for its children (`spacing`, ...).
351− /// inline, a `{@...}` is text, not an inline element (escapes and
165+ pub container: bool,
352− /// `$names` still work). Its indented body is verbatim: nothing in it is
166+ /// 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,
167+ pub void: bool,
355− /// Its indented body is foreign text written into the page exactly as
168+ /// 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,
169+ pub inline: bool,
358}
170}
359
171
360impl TagSpec {
172impl TagSpec {
@@ -363,413 +175,83 @@ impl TagSpec {
363 html: "",
175 html: "",
364 css: "",
176 css: "",
365 arg: TagArg::Child,
177 arg: TagArg::Child,
366− layout: Layout::Native,
178+ wraps_text: false,
367− literal: false,
179+ container: false,
180+ void: false,
368− verbatim: false,
181+ inline: false,
369 };
182 };
370}
183}
371
184
372/// Every element besides the core ones in [`ElementKind`].
185/// Every element besides the core ones in [`ElementKind`].
373−#[rustfmt::skip]
374pub static TAGS: &[TagSpec] = &[
186pub static TAGS: &[TagSpec] = &[
375− TagSpec { name: "nav", html: "nav", layout: Layout::Column, ..TagSpec::DEFAULT },
187+ 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 },
188+ 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 },
189+ 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 },
190+ 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 },
191+ 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 },
192+ 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 },
193+ 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 },
194+ 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 },
195+ 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 },
196+ TagSpec { name: "search", html: "search", wraps_text: true, container: true, ..TagSpec::DEFAULT },
385− TagSpec { name: "address", html: "address", layout: Layout::Column, ..TagSpec::DEFAULT },
197+ TagSpec { name: "address", html: "address", wraps_text: true, container: true, ..TagSpec::DEFAULT },
386− TagSpec { name: "noscript", html: "noscript", layout: Layout::Column, ..TagSpec::DEFAULT },
198+ 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 },
199+ 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 },
200+ 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 },
201+ 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 },
202+ 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 },
203+ 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 },
204+ 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 },
205+ 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 },
206+ 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 },
207+ TagSpec { name: "dt", html: "dt", arg: TagArg::Text, container: true, ..TagSpec::DEFAULT },
396− TagSpec { name: "dt", html: "dt", layout: Layout::Text, ..TagSpec::DEFAULT },
208+ 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 },
209+ TagSpec { name: "table", html: "table", ..TagSpec::DEFAULT },
398− TagSpec { name: "table", html: "table", layout: Layout::Native, ..TagSpec::DEFAULT },
210+ TagSpec { name: "thead", html: "thead", ..TagSpec::DEFAULT },
399− TagSpec { name: "thead", html: "thead", layout: Layout::Native, ..TagSpec::DEFAULT },
211+ TagSpec { name: "tbody", html: "tbody", ..TagSpec::DEFAULT },
400− TagSpec { name: "tbody", html: "tbody", layout: Layout::Native, ..TagSpec::DEFAULT },
212+ TagSpec { name: "tr", html: "tr", ..TagSpec::DEFAULT },
401− TagSpec { name: "tr", html: "tr", layout: Layout::Native, ..TagSpec::DEFAULT },
213+ TagSpec { name: "select", html: "select", ..TagSpec::DEFAULT },
402− TagSpec { name: "select", html: "select", layout: Layout::Native, ..TagSpec::DEFAULT },
214+ TagSpec { name: "picture", html: "picture", ..TagSpec::DEFAULT },
403− TagSpec { name: "picture", html: "picture", layout: Layout::Native, ..TagSpec::DEFAULT },
215+ TagSpec { name: "progress", html: "progress", ..TagSpec::DEFAULT },
404− TagSpec { name: "progress", html: "progress", layout: Layout::Native, ..TagSpec::DEFAULT },
216+ TagSpec { name: "meter", html: "meter", ..TagSpec::DEFAULT },
405− TagSpec { name: "meter", html: "meter", layout: Layout::Native, ..TagSpec::DEFAULT },
217+ TagSpec { name: "output", html: "output", ..TagSpec::DEFAULT },
406− TagSpec { name: "output", html: "output", layout: Layout::Native, ..TagSpec::DEFAULT },
218+ TagSpec { name: "canvas", html: "canvas", container: true, ..TagSpec::DEFAULT },
407− TagSpec { name: "canvas", html: "canvas", layout: Layout::Native, ..TagSpec::DEFAULT },
219+ TagSpec { name: "td", html: "td", arg: TagArg::Text, ..TagSpec::DEFAULT },
408− TagSpec { name: "td", html: "td", layout: Layout::Text, ..TagSpec::DEFAULT },
220+ TagSpec { name: "th", html: "th", arg: TagArg::Text, ..TagSpec::DEFAULT },
409− TagSpec { name: "th", html: "th", layout: Layout::Text, ..TagSpec::DEFAULT },
221+ TagSpec { name: "button", html: "button", arg: TagArg::Text, ..TagSpec::DEFAULT },
410− TagSpec { name: "button", html: "button", layout: Layout::Text, ..TagSpec::DEFAULT },
222+ TagSpec { name: "label", html: "label", arg: TagArg::Text, ..TagSpec::DEFAULT },
411− TagSpec { name: "label", html: "label", layout: Layout::Text, ..TagSpec::DEFAULT },
223+ TagSpec { name: "option", html: "option", arg: TagArg::Text, ..TagSpec::DEFAULT },
412− TagSpec { name: "option", html: "option", layout: Layout::Native, ..TagSpec::DEFAULT },
224+ TagSpec { name: "textarea", html: "textarea", arg: TagArg::Text, ..TagSpec::DEFAULT },
413− TagSpec { name: "textarea", html: "textarea", layout: Layout::Native, literal: true, ..TagSpec::DEFAULT },
225+ TagSpec { name: "summary", html: "summary", arg: TagArg::Text, ..TagSpec::DEFAULT },
414− TagSpec { name: "summary", html: "summary", layout: Layout::Text, ..TagSpec::DEFAULT },
226+ TagSpec { name: "cite", html: "cite", arg: TagArg::Text, ..TagSpec::DEFAULT },
415− TagSpec { name: "cite", html: "cite", layout: Layout::Text, ..TagSpec::DEFAULT },
227+ TagSpec { name: "figcaption", html: "figcaption", arg: TagArg::Text, ..TagSpec::DEFAULT },
416− TagSpec { name: "figcaption", html: "figcaption", layout: Layout::Text, ..TagSpec::DEFAULT },
228+ TagSpec { name: "legend", html: "legend", arg: TagArg::Text, ..TagSpec::DEFAULT },
417− TagSpec { name: "legend", html: "legend", layout: Layout::Text, ..TagSpec::DEFAULT },
229+ TagSpec { name: "time", html: "time", arg: TagArg::Text, ..TagSpec::DEFAULT },
418− TagSpec { name: "time", html: "time", layout: Layout::Text, ..TagSpec::DEFAULT },
230+ TagSpec { name: "mark", html: "mark", arg: TagArg::Text, ..TagSpec::DEFAULT },
419− TagSpec { name: "mark", html: "mark", layout: Layout::Text, ..TagSpec::DEFAULT },
231+ TagSpec { name: "abbr", html: "abbr", arg: TagArg::Text, ..TagSpec::DEFAULT },
420− TagSpec { name: "abbr", html: "abbr", layout: Layout::Text, ..TagSpec::DEFAULT },
232+ 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 },
233+ 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 },
234+ 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 },
235+ 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 },
236+ 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 },
237+ 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 },
238+ 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 },
239+ 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 },
240+ 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 },
241+ TagSpec { name: "input", html: "input", void: true, ..TagSpec::DEFAULT },
430− TagSpec { name: "input", html: "input", layout: Layout::Void, ..TagSpec::DEFAULT },
242+ TagSpec { name: "hr", html: "hr", void: true, ..TagSpec::DEFAULT },
431− TagSpec { name: "hr", html: "hr", layout: Layout::Void, ..TagSpec::DEFAULT },
243+ 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 },
244+ 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 },
245+ 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 },
246+ 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 },
247+ 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 },
248+ TagSpec { name: "stack", html: "div", css: "position:relative;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
437− // Flow containers
249+ 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 },
250+ 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−];
479−
480−/// HTML elements htmlang writes under another name. The HTML name isn't an
481−/// element: the unknown-element error for `@a` suggests `@link`. (Not a
482−/// second spelling: only the suggestion reads this.)
483−pub const HTML_NAMES_WRITTEN_OTHERWISE: &[(&str, &str)] = &[
484− ("a", "link"),
485− ("img", "image"),
486− ("span", "text"),
487− ("p", "paragraph"),
488− ("div", "el"),
489−];
490−
491−/// Whether the element named `name` (without the `@`) shows its text as
492−/// written: `{@code {@link /x y}}` prints the braces, and its indented body
493−/// is verbatim text, HTML-escaped. The syntax tree reads this, so every
494−/// tool agrees on it.
495−pub fn has_literal_text(name: &str) -> bool {
496− ElementKind::from_name(name)
497− .and_then(|kind| kind.spec())
498− .is_some_and(|spec| spec.literal)
499−}
500−
501−/// How a directive reads the rest of its line.
502−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503−pub enum ArgGrammar {
504− /// Free text, taken as written (`@include file.hl`, `@meta name value`).
505− /// For a directive with a verbatim body it is the one-line form of the
506− /// body (`@raw <hr>`, `@style .a { color: red }`) or, for `@markdown`,
507− /// the file.
508− Text,
509− /// An optional `[attributes]` list, then text (`@page [lang=en] Title`).
510− AttrsText,
511− /// An expression (`@if $count > 2`).
512− Expression,
513− /// A name, then a value, `= EXPR`, `"text"`, `[attributes]` or the
514− /// parameters of a function (`@let`).
515− Definition,
516− /// Nothing, or `if EXPR` (`@else`, `@else if`).
517− Else,
518− /// `$item in LIST` or `$item, $index in LIST` (`@each`).
519− Loop,
520− /// `$name SOURCE`; inline JSON may span several lines (`@data`).
521− Data,
522−}
523−
524−/// What the lines indented under a directive or element are.
525−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526−pub enum BodyKind {
527− /// Nothing: indented lines are an error.
528− None,
529− /// htmlang: elements, text and directives.
530− Htmlang,
531− /// Foreign text (CSS, HTML, JavaScript, Markdown, a code sample), kept
532− /// exactly as written: nothing in it is parsed, and `--` is not a
533− /// comment. The text on the header's line is its one-line form (or a
534− /// file or URL: `@markdown notes.md`, `@script app.js`); the line and a
535− /// body together are an error. A verbatim directive takes no
536− /// attributes.
537− Verbatim,
538−}
539−
540−/// A directive: a `@name` that isn't an element. The syntax tree
541−/// (`syntax.rs`), the evaluator, the formatter and the editor tools all
542−/// read the directives' shapes from this table.
543−#[derive(Debug, PartialEq)]
544−pub struct DirectiveSpec {
545− /// Name in htmlang source, without the `@`.
546− pub name: &'static str,
547− pub args: ArgGrammar,
548− pub body: BodyKind,
549−}
550−
551−/// Every directive.
552−#[rustfmt::skip]
553−pub static DIRECTIVES: &[DirectiveSpec] = &[
554− DirectiveSpec { name: "page", args: ArgGrammar::AttrsText, body: BodyKind::None },
555− DirectiveSpec { name: "let", args: ArgGrammar::Definition, body: BodyKind::Htmlang },
556− DirectiveSpec { name: "include", args: ArgGrammar::Text, body: BodyKind::None },
557− DirectiveSpec { name: "data", args: ArgGrammar::Data, body: BodyKind::None },
558− DirectiveSpec { name: "meta", args: ArgGrammar::Text, body: BodyKind::None },
559− DirectiveSpec { name: "if", args: ArgGrammar::Expression, body: BodyKind::Htmlang },
560− DirectiveSpec { name: "else", args: ArgGrammar::Else, body: BodyKind::Htmlang },
561− DirectiveSpec { name: "each", args: ArgGrammar::Loop, body: BodyKind::Htmlang },
562− DirectiveSpec { name: "style", args: ArgGrammar::Text, body: BodyKind::Verbatim },
563− DirectiveSpec { name: "head", args: ArgGrammar::Text, body: BodyKind::Verbatim },
564− DirectiveSpec { name: "raw", args: ArgGrammar::Text, body: BodyKind::Verbatim },
565− DirectiveSpec { name: "markdown", args: ArgGrammar::Text, body: BodyKind::Verbatim },
566];
251];
567
252
568−/// The directive named `name` (without the `@`).
253+/// Elements that print their argument as their text (`@text Hello`,
569−pub fn directive(name: &str) -> Option<&'static DirectiveSpec> {
254+/// `@item First`).
570− DIRECTIVES.iter().find(|d| d.name == name)
255+pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
571−}
256+ *kind == ElementKind::Text || kind.spec().is_some_and(|spec| spec.arg == TagArg::Text)
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}
257}
crates/htmlang-core/src/codegen.rs+1840−1591
@@ -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−
24−/// How many base-36 digits a class's name has after `hl-`: 7, about 36
25−/// bits of its style's hash, so a site with 10,000 different styles has
26−/// odds of about 6 in 10,000 that two of them share a name, which
27−/// `htmlang build` reports (`class-collision`). Within one file, a style
28−/// whose name is taken takes more digits.
29−pub const CLASS_DIGITS: usize = 7;
30−
31−/// A stable hash of `text`: FNV-1a, then SplitMix64's finalizer, so every
32−/// digit of a name depends on all of the text. It is the same on every
33−/// platform and in every build, unlike `std`'s hasher.
34−pub fn stable_hash(text: &str) -> u64 {
35− let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
36− for byte in text.bytes() {
37− hash ^= u64::from(byte);
38− hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
39− }
40− hash = (hash ^ (hash >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
41− hash = (hash ^ (hash >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
42− hash ^ (hash >> 31)
43−}
44−
45−/// `digits` base-36 digits of `hash`: `2k9xq0m`. With 13, all of it.
46−pub fn hash_digits(hash: u64, digits: usize) -> String {
22+pub(crate) fn short_class_name(idx: usize) -> String {
47− const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
23+ const REST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
24+ // Find the name length: 26 names of length 1, 26*36 of length 2, ...
48− let mut n = hash;
25+ let mut n = idx;
49− (0..digits)
26+ let mut len = 1;
50− .map(|_| {
27+ let mut count = 26usize;
51− let digit = DIGITS[(n % 36) as usize] as char;
28+ while n >= count {
29+ n -= count;
30+ len += 1;
31+ count = count.saturating_mul(36);
32+ }
33+ let mut tail = Vec::with_capacity(len - 1);
34+ for _ in 1..len {
35+ tail.push(REST[n % 36]);
52− n /= 36;
36+ 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−}
37+ }
66−
38+ let mut name = String::with_capacity(len);
67−/// When a style applies: the at-rule prefixes it is under (their ranks,
39+ name.push((b'a' + n as u8) as char);
68−/// see [`crate::vocab::at_rule_rank`], sorted, so `dark:md:` and `md:dark:`
40+ name.extend(tail.iter().rev().map(|&b| b as char));
69−/// are one condition) and its selector prefixes, left to right as the
41+ name
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
301− style: u64,
302− /// The defaults of the element's kind (its layout, a heading's
303− /// `margin:0`, ...), less what its own attributes set. They are written
304− /// as `:where(.hl-a)` in the layer `hl-kind`, so an element prefix
305− /// around it (`@h2:`), its parent's `children:` styles and its own
306− /// attributes, all in later layers, win over them.
307− defaults: String,
46+ base: String,
308− /// The element's rules: under each condition, its declarations, in the
47+ /// (CSS selector suffix, css_rules) — e.g. (":hover", "color:red;")
309− /// order they are written
48+ pseudo: Vec<(String, String)>,
310− rules: Vec<(Condition, String)>,
49+ /// Responsive overrides: (breakpoint_prefix, css)
311− /// What else tells two elements with the same CSS apart: the rules
50+ responsive: Vec<(String, String)>,
312− /// keyed on this class (see [`StyleCollector::keyed`]). Not written.
51+ /// Dark mode overrides
52+ dark: String,
53+ /// Print overrides
313− distinct: String,
54+ print: String,
55+ motion_safe: String,
56+ motion_reduce: String,
57+ landscape: String,
58+ portrait: String,
59+ /// Container query overrides: (breakpoint_prefix, css)
60+ container: Vec<(String, String)>,
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);
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+
364− if let Some(indices) = self.index.get(&style) {
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> + '_ {
394− self.entries.iter().map(|e| Class {
395− name: e.class_name.clone(),
396− style: e.style,
397− })
398− }
399−
400− /// All generated rules, in three layers (see [`LAYERS`]): the element
401− /// kinds' defaults in `hl-kind` (`:where(.hl-a)`), the rules for the
402− /// elements an element prefix picks out inside an element (`@td:`), and
403− /// a function's scoped `@style`, in `hl-inside`, and the elements' own
404− /// rules in `htmlang`. They are written in a fixed order, so which rule
405− /// wins never depends on where in the page an element is: the rules
406− /// without a prefix, then those under selector prefixes, then each block
407− /// of at-rules (see [`at_order`]), in which the rules keyed on a parent
408− /// come first, then the element's own, then its selectors (see
409− /// [`selector_order`]).
410− fn to_css_formatted(&self, dev: bool, scoped: &[String]) -> String {
159+ fn to_css_formatted(&self, dev: bool) -> String {
411− // The defaults: `:where(.hl-a,.hl-b){...}`
412− let mut kind = String::new();
160+ let mut css = String::new();
413− let mut defaults: Vec<(&str, Vec<&str>)> = Vec::new();
161+ let inner_indent = if dev { " " } else { "" };
414− for e in self.entries.iter().filter(|e| !e.defaults.is_empty()) {
162+ css.push_str(if dev { "@layer htmlang {\n" } else { "@layer htmlang{" });
415− match defaults.iter_mut().find(|(body, _)| *body == e.defaults) {
163+
416− Some((_, classes)) => classes.push(&e.class_name),
164+ // Non-responsive rules. Base and pseudo rules are merged separately by
417− None => defaults.push((&e.defaults, vec![&e.class_name])),
165+ // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
418− }
166+ // replaces two identical rules. Pseudo variants are grouped per selector
419− }
167+ // suffix (`:hover` with `:hover`, etc.) to keep each pseudo's cascade
420− for (body, classes) in defaults {
168+ // position independent of others.
421− let classes: Vec<String> = classes.iter().map(|c| format!(".{}", c)).collect();
169+ let mut base_pairs: Vec<(&str, &str)> = Vec::with_capacity(self.entries.len());
422− let selector = format!(":where({})", classes.join(","));
170+ for e in &self.entries {
423− if dev {
171+ if !e.base.is_empty() {
424− kind.push_str(&format!(" {} {{{}}}\n", selector, body));
172+ base_pairs.push((e.class_name.as_str(), e.base.as_str()));
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 {
463− let mut css = String::new();
464− let rules = || {
465− self.entries.iter().flat_map(move |e| {
205+ for e in &self.entries {
466− e.rules
206+ for (bp, rule_css) in &e.responsive {
467− .iter()
207+ if bp == bp_name && !rule_css.is_empty() {
468− .filter(move |(c, _)| c.inside().is_some() == inside)
208+ bp_pairs.push((e.class_name.as_str(), rule_css.as_str()));
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
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+ }
579− };
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
346+/// across runs. `selector_suffix` is appended to each class (e.g. `":hover"`,
590−/// across runs. `indent` is prepended to each rule line.
347+/// or `""` for plain class rules). `indent` is prepended to each rule line.
591−fn emit_grouped_rules(out: &mut String, pairs: &[(String, &str)], indent: &str, dev: bool) {
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;
403+ }
404+ let mut inner = String::new();
405+ let inner_indent = if dev { " " } else { "" };
406+ emit_grouped_rules(&mut inner, pairs, selector_suffix, inner_indent, dev);
407+ if inner.is_empty() {
408+ return;
409+ }
632− if dev {
410+ if dev {
633− out.push(' ');
411+ out.push_str(&format!("{} {{\n{}}}\n", header_dev, inner));
634− }
412+ } else {
635− out.push('{');
636− out.push_str(body);
413+ out.push_str(&format!("{}{{{}}}", header_min, inner));
637− out.push('}');
638− if dev {
639− out.push('\n');
640− }
641− }
642− i += run;
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 {
@@ -684,22 +448,15 @@ pub struct CodegenOptions {
684
448
685/// Generate HTML from a parsed document using the given options.
449/// Generate HTML from a parsed document using the given options.
686pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String {
450pub 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 {
451 let mut html = if opts.partial {
695− generate_partial_inner(doc, opts.dev, &mut classes)
452+ generate_partial_inner(doc, opts.dev)
696 } else {
453 } else {
697− generate_full_inner(doc, opts.dev, &mut classes)
454+ generate_full_inner(doc, opts.dev)
698 };
455 };
699 if opts.minify {
456 if opts.minify {
700 html = minify_html(&html);
457 html = minify_html(&html);
701 }
458 }
702− (html, classes)
459+ html
703}
460}
704
461
705pub fn generate(doc: &Document) -> String {
462pub fn generate(doc: &Document) -> String {
@@ -825,131 +582,266 @@ fn minify_html(html: &str) -> String {
825 result
582 result
826}
583}
827
584
828−fn generate_full_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
585+fn generate_full_inner(doc: &Document, dev: bool) -> String {
829− // Without `@page` the output is a fragment
830− let Some(page) = &doc.page else {
831− return generate_partial_inner(doc, dev, classes);
832− };
833 let mut styles = StyleCollector::new();
586 let mut styles = StyleCollector::new();
834 let mut ctx = GenContext {
587 let mut ctx = GenContext {
835 dev,
588 dev,
836 depth: 0,
589 depth: 0,
590+ image_count: 0,
837 has_interactive: false,
591 has_interactive: false,
838− in_text: false,
839− flow: Flow::default(),
840− in_picture: false,
841 };
592 };
842
593
843− // The page is the root element: `<body>` is a column (the reset makes
594+ // Check if document has @main for skip-to-content link
844− // it one, filling the viewport), `@page`'s styles are its class, and
595+ 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
596
867 let mut body = String::new();
597 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−
876− let element_css = build_element_css(doc, &styles, dev);
877− let nl = if dev { "\n" } else { "" };
878
598
879− let mut meta_html = String::new();
599+ // Skip-to-content link for accessibility (only when @main exists)
880− // A `@meta viewport` of the page's own replaces the usual one
600+ if has_main {
881− if !doc.meta_tags.iter().any(|(name, _)| name == "viewport") {
601+ if dev {
882− meta_html.push_str(&format!(
602+ body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>\n");
883− "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">{}",
603+ } else {
884− nl
604+ body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>");
885− ));
605+ }
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 }
606 }
915− for block in &doc.head_blocks {
607+
916− meta_html.push_str(block);
608+ for node in &doc.nodes {
917− meta_html.push_str(nl);
609+ generate_node(node, None, &mut body, &mut styles, &mut ctx);
918 }
610 }
919
611
920− // `@page`'s HTML attributes go on `<html>`
612+
613+ let element_css = build_element_css(doc, &styles, dev);
614+
615+ // Build meta tags string
616+ let meta_html = if doc.meta_tags.is_empty() {
617+ String::new()
618+ } else {
619+ let mut m = String::new();
620+ for (name, content) in &doc.meta_tags {
621+ if dev {
622+ m.push_str(&format!(
623+ "<meta name=\"{}\" content=\"{}\">\n",
624+ html_escape(name),
625+ html_escape(content)
626+ ));
627+ } else {
628+ m.push_str(&format!(
629+ "<meta name=\"{}\" content=\"{}\">",
630+ html_escape(name),
631+ html_escape(content)
632+ ));
633+ }
634+ }
635+ m
636+ };
637+
638+ // Build OG meta tags
639+ let og_html = if doc.og_tags.is_empty() {
640+ String::new()
641+ } else {
642+ let mut o = String::new();
643+ for (property, content) in &doc.og_tags {
644+ if dev {
645+ o.push_str(&format!(
646+ "<meta property=\"og:{}\" content=\"{}\">\n",
647+ html_escape(property),
648+ html_escape(content)
649+ ));
650+ } else {
651+ o.push_str(&format!(
652+ "<meta property=\"og:{}\" content=\"{}\">",
653+ html_escape(property),
654+ html_escape(content)
655+ ));
656+ }
657+ }
658+ o
659+ };
660+
661+ // Build head blocks string
662+ let head_html = if doc.head_blocks.is_empty() {
663+ String::new()
664+ } else {
665+ let mut h = String::new();
666+ for block in &doc.head_blocks {
667+ h.push_str(block);
668+ if dev {
669+ h.push('\n');
670+ }
671+ }
672+ h
673+ };
674+
675+ let lang_attr = match &doc.lang {
676+ Some(lang) => format!(" lang=\"{}\"", html_escape(lang)),
677+ None => String::new(),
678+ };
679+
680+ let favicon_html = match &doc.favicon {
681+ Some(path) => {
682+ // Try to read and inline the favicon
683+ if let Ok(data) = std::fs::read(path) {
684+ let mime = if path.ends_with(".ico") {
685+ "image/x-icon"
686+ } else if path.ends_with(".png") {
687+ "image/png"
688+ } else if path.ends_with(".svg") {
689+ "image/svg+xml"
690+ } else {
691+ "image/x-icon"
692+ };
693+ let b64 = base64_encode(&data);
694+ if dev {
695+ format!(
696+ "<link rel=\"icon\" href=\"data:{};base64,{}\">\n",
697+ mime, b64
698+ )
699+ } else {
700+ format!("<link rel=\"icon\" href=\"data:{};base64,{}\">", mime, b64)
701+ }
702+ } else {
703+ // Fall back to href
704+ if dev {
705+ format!("<link rel=\"icon\" href=\"{}\">\n", html_escape(path))
706+ } else {
707+ format!("<link rel=\"icon\" href=\"{}\">", html_escape(path))
708+ }
709+ }
710+ }
711+ None => String::new(),
712+ };
713+
714+ // Canonical URL
715+ let canonical_html = match &doc.canonical {
716+ Some(url) => {
717+ if dev {
718+ format!("<link rel=\"canonical\" href=\"{}\">\n", html_escape(url))
719+ } else {
720+ format!("<link rel=\"canonical\" href=\"{}\">", html_escape(url))
721+ }
722+ }
723+ None => String::new(),
724+ };
725+
726+ // Base URL
727+ let base_html = match &doc.base_url {
728+ Some(url) => {
729+ if dev {
730+ format!("<base href=\"{}\">\n", html_escape(url))
731+ } else {
732+ format!("<base href=\"{}\">", html_escape(url))
733+ }
734+ }
735+ None => String::new(),
736+ };
737+
738+ // Preload hints
921− let mut root_attrs = String::new();
739+ let mut preload_html = String::new();
922− for attr in html_attrs(&page.html_attrs) {
740+ // Explicit preload hints from the document
923− push_html_attr(&mut root_attrs, attr);
741+ for hint in &doc.preload_hints {
742+ if dev {
743+ preload_html.push_str(&format!(
744+ "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>\n",
745+ html_escape(&hint.href),
746+ hint.as_type,
747+ if hint.crossorigin { " crossorigin" } else { "" }
748+ ));
749+ } else {
750+ preload_html.push_str(&format!(
751+ "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>",
752+ html_escape(&hint.href),
753+ hint.as_type,
754+ if hint.crossorigin { " crossorigin" } else { "" }
755+ ));
756+ }
924 }
757 }
925− let mut body_attrs = String::new();
926− emit_class_attr(&mut body_attrs, body_class.as_deref(), None);
927
758
928 // Focus-visible CSS for interactive elements (accessibility)
759 // Focus-visible CSS for interactive elements (accessibility)
929 let focus_visible_css = if ctx.has_interactive {
760 let focus_visible_css = if ctx.has_interactive {
930− let focusable = ":where(a,button,input,select,textarea):focus-visible";
931 if dev {
761 if dev {
932− format!(
933− "{}{} {{ outline: 2px solid currentColor; outline-offset: 2px; }}\n",
762+ "a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n"
934− OWN_ELEMENTS, focusable
935− )
936 } else {
763 } else {
937− format!(
938− "{}{}{{outline:2px solid currentColor;outline-offset:2px}}",
764+ "a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}"
939− OWN_ELEMENTS, focusable
940− )
941 }
765 }
942 } else {
766 } else {
943− String::new()
767+ ""
944 };
768 };
945− let reset_css = reset_css(dev, true, &focus_visible_css);
946− classes.extend(styles.classes());
947
769
770+ // Skip-to-content CSS (visually hidden but accessible)
771+ let skip_link_css = if has_main {
772+ if dev {
773+ ".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"
774+ } else {
775+ ".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}"
776+ }
777+ } else {
778+ ""
779+ };
780+
781+ let reset_css = reset_css(dev, focus_visible_css, skip_link_css);
782+
783+ match &doc.page_title {
784+ Some(title) => {
785+ if dev {
948− format!(
786+ format!(
949− "<!DOCTYPE html>{nl}<html{root_attrs}>{nl}<head>{nl}<meta charset=\"utf-8\">{nl}\
787+ "\
950− {meta_html}<style>{nl}{reset_css}{element_css}</style>{nl}</head>{nl}\
788+<!DOCTYPE html>
951− <body{body_attrs}>{nl}{body}</body>{nl}</html>{nl}",
789+<html{lang_attr}>
952− )
790+<head>
791+<meta charset=\"utf-8\">
792+<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
793+<title>{title}</title>
794+{base_html}{canonical_html}{preload_html}{meta_html}{og_html}{favicon_html}{head_html}\
795+<style>
796+{reset_css}{element_css}\
797+</style>
798+</head>
799+<body>
800+{body}\
801+</body>
802+</html>
803+",
804+ title = html_escape(title),
805+ lang_attr = lang_attr,
806+ base_html = base_html,
807+ canonical_html = canonical_html,
808+ preload_html = preload_html,
809+ meta_html = meta_html,
810+ favicon_html = favicon_html,
811+ head_html = head_html,
812+ og_html = og_html,
813+ reset_css = reset_css,
814+ element_css = element_css,
815+ body = body,
816+ )
817+ } else {
818+ format!(
819+ "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{base_html}{canonical_html}{preload_html}{meta_html}{og_html}{favicon_html}{head_html}<style>{reset_css}{element_css}</style></head><body>{body}</body></html>",
820+ title = html_escape(title),
821+ lang_attr = lang_attr,
822+ base_html = base_html,
823+ canonical_html = canonical_html,
824+ preload_html = preload_html,
825+ meta_html = meta_html,
826+ og_html = og_html,
827+ favicon_html = favicon_html,
828+ head_html = head_html,
829+ reset_css = reset_css,
830+ element_css = element_css,
831+ body = body,
832+ )
833+ }
834+ }
835+ None => {
836+ if element_css.is_empty() {
837+ body
838+ } else if dev {
839+ format!("<style>\n{}</style>\n{}", element_css, body)
840+ } else {
841+ format!("<style>{}</style>{}", element_css, body)
842+ }
843+ }
844+ }
953}
845}
954
846
955/// Assemble every CSS block the document needs (font faces, custom
847/// Assemble every CSS block the document needs (font faces, custom
@@ -958,16 +850,21 @@ fn generate_full_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> S
958fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
850fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
959 let mut element_css = String::new();
851 let mut element_css = String::new();
960
852
961− // Collect the CSS custom properties declared with `@let --name` so they
853+ // Collect all CSS custom properties (explicit `@let --name` / `@theme`
854+ // tokens, plus any auto-extracted repeats) so they can be emitted in a
962− // can be emitted in a single `:root` block below.
855+ // single `:root` block below.
963 let mut root_vars: Vec<(String, String)> = Vec::new();
856 let mut root_vars: Vec<(String, String)> = Vec::new();
964 for (name, value) in &doc.css_vars {
857 for (name, value) in &doc.css_vars {
965 root_vars.push((name.clone(), value.clone()));
858 root_vars.push((name.clone(), value.clone()));
966 }
859 }
967
860
968− // Generated rules always go in htmlang's layers, so unlayered user CSS
861+ // Generated rules always go in `@layer htmlang`, so unlayered user CSS
969 // (`@style`, `@raw`) overrides them regardless of specificity.
862 // (`@style`, `@raw`) overrides them regardless of specificity.
970− let styles_css = styles.to_css_formatted(dev, &doc.scoped_css);
863+ let styles_css = styles.to_css_formatted(dev);
864+ // Fold literal values declared via `@theme` / `@let --name` back into
865+ // `var(--name)` references so the generated CSS actually uses the
866+ // custom properties emitted in `:root`.
867+ let styles_css = substitute_css_vars(&styles_css, &root_vars);
971
868
972 // Emit the :root block first so the cascade picks up the custom
869 // Emit the :root block first so the cascade picks up the custom
973 // properties before the class rules consume them.
870 // properties before the class rules consume them.
@@ -992,121 +889,71 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
992
889
993 element_css.push_str(&styles_css);
890 element_css.push_str(&styles_css);
994
891
892+ // @keyframes
893+ for (name, kf_body) in &doc.keyframes {
894+ if dev {
895+ element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
896+ } else {
897+ element_css.push_str(&format!("@keyframes {}{{{}}}", name, kf_body));
898+ }
899+ }
900+
901+ // Keyframes for the standard library's `$skeleton` bundle, when used
902+ if element_css.contains("hl-skeleton") {
903+ if dev {
904+ element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
905+ } else {
906+ element_css.push_str("@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}");
907+ }
908+ }
909+
995 // @style blocks (custom CSS)
910 // @style blocks (custom CSS)
996 for block in &doc.custom_css {
911 for block in &doc.custom_css {
997 if dev {
912 if dev {
998 element_css.push_str(block);
913 element_css.push_str(block);
999 element_css.push('\n');
914 element_css.push('\n');
1000 } else {
915 } else {
916+ let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1001− element_css.push_str(&compact_css(block));
917+ element_css.push_str(&minified);
1002 }
918 }
1003 }
919 }
1004
920
1005 element_css
921 element_css
1006}
922}
1007
923
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
924/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1037−/// every layer, so an unlayered rule here would override the generated
925+/// every layer, so an unlayered `a{color:inherit}` would override
1038−/// ones. A page resets `box-sizing` everywhere and makes `<body>` a column
926+/// `@link [color red]`.
1039−/// that fills the window; a fragment, which goes into a page it doesn't
1040−/// own, touches only htmlang's own elements. In both, an element htmlang
1041−/// lays out stays hidden while it is `hidden`, a closed `<dialog>` or a
1042−/// popover that isn't showing: its generated `display` would otherwise
1043−/// beat the browser's `display: none`.
1044−fn reset_css(dev: bool, page: bool, focus_visible_css: &str) -> String {
927+fn reset_css(dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
1045− let own = OWN_ELEMENTS;
928+ let base = if dev {
1046− let mut rules = Vec::new();
929+ "*, *::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"
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 {
1059− rules.push(if dev {
1060− format!("{own}, {own}::before, {own}::after {{ box-sizing: border-box; }}")
1061− } else {
1062− format!("{own},{own}::before,{own}::after{{box-sizing:border-box}}")
1063− });
1064− }
1065− let hidden = "[hidden]:not([hidden=\"until-found\"]),dialog:not([open]):not(:popover-open),\
1066− [popover]:not(:popover-open):not(dialog[open])";
1067− rules.push(if dev {
1068− format!("{own}:where({hidden}) {{ display: none !important; }}")
1069 } else {
930 } else {
1070− format!("{own}:where({hidden}){{display:none!important}}")
931+ "*,*::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}"
1071− });
932+ };
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);
933+ let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
1077 if dev {
934 if dev {
1078− format!(
1079− "@layer {};\n@layer hl-reset {{\n{}}}\n",
935+ format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
1080− LAYERS.join(", "),
1081− rules
1082− )
1083 } else {
936 } else {
1084− format!("@layer {};@layer hl-reset{{{}}}", LAYERS.join(","), rules)
937+ format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
1085 }
938 }
1086}
939}
1087
940
1088/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
941/// 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 {
942+fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1090 let mut styles = StyleCollector::new();
943 let mut styles = StyleCollector::new();
1091 let mut ctx = GenContext {
944 let mut ctx = GenContext {
1092 dev,
945 dev,
1093 depth: 0,
946 depth: 0,
947+ image_count: 0,
1094 has_interactive: false,
948 has_interactive: false,
1095− in_text: false,
1096− flow: Flow::default(),
1097− in_picture: false,
1098 };
949 };
1099 let mut body = String::new();
950 let mut body = String::new();
1100
951
1101− generate_children(&doc.nodes, None, &mut body, &mut styles, &mut ctx);
952+ for node in &doc.nodes {
1102−
953+ 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 }
954 }
1109− classes.extend(styles.classes());
955+
956+ let element_css = build_element_css(doc, &styles, dev);
1110
957
1111 if element_css.is_empty() {
958 if element_css.is_empty() {
1112 body
959 body
@@ -1121,20 +968,22 @@ fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -
1121// Node generation
968// Node generation
1122// ---------------------------------------------------------------------------
969// ---------------------------------------------------------------------------
1123
970
1124−/// Write `node`, which is inside an element with the layout `parent`
1125−/// (`None` at the top of the page).
1126fn generate_node(
971fn generate_node(
1127 node: &Node,
972 node: &Node,
1128− parent: Option<Layout>,
973+ parent_kind: Option<&ElementKind>,
1129 out: &mut String,
974 out: &mut String,
1130 styles: &mut StyleCollector,
975 styles: &mut StyleCollector,
1131 ctx: &mut GenContext,
976 ctx: &mut GenContext,
1132) {
977) {
1133 match node {
978 match node {
1134− Node::Element(elem) => generate_element(elem, parent, out, styles, ctx),
979+ Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
1135 Node::Text(segments) => {
980 Node::Text(segments) => {
1136− // In a row, column or grid each line of text is a child
981+ let needs_wrap = matches!(
1137− let needs_wrap = parent.is_some_and(Layout::is_container);
982+ parent_kind,
983+ Some(ElementKind::Row | ElementKind::Column | ElementKind::El)
984+ ) || parent_kind
985+ .and_then(ElementKind::spec)
986+ .is_some_and(|spec| spec.wraps_text);
1138 if needs_wrap {
987 if needs_wrap {
1139 out.push_str(&ctx.indent());
988 out.push_str(&ctx.indent());
1140 out.push_str("<span>");
989 out.push_str("<span>");
@@ -1153,156 +1002,73 @@ fn generate_node(
1153 }
1002 }
1154}
1003}
1155
1004
1156−/// What goes between two things written one after the other in an element
1005+
1157−/// with this layout (`None`: the top of the page): text flows, so its lines
1006+/// Emit the argument of an element whose argument is an HTML attribute
1158−/// and children are joined with a space; in HTML's own layout, and at the
1007+/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`).
1159−/// top of the page, two lines of text are separated by a line break (a
1008+fn emit_argument_attr(out: &mut String, elem: &Element) {
1160−/// space, except where whitespace is kept, as in `@pre`).
1009+ if let Some(TagSpec {
1161−fn separator(layout: Option<Layout>, previous_is_text: bool, next: &Node) -> Option<char> {
1010+ arg: TagArg::Attr(attr),
1162− match layout {
1011+ ..
1163− Some(Layout::Text) => Some(' '),
1012+ }) = elem.kind.spec()
1164− Some(Layout::Native) | None if previous_is_text && matches!(next, Node::Text(_)) => {
1013+ && let Some(value) = &elem.argument
1165− Some('\n')
1014+ {
1166− }
1015+ out.push(' ');
1167− _ => None,
1016+ out.push_str(attr);
1017+ out.push_str("=\"");
1018+ out.push_str(&html_escape(value));
1019+ out.push('"');
1168 }
1020 }
1169}
1021}
1170
1022
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−
1239−/// A file's bytes as a `data:` URI, its type from its extension.
1240−pub(crate) fn data_uri(path: &str, data: &[u8]) -> String {
1241− let mime = match path
1242− .rsplit('.')
1243− .next()
1244− .map(str::to_ascii_lowercase)
1245− .as_deref()
1246− {
1247− Some("png") => "image/png",
1248− Some("jpg" | "jpeg") => "image/jpeg",
1249− Some("gif") => "image/gif",
1250− Some("webp") => "image/webp",
1251− Some("avif") => "image/avif",
1252− Some("svg") => "image/svg+xml",
1253− Some("ico") => "image/x-icon",
1254− _ => "application/octet-stream",
1255− };
1256− format!("data:{};base64,{}", mime, base64_encode(data))
1257−}
1258−
1259/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1023/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1260−/// are emitted with the generated class) and flags like `required`.
1024+/// are emitted with the generated class) and bare booleans like `required`.
1261fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1025fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1262− for attr in html_attrs(attrs) {
1026+ for attr in attrs {
1263 let key = attr.key.as_str();
1027 let key = attr.key.as_str();
1264− if key != "id" && key != "class" {
1028+ if attr.html && key != "id" && key != "class" {
1265− push_html_attr(out, attr);
1029+ out.push(' ');
1030+ out.push_str(key);
1031+ out.push_str("=\"");
1032+ out.push_str(&html_escape(strip_string_quotes(
1033+ attr.value.as_deref().unwrap_or(""),
1034+ )));
1035+ out.push('"');
1036+ } else if !attr.html
1037+ && attr.value.is_none()
1038+ && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&key)
1039+ {
1040+ out.push(' ');
1041+ out.push_str(key);
1266 }
1042 }
1267 }
1043 }
1268}
1044}
1269
1045
1270−/// The HTML attributes of `attrs`, `key=value` ones and flags, each name
1046+/// 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
1047+/// `"Avatar"`), return the inner content. Quotes act as source-level
1272−/// name, the browser keeps the first, so a bundle's attribute or a
1048+/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute
1273−/// function's own would win over one written after it.)
1049+/// values. Multi-quoted values like `"h h" "s m"` are left unchanged —
1274−fn html_attrs(attrs: &[Attribute]) -> impl Iterator<Item = &Attribute> {
1050+/// those are real string tokens (used e.g. by CSS `grid-template-areas`).
1275− let is_html =
1051+fn strip_string_quotes(val: &str) -> &str {
1276− |a: &Attribute| a.html || (a.value.is_none() && crate::vocab::is_html_flag(&a.key));
1052+ let bytes = val.as_bytes();
1277− attrs.iter().enumerate().filter_map(move |(i, attr)| {
1053+ if bytes.len() < 2 {
1278− let later = attrs[i + 1..]
1054+ return val;
1279− .iter()
1055+ }
1280− .any(|other| is_html(other) && other.key == attr.key);
1056+ let first = bytes[0];
1281− (is_html(attr) && !later).then_some(attr)
1057+ let last = bytes[bytes.len() - 1];
1282− })
1058+ if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1283−}
1059+ let inner = &val[1..val.len() - 1];
1284−
1060+ if !inner.as_bytes().contains(&first) {
1285−/// ` key="value"`, or ` key` for a flag.
1061+ return inner;
1286−fn push_html_attr(out: &mut String, attr: &Attribute) {
1062+ }
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 }
1063 }
1064+ val
1295}
1065}
1296
1066
1297// True if any direct child is `@in-front` or `@behind`. Such children render
1067// True if any direct child is `@in-front` or `@behind`. Such children render
1298// as absolutely positioned overlays, so the parent automatically becomes a
1068// as absolutely positioned overlays, so the parent automatically becomes a
1299// positioning context (position:relative + isolation:isolate).
1069// positioning context (position:relative + isolation:isolate).
1300fn has_overlay_children(elem: &Element) -> bool {
1070fn has_overlay_children(elem: &Element) -> bool {
1301− holds_overlays(&elem.children)
1302−}
1303−
1304−fn holds_overlays(children: &[Node]) -> bool {
1305− children.iter().any(|child| {
1071+ elem.children.iter().any(|child| {
1306 matches!(
1072 matches!(
1307 child,
1073 child,
1308 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
1074 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
@@ -1312,32 +1078,48 @@ fn holds_overlays(children: &[Node]) -> bool {
1312
1078
1313fn generate_element(
1079fn generate_element(
1314 elem: &Element,
1080 elem: &Element,
1315− parent: Option<Layout>,
1081+ parent_kind: Option<&ElementKind>,
1316 out: &mut String,
1082 out: &mut String,
1317 styles: &mut StyleCollector,
1083 styles: &mut StyleCollector,
1318 ctx: &mut GenContext,
1084 ctx: &mut GenContext,
1319) {
1085) {
1320 // Self-closing elements
1086 // Self-closing elements
1321− if elem.kind.layout() == Layout::Void {
1087+ if elem.kind == ElementKind::Image || elem.kind.spec().is_some_and(|spec| spec.void) {
1322− generate_self_closing(elem, parent, out, styles, ctx);
1088+ generate_self_closing(elem, parent_kind, out, styles, ctx);
1323 return;
1089 return;
1324 }
1090 }
1325− // A verbatim element (@script) writes its body as it is, with no HTML
1091+ // @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)
1327− if elem.kind.is_verbatim() {
1092+ if elem.kind == ElementKind::Script {
1328− let tag = elem.kind.spec().map_or("script", |spec| spec.html);
1329 out.push_str(&ctx.indent());
1093 out.push_str(&ctx.indent());
1330− out.push('<');
1094+ out.push_str("<script");
1331− out.push_str(tag);
1095+ // Pass through src, type, defer, async, etc.
1332− emit_argument_attr(out, elem, false);
1096+ for attr in &elem.attrs {
1333− let (id, class) = extract_id_class(&elem.attrs);
1097+ let key = attr.key.as_str();
1334− for (key, value) in [("id", id), ("class", class)] {
1098+ if matches!(
1335− if let Some(value) = value {
1099+ key,
1336− out.push_str(&format!(" {}=\"{}\"", key, html_escape(&value)));
1100+ "src"
1101+ | "type"
1102+ | "defer"
1103+ | "async"
1104+ | "crossorigin"
1105+ | "integrity"
1106+ | "nomodule"
1107+ | "id"
1108+ ) {
1109+ if let Some(val) = &attr.value {
1110+ out.push(' ');
1111+ out.push_str(key);
1112+ out.push_str("=\"");
1113+ out.push_str(&html_escape(val));
1114+ out.push('"');
1115+ } else {
1116+ out.push(' ');
1117+ out.push_str(key);
1118+ }
1337 }
1119 }
1338 }
1120 }
1339− emit_html_attrs(out, &elem.attrs);
1340 out.push('>');
1121 out.push('>');
1122+ // Children are raw JS code, not HTML
1341 for child in &elem.children {
1123 for child in &elem.children {
1342 match child {
1124 match child {
1343 Node::Text(segments) => {
1125 Node::Text(segments) => {
@@ -1351,9 +1133,7 @@ fn generate_element(
1351 _ => {}
1133 _ => {}
1352 }
1134 }
1353 }
1135 }
1354− out.push_str("</");
1136+ out.push_str("</script>");
1355− out.push_str(tag);
1356− out.push('>');
1357 out.push_str(ctx.nl());
1137 out.push_str(ctx.nl());
1358 return;
1138 return;
1359 }
1139 }
@@ -1364,26 +1144,26 @@ fn generate_element(
1364 return;
1144 return;
1365 }
1145 }
1366 if elem.kind == ElementKind::Fragment {
1146 if elem.kind == ElementKind::Fragment {
1367− // Render children without a wrapper element, as if they were
1147+ // Render children without a wrapper element
1368− // written where the fragment is
1148+ for child in &elem.children {
1369− generate_children(&elem.children, parent, out, styles, ctx);
1149+ generate_node(child, parent_kind, out, styles, ctx);
1150+ }
1370 return;
1151 return;
1371 }
1152 }
1372
1153
1373− let mut own = Flow::of(elem);
1374 let tag = match &elem.kind {
1154 let tag = match &elem.kind {
1375− ElementKind::Row | ElementKind::El => "div",
1155+ ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
1376 ElementKind::Text => "span",
1156 ElementKind::Text => "span",
1377 ElementKind::Paragraph => "p",
1157 ElementKind::Paragraph => "p",
1378 ElementKind::Link => "a",
1158 ElementKind::Link => "a",
1159+ ElementKind::Tag(spec)
1160+ if spec.name == "list" && elem.attrs.iter().any(|a| a.key == "ordered") =>
1161+ {
1162+ "ol"
1163+ }
1379 ElementKind::Tag(spec) => spec.html,
1164 ElementKind::Tag(spec) => spec.html,
1380 _ => "",
1165 _ => "",
1381 };
1166 };
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();
1167 let kind_label = elem.kind.name();
1388
1168
1389 // Track interactive elements for focus-visible CSS
1169 // Track interactive elements for focus-visible CSS
@@ -1394,8 +1174,14 @@ fn generate_element(
1394 }
1174 }
1395
1175
1396 // Compute CSS for each state and get a class name
1176 // Compute CSS for each state and get a class name
1397− let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1177+ let overlay_children = has_overlay_children(elem);
1398− own.class = gen_class.clone();
1178+ let gen_class = compute_class(
1179+ &elem.attrs,
1180+ &elem.kind,
1181+ parent_kind,
1182+ styles,
1183+ overlay_children,
1184+ );
1399 let (id, user_class) = extract_id_class(&elem.attrs);
1185 let (id, user_class) = extract_id_class(&elem.attrs);
1400
1186
1401 if ctx.dev && elem.line_num > 0 {
1187 if ctx.dev && elem.line_num > 0 {
@@ -1409,7 +1195,32 @@ fn generate_element(
1409 out.push('<');
1195 out.push('<');
1410 out.push_str(tag);
1196 out.push_str(tag);
1411
1197
1198+ // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1199+ if elem.kind.is_tag("main") && id.is_none() {
1200+ out.push_str(" id=\"hl-main\"");
1201+ }
1202+
1203+ if elem.kind == ElementKind::Link
1204+ && let Some(url) = &elem.argument
1205+ {
1206+ out.push_str(" href=\"");
1207+ out.push_str(&html_escape(url));
1208+ out.push('"');
1209+ // Auto rel="noopener noreferrer" and target="_blank" for external links
1210+ let is_external = url.starts_with("http://") || url.starts_with("https://");
1211+ if is_external {
1212+ let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1213+ let has_target = elem.attrs.iter().any(|a| a.key == "target");
1214+ if !has_rel {
1215+ out.push_str(" rel=\"noopener noreferrer\"");
1216+ }
1217+ if !has_target {
1218+ out.push_str(" target=\"_blank\"");
1219+ }
1220+ }
1221+ }
1222+
1412− emit_argument_attr(out, elem, ctx.in_picture);
1223+ emit_argument_attr(out, elem);
1413
1224
1414 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1225 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1415
1226
@@ -1430,27 +1241,28 @@ fn generate_element(
1430 }
1241 }
1431
1242
1432 out.push('>');
1243 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());
1244 out.push_str(ctx.nl());
1439
1245
1440− let layout = elem.kind.layout();
1246+ // Inline text argument
1247+ if renders_argument_as_text(&elem.kind)
1248+ && let Some(text) = &elem.argument
1249+ {
1250+ out.push_str(&html_escape(text));
1251+ }
1252+
1253+ // Children
1441 ctx.depth += 1;
1254 ctx.depth += 1;
1442− let outer_in_text = ctx.in_text;
1255+ let is_paragraph = elem.kind == ElementKind::Paragraph
1443− ctx.in_text = in_text || layout == Layout::Text;
1256+ || elem.kind.spec().is_some_and(|spec| spec.inline);
1444− let outer_flow = std::mem::replace(&mut ctx.flow, own);
1257+ for (i, child) in elem.children.iter().enumerate() {
1445− let outer_picture = std::mem::replace(&mut ctx.in_picture, elem.kind.is_tag("picture"));
1258+ generate_node(child, Some(&elem.kind), out, styles, ctx);
1446− generate_children(&elem.children, Some(layout), out, styles, ctx);
1259+ if is_paragraph && i < elem.children.len() - 1 {
1447− ctx.in_picture = outer_picture;
1260+ out.push(' ');
1448− ctx.flow = outer_flow;
1261+ }
1449− ctx.in_text = outer_in_text;
1262+ }
1450 ctx.depth -= 1;
1263 ctx.depth -= 1;
1451
1264
1452 out.push_str(&ctx.indent());
1265 out.push_str(&ctx.indent());
1453− ctx.dev = outer_dev;
1454 out.push_str("</");
1266 out.push_str("</");
1455 out.push_str(tag);
1267 out.push_str(tag);
1456 out.push('>');
1268 out.push('>');
@@ -1459,12 +1271,12 @@ fn generate_element(
1459
1271
1460fn generate_self_closing(
1272fn generate_self_closing(
1461 elem: &Element,
1273 elem: &Element,
1462− parent: Option<Layout>,
1274+ parent_kind: Option<&ElementKind>,
1463 out: &mut String,
1275 out: &mut String,
1464 styles: &mut StyleCollector,
1276 styles: &mut StyleCollector,
1465 ctx: &mut GenContext,
1277 ctx: &mut GenContext,
1466) {
1278) {
1467− let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1279+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1468 let (id, user_class) = extract_id_class(&elem.attrs);
1280 let (id, user_class) = extract_id_class(&elem.attrs);
1469
1281
1470 let (tag, kind_label) = match &elem.kind {
1282 let (tag, kind_label) = match &elem.kind {
@@ -1483,7 +1295,44 @@ fn generate_self_closing(
1483 out.push_str(&ctx.indent());
1295 out.push_str(&ctx.indent());
1484 out.push('<');
1296 out.push('<');
1485 out.push_str(tag);
1297 out.push_str(tag);
1486− emit_argument_attr(out, elem, ctx.in_picture);
1298+ emit_argument_attr(out, elem);
1299+
1300+ // Image src (with optional non-SVG base64 inlining)
1301+ if elem.kind == ElementKind::Image {
1302+ let src = elem.argument.as_deref().unwrap_or("");
1303+ let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
1304+ if is_inline && !src.is_empty() && !src.ends_with(".svg") {
1305+ let mime = if src.ends_with(".png") {
1306+ "image/png"
1307+ } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
1308+ "image/jpeg"
1309+ } else if src.ends_with(".gif") {
1310+ "image/gif"
1311+ } else if src.ends_with(".webp") {
1312+ "image/webp"
1313+ } else if src.ends_with(".avif") {
1314+ "image/avif"
1315+ } else {
1316+ "application/octet-stream"
1317+ };
1318+ if let Ok(data) = std::fs::read(src) {
1319+ let b64 = base64_encode(&data);
1320+ out.push_str(" src=\"data:");
1321+ out.push_str(mime);
1322+ out.push_str(";base64,");
1323+ out.push_str(&b64);
1324+ out.push('"');
1325+ } else {
1326+ out.push_str(" src=\"");
1327+ out.push_str(&html_escape(src));
1328+ out.push('"');
1329+ }
1330+ } else {
1331+ out.push_str(" src=\"");
1332+ out.push_str(&html_escape(src));
1333+ out.push('"');
1334+ }
1335+ }
1487
1336
1488 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1337 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1489
1338
@@ -1503,6 +1352,93 @@ fn generate_self_closing(
1503 ));
1352 ));
1504 }
1353 }
1505
1354
1355+ // Image optimization: auto-add loading="lazy" and decoding="async"
1356+ if elem.kind == ElementKind::Image {
1357+ // SVG inlining: @image [inline] logo.svg
1358+ if elem.attrs.iter().any(|a| a.key == "inline")
1359+ && let Some(src) = &elem.argument
1360+ && src.ends_with(".svg")
1361+ && let Ok(svg_content) = std::fs::read_to_string(src)
1362+ {
1363+ // Close the tag we opened, then emit inline SVG instead
1364+ out.truncate(out.rfind('<').unwrap_or(0));
1365+ out.push_str(&ctx.indent());
1366+ out.push_str(svg_content.trim());
1367+ out.push_str(ctx.nl());
1368+ return;
1369+ }
1370+ // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
1371+ let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
1372+ if let Some(resp) = responsive_attr
1373+ && let Some(ref sizes_str) = resp.value
1374+ {
1375+ let widths: Vec<&str> = sizes_str.split_whitespace().collect();
1376+ if !widths.is_empty() {
1377+ let src = elem.argument.as_deref().unwrap_or("");
1378+ if !src.is_empty() {
1379+ // Generate srcset with width descriptors
1380+ // Convention: file-{width}.ext (e.g., photo-400.jpg)
1381+ let dot_pos = src.rfind('.').unwrap_or(src.len());
1382+ let base = &src[..dot_pos];
1383+ let ext = &src[dot_pos..];
1384+ let mut srcset_parts = Vec::new();
1385+ for w in &widths {
1386+ srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
1387+ }
1388+ out.push_str(" srcset=\"");
1389+ out.push_str(&srcset_parts.join(", "));
1390+ out.push('"');
1391+ // Generate sizes attribute
1392+ let max_width = widths.last().unwrap_or(&"100vw");
1393+ out.push_str(&format!(
1394+ " sizes=\"(max-width: {}px) 100vw, {}px\"",
1395+ max_width, max_width
1396+ ));
1397+ }
1398+ }
1399+ }
1400+
1401+ // Auto image dimensions: read local image file to inject width/height + aspect-ratio
1402+ let has_width = elem.attrs.iter().any(|a| a.key == "width");
1403+ let has_height = elem.attrs.iter().any(|a| a.key == "height");
1404+ if (!has_width || !has_height)
1405+ && let Some(ref src) = elem.argument
1406+ && !src.starts_with("http://")
1407+ && !src.starts_with("https://")
1408+ && !src.starts_with("data:")
1409+ && let Some((w, h)) = read_image_dimensions(src)
1410+ {
1411+ // Intrinsic size attributes only when neither dimension is set in
1412+ // CSS; with one CSS dimension, a leftover intrinsic attribute for
1413+ // the other would distort the image (200 wide but 1000 tall).
1414+ if !has_width && !has_height {
1415+ out.push_str(&format!(" width=\"{}\" height=\"{}\"", w, h));
1416+ }
1417+ // Auto aspect-ratio to prevent CLS
1418+ if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
1419+ out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
1420+ }
1421+ }
1422+
1423+ // Smart image loading: first 3 images get fetchpriority="high" (above the fold),
1424+ // subsequent images get loading="lazy" + decoding="async"
1425+ ctx.image_count += 1;
1426+ if ctx.image_count <= 3 {
1427+ // Above-the-fold: eager loading with high priority
1428+ if !elem.attrs.iter().any(|a| a.key == "fetchpriority") {
1429+ out.push_str(" fetchpriority=\"high\"");
1430+ }
1431+ } else {
1432+ // Below-the-fold: lazy loading
1433+ if !elem.attrs.iter().any(|a| a.key == "loading") {
1434+ out.push_str(" loading=\"lazy\"");
1435+ }
1436+ if !elem.attrs.iter().any(|a| a.key == "decoding") {
1437+ out.push_str(" decoding=\"async\"");
1438+ }
1439+ }
1440+ }
1441+
1506 out.push('>');
1442 out.push('>');
1507 out.push_str(ctx.nl());
1443 out.push_str(ctx.nl());
1508}
1444}
@@ -1518,10 +1454,7 @@ fn generate_text_segments(
1518 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1454 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1519 TextSegment::Inline(elem) => {
1455 TextSegment::Inline(elem) => {
1520 let mut buf = String::new();
1456 let mut buf = String::new();
1521− // An element inside a line of text is in text
1522− let outer_flow = std::mem::take(&mut ctx.flow);
1523− generate_element(elem, Some(Layout::Text), &mut buf, styles, ctx);
1457+ generate_element(elem, None, &mut buf, styles, ctx);
1524− ctx.flow = outer_flow;
1525 out.push_str(buf.trim_end());
1458 out.push_str(buf.trim_end());
1526 }
1459 }
1527 }
1460 }
@@ -1534,97 +1467,135 @@ fn generate_text_segments(
1534
1467
1535fn compute_class(
1468fn compute_class(
1536 attrs: &[Attribute],
1469 attrs: &[Attribute],
1537− site: &Site,
1470+ kind: &ElementKind,
1471+ parent_kind: Option<&ElementKind>,
1538 styles: &mut StyleCollector,
1472 styles: &mut StyleCollector,
1539− distinct: String,
1473+ has_overlay_children: bool,
1540) -> Option<String> {
1474) -> Option<String> {
1541− // Every condition an attribute is under, and none
1475+ let base = attrs_to_css(attrs, "", kind, parent_kind, has_overlay_children);
1542− let mut conditions = vec![Condition::default()];
1476+
1543− for attr in attrs.iter().filter(|a| !a.html) {
1477+ // Collect pseudo-state overrides
1544− let (condition, _) = Condition::of(&attr.key);
1478+ let mut pseudo = Vec::new();
1545− if !conditions.contains(&condition) {
1479+ for &(prefix, selector) in crate::vocab::PSEUDO_PREFIXES {
1546− conditions.push(condition);
1480+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1481+ if !css.is_empty() {
1482+ pseudo.push((selector.to_string(), css));
1483+ }
1484+ }
1485+
1486+ // Collect nth:EXPR: dynamic pseudo selectors
1487+ let mut nth_prefixes: Vec<String> = Vec::new();
1488+ for attr in attrs {
1489+ if attr.key.starts_with("nth:") {
1490+ let rest = &attr.key[4..];
1491+ if let Some(colon_pos) = rest.find(':') {
1492+ let prefix = format!("nth:{}:", &rest[..colon_pos]);
1493+ if !nth_prefixes.contains(&prefix) {
1494+ nth_prefixes.push(prefix);
1495+ }
1496+ }
1497+ }
1498+ }
1499+ for prefix in &nth_prefixes {
1500+ let expr = &prefix[4..prefix.len() - 1];
1501+ let selector = format!(":nth-child({})", expr);
1502+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1503+ if !css.is_empty() {
1504+ pseudo.push((selector, css));
1547 }
1505 }
1548 }
1506 }
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
1507
1573−/// The defaults of an element's kind where it is: an overlay's positioning
1508+ // Collect has(...): dynamic pseudo selectors
1574−/// context, its layout, and its kind's own CSS (a heading's `margin:0`).
1509+ let mut has_prefixes: Vec<String> = Vec::new();
1575−fn default_css(site: &Site) -> String {
1510+ for attr in attrs {
1576− let mut css = String::new();
1511+ if attr.key.starts_with("has(")
1577− // Elements with @in-front / @behind children become positioning
1512+ && let Some(close) = attr.key.find("):")
1578− // contexts
1513+ {
1579− if site.has_overlay_children {
1514+ let prefix = format!("{}:", &attr.key[..close + 1]);
1580− css.push_str("position:relative;isolation:isolate;");
1515+ if !has_prefixes.contains(&prefix) {
1516+ has_prefixes.push(prefix);
1517+ }
1518+ }
1581 }
1519 }
1582− if !site.root {
1520+ for prefix in &has_prefixes {
1583− css.push_str(layout_css(site.kind.layout(), site.inline));
1521+ let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
1522+ let selector = format!(":has({})", inner);
1523+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1524+ if !css.is_empty() {
1525+ pseudo.push((selector, css));
1526+ }
1584 }
1527 }
1585− css.push_str(site.kind.css());
1586− css
1587−}
1588
1528
1589−/// The declarations of a rule body, as (property, `property:value;`), for
1529+ // Collect responsive overrides
1590−/// bodies htmlang wrote: every declaration ends in `;`, and a `;` inside
1530+ let mut responsive = Vec::new();
1591−/// quotes or parentheses doesn't end one.
1531+ for &(bp_name, _) in BREAKPOINTS {
1592−fn declarations(css: &str) -> impl Iterator<Item = (&str, &str)> {
1532+ let prefix = format!("{}:", bp_name);
1593− let mut rest = css;
1533+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1594− std::iter::from_fn(move || {
1595− if rest.is_empty() {
1534+ if !css.is_empty() {
1596− return None;
1535+ responsive.push((bp_name.to_string(), css));
1597 }
1536 }
1598− let (mut depth, mut quote, mut escaped) = (0i32, None, false);
1537+ }
1599− let mut end = rest.len();
1538+
1600− for (i, c) in rest.char_indices() {
1539+ // Collect container query overrides
1601− match c {
1540+ let mut container = Vec::new();
1602− _ if escaped => escaped = false,
1541+ for &(bp_name, _) in BREAKPOINTS {
1603− '\\' => escaped = true,
1542+ let prefix = format!("cq-{}:", bp_name);
1604− c if Some(c) == quote => quote = None,
1543+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1605− _ if quote.is_some() => {}
1544+ if !css.is_empty() {
1606− '"' | '\'' => quote = Some(c),
1545+ 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 }
1546 }
1616− let (declaration, after) = rest.split_at(end);
1617− rest = after;
1618− let name = declaration.split(':').next().unwrap_or("").trim();
1619− Some((name, declaration))
1620− })
1547+ }
1548+
1549+ let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
1550+ let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
1551+ let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind, has_overlay_children);
1552+ let motion_reduce =
1553+ attrs_to_css(attrs, "motion-reduce:", kind, parent_kind, has_overlay_children);
1554+ let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
1555+ let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
1556+
1557+ // Dedupe: if a property is declared twice within a single rule, keep only
1558+ // the last occurrence (element-kind defaults are written before
1559+ // user attributes, so a user [list-style disc] correctly overrides the
1560+ // default list-style:none, and we don't need to ship both).
1561+ let base = dedupe_declarations(&base);
1562+ let pseudo: Vec<(String, String)> = pseudo
1563+ .into_iter()
1564+ .map(|(sel, css)| (sel, dedupe_declarations(&css)))
1565+ .collect();
1566+ let responsive: Vec<(String, String)> = responsive
1567+ .into_iter()
1568+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1569+ .collect();
1570+ let dark = dedupe_declarations(&dark);
1571+ let print = dedupe_declarations(&print);
1572+ let motion_safe = dedupe_declarations(&motion_safe);
1573+ let motion_reduce = dedupe_declarations(&motion_reduce);
1574+ let landscape = dedupe_declarations(&landscape);
1575+ let portrait = dedupe_declarations(&portrait);
1576+ let container: Vec<(String, String)> = container
1577+ .into_iter()
1578+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1579+ .collect();
1580+
1581+ styles.get_class(
1582+ base,
1583+ pseudo,
1584+ responsive,
1585+ dark,
1586+ print,
1587+ motion_safe,
1588+ motion_reduce,
1589+ landscape,
1590+ portrait,
1591+ container,
1592+ )
1621}
1593}
1622
1594
1623/// Dedupe CSS declarations within a single rule body: for any property
1595/// Dedupe CSS declarations within a single rule body: for any property
1624/// declared more than once, keep only the last occurrence. Unparseable
1596/// declared more than once, keep only the last occurrence. Unparseable
1625−/// segments (no `:`) are preserved as-is. Semicolons inside parentheses or
1597+/// segments (no `:`) are preserved as-is. Semicolons inside parentheses are
1626−/// quoted strings are treated as part of a value, not as declaration
1598+/// treated as part of a value, not as declaration separators.
1627−/// separators.
1628fn dedupe_declarations(css: &str) -> String {
1599fn dedupe_declarations(css: &str) -> String {
1629 if css.is_empty() {
1600 if css.is_empty() {
1630 return String::new();
1601 return String::new();
@@ -1639,26 +1610,8 @@ fn dedupe_declarations(css: &str) -> String {
1639 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1610 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1640 let mut current = String::new();
1611 let mut current = String::new();
1641 let mut depth: i32 = 0;
1612 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() {
1613 for ch in css.chars() {
1647− if escaped {
1614+ 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;
1615 depth += 1;
1663 current.push(ch);
1616 current.push(ch);
1664 } else if ch == ')' {
1617 } else if ch == ')' {
@@ -1740,570 +1693,597 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
1740// Attribute → CSS mapping
1693// Attribute → CSS mapping
1741// ---------------------------------------------------------------------------
1694// ---------------------------------------------------------------------------
1742
1695
1743−/// Where an element is written, which its CSS depends on.
1696+/// (htmlang prefix, CSS selector suffix)
1744−struct Site<'a> {
1697+/// `display:flex;flex-direction:column;` — base layout for `@el` and every
1745− kind: &'a ElementKind,
1698+/// semantic wrapper that behaves like a column.
1746− /// How its parent lays out its children: its `width fill`, `height
1699+const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
1747− /// fill` and `shrink` compile against it.
1748− parent: &'a Flow,
1749− /// It has `@in-front` / `@behind` children.
1750− has_overlay_children: bool,
1751− /// A row, column or grid inside text, laid out inline.
1752− inline: bool,
1753− /// The page's `<body>`, which the reset already makes a column.
1754− root: bool,
1755−}
1756−
1757−impl Site<'_> {
1758− /// Where an element in a parent with the layout `layout` and the flow
1759− /// `parent` is.
1760− fn new<'a>(elem: &'a Element, layout: Option<Layout>, parent: &'a Flow) -> Site<'a> {
1761− Site {
1762− kind: &elem.kind,
1763− parent,
1764− has_overlay_children: has_overlay_children(elem),
1765− inline: layout == Some(Layout::Text) && elem.kind.layout().is_container(),
1766− root: false,
1767− }
1768− }
1769−}
1770−
1771−// ---------------------------------------------------------------------------
1772−// Direction: what a child's `fill` and `shrink` compile against
1773−// ---------------------------------------------------------------------------
1774−
1775−/// The way a flex row or column lays out its children.
1776−#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1777−enum Axis {
1778− Row,
1779− Column,
1780−}
1781−
1782−impl Axis {
1783− /// The direction a `flex-direction` or `flex-flow` value sets, if it
1784− /// names one (not `var(--dir)`, which only the browser knows).
1785− fn of_value(value: &str) -> Option<Axis> {
1786− value.split_whitespace().find_map(|word| match word {
1787− "row" | "row-reverse" => Some(Axis::Row),
1788− "column" | "column-reverse" => Some(Axis::Column),
1789− _ => None,
1790− })
1791− }
1792−}
1793−
1794−/// How an element lays out its children, as its CSS sets it: the
1795−/// direction its children's `width`/`height` `fill` and `shrink` compile
1796−/// against. That is the direction of `@row` or of a column, or the one its
1797−/// own `flex-direction` (or `flex-flow`) sets, with or without media or
1798−/// container prefixes. Like elm-ui's `.r > .wf`, the rules for a direction
1799−/// set under a prefix are keyed on the element's class: `@media (...) {
1800−/// :where(.a)>.b {...} }`.
1801−#[derive(Clone, Default)]
1802−struct Flow {
1803− /// Without a prefix; `None` when the element isn't a flex row or column
1804− /// (a grid, text, HTML's own layout, the top of the page)
1805− base: Option<Axis>,
1806− /// The direction under each chain of at-rule prefixes that sets one, in
1807− /// the order their blocks are written
1808− changes: Vec<(Vec<usize>, Axis)>,
1809− /// The element's generated class, which the rules for the changes are
1810− /// keyed on
1811− class: Option<String>,
1812−}
1813−
1814−impl Flow {
1815− fn of(elem: &Element) -> Flow {
1816− let mut base = match elem.kind.layout() {
1817− Layout::Row => Axis::Row,
1818− Layout::Column => Axis::Column,
1819− _ => return Flow::default(),
1820− };
1821− let mut changes: Vec<(Vec<usize>, Axis)> = Vec::new();
1822− for attr in elem.attrs.iter().filter(|a| !a.html) {
1823− let (condition, name) = Condition::of(&attr.key);
1824− if !matches!(name, "flex-direction" | "flex-flow") || !condition.selector.is_empty() {
1825− continue;
1826− }
1827− let Some(axis) = attr.value.as_deref().and_then(Axis::of_value) else {
1828− continue;
1829− };
1830− if condition.at.is_empty() {
1831− base = axis;
1832− } else {
1833− // The later one wins, as in the CSS
1834− changes.retain(|(at, _)| *at != condition.at);
1835− changes.push((condition.at, axis));
1836− }
1837− }
1838− changes.sort_by_key(|(at, _)| at_order(at));
1839− Flow {
1840− base: Some(base),
1841− changes,
1842− class: None,
1843− }
1844− }
1845−
1846− /// The direction under the at-rules `at`: the latest one set among
1847− /// the conditions that hold there (`md:` holds at `lg:`), else the one
1848− /// without a prefix.
1849− fn at(&self, at: &[usize]) -> Option<Axis> {
1850− let base = self.base?;
1851− let here = Condition::at_only(at);
1852− let changed = self
1853− .changes
1854− .iter()
1855− .rev()
1856− .find(|(chain, _)| Condition::at_only(chain).holds_at(&here))
1857− .map(|&(_, axis)| axis);
1858− Some(changed.unwrap_or(base))
1859− }
1860−}
1861−
1862−/// `width` or `height`.
1863−#[derive(Clone, Copy, PartialEq, Eq)]
1864−enum Dim {
1865− Width,
1866− Height,
1867−}
1868−
1869−/// What a `width` or `height` says.
1870−#[derive(Clone, Copy, PartialEq, Eq)]
1871−enum Size {
1872− Fill,
1873− Shrink,
1874− /// A size of its own (`width 300`)
1875− Set,
1876−}
1877−
1878−impl Size {
1879− fn of(value: &str) -> Size {
1880− match value.trim() {
1881− "fill" => Size::Fill,
1882− "shrink" => Size::Shrink,
1883− _ => Size::Set,
1884− }
1885− }
1886−}
1887−
1888−/// The declarations `fill` and `shrink` make in a parent laid out along
1889−/// `axis` (`None`: not a flex row or column). Along the parent's direction,
1890−/// `fill` takes the remaining space and `shrink` keeps the content's size;
1891−/// across it (or outside flex), `fill` is the full size and `shrink` fits
1892−/// the content.
1893−fn sizing(dim: Dim, size: Size, axis: Option<Axis>) -> &'static [(&'static str, &'static str)] {
1894− match (dim, size, axis) {
1895− (_, Size::Set, _) => &[],
1896− (Dim::Width, Size::Fill, Some(Axis::Row)) => &[("flex", "1"), ("min-width", "0")],
1897− (Dim::Width, Size::Fill, _) => &[("width", "100%")],
1898− (Dim::Width, Size::Shrink, Some(Axis::Row)) => &[("flex-shrink", "0")],
1899− (Dim::Width, Size::Shrink, _) => &[("width", "fit-content")],
1900− (Dim::Height, Size::Fill, Some(Axis::Column)) => &[("flex", "1"), ("min-height", "0")],
1901− (Dim::Height, Size::Fill, _) => &[("height", "100%")],
1902− (Dim::Height, Size::Shrink, Some(Axis::Column)) => &[("flex-shrink", "0")],
1903− (Dim::Height, Size::Shrink, _) => &[("height", "fit-content")],
1904− }
1905−}
1906−
1907−/// Every property `fill` and `shrink` of `dim` can set, with its initial
1908−/// value (`flex` covers `flex-shrink`).
1909−fn sizing_properties(dim: Dim) -> [(&'static str, &'static str); 3] {
1910− match dim {
1911− Dim::Width => [
1912− ("flex", "0 1 auto"),
1913− ("min-width", "auto"),
1914− ("width", "auto"),
1915− ],
1916− Dim::Height => [
1917− ("flex", "0 1 auto"),
1918− ("min-height", "auto"),
1919− ("height", "auto"),
1920− ],
1921− }
1922−}
1923
1700
1924−/// What an element's attributes under some prefixes say about its size.
1925−#[derive(Default)]
1926−struct Sizes {
1927− /// The last `width` and `height`
1928− width: Option<Size>,
1929− height: Option<Size>,
1930− /// A `fill` or `shrink` for the width / height is among them (a later
1931− /// size may replace it)
1932− width_word: bool,
1933− height_word: bool,
1934− /// It writes `flex`, one of its longhands (`flex-grow`, `flex-shrink`,
1935− /// `flex-basis`), `min-width` or `min-height` itself
1936− flex: bool,
1937− flex_grow: bool,
1938− flex_shrink: bool,
1939− flex_basis: bool,
1940− min_width: bool,
1941− min_height: bool,
1942−}
1943
1701
1944−impl Sizes {
1945− /// Read the attributes under the conditions that hold wherever `at`
1946− /// does, a later condition over an earlier one.
1947− fn of(attrs: &[Attribute], at: &Condition) -> Sizes {
1948− let mut sizes = Sizes::default();
1949− let mut read: Vec<(Condition, &str, &str)> = Vec::new();
1950− for attr in attrs.iter().filter(|a| !a.html) {
1951− let (condition, name) = Condition::of(&attr.key);
1952− let Some(value) = attr.value.as_deref().filter(|v| !v.trim().is_empty()) else {
1953− continue;
1954− };
1955− if condition.holds_at(at) {
1956− read.push((condition, name, value));
1957− }
1958− }
1959− read.sort_by_cached_key(|(condition, _, _)| condition.order());
1960− for (_, name, value) in read {
1961− match name {
1962− "width" => {
1963− let size = Size::of(value);
1964− sizes.width = Some(size);
1965− sizes.width_word |= size != Size::Set;
1966− }
1967− "height" => {
1968− let size = Size::of(value);
1969− sizes.height = Some(size);
1970− sizes.height_word |= size != Size::Set;
1971− }
1972− "flex" => sizes.flex = true,
1973− "flex-grow" => sizes.flex_grow = true,
1974− "flex-shrink" => sizes.flex_shrink = true,
1975− "flex-basis" => sizes.flex_basis = true,
1976− "min-width" => sizes.min_width = true,
1977− "min-height" => sizes.min_height = true,
1978− _ => {}
1979− }
1980− }
1981− sizes
1982− }
1983−
1984− /// The element writes `property` itself: a layout word leaves it alone.
1985− fn writes(&self, property: &str) -> bool {
1986− match property {
1987− "flex" => self.flex,
1988− "flex-grow" => self.flex || self.flex_grow,
1989− "flex-shrink" => self.flex || self.flex_shrink,
1990− "flex-basis" => self.flex || self.flex_basis,
1991− "min-width" => self.min_width,
1992− "min-height" => self.min_height,
1993− "width" => self.width == Some(Size::Set),
1994− "height" => self.height == Some(Size::Set),
1995− _ => false,
1996− }
1997− }
1998−
1999− /// Write a layout word's `property: value`, unless the element writes
2000− /// the property itself. When it writes some of `flex`'s longhands, a
2001− /// `flex` is written as the others (`flex-shrink 0, width fill` still
2002− /// grows).
2003− fn push(&self, css: &mut String, property: &str, value: &str) {
2004− if property == "flex" && !self.flex {
2005− let longhands = match value {
2006− "1" => [
2007− ("flex-grow", "1"),
2008− ("flex-shrink", "1"),
2009− ("flex-basis", "0%"),
2010− ],
2011− // The reset, `0 1 auto`
2012− _ => [
2013− ("flex-grow", "0"),
2014− ("flex-shrink", "1"),
2015− ("flex-basis", "auto"),
2016− ],
2017− };
2018− if longhands.iter().any(|&(p, _)| self.writes(p)) {
2019− for (longhand, value) in longhands {
2020− if !self.writes(longhand) {
2021− push_css(css, longhand, value);
2022− }
2023− }
2024− return;
2025− }
2026− }
2027− if !self.writes(property) {
2028− push_css(css, property, value);
2029− }
2030− }
2031−}
2032
1702
2033−/// The rule that gives an element's `fill` and `shrink`, as its attributes
1703+fn attrs_to_css(
2034−/// under the at-rules `at` set them, their meaning in a parent laid out
2035−/// along `axis`. Every other property a layout word could have set is put
2036−/// back to its initial value, so the rule is right whichever earlier rule
2037−/// it overrides. Properties the element writes itself are left alone.
2038−fn sizing_rule(attrs: &[Attribute], at: &[usize], axis: Axis) -> String {
1704+ attrs: &[Attribute],
2039− let sizes = Sizes::of(attrs, &Condition::at_only(at));
1705+ state_prefix: &str,
2040− let mut sets: Vec<(&str, &str)> = Vec::new();
1706+ kind: &ElementKind,
2041− let mut resets: Vec<(&str, &str)> = Vec::new();
1707+ parent_kind: Option<&ElementKind>,
2042− for (dim, size, word) in [
1708+ has_overlay_children: bool,
2043− (Dim::Width, sizes.width, sizes.width_word),
1709+) -> String {
2044− (Dim::Height, sizes.height, sizes.height_word),
2045− ] {
2046− if !word {
2047− continue;
2048− }
2049− if let Some(size) = size {
2050− sets.extend_from_slice(sizing(dim, size, Some(axis)));
2051− }
2052− resets.extend_from_slice(&sizing_properties(dim));
2053− }
2054 let mut css = String::new();
1710 let mut css = String::new();
2055− let mut written: Vec<&str> = Vec::new();
2056− for &(property, value) in &resets {
2057− if sets.iter().any(|&(p, _)| p == property) || written.contains(&property) {
2058− continue;
2059− }
2060− written.push(property);
2061− sizes.push(&mut css, property, value);
2062− }
2063− for &(property, value) in &sets {
2064− sizes.push(&mut css, property, value);
2065− }
2066− css
2067−}
2068−
2069−/// The rules an element's `fill` and `shrink` need under each chain of
2070−/// at-rules where the direction of `flow` (its parent's) changes: `(at,
2071−/// body)`.
2072−fn sizing_changes(attrs: &[Attribute], flow: &Flow) -> Vec<(Vec<usize>, String)> {
2073− flow.changes
2074− .iter()
2075− .map(|(at, axis)| (at.clone(), sizing_rule(attrs, at, *axis)))
2076− .filter(|(_, body)| !body.is_empty())
2077− .collect()
2078−}
2079
1711
2080−/// An element's generated class, with the rules keyed on its parent's
1712+ // Base element styles only for the default (non-state) pass
2081−/// class that its `fill` and `shrink` need where the parent's direction
1713+ if state_prefix.is_empty() {
2082−/// changes under a prefix (see [`Flow`]): `:where(.a)>.b`, one class of
1714+ // Elements with @in-front / @behind children become positioning
2083−/// specificity like any class rule.
1715+ // contexts. Pushed before user attrs so an explicit `position` wins
2084−fn element_class(elem: &Element, site: &Site, styles: &mut StyleCollector) -> Option<String> {
1716+ // via dedupe (only the last declaration of a property is kept).
2085− let as_child = match site.parent.class {
1717+ if has_overlay_children {
2086− Some(_) => sizing_changes(&elem.attrs, site.parent),
1718+ css.push_str("position:relative;isolation:isolate;");
2087− None => Vec::new(),
1719+ }
2088− };
1720+ match kind {
2089− // Two elements with the same CSS but different rules keyed on them
1721+ ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2090− // need two classes
1722+ ElementKind::Column | ElementKind::El => css.push_str(FLEX_COLUMN),
2091− let mut distinct = String::new();
1723+ ElementKind::Paragraph => css.push_str("margin:0;"),
2092− for (at, body) in &as_child {
1724+ ElementKind::Tag(spec) => css.push_str(spec.css),
2093− distinct.push_str(&format!("<{:?}{}", at, body));
1725+ _ => {}
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 }
1726 }
2100 }
1727 }
2101− Some(class)
2102−}
2103−
2104−/// The `display` of a layout (nothing for text, native and void elements,
2105−/// which keep HTML's own).
2106−fn layout_css(layout: Layout, inline: bool) -> &'static str {
2107− match (layout, inline) {
2108− (Layout::Column, false) => "display:flex;flex-direction:column;",
2109− (Layout::Column, true) => "display:inline-flex;flex-direction:column;",
2110− (Layout::Row, false) => "display:flex;flex-direction:row;",
2111− (Layout::Row, true) => "display:inline-flex;flex-direction:row;",
2112− (Layout::Grid, false) => "display:grid;",
2113− (Layout::Grid, true) => "display:inline-grid;",
2114− _ => "",
2115− }
2116−}
2117
1728
2118−/// The declarations of the attributes under `condition` (the defaults of
2119−/// the element's kind are [`default_css`]'s).
2120−fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> String {
2121− let mut css = String::new();
2122− // The auto margins of `center-x`, `align-left`, ..., written at the end
2123− let mut aligned = String::new();
2124− let kind = site.kind;
2125− // Under `children:` the styles go on the children, whose parent is this
2126− // element: htmlang's words that place an element in its parent are an
2127− // error there (the parser reports them), and left out
2128− let on_children = condition.on_children();
2129− let inside = condition.inside().is_some();
2130− // The direction `fill` and `shrink` compile against
2131− let axis = site.parent.at(&condition.at);
2132−
2133− // htmlang's words for laying out children (`spacing`, `wrap`,
2134− // `grid-cols`) mean nothing on an element that doesn't lay out its
2135− // children, which the parser reports; they are left out. Under
2136− // `children:` they go on the children, whose layout isn't known here.
2137− let lays_out_children = kind.layout().is_container() || on_children;
2138− fn under<'k>(key: &'k str, condition: &Condition) -> Option<&'k str> {
2139− let (c, name) = Condition::of(key);
2140− (c == *condition).then_some(name)
2141− }
2142−
2143− for (index, attr) in attrs.iter().enumerate() {
1729+ for attr in attrs {
2144 if attr.html {
1730 if attr.html {
2145 continue;
1731 continue;
2146 }
1732 }
2147− let Some(effective_key) = under(&attr.key, condition) else {
1733+ // Determine the effective key for this pass
1734+ let effective_key = if state_prefix.is_empty() {
1735+ if crate::vocab::is_prefixed(&attr.key) {
1736+ continue;
1737+ }
1738+ attr.key.as_str()
1739+ } else {
1740+ match attr.key.strip_prefix(state_prefix) {
1741+ Some(k) => k,
2148− continue;
1742+ None => continue,
1743+ }
2149 };
1744 };
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
1745
2160 let val = attr.value.as_deref();
1746 let val = attr.value.as_deref();
2161− // A style whose value came out empty (a field a record doesn't
2162− // have, `${if()}` without its other branch) is left out
2163− if val.is_some_and(|v| v.trim().is_empty()) {
2164− continue;
2165− }
2166
1747
2167 match effective_key {
1748 match effective_key {
2168− // htmlang's own words: they lay out children or place the
1749+ // Layout
2169− // element in its parent, and mean more than one CSS property
1750+ "spacing" | "gap" => {
2170− "spacing" if !lays_out_children => {}
2171− "spacing" => {
2172 if let Some(v) = val {
1751 if let Some(v) = val {
2173− push_css(&mut css, "gap", &with_px("gap", v));
1752+ push_css(&mut css, "gap", &css_px(v));
2174 }
1753 }
2175 }
1754 }
2176−
1755+ "padding" => {
2177− // Sizing: `fill` and `shrink` compile against the parent's
1756+ if let Some(v) = val {
2178− // direction. Only the last `width` (`height`) counts, as for
1757+ push_css(&mut css, "padding", &css_px_multi(v));
2179− // any style written twice.
2180− "width" | "height" if val.is_some_and(|v| Size::of(v) != Size::Set) => {
2181− let later = attrs[index + 1..].iter().any(|a| {
2182− !a.html
2183− && under(&a.key, condition) == Some(effective_key)
2184− && a.value.as_deref().is_some_and(|v| !v.trim().is_empty())
2185− });
2186− if later {
2187− continue;
2188 }
1758 }
2189− let dim = if effective_key == "width" {
1759+ }
2190− Dim::Width
1760+ "padding-x" => {
2191− } else {
1761+ if let Some(v) = val {
2192− Dim::Height
1762+ // Logical property covers both inline sides in one
2193− };
1763+ // declaration; for symmetric values this is visually
2194− let size = Size::of(val.unwrap_or_default());
1764+ // identical to padding-left/right in LTR and RTL.
2195− // What the element writes itself, which a layout word
1765+ push_css(&mut css, "padding-inline", &css_px(v));
2196− // leaves alone
2197− let sizes = Sizes::of(attrs, condition);
2198− for &(property, value) in sizing(dim, size, axis) {
2199− sizes.push(&mut css, property, value);
2200− }
2201− }
2202−
2203− // A size of its own under a prefix, where a `fill` or
2204− // `shrink` that sets `flex` (along a flex parent's direction)
2205− // holds too: the word's `flex` and minimum are put back, so the
2206− // size is the one the element gets (`width fill, lg:width 200`
2207− // in a row)
2208− "width" | "height"
2209− if *condition != Condition::default()
2210− && attrs.iter().any(|a| {
2211− let (c, name) = Condition::of(&a.key);
2212− let along = match site.parent.at(&c.at) {
2213− Some(Axis::Row) => name == "width",
2214− Some(Axis::Column) => name == "height",
2215− None => false,
2216− };
2217− !a.html
2218− && name == effective_key
2219− && along
2220− && c != *condition
2221− && c.holds_at(condition)
2222− && a.value
2223− .as_deref()
2224− .is_some_and(|v| !v.trim().is_empty() && Size::of(v) != Size::Set)
2225− }) =>
2226− {
2227− let dim = if effective_key == "width" {
2228− Dim::Width
2229− } else {
2230− Dim::Height
2231− };
2232− let sizes = Sizes::of(attrs, condition);
2233− for (property, value) in sizing_properties(dim) {
2234− if property != effective_key {
2235− sizes.push(&mut css, property, value);
2236− }
2237 }
1766 }
1767+ }
1768+ "padding-y" => {
2238 if let Some(v) = val {
1769 if let Some(v) = val {
2239− push_css(&mut css, effective_key, &with_px(effective_key, v));
1770+ push_css(&mut css, "padding-block", &css_px(v));
2240 }
1771 }
2241 }
1772 }
2242−
1773+ "padding-top" => {
2243− // Alignment: auto margins, which work along either direction.
1774+ if let Some(v) = val {
2244− // They are written last, so a `margin` on the same element
1775+ push_css(&mut css, "padding-top", &css_px(v));
2245− // keeps its other sides (`center-x, margin 20`)
1776+ }
2246− "center-x" => {
2247− push_css(&mut aligned, "margin-left", "auto");
2248− push_css(&mut aligned, "margin-right", "auto");
2249 }
1777 }
2250− "center-y" => {
1778+ "padding-bottom" => {
2251− push_css(&mut aligned, "margin-top", "auto");
1779+ if let Some(v) = val {
2252− push_css(&mut aligned, "margin-bottom", "auto");
1780+ push_css(&mut css, "padding-bottom", &css_px(v));
1781+ }
2253 }
1782 }
2254− "align-left" => push_css(&mut aligned, "margin-right", "auto"),
1783+ "padding-left" => {
2255− "align-right" => push_css(&mut aligned, "margin-left", "auto"),
2256− "align-top" => push_css(&mut aligned, "margin-bottom", "auto"),
2257− "align-bottom" => push_css(&mut aligned, "margin-top", "auto"),
2258−
2259− "wrap" if lays_out_children => push_css(&mut css, "flex-wrap", "wrap"),
2260− "wrap" => {}
2261−
2262− // Grid
2263− "grid-cols" | "grid-rows" if !lays_out_children => {}
2264− "grid-cols" | "grid-rows" => {
2265 if let Some(v) = val {
1784 if let Some(v) = val {
2266− let property = if effective_key == "grid-cols" {
1785+ push_css(&mut css, "padding-left", &css_px(v));
2267− "grid-template-columns"
2268− } else {
2269− "grid-template-rows"
2270− };
2271− match v.parse::<u32>() {
2272− Ok(n) => push_css(&mut css, property, &format!("repeat({},1fr)", n)),
2273− Err(_) => push_css(&mut css, property, &with_px(property, v)),
2274− }
2275 }
1786 }
2276 }
1787 }
2277− "col-span" | "row-span" => {
1788+ "padding-right" => {
2278 if let Some(v) = val {
1789 if let Some(v) = val {
2279− let property = if effective_key == "col-span" {
2280− "grid-column"
2281− } else {
2282− "grid-row"
2283− };
2284− push_css(&mut css, property, &format!("span {}", v));
1790+ push_css(&mut css, "padding-right", &css_px(v));
2285 }
1791 }
2286 }
1792 }
2287
1793
2288− // CSS's `line-clamp` has no support yet without its
1794+ // Sizing
2289− // `-webkit-` fallback, which needs a box to clamp
1795+ "width" => {
2290− "line-clamp" => {
2291 if let Some(v) = val {
1796 if let Some(v) = val {
1797+ match v {
1798+ "fill" => match parent_kind {
1799+ Some(ElementKind::Row) => {
1800+ push_css(&mut css, "flex", "1");
1801+ push_css(&mut css, "min-width", "0");
1802+ }
1803+ _ => push_css(&mut css, "width", "100%"),
1804+ },
1805+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
1806+ _ => push_css(&mut css, "width", &css_px(v)),
1807+ }
1808+ }
1809+ }
1810+ "height" => {
1811+ if let Some(v) = val {
1812+ match v {
1813+ "fill" => match parent_kind {
1814+ Some(ElementKind::Column) => {
1815+ push_css(&mut css, "flex", "1");
1816+ push_css(&mut css, "min-height", "0");
1817+ }
1818+ _ => push_css(&mut css, "height", "100%"),
1819+ },
1820+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
1821+ _ => push_css(&mut css, "height", &css_px(v)),
1822+ }
1823+ }
1824+ }
1825+ "min-width" => {
1826+ if let Some(v) = val {
1827+ push_css(&mut css, "min-width", &css_px(v));
1828+ }
1829+ }
1830+ "max-width" => {
1831+ if let Some(v) = val {
1832+ push_css(&mut css, "max-width", &css_px(v));
1833+ }
1834+ }
1835+ "min-height" => {
1836+ if let Some(v) = val {
1837+ push_css(&mut css, "min-height", &css_px(v));
1838+ }
1839+ }
1840+ "max-height" => {
1841+ if let Some(v) = val {
1842+ push_css(&mut css, "max-height", &css_px(v));
1843+ }
1844+ }
1845+
1846+ // Alignment
1847+ "center-x" => match parent_kind {
1848+ Some(ElementKind::Column) | Some(ElementKind::El) => {
1849+ push_css(&mut css, "align-self", "center");
1850+ }
1851+ _ => {
1852+ push_css(&mut css, "margin-left", "auto");
1853+ push_css(&mut css, "margin-right", "auto");
1854+ }
1855+ },
1856+ "center-y" => match parent_kind {
1857+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
1858+ _ => {
1859+ push_css(&mut css, "margin-top", "auto");
1860+ push_css(&mut css, "margin-bottom", "auto");
1861+ }
1862+ },
1863+ "align-left" => match parent_kind {
1864+ Some(ElementKind::Column) | Some(ElementKind::El) => {
1865+ push_css(&mut css, "align-self", "flex-start");
1866+ }
1867+ _ => push_css(&mut css, "margin-right", "auto"),
1868+ },
1869+ "align-right" => match parent_kind {
1870+ Some(ElementKind::Column) | Some(ElementKind::El) => {
1871+ push_css(&mut css, "align-self", "flex-end");
1872+ }
1873+ _ => push_css(&mut css, "margin-left", "auto"),
1874+ },
1875+ "align-top" => match parent_kind {
1876+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
1877+ _ => push_css(&mut css, "margin-bottom", "auto"),
1878+ },
1879+ "align-bottom" => match parent_kind {
1880+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
1881+ _ => push_css(&mut css, "margin-top", "auto"),
1882+ },
1883+
1884+ // Style
1885+ "border" => {
1886+ if let Some(v) = val {
1887+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
1888+ if parts.len() == 2 {
1889+ push_css(
1890+ &mut css,
1891+ "border",
1892+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
1893+ );
1894+ } else {
1895+ push_css(
1896+ &mut css,
1897+ "border",
1898+ &format!("{} solid currentColor", css_px(parts[0])),
1899+ );
1900+ }
1901+ }
1902+ }
1903+ "border-top" | "border-bottom" | "border-left" | "border-right" => {
1904+ if let Some(v) = val {
1905+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
1906+ if parts.len() == 2 {
1907+ push_css(
1908+ &mut css,
1909+ effective_key,
1910+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
1911+ );
1912+ } else {
1913+ push_css(
1914+ &mut css,
1915+ effective_key,
1916+ &format!("{} solid currentColor", css_px(parts[0])),
1917+ );
1918+ }
1919+ }
1920+ }
1921+ "rounded" => {
1922+ if let Some(v) = val {
1923+ push_css(&mut css, "border-radius", &css_px(v));
1924+ }
1925+ }
1926+ "bold" => push_css(&mut css, "font-weight", "bold"),
1927+ "italic" => push_css(&mut css, "font-style", "italic"),
1928+ "underline" => push_css(&mut css, "text-decoration", "underline"),
1929+ "size" => {
1930+ if let Some(v) = val {
1931+ push_css(&mut css, "font-size", &css_px(v));
1932+ }
1933+ }
1934+ "font" => {
1935+ if let Some(v) = val {
1936+ // `font "Inter, sans-serif"` quotes a whole font stack so
1937+ // its commas don't split the attribute list.
1938+ let v = match v.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
1939+ Some(stack) if stack.contains(',') => stack,
1940+ _ => v,
1941+ };
1942+ push_css(&mut css, "font-family", v);
1943+ }
1944+ }
1945+
1946+ // Typography
1947+ "line-height" => {
1948+ if let Some(v) = val {
1949+ push_css(&mut css, "line-height", &css_line_height(v));
1950+ }
1951+ }
1952+ "letter-spacing" => {
1953+ if let Some(v) = val {
1954+ push_css(&mut css, "letter-spacing", &css_px(v));
1955+ }
1956+ }
1957+
1958+ // Overflow & positioning
1959+ "top" => {
1960+ if let Some(v) = val {
1961+ push_css(&mut css, "top", &css_px(v));
1962+ }
1963+ }
1964+ "right" => {
1965+ if let Some(v) = val {
1966+ push_css(&mut css, "right", &css_px(v));
1967+ }
1968+ }
1969+ "bottom" => {
1970+ if let Some(v) = val {
1971+ push_css(&mut css, "bottom", &css_px(v));
1972+ }
1973+ }
1974+ "left" => {
1975+ if let Some(v) = val {
1976+ push_css(&mut css, "left", &css_px(v));
1977+ }
1978+ }
1979+
1980+ // Display & visibility
1981+ "hidden" => push_css(&mut css, "display", "none"),
1982+
1983+ // Effects
1984+
1985+ // Flow
1986+ "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
1987+
1988+ // Grid
1989+ "grid" => {
1990+ push_css(&mut css, "display", "grid");
1991+ }
1992+ "grid-cols" => {
1993+ if let Some(v) = val {
1994+ if let Ok(n) = v.parse::<u32>() {
1995+ push_css(
1996+ &mut css,
1997+ "grid-template-columns",
1998+ &format!("repeat({},1fr)", n),
1999+ );
2000+ } else {
2001+ push_css(&mut css, "grid-template-columns", v);
2002+ }
2003+ }
2004+ }
2005+ "grid-rows" => {
2006+ if let Some(v) = val {
2007+ if let Ok(n) = v.parse::<u32>() {
2008+ push_css(
2009+ &mut css,
2010+ "grid-template-rows",
2011+ &format!("repeat({},1fr)", n),
2012+ );
2013+ } else {
2014+ push_css(&mut css, "grid-template-rows", v);
2015+ }
2016+ }
2017+ }
2018+ "col-span" => {
2019+ if let Some(v) = val {
2020+ push_css(&mut css, "grid-column", &format!("span {}", v));
2021+ }
2022+ }
2023+ "row-span" => {
2024+ if let Some(v) = val {
2025+ push_css(&mut css, "grid-row", &format!("span {}", v));
2026+ }
2027+ }
2028+
2029+ // CSS containment for rendering performance
2030+ "contain" => {
2031+ if let Some(v) = val {
2032+ push_css(&mut css, "contain", v);
2033+ } else {
2034+ push_css(&mut css, "contain", "layout style paint");
2035+ }
2036+ }
2037+ "content-visibility" => {
2038+ if let Some(v) = val {
2039+ push_css(&mut css, "content-visibility", v);
2040+ } else {
2041+ push_css(&mut css, "content-visibility", "auto");
2042+ }
2043+ }
2044+
2045+ // Outline (like border but doesn't affect layout)
2046+ "outline" => {
2047+ if let Some(v) = val {
2048+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
2049+ if parts.len() == 2 {
2050+ push_css(
2051+ &mut css,
2052+ "outline",
2053+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
2054+ );
2055+ } else {
2056+ push_css(
2057+ &mut css,
2058+ "outline",
2059+ &format!("{} solid currentColor", css_px(parts[0])),
2060+ );
2061+ }
2062+ }
2063+ }
2064+
2065+ // Logical properties (i18n-aware)
2066+ "padding-inline" => {
2067+ if let Some(v) = val {
2068+ push_css(&mut css, "padding-inline", &css_px_multi(v));
2069+ }
2070+ }
2071+ "padding-block" => {
2072+ if let Some(v) = val {
2073+ push_css(&mut css, "padding-block", &css_px_multi(v));
2074+ }
2075+ }
2076+ "margin-inline" => {
2077+ if let Some(v) = val {
2078+ push_css(&mut css, "margin-inline", &css_px_multi(v));
2079+ }
2080+ }
2081+ "margin-block" => {
2082+ if let Some(v) = val {
2083+ push_css(&mut css, "margin-block", &css_px_multi(v));
2084+ }
2085+ }
2086+
2087+ // Logical property start/end variants
2088+ "padding-inline-start"
2089+ | "padding-inline-end"
2090+ | "padding-block-start"
2091+ | "padding-block-end"
2092+ | "margin-inline-start"
2093+ | "margin-inline-end"
2094+ | "margin-block-start"
2095+ | "margin-block-end" => {
2096+ if let Some(v) = val {
2097+ push_css(&mut css, effective_key, &css_px(v));
2098+ }
2099+ }
2100+
2101+ // Logical inset
2102+ "inset-inline" | "inset-block" => {
2103+ if let Some(v) = val {
2104+ push_css(&mut css, effective_key, &css_px_multi(v));
2105+ }
2106+ }
2107+ "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
2108+ if let Some(v) = val {
2109+ push_css(&mut css, effective_key, &css_px(v));
2110+ }
2111+ }
2112+
2113+ // Logical border
2114+ "border-inline"
2115+ | "border-block"
2116+ | "border-inline-start"
2117+ | "border-inline-end"
2118+ | "border-block-start"
2119+ | "border-block-end" => {
2120+ if let Some(v) = val {
2121+ push_css(&mut css, effective_key, v);
2122+ }
2123+ }
2124+
2125+ // Logical border-radius
2126+ "border-start-start-radius"
2127+ | "border-start-end-radius"
2128+ | "border-end-start-radius"
2129+ | "border-end-end-radius" => {
2130+ if let Some(v) = val {
2131+ push_css(&mut css, effective_key, &css_px(v));
2132+ }
2133+ }
2134+
2135+ // Logical scroll margins & padding
2136+ "scroll-margin-inline"
2137+ | "scroll-margin-block"
2138+ | "scroll-padding-inline"
2139+ | "scroll-padding-block" => {
2140+ if let Some(v) = val {
2141+ push_css(&mut css, effective_key, &css_px_multi(v));
2142+ }
2143+ }
2144+
2145+ // Logical sizing
2146+ "inline-size" | "block-size" | "min-inline-size" | "max-inline-size"
2147+ | "min-block-size" | "max-block-size" => {
2148+ if let Some(v) = val {
2149+ push_css(&mut css, effective_key, &css_px(v));
2150+ }
2151+ }
2152+
2153+ // Margin
2154+ "margin" => {
2155+ if let Some(v) = val {
2156+ push_css(&mut css, "margin", &css_px_multi(v));
2157+ }
2158+ }
2159+ "margin-x" => {
2160+ if let Some(v) = val {
2161+ push_css(&mut css, "margin-inline", &css_px(v));
2162+ }
2163+ }
2164+ "margin-y" => {
2165+ if let Some(v) = val {
2166+ push_css(&mut css, "margin-block", &css_px(v));
2167+ }
2168+ }
2169+
2170+ // Container queries
2171+ "container" => {
2172+ push_css(&mut css, "container-type", "inline-size");
2173+ }
2174+
2175+ // Inset (shorthand for top/right/bottom/left)
2176+ "inset" => {
2177+ if let Some(v) = val {
2178+ push_css(&mut css, "inset", &css_px(v));
2179+ }
2180+ }
2181+
2182+ // Table
2183+ "border-spacing" => {
2184+ if let Some(v) = val {
2185+ push_css(&mut css, "border-spacing", &css_px(v));
2186+ }
2187+ }
2188+
2189+ // Text decoration
2190+ "text-decoration-thickness" => {
2191+ if let Some(v) = val {
2192+ push_css(&mut css, "text-decoration-thickness", &css_px(v));
2193+ }
2194+ }
2195+ "text-underline-offset" => {
2196+ if let Some(v) = val {
2197+ push_css(&mut css, "text-underline-offset", &css_px(v));
2198+ }
2199+ }
2200+
2201+ // Multi-column
2202+ "column-width" => {
2203+ if let Some(v) = val {
2204+ push_css(&mut css, "column-width", &css_px(v));
2205+ }
2206+ }
2207+
2208+ // New CSS properties
2209+ "column-gap" => {
2210+ if let Some(v) = val {
2211+ push_css(&mut css, "column-gap", &css_px(v));
2212+ }
2213+ }
2214+ "text-indent" => {
2215+ if let Some(v) = val {
2216+ push_css(&mut css, "text-indent", &css_px(v));
2217+ }
2218+ }
2219+ "flex-basis" => {
2220+ if let Some(v) = val {
2221+ push_css(&mut css, "flex-basis", &css_px(v));
2222+ }
2223+ }
2224+ "scroll-margin" => {
2225+ if let Some(v) = val {
2226+ push_css(&mut css, "scroll-margin", &css_px(v));
2227+ }
2228+ }
2229+ "scroll-margin-top"
2230+ | "scroll-margin-bottom"
2231+ | "scroll-margin-left"
2232+ | "scroll-margin-right" => {
2233+ if let Some(v) = val {
2234+ push_css(&mut css, effective_key, &css_px(v));
2235+ }
2236+ }
2237+ "scroll-padding" => {
2238+ if let Some(v) = val {
2239+ push_css(&mut css, "scroll-padding", &css_px(v));
2240+ }
2241+ }
2242+ "scroll-padding-top"
2243+ | "scroll-padding-bottom"
2244+ | "scroll-padding-left"
2245+ | "scroll-padding-right" => {
2246+ if let Some(v) = val {
2247+ push_css(&mut css, effective_key, &css_px(v));
2248+ }
2249+ }
2250+
2251+ "content" => {
2252+ if let Some(v) = val {
2253+ // Wrap in quotes if not already quoted and not a CSS keyword
2254+ if v.starts_with('"')
2255+ || v.starts_with('\'')
2256+ || v == "none"
2257+ || v == "normal"
2258+ || v.starts_with("attr(")
2259+ || v.starts_with("counter(")
2260+ {
2261+ push_css(&mut css, "content", v);
2262+ } else {
2263+ push_css(&mut css, "content", &format!("\"{}\"", v));
2264+ }
2265+ }
2266+ }
2267+
2268+ // --- CSS Shorthands ---
2269+ "line-clamp" => {
2270+ if let Some(v) = val {
2292− push_css(&mut css, "display", "-webkit-box");
2271+ push_css(&mut css, "display", "-webkit-box");
2293 push_css(&mut css, "-webkit-line-clamp", v);
2272 push_css(&mut css, "-webkit-line-clamp", v);
2294 push_css(&mut css, "-webkit-box-orient", "vertical");
2273 push_css(&mut css, "-webkit-box-orient", "vertical");
2295 push_css(&mut css, "overflow", "hidden");
2274 push_css(&mut css, "overflow", "hidden");
2296 }
2275 }
2297 }
2276 }
2298
2277
2299− // Every other name is a CSS property (standard, custom,
2278+ // Any other standard CSS property is copied through, with `px`
2300− // vendor-prefixed, or one htmlang doesn't know, which the
2301− // parser warned about), written under its own name with the
2302− // value as written, except for `px` after a bare number where
2303− // the property takes a length
2279+ // added to bare numbers where the property takes a length.
2304− key if crate::vocab::is_property_name(key) => {
2280+ key if crate::vocab::is_css_property(key) => {
2305 if let Some(v) = val {
2281 if let Some(v) = val {
2282+ if crate::vocab::is_length_property(key) {
2306− push_css(&mut css, key, &with_px(key, v));
2283+ push_css(&mut css, key, &css_px_multi(v));
2284+ } else {
2285+ push_css(&mut css, key, v);
2286+ }
2307 }
2287 }
2308 }
2288 }
2309
2289
@@ -2311,7 +2291,6 @@ fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> Stri
2311 }
2291 }
2312 }
2292 }
2313
2293
2314− css.push_str(&aligned);
2315 css
2294 css
2316}
2295}
2317
2296
@@ -2322,6 +2301,146 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
2322 css.push(';');
2301 css.push(';');
2323}
2302}
2324
2303
2304+/// Known CSS units — if a value ends with one, skip appending `px`.
2305+/// Format a length value: a bare number gets `px` appended; anything else
2306+/// (values with units, keywords like `auto`, functions like `calc(...)`) is
2307+/// passed through unchanged.
2308+fn css_px(value: &str) -> String {
2309+ let v = value.trim();
2310+ if v == "0" {
2311+ return "0".to_string();
2312+ }
2313+ let is_bare_number = !v.is_empty()
2314+ && v.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
2315+ && v.parse::<f64>().is_ok();
2316+ if is_bare_number {
2317+ format!("{}px", v)
2318+ } else {
2319+ v.to_string()
2320+ }
2321+}
2322+
2323+/// Rewrite literal values that match a declared CSS custom property (from
2324+/// `@theme` or `@let --name value`) to `var(--name)` references. Matches are
2325+/// anchored to CSS value boundaries so e.g. `#3b82f6` inside a longer hex or
2326+/// inside an identifier is not replaced. The `:root` block is emitted before
2327+/// this call runs, so its declarations are not affected.
2328+fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
2329+ if css.is_empty() || vars.is_empty() {
2330+ return css.to_string();
2331+ }
2332+ // Prefer longer values first so that if two vars share a prefix, the
2333+ // longer (more specific) match wins.
2334+ let mut pairs: Vec<(&str, String)> = vars
2335+ .iter()
2336+ .filter(|(name, value)| name.starts_with("--") && !value.is_empty())
2337+ .map(|(name, value)| (value.as_str(), format!("var({})", name)))
2338+ .collect();
2339+ pairs.sort_by_key(|(v, _)| std::cmp::Reverse(v.len()));
2340+ if pairs.is_empty() {
2341+ return css.to_string();
2342+ }
2343+
2344+ let is_boundary_before = |b: Option<u8>| match b {
2345+ None => true,
2346+ Some(c) => matches!(c, b':' | b' ' | b',' | b'(' | b';' | b'{' | b'\n' | b'\t'),
2347+ };
2348+ let is_boundary_after = |b: Option<u8>| match b {
2349+ None => true,
2350+ Some(c) => matches!(c, b';' | b'}' | b',' | b' ' | b')' | b'\n' | b'\t'),
2351+ };
2352+
2353+ let bytes = css.as_bytes();
2354+ let mut out = String::with_capacity(css.len());
2355+ let mut i = 0;
2356+ let mut prev: Option<u8> = None;
2357+ // Only declaration values are rewritten — never selectors or at-rule
2358+ // preludes such as `@media (min-width:768px)`. `blocks` records, for
2359+ // each open `{`, whether it holds declarations (a style rule) or nested
2360+ // rules (an at-rule like `@media` / `@layer`).
2361+ let mut blocks: Vec<bool> = Vec::new();
2362+ let mut prelude_start = 0;
2363+ let mut in_value = false;
2364+ while i < bytes.len() {
2365+ match bytes[i] {
2366+ b'{' => {
2367+ let prelude = css[prelude_start..i].trim_start();
2368+ blocks.push(!prelude.starts_with('@'));
2369+ in_value = false;
2370+ prelude_start = i + 1;
2371+ }
2372+ b'}' => {
2373+ blocks.pop();
2374+ in_value = false;
2375+ prelude_start = i + 1;
2376+ }
2377+ b';' => {
2378+ in_value = false;
2379+ prelude_start = i + 1;
2380+ }
2381+ b':' if blocks.last() == Some(&true) => in_value = true,
2382+ _ => {}
2383+ }
2384+ if in_value && is_boundary_before(prev) {
2385+ let mut matched = false;
2386+ for (val, repl) in &pairs {
2387+ let vb = val.as_bytes();
2388+ if i + vb.len() <= bytes.len() && &bytes[i..i + vb.len()] == vb {
2389+ let next = bytes.get(i + vb.len()).copied();
2390+ if is_boundary_after(next) {
2391+ out.push_str(repl);
2392+ prev = repl.as_bytes().last().copied();
2393+ i += vb.len();
2394+ matched = true;
2395+ break;
2396+ }
2397+ }
2398+ }
2399+ if matched {
2400+ continue;
2401+ }
2402+ }
2403+ // Advance by one UTF-8 code point.
2404+ let start = i;
2405+ i += 1;
2406+ while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
2407+ i += 1;
2408+ }
2409+ out.push_str(&css[start..i]);
2410+ prev = bytes.get(i - 1).copied();
2411+ }
2412+ out
2413+}
2414+
2415+
2416+/// Format a `line-height` value. CSS accepts either a unitless multiplier
2417+/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
2418+/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which
2419+/// CSS interprets as 24× font-size — almost never what anyone wants). Treat
2420+/// integers ≥ 2 as pixel lengths; anything with a decimal, an existing unit,
2421+/// or the value `0`/`1` passes through unchanged.
2422+fn css_line_height(value: &str) -> String {
2423+ let v = value.trim();
2424+ if v == "0" || v == "1" {
2425+ return v.to_string();
2426+ }
2427+ // Plain integer — treat as pixel length. Decimals, units, keywords and
2428+ // functions pass through.
2429+ if !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()) {
2430+ return format!("{}px", v);
2431+ }
2432+ v.to_string()
2433+}
2434+
2435+/// Format multiple space-separated values, each getting px if needed.
2436+fn css_px_multi(value: &str) -> String {
2437+ value
2438+ .split_whitespace()
2439+ .map(css_px)
2440+ .collect::<Vec<_>>()
2441+ .join(" ")
2442+}
2443+
2325fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2444fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2326 let mut id = None;
2445 let mut id = None;
2327 let mut class = None;
2446 let mut class = None;
@@ -2335,6 +2454,153 @@ fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2335 (id, class)
2454 (id, class)
2336}
2455}
2337
2456
2457+/// Read image dimensions from a local file by parsing the header bytes.
2458+/// Supports PNG, JPEG, GIF, WebP, AVIF, and SVG.
2459+fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
2460+ // SVG: parse as text for viewBox/width/height attributes
2461+ if path.ends_with(".svg") || path.ends_with(".SVG") {
2462+ let text = std::fs::read_to_string(path).ok()?;
2463+ return read_svg_dimensions(&text);
2464+ }
2465+
2466+ let data = std::fs::read(path).ok()?;
2467+ if data.len() < 12 {
2468+ return None;
2469+ }
2470+
2471+ // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
2472+ if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") {
2473+ let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
2474+ let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
2475+ return Some((w, h));
2476+ }
2477+
2478+ // GIF: "GIF87a" or "GIF89a", width/height at bytes 6-9 (little-endian)
2479+ if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
2480+ let w = u16::from_le_bytes([data[6], data[7]]) as u32;
2481+ let h = u16::from_le_bytes([data[8], data[9]]) as u32;
2482+ return Some((w, h));
2483+ }
2484+
2485+ // JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2)
2486+ if data.starts_with(b"\xff\xd8") {
2487+ let mut i = 2;
2488+ while i + 9 < data.len() {
2489+ if data[i] != 0xFF {
2490+ i += 1;
2491+ continue;
2492+ }
2493+ let marker = data[i + 1];
2494+ if marker == 0xC0 || marker == 0xC2 {
2495+ let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
2496+ let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
2497+ return Some((w, h));
2498+ }
2499+ if i + 3 < data.len() {
2500+ let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2501+ i += 2 + len;
2502+ } else {
2503+ break;
2504+ }
2505+ }
2506+ }
2507+
2508+ // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
2509+ if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
2510+ if &data[12..16] == b"VP8 " && data.len() >= 30 {
2511+ let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
2512+ let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
2513+ return Some((w, h));
2514+ }
2515+ if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
2516+ let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
2517+ let w = (bits & 0x3FFF) + 1;
2518+ let h = ((bits >> 14) & 0x3FFF) + 1;
2519+ return Some((w, h));
2520+ }
2521+ }
2522+
2523+ // AVIF: ISOBMFF container with "ftyp" box containing "avif"/"avis" brand,
2524+ // then "ispe" box with width/height
2525+ if data.len() >= 12 && &data[4..8] == b"ftyp" {
2526+ let brand = &data[8..12];
2527+ if brand == b"avif" || brand == b"avis" || brand == b"mif1" {
2528+ return read_avif_dimensions(&data);
2529+ }
2530+ }
2531+
2532+ None
2533+}
2534+
2535+/// Parse SVG viewBox or width/height attributes to get dimensions.
2536+fn read_svg_dimensions(text: &str) -> Option<(u32, u32)> {
2537+ // Try viewBox first: viewBox="minX minY width height"
2538+ if let Some(vb_start) = text.find("viewBox=\"") {
2539+ let rest = &text[vb_start + 9..];
2540+ if let Some(end) = rest.find('"') {
2541+ let parts: Vec<&str> = rest[..end].split_whitespace().collect();
2542+ if parts.len() == 4
2543+ && let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
2544+ && w > 0.0
2545+ && h > 0.0
2546+ {
2547+ return Some((w.round() as u32, h.round() as u32));
2548+ }
2549+ }
2550+ }
2551+ // Fall back to width/height attributes on <svg>
2552+ let svg_tag = text.find("<svg")?;
2553+ let tag_end = text[svg_tag..].find('>')? + svg_tag;
2554+ let tag = &text[svg_tag..tag_end];
2555+ let w = extract_svg_attr(tag, "width")?;
2556+ let h = extract_svg_attr(tag, "height")?;
2557+ Some((w, h))
2558+}
2559+
2560+fn extract_svg_attr(tag: &str, attr: &str) -> Option<u32> {
2561+ let needle = format!("{}=\"", attr);
2562+ let start = tag.find(&needle)? + needle.len();
2563+ let rest = &tag[start..];
2564+ let end = rest.find('"')?;
2565+ let val = rest[..end].trim_end_matches("px");
2566+ val.parse::<f64>().ok().map(|v| v.round() as u32)
2567+}
2568+
2569+/// Parse AVIF (ISOBMFF) container to find ispe box with image dimensions.
2570+fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
2571+ // Walk ISOBMFF boxes looking for "ispe" (image spatial extents)
2572+ let mut i = 0;
2573+ while i + 8 <= data.len() {
2574+ let box_size =
2575+ u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
2576+ let box_type = &data[i + 4..i + 8];
2577+ if box_size < 8 {
2578+ break;
2579+ }
2580+ let box_end = (i + box_size).min(data.len());
2581+ // ispe box: 4 bytes version/flags + 4 bytes width + 4 bytes height
2582+ if box_type == b"ispe" && box_end >= i + 20 {
2583+ let w = u32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
2584+ let h = u32::from_be_bytes([data[i + 16], data[i + 17], data[i + 18], data[i + 19]]);
2585+ return Some((w, h));
2586+ }
2587+ // Recurse into container boxes (meta, iprp, ipco)
2588+ if matches!(
2589+ box_type,
2590+ b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia"
2591+ ) {
2592+ let header_size = if box_type == b"meta" { 12 } else { 8 };
2593+ if i + header_size < box_end
2594+ && let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end])
2595+ {
2596+ return Some(dims);
2597+ }
2598+ }
2599+ i = box_end;
2600+ }
2601+ None
2602+}
2603+
2338fn html_escape(s: &str) -> String {
2604fn html_escape(s: &str) -> String {
2339 s.replace('&', "&amp;")
2605 s.replace('&', "&amp;")
2340 .replace('<', "&lt;")
2606 .replace('<', "&lt;")
@@ -2450,30 +2716,13 @@ fn vlq_encode(value: i64, out: &mut String) {
2450 }
2716 }
2451}
2717}
2452
2718
2453−#[cfg(test)]
2719+
2454−mod tests {
2720+/// Does the tree contain the table element `name` (e.g. `"main"`)?
2455− use super::*;
2721+fn has_tag(nodes: &[Node], name: &str) -> bool {
2456−
2722+ nodes.iter().any(|node| match node {
2457− #[test]
2723+ Node::Element(elem) => elem.kind.is_tag(name) || has_tag(&elem.children, name),
2458− fn a_name_is_a_stable_hash_of_the_style() {
2724+ _ => false,
2459− // The same in every build and on every platform
2460− assert_eq!(stable_hash(""), 0xf52a_15e9_a9b5_e89b);
2461− assert_eq!(hash_digits(35, 3), "z00");
2462− assert_eq!(hash_digits(u64::MAX, 13), "fsgs46211e5w3");
2463− }
2464−
2465− #[test]
2466− fn a_name_taken_in_the_file_takes_more_digits() {
2467− // These two styles' hashes end in the same 7 digits
2468− let mut styles = StyleCollector::new();
2469− let first = styles.get_class("width:129555px;".into(), Vec::new(), String::new());
2470− let second = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
2471− let (first, second) = (first.unwrap(), second.unwrap());
2472− assert_eq!(first.len(), 3 + CLASS_DIGITS, "{first}");
2473− assert_eq!(second.len(), 3 + CLASS_DIGITS + 1, "{second}");
2474− assert!(second.starts_with(&first), "{first} {second}");
2475− // The same style gets the name it got before
2476− let again = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
2477− assert_eq!(again.as_deref(), Some(second.as_str()));
2478− }
2725+ })
2479}
2726}
2727+
2728+
crates/htmlang-core/src/codegen_tests.rs+8−33
@@ -1,8 +1,6 @@
1#[cfg(test)]
1#[cfg(test)]
2mod tests {
2mod tests {
3− use crate::codegen::{
4− CodegenOptions, generate, generate_dev, generate_partial, generate_with_classes,
3+ use crate::codegen::{generate, generate_dev, generate_partial, short_class_name};
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 =
53− "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
50+ let src = "@row [spacing 10]\n @text [bold] a\n @text [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]
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+280−483
@@ -1,23 +1,18 @@
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"
14+//! | "starts-with" | "ends-with") concat)?
20−//! range := sum (".." sum ("step" sum)?)?
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
@@ -25,89 +20,68 @@
25//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
20//! | NAME "(" 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. Functions:
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`,
24+//! `if(cond, a, b)`, text (`uppercase`, `lowercase`, `capitalize`, `trim`,
34−//! `capitalize`, `trim`, `length`, `reverse`, `truncate(s, n)`,
25+//! `length`, `reverse`, `truncate(s, n)`, `replace(s, old, new)`,
35−//! `replace(s, old, new)`, `default(s, fallback)`). A colour variant is
26+//! `default(s, fallback)`) and colors (`lighten(c, pct)`, `darken(c, pct)`,
36−//! CSS, not a function here: `color-mix(in srgb, var(--brand), black 8%)`
27+//! `alpha(c, a)`, `mix(c1, c2, pct)`). In text, `${expr}` interpolates an
37−//! is an attribute's value, written as it is.
28+//! expression.
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
29
45use std::fmt;
30use std::fmt;
46
31
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)]
32#[derive(Debug, Clone, PartialEq)]
53−pub enum Error {
33+pub enum Value {
54− /// `$name` has no definition; `offset` is where the `$` is.
34+ Num(f64),
55− Undefined { name: String, offset: usize },
35+ Str(String),
56− /// Anything else: a syntax error, a type error, division by zero.
36+ Bool(bool),
57− Invalid(String),
58}
37}
59
38
60−impl Error {
39+impl Value {
61− /// This error as a problem of a text in which the expression starts
40+ /// Empty strings, `false`, `0` and `"false"` / `"0"` are false.
62− /// at `base`.
41+ pub fn truthy(&self) -> bool {
63− pub(crate) fn at(self, base: usize) -> Problem {
64 match self {
42 match self {
65− Error::Undefined { name, offset } => Problem::Undefined {
43+ Value::Bool(b) => *b,
66− name,
44+ Value::Num(n) => *n != 0.0,
67− offset: base + offset,
45+ Value::Str(s) => !s.is_empty() && s != "false" && s != "0",
68− },
69− Error::Invalid(message) => Problem::Invalid {
70− message,
71− offset: base,
72− },
73 }
46 }
74 }
47 }
75−}
76
48
77−impl From<String> for Error {
49+ fn as_num(&self) -> Option<f64> {
78− fn from(message: String) -> Self {
50+ match self {
79− Error::Invalid(message)
51+ Value::Num(n) => Some(*n),
52+ Value::Str(s) => s.trim().parse().ok(),
53+ Value::Bool(_) => None,
54+ }
80 }
55 }
81}
56}
82
57
83−impl fmt::Display for Error {
58+impl fmt::Display for Value {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
60 match self {
86− Error::Undefined { name, .. } => write!(f, "undefined variable '${}'", name),
61+ Value::Num(n) if n.fract() == 0.0 && n.abs() < 1e15 => write!(f, "{}", *n as i64),
87− Error::Invalid(message) => f.write_str(message),
62+ Value::Num(n) => write!(f, "{}", n),
63+ Value::Str(s) => f.write_str(s),
64+ Value::Bool(b) => write!(f, "{}", b),
88 }
65 }
89 }
66 }
90}
67}
91
68
92−/// Evaluate `src` with the variables of `scope`. An undefined variable is
69+/// Resolves a variable name (the text after `$`). Returns `None` for
93−/// an error; a field that a record doesn't have is empty.
70+/// undefined variables.
71+pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<String>;
72+
73+/// Evaluate `src`. Undefined variables are empty strings.
94−pub fn eval(src: &str, scope: &dyn Scope) -> Result<Value, Error> {
74+pub fn eval(src: &str, resolve: Resolver) -> Result<Value, String> {
95− let mut tokens = Vec::new();
75+ let tokens = tokenize(src)?;
96− tokenize(src, scope, 0, &mut tokens)?;
97 let mut parser = Parser {
76 let mut parser = Parser {
98 tokens: &tokens,
77 tokens: &tokens,
99 pos: 0,
78 pos: 0,
100− scope,
79+ resolve,
101− skip: false,
102 };
80 };
103 let value = parser.expr()?;
81 let value = parser.expr()?;
104 match parser.tokens.get(parser.pos) {
82 match parser.tokens.get(parser.pos) {
105 None => Ok(value),
83 None => Ok(value),
106− Some((tok, _)) => Err(Error::Invalid(format!(
107− "unexpected {} in `{}`",
84+ Some(tok) => Err(format!("unexpected {} in `{}`", tok, src.trim())),
108− tok,
109− src.trim()
110− ))),
111 }
85 }
112}
86}
113
87
@@ -138,116 +112,84 @@ impl fmt::Display for Token {
138 }
112 }
139}
113}
140
114
141−const OPERATORS: &[&str] = &[
115+const OPERATORS: &[&str] = &["==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%", "~"];
142− "..", "==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%",
116+
143−];
144−
145−/// Read `src` into tokens, each with its byte offset (plus `base`).
146−/// Variable names end as in text (see `interp.rs`), and `${...}` is a
147−/// variable (`${name}`) or a parenthesized expression.
148−fn tokenize(
117+fn tokenize(src: &str) -> Result<Vec<Token>, String> {
149− src: &str,
118+ let chars: Vec<char> = src.chars().collect();
150− scope: &dyn Scope,
119+ let mut tokens = Vec::new();
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;
120 let mut i = 0;
156− while let Some(c) = src[i..].chars().next() {
121+ let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
157− let at = base + i;
122+ while i < chars.len() {
123+ let c = chars[i];
158 if c.is_whitespace() {
124 if c.is_whitespace() {
159− i += c.len_utf8();
125+ i += 1;
160 } else if c == '(' {
126 } else if c == '(' {
161− tokens.push((Token::LParen, at));
127+ tokens.push(Token::LParen);
162 i += 1;
128 i += 1;
163 } else if c == ')' {
129 } else if c == ')' {
164− tokens.push((Token::RParen, at));
130+ tokens.push(Token::RParen);
165 i += 1;
131 i += 1;
166 } else if c == ',' {
132 } else if c == ',' {
167− tokens.push((Token::Comma, at));
133+ tokens.push(Token::Comma);
168 i += 1;
134 i += 1;
169 } else if c == '"' {
135 } else if c == '"' {
170 let start = i + 1;
136 let start = i + 1;
171− let end = src[start..]
137+ let end = chars[start..]
172− .find('"')
138+ .iter()
139+ .position(|&c| c == '"')
173 .map(|p| start + p)
140 .map(|p| start + p)
174 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
141 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
175− tokens.push((Token::Str(src[start..end].to_string()), at));
142+ tokens.push(Token::Str(chars[start..end].iter().collect()));
176 i = end + 1;
143 i = end + 1;
177 } else if c == '$' {
144 } else if c == '$' {
178− match interp::reference(&src[i + 1..], scope) {
145+ let start = i + 1;
179− Some((Reference::Var(path), len)) => {
146+ let mut end = start;
180− tokens.push((Token::Var(path), at));
147+ while end < chars.len() && is_var_char(chars[end]) {
181− i += 1 + len;
148+ end += 1;
182− }
183− Some((Reference::Expr(inner), len)) => {
184− tokens.push((Token::LParen, at));
185− tokenize(inner, scope, at + 2, tokens)?;
186− tokens.push((Token::RParen, at + len));
187− i += 1 + len;
188− }
149+ }
189− None => {
150+ while end > start && chars[end - 1] == '.' {
190− let n = interp::custom_property_len(&src[i + 1..]);
151+ end -= 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 }
152 }
153+ if end == start {
154+ return Err(format!("`$` without a variable name in `{}`", src.trim()));
155+ }
156+ tokens.push(Token::Var(chars[start..end].iter().collect()));
157+ i = end;
197 } else if let Some(op) = OPERATORS
158 } else if let Some(op) = OPERATORS
198 .iter()
159 .iter()
199− .find(|op| src[i..].starts_with(**op))
160+ .find(|op| chars[i..].iter().take(op.len()).copied().eq(op.chars()))
200 // A `-` followed by a letter starts a word (`-webkit-box`);
161 // A `-` followed by a letter starts a word (`-webkit-box`);
201 // inside words (`ease-in`) it is consumed by the word itself.
162 // inside words (`ease-in`) it is consumed by the word itself.
202− .filter(|op| **op != "-" || !src[i + 1..].starts_with(char::is_alphabetic))
163+ .filter(|op| **op != "-" || !chars.get(i + 1).is_some_and(|c| c.is_alphabetic()))
203 {
164 {
204− tokens.push((Token::Op(op), at));
165+ tokens.push(Token::Op(op));
205 i += op.len();
166 i += op.len();
206 } else if is_word_char(c) {
167 } else if is_word_char(c) {
207 let start = i;
168 let start = i;
208− // A word ends at `..`, so `1..5` is a range
169+ while i < chars.len() && is_word_char(chars[i]) {
209− while let Some(c) = src[i..]
170+ i += 1;
210− .chars()
211− .next()
212− .filter(|&c| is_word_char(c) && !src[i..].starts_with(".."))
213− {
214− i += c.len_utf8();
215 }
171 }
216− let word = &src[start..i];
172+ let word: String = chars[start..i].iter().collect();
217− tokens.push((
218− match word.parse::<f64>() {
173+ tokens.push(match word.parse::<f64>() {
219− Ok(n) if !word.ends_with('.') => Token::Num(n),
174+ Ok(n) if !word.ends_with('.') => Token::Num(n),
220− _ => Token::Word(word.to_string()),
175+ _ => Token::Word(word),
221− },
222− at,
223− ));
176+ });
224 } else {
177 } else {
225− return Err(Error::Invalid(format!(
226− "unexpected `{}` in `{}`",
178+ return Err(format!("unexpected `{}` in `{}`", c, src.trim()));
227− c,
228− src.trim()
229− )));
230 }
179 }
231 }
180 }
232− Ok(())
181+ Ok(tokens)
233}
182}
234
183
235struct Parser<'a> {
184struct Parser<'a> {
236− tokens: &'a [(Token, usize)],
185+ tokens: &'a [Token],
237 pos: usize,
186 pos: usize,
238− scope: &'a dyn Scope,
187+ 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}
188}
244
189
245−/// The value a skipped part stands for; nothing reads it.
246−const SKIPPED: Value = Value::Bool(false);
247−
248impl Parser<'_> {
190impl Parser<'_> {
249 fn peek(&self) -> Option<&Token> {
191 fn peek(&self) -> Option<&Token> {
250− self.tokens.get(self.pos).map(|(token, _)| token)
192+ self.tokens.get(self.pos)
251 }
193 }
252
194
253 fn eat_word(&mut self, word: &str) -> bool {
195 fn eat_word(&mut self, word: &str) -> bool {
@@ -270,167 +212,128 @@ impl Parser<'_> {
270 }
212 }
271 }
213 }
272
214
273− fn expect(&mut self, token: Token) -> Result<(), Error> {
215+ fn expect(&mut self, token: Token) -> Result<(), String> {
274 match self.peek() {
216 match self.peek() {
275 Some(t) if *t == token => {
217 Some(t) if *t == token => {
276 self.pos += 1;
218 self.pos += 1;
277 Ok(())
219 Ok(())
278 }
220 }
279− Some(t) => Err(format!("expected {} but found {}", token, t).into()),
221+ Some(t) => Err(format!("expected {} but found {}", token, t)),
280− None => Err(format!("expected {} at the end", token).into()),
222+ None => Err(format!("expected {} at the end", token)),
281 }
223 }
282 }
224 }
283
225
284− /// Parse with `f`, skipping evaluation when `skip` (or when already
285− /// skipping).
286− fn skipping(
287− &mut self,
288− skip: bool,
289− f: impl FnOnce(&mut Self) -> Result<Value, Error>,
290− ) -> Result<Value, Error> {
291− let outer = self.skip;
292− self.skip |= skip;
293− let value = f(self);
294− self.skip = outer;
295− value
296− }
297−
298− fn expr(&mut self) -> Result<Value, Error> {
226+ fn expr(&mut self) -> Result<Value, String> {
299 let mut left = self.and()?;
227 let mut left = self.and()?;
300 while self.eat_word("or") {
228 while self.eat_word("or") {
301− let decided = left.truthy();
302− let right = self.skipping(decided, Self::and)?;
229+ let right = self.and()?;
303− left = Value::Bool(decided || right.truthy());
230+ left = Value::Bool(left.truthy() || right.truthy());
304 }
231 }
305 Ok(left)
232 Ok(left)
306 }
233 }
307
234
308− fn and(&mut self) -> Result<Value, Error> {
235+ fn and(&mut self) -> Result<Value, String> {
309 let mut left = self.not()?;
236 let mut left = self.not()?;
310 while self.eat_word("and") {
237 while self.eat_word("and") {
311− let decided = !left.truthy();
312− let right = self.skipping(decided, Self::not)?;
238+ let right = self.not()?;
313− left = Value::Bool(!decided && right.truthy());
239+ left = Value::Bool(left.truthy() && right.truthy());
314 }
240 }
315 Ok(left)
241 Ok(left)
316 }
242 }
317
243
318− fn not(&mut self) -> Result<Value, Error> {
244+ fn not(&mut self) -> Result<Value, String> {
319 if self.eat_word("not") {
245 if self.eat_word("not") {
320 return Ok(Value::Bool(!self.not()?.truthy()));
246 return Ok(Value::Bool(!self.not()?.truthy()));
321 }
247 }
322 self.compare()
248 self.compare()
323 }
249 }
324
250
325− fn compare(&mut self) -> Result<Value, Error> {
251+ fn compare(&mut self) -> Result<Value, String> {
326− let left = self.range()?;
252+ let left = self.concat()?;
327− let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
253+ let op = if let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) {
254+ op
255+ } else if self.eat_word("contains") {
256+ "contains"
257+ } else if self.eat_word("starts-with") {
258+ "starts-with"
259+ } else if self.eat_word("ends-with") {
260+ "ends-with"
261+ } else {
328 return Ok(left);
262 return Ok(left);
329 };
263 };
330− let right = self.range()?;
264+ let right = self.concat()?;
331− if self.skip {
265+ let numbers = left.as_num().zip(right.as_num());
332− return Ok(SKIPPED);
266+ let (l, r) = (left.to_string(), right.to_string());
267+ Ok(Value::Bool(match (op, numbers) {
268+ ("==", Some((a, b))) => a == b,
269+ ("!=", Some((a, b))) => a != b,
270+ ("<", Some((a, b))) => a < b,
271+ (">", Some((a, b))) => a > b,
272+ ("<=", Some((a, b))) => a <= b,
273+ (">=", Some((a, b))) => a >= b,
274+ ("==", None) => l == r,
275+ ("!=", None) => l != r,
276+ ("<", None) => l < r,
277+ (">", None) => l > r,
278+ ("<=", None) => l <= r,
279+ (">=", None) => l >= r,
280+ ("contains", _) => l.contains(&r),
281+ ("starts-with", _) => l.starts_with(&r),
282+ ("ends-with", _) => l.ends_with(&r),
283+ _ => unreachable!(),
284+ }))
285+ }
286+
287+ fn concat(&mut self) -> Result<Value, String> {
288+ let mut left = self.sum()?;
289+ while self.eat_op(&["~"]).is_some() {
290+ let right = self.sum()?;
291+ left = Value::Str(format!("{}{}", left, right));
333 }
292 }
334− Ok(Value::Bool(left.compare(op, &right)?))
293+ Ok(left)
335− }
336−
337− /// `A..B` or `A..B step N`: the whole numbers from A to B.
338− fn range(&mut self) -> Result<Value, Error> {
339− let start = self.sum()?;
340− if self.eat_op(&[".."]).is_none() {
341− return Ok(start);
342− }
343− let end = self.sum()?;
344− let step = match self.eat_word("step") {
345− true => Some(self.sum()?),
346− false => None,
347− };
348− if self.skip {
349− return Ok(SKIPPED);
350− }
351− let number = |value: &Value| {
352− value.as_num().ok_or_else(|| {
353− Error::Invalid(format!(
354− "a range goes from one number to another, not from {} (`{}`)",
355− value.describe(),
356− value
357− ))
358− })
359− };
360− let step = match &step {
361− Some(step) => number(step)?,
362− None => 1.0,
363− };
364− Ok(crate::value::range(number(&start)?, number(&end)?, step)?)
365 }
294 }
366
295
367− fn sum(&mut self) -> Result<Value, Error> {
296+ fn sum(&mut self) -> Result<Value, String> {
368 let mut left = self.product()?;
297 let mut left = self.product()?;
369 while let Some(op) = self.eat_op(&["+", "-"]) {
298 while let Some(op) = self.eat_op(&["+", "-"]) {
370 let right = self.product()?;
299 let right = self.product()?;
371− left = self.arithmetic(op, &left, &right)?;
300+ left = arithmetic(op, &left, &right)?;
372 }
301 }
373 Ok(left)
302 Ok(left)
374 }
303 }
375
304
376− fn product(&mut self) -> Result<Value, Error> {
305+ fn product(&mut self) -> Result<Value, String> {
377 let mut left = self.unary()?;
306 let mut left = self.unary()?;
378 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
307 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
379 let right = self.unary()?;
308 let right = self.unary()?;
380− left = self.arithmetic(op, &left, &right)?;
309+ left = arithmetic(op, &left, &right)?;
381 }
310 }
382 Ok(left)
311 Ok(left)
383 }
312 }
384
313
385− fn unary(&mut self) -> Result<Value, Error> {
314+ fn unary(&mut self) -> Result<Value, String> {
386 if self.eat_op(&["-"]).is_some() {
315 if self.eat_op(&["-"]).is_some() {
387 let value = self.unary()?;
316 let value = self.unary()?;
388− if self.skip {
389− return Ok(SKIPPED);
390− }
391 return value
317 return value
392 .as_num()
318 .as_num()
393 .map(|n| Value::Num(-n))
319 .map(|n| Value::Num(-n))
394− .ok_or_else(|| Error::Invalid(format!("cannot negate `{}`", value)));
320+ .ok_or_else(|| format!("cannot negate `{}`", value));
395 }
321 }
396 self.primary()
322 self.primary()
397 }
323 }
398
324
399− fn primary(&mut self) -> Result<Value, Error> {
325+ fn primary(&mut self) -> Result<Value, String> {
400− let (token, at) = self
326+ let token = self
401− .tokens
327+ .peek()
402− .get(self.pos)
403 .cloned()
328 .cloned()
404 .ok_or_else(|| "expected a value at the end".to_string())?;
329 .ok_or_else(|| "expected a value at the end".to_string())?;
405 self.pos += 1;
330 self.pos += 1;
406 match token {
331 match token {
407 Token::Num(n) => Ok(Value::Num(n)),
332 Token::Num(n) => Ok(Value::Num(n)),
408− Token::Str(_) | Token::Var(_) if self.skip => Ok(SKIPPED),
333+ Token::Str(s) => Ok(Value::Str(interpolate(&s, self.resolve))),
409− Token::Str(s) => {
334+ 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)),
335 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
429 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
336 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
430− Token::Word(name) if name == "if" && self.peek() == Some(&Token::LParen) => {
431− self.pos += 1;
432− self.choice()
433− }
434 Token::Word(name) if self.peek() == Some(&Token::LParen) => {
337 Token::Word(name) if self.peek() == Some(&Token::LParen) => {
435 self.pos += 1;
338 self.pos += 1;
436 let mut args = Vec::new();
339 let mut args = Vec::new();
@@ -442,11 +345,7 @@ impl Parser<'_> {
442 }
345 }
443 }
346 }
444 self.expect(Token::RParen)?;
347 self.expect(Token::RParen)?;
445− check_call(&name, args.len())?;
446− if self.skip {
447− return Ok(SKIPPED);
448− }
449− Ok(call(&name, args)?)
348+ call(&name, args)
450 }
349 }
451 Token::Word(w) => Ok(Value::Str(w)),
350 Token::Word(w) => Ok(Value::Str(w)),
452 Token::LParen => {
351 Token::LParen => {
@@ -454,43 +353,8 @@ impl Parser<'_> {
454 self.expect(Token::RParen)?;
353 self.expect(Token::RParen)?;
455 Ok(value)
354 Ok(value)
456 }
355 }
457− other => Err(format!("unexpected {}", other).into()),
356+ 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− }
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 }
357 }
493− Ok(arithmetic(op, left, right)?)
494 }
358 }
495}
359}
496
360
@@ -510,137 +374,167 @@ fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
510 }))
374 }))
511}
375}
512
376
513−/// The built-in functions (other than `if`) and how many arguments each
377+fn is_var_char(c: char) -> bool {
514−/// takes.
378+ c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
515−const FUNCTIONS: &[(&str, usize)] = &[
516− ("contains", 2),
517− ("starts-with", 2),
518− ("ends-with", 2),
519− ("uppercase", 1),
520− ("lowercase", 1),
521− ("capitalize", 1),
522− ("trim", 1),
523− ("length", 1),
524− ("reverse", 1),
525− ("truncate", 2),
526− ("replace", 3),
527− ("default", 2),
528−];
529−
530−/// Check that `name` is a built-in function and gets `count` arguments.
531−fn check_call(name: &str, count: usize) -> Result<(), String> {
532− let Some(&(_, arity)) = FUNCTIONS.iter().find(|(n, _)| *n == name) else {
533− return Err(format!("unknown function `{}()`", name));
534− };
535− if count == arity {
536− Ok(())
537− } else {
538− Err(format!(
539− "{}() takes {} argument{}",
540− name,
541− arity,
542− if arity == 1 { "" } else { "s" }
543− ))
544− }
545}
379}
546
380
547−/// Call a built-in function with the right number of arguments (see
381+/// Call a built-in function.
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.
552fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
382fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
553− let text = |i: usize| -> Result<String, String> {
383+ let text = |i: usize| args.get(i).map(|v| v.to_string()).unwrap_or_default();
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| {
384 let num = |i: usize| {
571 args.get(i)
385 args.get(i)
572 .and_then(Value::as_num)
386 .and_then(Value::as_num)
573 .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
387 .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
574 };
388 };
389+ let arity = |n: usize| {
390+ if args.len() == n {
391+ Ok(())
392+ } else {
393+ Err(format!("{}() takes {} argument{}", name, n, if n == 1 { "" } else { "s" }))
394+ }
395+ };
396+ let color = |rgb: (u8, u8, u8)| Value::Str(format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2));
575 Ok(match name {
397 Ok(match name {
576− "contains" => Value::Bool(match &args[0] {
398+ "if" => {
577− Value::List(list) => list.items.iter().any(|item| item.equals(&args[1])),
399+ arity(3)?;
578− _ => text(0)?.contains(&text(1)?),
400+ let mut args = args;
579− }),
401+ let otherwise = args.pop().unwrap();
580− "starts-with" => Value::Bool(text(0)?.starts_with(&text(1)?)),
402+ let then = args.pop().unwrap();
581− "ends-with" => Value::Bool(text(0)?.ends_with(&text(1)?)),
403+ if args[0].truthy() { then } else { otherwise }
582− "uppercase" => Value::Str(text(0)?.to_uppercase()),
404+ }
583− "lowercase" => Value::Str(text(0)?.to_lowercase()),
405+ "uppercase" => { arity(1)?; Value::Str(text(0).to_uppercase()) }
406+ "lowercase" => { arity(1)?; Value::Str(text(0).to_lowercase()) }
584 "capitalize" => {
407 "capitalize" => {
585− let s = text(0)?;
408+ arity(1)?;
409+ let s = text(0);
586 let mut chars = s.chars();
410 let mut chars = s.chars();
587 Value::Str(match chars.next() {
411 Value::Str(match chars.next() {
588 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
412 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
589 None => String::new(),
413 None => String::new(),
590 })
414 })
591 }
415 }
592− "trim" => Value::Str(text(0)?.trim().to_string()),
416+ "trim" => { arity(1)?; Value::Str(text(0).trim().to_string()) }
593− "length" => Value::Num(match &args[0] {
417+ "length" => { arity(1)?; Value::Num(text(0).chars().count() as f64) }
594− Value::List(list) => list.items.len(),
418+ "reverse" => { arity(1)?; Value::Str(text(0).chars().rev().collect()) }
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" => {
419 "truncate" => {
603− let (s, n) = (text(0)?, num(1)? as usize);
420+ arity(2)?;
421+ let (s, n) = (text(0), num(1)? as usize);
604 Value::Str(if s.chars().count() > n {
422 Value::Str(if s.chars().count() > n {
605 format!("{}...", s.chars().take(n).collect::<String>())
423 format!("{}...", s.chars().take(n).collect::<String>())
606 } else {
424 } else {
607 s
425 s
608 })
426 })
609 }
427 }
610− "replace" => Value::Str(text(0)?.replace(&text(1)?, &text(2)?)),
428+ "replace" => { arity(3)?; Value::Str(text(0).replace(&text(1), &text(2))) }
611 "default" => {
429 "default" => {
612− let empty = match &args[0] {
430+ arity(2)?;
613− Value::List(list) => list.items.is_empty(),
431+ let s = text(0);
614− Value::Record(_) => false,
432+ Value::Str(if s.is_empty() { text(1) } else { s })
615− value => value.to_string().is_empty(),
433+ }
434+ "lighten" | "darken" | "alpha" | "mix" => {
435+ let expected = if name == "mix" { 3 } else { 2 };
436+ arity(expected)?;
437+ let Some(rgb) = parse_hex_rgb(&text(0)) else {
438+ return Err(format!("{}() needs a hex color, got `{}`", name, text(0)));
616 };
439 };
617− let mut args = args;
440+ match name {
618− args.swap_remove(usize::from(empty))
441+ "lighten" => color(lighten_color(rgb, num(1)? / 100.0)),
442+ "darken" => color(darken_color(rgb, num(1)? / 100.0)),
443+ "alpha" => {
444+ let a = (num(1)?.clamp(0.0, 1.0) * 255.0) as u8;
445+ Value::Str(format!("#{:02x}{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2, a))
446+ }
447+ _ => {
448+ let Some(other) = parse_hex_rgb(&text(1)) else {
449+ return Err(format!("mix() needs a hex color, got `{}`", text(1)));
450+ };
451+ color(mix_colors(rgb, other, num(2)? / 100.0))
452+ }
453+ }
619 }
454 }
620 _ => return Err(format!("unknown function `{}()`", name)),
455 _ => return Err(format!("unknown function `{}()`", name)),
621 })
456 })
622}
457}
623
458
459+pub(crate) fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
460+ let s = s.strip_prefix('#')?;
461+ if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
462+ return None;
463+ }
464+ match s.len() {
465+ 3 => {
466+ let r = u8::from_str_radix(&s[0..1], 16).ok()?;
467+ let g = u8::from_str_radix(&s[1..2], 16).ok()?;
468+ let b = u8::from_str_radix(&s[2..3], 16).ok()?;
469+ Some((r * 17, g * 17, b * 17))
470+ }
471+ 6 => {
472+ let r = u8::from_str_radix(&s[0..2], 16).ok()?;
473+ let g = u8::from_str_radix(&s[2..4], 16).ok()?;
474+ let b = u8::from_str_radix(&s[4..6], 16).ok()?;
475+ Some((r, g, b))
476+ }
477+ _ => None,
478+ }
479+}
480+
481+fn lighten_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
482+ let r = rgb.0 as f64 + (255.0 - rgb.0 as f64) * amount.clamp(0.0, 1.0);
483+ let g = rgb.1 as f64 + (255.0 - rgb.1 as f64) * amount.clamp(0.0, 1.0);
484+ let b = rgb.2 as f64 + (255.0 - rgb.2 as f64) * amount.clamp(0.0, 1.0);
485+ (r.round() as u8, g.round() as u8, b.round() as u8)
486+}
487+
488+fn darken_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
489+ let factor = 1.0 - amount.clamp(0.0, 1.0);
490+ let r = (rgb.0 as f64 * factor).round() as u8;
491+ let g = (rgb.1 as f64 * factor).round() as u8;
492+ let b = (rgb.2 as f64 * factor).round() as u8;
493+ (r, g, b)
494+}
495+
496+fn mix_colors(c1: (u8, u8, u8), c2: (u8, u8, u8), weight: f64) -> (u8, u8, u8) {
497+ let w = weight.clamp(0.0, 1.0);
498+ let r = (c1.0 as f64 * (1.0 - w) + c2.0 as f64 * w).round() as u8;
499+ let g = (c1.1 as f64 * (1.0 - w) + c2.1 as f64 * w).round() as u8;
500+ let b = (c1.2 as f64 * (1.0 - w) + c2.2 as f64 * w).round() as u8;
501+ (r, g, b)
502+}
503+
504+
505+/// Replace `$name` references inside a string literal.
506+fn interpolate(s: &str, resolve: Resolver) -> String {
507+ let mut out = String::with_capacity(s.len());
508+ let mut rest = s;
509+ while let Some(pos) = rest.find('$') {
510+ out.push_str(&rest[..pos]);
511+ let after = &rest[pos + 1..];
512+ let end = after.find(|c: char| !is_var_char(c)).unwrap_or(after.len());
513+ if end == 0 {
514+ out.push('$');
515+ } else {
516+ out.push_str(&resolve(&after[..end]).unwrap_or_default());
517+ }
518+ rest = &after[end..];
519+ }
520+ out.push_str(rest);
521+ out
522+}
523+
624#[cfg(test)]
524#[cfg(test)]
625mod tests {
525mod tests {
626 use super::*;
526 use super::*;
627
527
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 {
528 fn ev(src: &str) -> Value {
643− eval(src, &scope()).unwrap()
529+ let resolve = |name: &str| match name {
530+ "count" => Some("3".to_string()),
531+ "name" => Some("World".to_string()),
532+ "theme" => Some("dark".to_string()),
533+ "tricky" => Some("a == b".to_string()),
534+ "empty" => Some(String::new()),
535+ _ => None,
536+ };
537+ eval(src, &resolve).unwrap()
644 }
538 }
645
539
646 #[test]
540 #[test]
@@ -658,8 +552,7 @@ mod tests {
658 assert!(ev("$theme == dark").truthy());
552 assert!(ev("$theme == dark").truthy());
659 assert!(ev("$theme != light and not $empty").truthy());
553 assert!(ev("$theme != light and not $empty").truthy());
660 assert!(!ev("$count < 2 or $empty").truthy());
554 assert!(!ev("$count < 2 or $empty").truthy());
661− assert!(ev("contains($name, or)").truthy());
555+ assert!(ev("$name contains or").truthy());
662− assert!(ev("starts-with($name, Wo) and ends-with($name, ld)").truthy());
663 assert!(ev("uppercase($name) == WORLD").truthy());
556 assert!(ev("uppercase($name) == WORLD").truthy());
664 assert!(ev("#3b82f6 != red").truthy());
557 assert!(ev("#3b82f6 != red").truthy());
665 }
558 }
@@ -673,64 +566,10 @@ mod tests {
673
566
674 #[test]
567 #[test]
675 fn strings_and_if() {
568 fn strings_and_if() {
569+ assert_eq!(ev("\"Hello \" ~ $name").to_string(), "Hello World");
676 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
570 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
677 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
571 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
678− assert_eq!(ev("${name}").to_string(), "World");
572+ 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 }
573 }
735
574
736 #[test]
575 #[test]
@@ -740,62 +579,20 @@ mod tests {
740 assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
579 assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
741 assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
580 assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
742 assert_eq!(ev("default($empty, none)").to_string(), "none");
581 assert_eq!(ev("default($empty, none)").to_string(), "none");
743− }
582+ assert_eq!(ev("darken(#ffffff, 50)").to_string(), "#808080");
744−
583+ assert_eq!(ev("mix(#000000, #ffffff, 50)").to_string(), "#808080");
745− #[test]
584+ assert_eq!(ev("alpha(#3b82f6, 0.5)").to_string(), "#3b82f67f");
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 }
585 }
780
586
781 #[test]
587 #[test]
782 fn errors_are_reported() {
588 fn errors_are_reported() {
783− let none = Map::new(&[]);
589+ let none = |_: &str| None;
784 assert!(eval("dark * 2", &none).is_err());
590 assert!(eval("dark * 2", &none).is_err());
785 assert!(eval("1 +", &none).is_err());
591 assert!(eval("1 +", &none).is_err());
786 assert!(eval("(1", &none).is_err());
592 assert!(eval("(1", &none).is_err());
787 assert!(eval("1 / 0", &none).is_err());
593 assert!(eval("1 / 0", &none).is_err());
788 assert!(eval("nope(1)", &none).is_err());
594 assert!(eval("nope(1)", &none).is_err());
789 assert!(eval("uppercase(a, b)", &none).is_err());
595 assert!(eval("uppercase(a, b)", &none).is_err());
790− // Colour maths is CSS's (`color-mix()`), and a custom property is
596+ assert!(eval("darken(red, 10)", &none).is_err());
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 }
597 }
801}
598}
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+3303−6192
@@ -1,193 +1,89 @@
1use std::collections::{HashMap, HashSet};
1use std::collections::{HashMap, HashSet};
2+use std::fmt;
2use std::path::{Path, PathBuf};
3use std::path::{Path, PathBuf};
3−use std::rc::Rc;
4
4
5use crate::ast::*;
5use crate::ast::*;
6−use crate::diagnostic::code;
7−pub use crate::diagnostic::{Diagnostic, Severity};
8−use crate::interp::{self, Sink};
9−use crate::syntax::{self, DirectiveArgs, LetForm, NodeKind, Segment, Tree};
10−use crate::value::{self, Value};
11
6
12// ---------------------------------------------------------------------------
7// ---------------------------------------------------------------------------
13// Public types
8// Public types
14// ---------------------------------------------------------------------------
9// ---------------------------------------------------------------------------
15
10
11+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12+pub enum Severity {
13+ Error,
14+ Warning,
15+ Info,
16+ Help,
17+}
18+
19+#[derive(Debug, Clone)]
20+pub struct Diagnostic {
21+ pub line: usize,
22+ pub column: Option<usize>,
23+ pub message: String,
24+ pub severity: Severity,
25+ pub source_line: Option<String>,
26+}
27+
16pub struct ParseResult {
28pub struct ParseResult {
17 pub document: Document,
29 pub document: Document,
18 pub diagnostics: Vec<Diagnostic>,
30 pub diagnostics: Vec<Diagnostic>,
19 pub included_files: Vec<PathBuf>,
31 pub included_files: Vec<PathBuf>,
20}
32}
21
33
22−/// An error that stops one line from being evaluated.
34+#[derive(Debug)]
23−type ParseError = Diagnostic;
35+struct ParseError {
24−
25−// ---------------------------------------------------------------------------
26−// Internal types
27−// ---------------------------------------------------------------------------
28−
29−#[derive(Clone)]
30−struct FnDef {
31− /// The parameters in the order they are declared; one without a
32− /// default is required.
33− params: Vec<syntax::Param>,
34− /// The definition's line and its text (when it is one physical line),
35− /// and its node, for diagnostics about a default.
36 line: usize,
36 line: usize,
37− source: Option<String>,
37+ message: String,
38− node: usize,
39− body: Vec<syntax::Node>,
40− /// The file it is defined in, when that isn't the file being compiled
41− /// (see `ParseContext::current_file`).
42− file: Option<String>,
43− /// The slots its body declares (`@slot NAME`), in order.
44− slots: Vec<String>,
45− /// Whether its body has a `@children` for the content of a call.
46− has_children: bool,
47− /// Its scoped `@style`: its class (`hl-fn-NAME-HASH`), and the
48− /// stylesheet nested under it, written into the page once the function
49− /// is called (see `Document::scoped_css`).
50− scoped: Option<(String, String)>,
51− /// What was visible where it was defined: its body and its defaults
52− /// see this, plus its parameters.
53− env: Env,
54− /// It is named like a built-in element and its body calls that name
55− /// outside any `@if`, meaning the element (`@let @nav` around `@nav`):
56− /// it would call itself forever, so a call writes nothing (the
57− /// definition's error says so).
58− wraps_itself: bool,
59}
38}
60
39
61−impl FnDef {
40+impl fmt::Display for ParseError {
62− fn is_param(&self, name: &str) -> bool {
41+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
63− self.params.iter().any(|p| p.name == name)
42+ write!(f, "line {}: {}", self.line, self.message)
64 }
43 }
65}
44}
66
45
67−/// What a name means: every `@let`, parameter, loop variable and `@data`
46+// ---------------------------------------------------------------------------
68−/// binds one, and they share one namespace.
47+// Internal types
69−#[derive(Clone)]
48+// ---------------------------------------------------------------------------
70−enum Binding {
71− Value(Value),
72− Bundle(Rc<Vec<Attribute>>),
73− Function(Rc<FnDef>),
74−}
75
49
76−/// The names visible at a line: one frame per block it is in (the
50+#[derive(Clone)]
77−/// standard library, the file, an element's children, an `@if` branch, an
51+enum LineContent {
78−/// iteration of `@each`, a function's body), the innermost last. A
52+ Normal(String),
79−/// definition goes into the innermost frame, so it is visible from its
53+ Raw(String),
80−/// line to the end of its block. A function keeps the frames visible
81−/// where it is defined; a frame is shared until one side changes it.
82−#[derive(Clone, Default)]
83−struct Env {
84− frames: Vec<Rc<HashMap<String, Binding>>>,
85}
54}
86
55
87−impl Env {
56+#[derive(Clone)]
88− fn push(&mut self) {
57+struct Line {
89− self.frames.push(Rc::default());
58+ indent: usize,
90− }
59+ content: LineContent,
91−
60+ line_num: usize,
92− fn pop(&mut self) {
93− self.frames.pop();
94− }
95−
96− /// Give `name` a meaning in the innermost frame, replacing what it
97− /// meant there and hiding what it means outside it.
98− fn define(&mut self, name: &str, binding: Binding) {
99− if self.frames.is_empty() {
100− self.push();
101− }
102− if let Some(frame) = self.frames.last_mut() {
103− Rc::make_mut(frame).insert(name.to_string(), binding);
104− }
105− }
106−
107− /// What `name` means here: the innermost definition.
108− fn get(&self, name: &str) -> Option<&Binding> {
109− self.frames.iter().rev().find_map(|frame| frame.get(name))
110− }
111−
112− fn value(&self, name: &str) -> Option<&Value> {
113− match self.get(name)? {
114− Binding::Value(value) => Some(value),
115− _ => None,
116− }
117− }
118−
119− fn bundle(&self, name: &str) -> Option<&Rc<Vec<Attribute>>> {
120− match self.get(name)? {
121− Binding::Bundle(attrs) => Some(attrs),
122− _ => None,
123− }
124− }
125−
126− fn function(&self, name: &str) -> Option<&Rc<FnDef>> {
127− match self.get(name)? {
128− Binding::Function(function) => Some(function),
129− _ => None,
130− }
131− }
132−
133− /// Every name visible here, with what it means.
134− fn visible(&self) -> HashMap<&str, &Binding> {
135− let mut names = HashMap::new();
136− for frame in &self.frames {
137− for (name, binding) in frame.iter() {
138− names.insert(name.as_str(), binding);
139− }
140− }
141− names
142− }
143−
144− /// The names of the values visible here.
145− fn value_names(&self) -> Vec<&str> {
146− self.visible()
147− .into_iter()
148− .filter(|(_, binding)| matches!(binding, Binding::Value(_)))
149− .map(|(name, _)| name)
150− .collect()
151− }
152−
153− /// The names of the bundles visible here.
154− fn bundle_names(&self) -> Vec<&str> {
155− self.visible()
156− .into_iter()
157− .filter(|(_, binding)| matches!(binding, Binding::Bundle(_)))
158− .map(|(name, _)| name)
159− .collect()
160− }
161}
61}
162
62
163−impl interp::Scope for Env {
63+#[derive(Clone)]
164− fn get(&self, name: &str) -> Option<Value> {
64+struct FnDef {
165− self.value(name).cloned()
65+ params: Vec<String>,
166− }
66+ defaults: HashMap<String, String>,
67+ body_lines: Vec<Line>,
167}
68}
168
69
169struct ParseContext {
70struct ParseContext {
170 /// Source line currently being parsed, for diagnostics raised deep
71 /// Source line currently being parsed, for diagnostics raised deep
171 /// inside helpers that don't take a line number.
72 /// inside helpers that don't take a line number.
172 current_line: usize,
73 current_line: usize,
173− /// What the `@page` said, once one has run
74+ /// Functions whose body has a scoped @style block.
174− page: Option<Page>,
75+ scoped_functions: std::collections::HashSet<String>,
175− /// Where that `@page` is, for the error a second one gets
76+ page_title: Option<String>,
176− page_at: Option<String>,
77+ lang: Option<String>,
78+ favicon: Option<String>,
177 meta_tags: Vec<(String, String)>,
79 meta_tags: Vec<(String, String)>,
178 head_blocks: Vec<String>,
80 head_blocks: Vec<String>,
179− /// The first `@meta` or `@head` that ran, for the warning a file
81+ variables: HashMap<String, String>,
180− /// without `@page` gets: a fragment has no `<head>` for them.
82+ defines: HashMap<String, Vec<Attribute>>,
181− head_at: Option<usize>,
83+ functions: HashMap<String, FnDef>,
182− /// What each name means at the line being evaluated.
84+ keyframes: Vec<(String, String)>,
183− env: Env,
184− /// Every function whose definition ran, by name (the last one), for
185− /// the checks of code that never runs.
186− defined_functions: HashMap<String, Rc<FnDef>>,
187 css_vars: Vec<(String, String)>,
85 css_vars: Vec<(String, String)>,
188 custom_css: Vec<String>,
86 custom_css: Vec<String>,
189− /// The scoped `@style` of each function called so far
190− scoped_css: Vec<String>,
191 og_tags: Vec<(String, String)>,
87 og_tags: Vec<(String, String)>,
192 diagnostics: Vec<Diagnostic>,
88 diagnostics: Vec<Diagnostic>,
193 base_path: Option<PathBuf>,
89 base_path: Option<PathBuf>,
@@ -207,550 +103,44 @@ struct ParseContext {
207 fn_lines: HashMap<String, usize>,
103 fn_lines: HashMap<String, usize>,
208 /// Line numbers of attribute bundle definitions (name -> line)
104 /// Line numbers of attribute bundle definitions (name -> line)
209 define_lines: HashMap<String, usize>,
105 define_lines: HashMap<String, usize>,
210− /// Syntax nodes evaluated at least once, by id.
106+ /// Canonical URL
211− visited: HashSet<usize>,
107+ canonical: Option<String>,
212− /// The id the next parsed tree starts at, so ids are unique across
108+ /// Base URL for relative links
213− /// the file, its includes and the standard library.
109+ base_url: Option<String>,
214− next_id: usize,
110+ /// Track @import paths for circular dependency detection
215− /// The file's tree and those of the files it includes, with the
111+ import_stack: Vec<PathBuf>,
216− /// include chain that leads to each, for the checks of code that never
217− /// runs.
218− trees: Vec<(Rc<Tree>, Option<String>)>,
219− /// The files each `@include` line brought in (by the line's node id),
220− /// with the include chain that leads to each, so the checks of
221− /// `@slot` and `@children` see an included file's lines where the
222− /// `@include` is written.
223− included_by: Rc<IncludedBy>,
224− /// Every function defined anywhere in the file or the files it
225− /// includes, whether or not its definition runs.
226− namespace: HashSet<String>,
227− /// The line being evaluated, and its text as written when it is one
228− /// physical line, for diagnostics that point at a column.
229− current_source: (usize, Option<String>),
230− /// Variable diagnostics already reported, so a line evaluated many
231− /// times (in a loop, in a function) reports each problem once.
232− /// Keyed by the syntax node (ids are unique across files), line,
233− /// column and message.
234− reported: HashSet<(usize, usize, Option<usize>, String)>,
235− /// The id of the syntax node being evaluated.
236− current_node: usize,
237− /// The file whose lines are being evaluated, when it isn't the file
238− /// being compiled: the standard library, or an included file (by its
239− /// include chain).
240− current_file: Option<String>,
241− /// A call went deeper than [`MAX_CALL_DEPTH`]: the calls it is nested
242− /// in expand to nothing, until the outermost one returns.
243− too_deep: bool,
244− /// A bundle's attributes are being read: a name that could be a
245− /// parameter of the function the bundle is passed to is checked where
246− /// the bundle is used instead.
247− in_bundle: bool,
248− /// `@page`'s attributes are being read: a name close to `favicon` is
249− /// a misspelled word of `@page`'s.
250− in_page: bool,
251−}
252−
253−/// How deeply function calls may nest, so that a function that calls
254−/// itself without an `@if` that stops it is an error instead of a hang.
255−const MAX_CALL_DEPTH: usize = 64;
256−
257−/// A CSS rule at the top level of a stylesheet that starts with `&`
258−/// (outside any `{}`, strings and comments).
259−fn has_top_level_ampersand(css: &str) -> bool {
260− let mut depth = 0usize;
261− let mut chars = css.chars().peekable();
262− let mut at_rule_start = true;
263− while let Some(c) = chars.next() {
264− match c {
265− '/' if chars.peek() == Some(&'*') => {
266− chars.next();
267− let mut last = ' ';
268− for c in chars.by_ref() {
269− if last == '*' && c == '/' {
270− break;
271− }
272− last = c;
273− }
274− continue;
275− }
276− '"' | '\'' => {
277− for d in chars.by_ref() {
278− if d == c {
279− break;
280− }
281− }
282− }
283− '{' => depth += 1,
284− '}' => depth = depth.saturating_sub(1),
285− '&' if depth == 0 && at_rule_start => return true,
286− _ => {}
287− }
288− at_rule_start = match c {
289− '{' | '}' | ';' => true,
290− c if c.is_whitespace() => at_rule_start,
291− _ => false,
292− };
293− }
294− false
295}
112}
296
113
297−/// Whether a file is a library (see [`Tree::is_library`]), with the files
114+struct Parser {
298−/// its `@include` lines name read relative to `base`.
115+ lines: Vec<Line>,
299−fn is_library_file(
116+ pos: usize,
300− tree: &Tree,
301− base: Option<&Path>,
302− ctx: &mut ParseContext,
303− seen: &mut Vec<PathBuf>,
304−) -> bool {
305− tree.is_library_with(&mut |path| {
306− if path.contains('$') {
307− return false;
308− }
309− let resolved = base.map_or_else(|| PathBuf::from(path), |base| base.join(path));
310− if seen.contains(&resolved) {
311− return false;
312− }
313− seen.push(resolved.clone());
314− let Ok(text) = ctx.read_file(&resolved) else {
315− return false;
316− };
317− let included = syntax::parse(&text);
318− is_library_file(&included, resolved.parent(), ctx, seen)
319− })
320}
117}
321
118
322−/// The frames around the file's top level: the standard library's and
323−/// the file's own (see [`load_prelude`]).
324−const FILE_FRAMES: usize = 2;
325−
326−/// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes.
327−struct Evaluator;
328−
329impl ParseContext {
119impl ParseContext {
330− /// Bind a value to `name` in the innermost block.
120+ /// Evaluate an expression (see `expr.rs`), reporting errors at `line`.
331− fn bind(&mut self, name: &str, value: Value) {
121+ fn eval(&mut self, src: &str, line: usize) -> Option<crate::expr::Value> {
332− self.env.define(name, Binding::Value(value));
333− }
334−
335− /// Evaluate an expression (see `expr.rs`) written at `line` and
336− /// `column` (when known), reporting its errors there.
337− fn eval(
338− &mut self,
339− src: &str,
340− line: usize,
341− column: Option<usize>,
342− ) -> Option<crate::expr::Value> {
343 track_var_refs(src, &mut self.used_variables);
122 track_var_refs(src, &mut self.used_variables);
344− match crate::expr::eval(src, &self.env) {
123+ let vars = &self.variables;
124+ let resolve = |name: &str| vars.get(name).cloned();
125+ let result = crate::expr::eval(src, &resolve);
126+ match result {
345 Ok(value) => Some(value),
127 Ok(value) => Some(value),
346− Err(error) => {
128+ Err(message) => {
347− // Without the line to point into, show the expression
129+ self.diagnostics.push(Diagnostic {
348− let shown = matches!(&self.current_source, (current, Some(_)) if *current == line);
130+ line,
349− let before = self.diagnostics.len();
131+ column: None,
350− self.report(error.at(0), line, column);
132+ message: format!("invalid expression: {}", message),
351− if !shown && let Some(diagnostic) = self.diagnostics.get_mut(before) {
133+ severity: Severity::Error,
352− diagnostic.source_line = Some(src.into());
134+ source_line: Some(src.to_string()),
353− }
135+ });
354 None
136 None
355 }
137 }
356 }
138 }
357 }
139 }
358
140
359 /// Evaluate a condition; an invalid one is reported and counts as false.
141 /// Evaluate a condition; an invalid one is reported and counts as false.
360− fn condition(&mut self, src: &str, line: usize, column: Option<usize>) -> bool {
142+ fn condition(&mut self, src: &str, line: usize) -> bool {
361− self.eval(src, line, column)
143+ self.eval(src, line).is_some_and(|value| value.truthy())
362− .is_some_and(|value| value.truthy())
363− }
364−
365− /// Fill in one slot of htmlang text written at `line` and `column`
366− /// (when known), for `sink`: its escapes stand for themselves and its
367− /// variables are filled in (see `interp.rs`). What can't be filled is
368− /// left as written and reported; the flag says whether everything was
369− /// filled.
370− fn fill(
371− &mut self,
372− raw: &str,
373− line: usize,
374− column: Option<usize>,
375− sink: Sink,
376− ) -> (String, bool) {
377− let (filled, ok) = self.fill_protected(raw, line, column, sink);
378− (restore_escapes(&filled), ok)
379− }
380−
381− /// [`fill`](Self::fill) for text.
382− fn interpolate_text(&mut self, raw: &str, line: usize, column: Option<usize>) -> String {
383− self.fill(raw, line, column, Sink::Text).0
384− }
385−
386− /// [`fill`](Self::fill), with the escapes still placeholders (see
387− /// [`protect_escapes`]).
388− fn fill_protected(
389− &mut self,
390− raw: &str,
391− line: usize,
392− column: Option<usize>,
393− sink: Sink,
394− ) -> (String, bool) {
395− let mut protected = protect_escapes(raw);
396− if sink == Sink::Css {
397− protected = keep_css_string_escapes(&protected);
398− }
399− if !protected.contains('$') {
400− return (protected, true);
401− }
402− track_var_refs(&protected, &mut self.used_variables);
403− let (out, problems) = interp::interpolate_for(&protected, &self.env, sink);
404− let filled = problems.is_empty();
405− for mut problem in problems {
406− match &mut problem {
407− interp::Problem::Undefined { offset, .. }
408− | interp::Problem::CustomProperty { offset, .. }
409− | interp::Problem::Invalid { offset, .. }
410− | interp::Problem::Record { offset, .. } => {
411− // An offset into the protected text, as one into `raw`
412− *offset = protected[..*offset]
413− .chars()
414− .map(|c| escape_of(c).map_or(c.len_utf8(), str::len))
415− .sum();
416− }
417− }
418− self.report(problem, line, column);
419− }
420− (out, filled)
421− }
422−
423− /// Fill in a value slot (an attribute's value, a `@let` value, `@meta`'s
424− /// value) for `sink`, and say whether it is quoted text: one `"..."`, or
425− /// one variable that holds quoted text. Quoted text keeps its quotes
426− /// only in CSS.
427− fn fill_value(
428− &mut self,
429− raw: &str,
430− line: usize,
431− column: Option<usize>,
432− sink: Sink,
433− ) -> (String, Option<Quoted>, bool) {
434− if let Some(inner) = syntax::quoted_string(raw) {
435− let (css, ok) = self.fill(raw, line, column, Sink::Css);
436− // Already reported by the CSS pass, so filled in quietly
437− let (text, _) = interp::interpolate(&protect_escapes(inner), &self.env);
438− let quoted = Quoted {
439− text: restore_escapes(&text),
440− css,
441− };
442− let out = match sink {
443− Sink::Css => quoted.css.clone(),
444− Sink::Text => quoted.text.clone(),
445− };
446− return (out, Some(quoted), ok);
447− }
448− let (out, ok) = self.fill(raw, line, column, sink);
449− let quoted = whole_reference(raw, &self.env).and_then(|path| {
450− match interp::resolve(&path, &self.env)? {
451− Value::Quoted(quoted) => Some(quoted),
452− _ => None,
453− }
454− });
455− (out, quoted, ok)
456− }
457−
458− /// The value of a value slot (a `@let` value, the source of `@each`, a
459− /// parameter's value or default), written at `line` and `column`:
460− ///
461− /// - exactly one `$name` or `${...}` is the value it holds or gives,
462− /// whatever its type (a list, a record, quoted text);
463− /// - one `"..."` is quoted text;
464− /// - commas outside quotes, brackets and `${...}`, and not escaped
465− /// (`\,`), make a list, which prints as written;
466− /// - `A..B` or `A..B step N` with whole numbers is a range;
467− /// - anything else is text, with its variables filled in.
468− ///
469− /// What can't be filled in is reported; the flag says whether
470− /// everything was.
471− fn slot_value(&mut self, raw: &str, line: usize, column: Option<usize>) -> (Value, bool) {
472− let trimmed = raw.trim();
473− let column = column.map(|c| c + (raw.len() - raw.trim_start().len()));
474− if let Some(value) = self.whole_value(trimmed, line, column) {
475− return value;
476− }
477− if syntax::quoted_string(trimmed).is_some() {
478− let (text, quoted, ok) = self.fill_value(trimmed, line, column, Sink::Text);
479− return (quoted.map_or(Value::Str(text), Value::Quoted), ok);
480− }
481− let parts = syntax::split_list(trimmed);
482− if parts.len() > 1 {
483− // Each item is a value of its own; the list prints as written
484− let mut items = Vec::new();
485− let mut written = String::new();
486− let mut ok = true;
487− let mut end = 0;
488− for part in parts {
489− let item = &trimmed[part.clone()];
490− let lead = item.len() - item.trim_start().len();
491− written.push_str(&trimmed[end..part.start + lead]);
492− end = part.start + lead + item.trim().len();
493− if item.trim().is_empty() {
494− continue;
495− }
496− let at = column.map(|c| c + part.start + lead);
497− let (value, text, filled) = self.item_value(item.trim(), line, at);
498− ok &= filled;
499− written.push_str(&text);
500− items.push(value);
501− }
502− written.push_str(&trimmed[end..]);
503− let list = value::List {
504− items: Rc::new(items),
505− written: Some(written.into()),
506− };
507− return (Value::List(list), ok);
508− }
509− let (text, ok) = self.fill(trimmed, line, column, Sink::Text);
510− match value::written_range(&text) {
511− Some(Ok(range)) => (range, ok),
512− Some(Err(message)) => {
513− self.report(
514− interp::Problem::Invalid { message, offset: 0 },
515− line,
516− column,
517− );
518− (Value::list(Vec::new()), false)
519− }
520− None => (Value::Str(text), ok),
521− }
522− }
523−
524− /// An item of a list written in the source (see
525− /// [`slot_value`](Self::slot_value)), and how it is written in the
526− /// list's text.
527− fn item_value(
528− &mut self,
529− item: &str,
530− line: usize,
531− column: Option<usize>,
532− ) -> (Value, String, bool) {
533− let (value, ok) = match self.whole_value(item, line, column) {
534− Some(value) => value,
535− None if syntax::quoted_string(item).is_some() => {
536− let (text, quoted, ok) = self.fill_value(item, line, column, Sink::Text);
537− (quoted.map_or(Value::Str(text), Value::Quoted), ok)
538− }
539− None => {
540− let (text, ok) = self.fill(item, line, column, Sink::Text);
541− (Value::Str(text), ok)
542− }
543− };
544− // Quoted text keeps its quotes in the list's text, as written
545− let text = value
546− .quoted_css()
547− .map_or_else(|| value.to_string(), str::to_string);
548− (value, text, ok)
549− }
550−
551− /// The value of a slot that is exactly one `$name` or `${...}`, with its
552− /// type; `None` when it is anything else.
553− fn whole_value(
554− &mut self,
555− raw: &str,
556− line: usize,
557− column: Option<usize>,
558− ) -> Option<(Value, bool)> {
559− let after = raw.strip_prefix('$')?;
560− let (reference, len) = interp::reference(after, &self.env)?;
561− if len != after.len() {
562− return None;
563− }
564− track_var_refs(raw, &mut self.used_variables);
565− let problem = match reference {
566− interp::Reference::Var(path) => match interp::resolve(&path, &self.env) {
567− Some(value) => return Some((value, true)),
568− None => interp::Problem::Undefined {
569− name: path,
570− offset: 0,
571− },
572− },
573− interp::Reference::Expr(source) => match crate::expr::eval(source, &self.env) {
574− Ok(value) => return Some((value, true)),
575− Err(crate::expr::Error::Invalid(message)) => {
576− interp::Problem::Invalid { message, offset: 0 }
577− }
578− Err(error) => error.at(2),
579− },
580− };
581− self.report(problem, line, column);
582− Some((Value::Str(raw.to_string()), false))
583− }
584−
585− /// Report a variable that can't be filled in, or an invalid `${...}`,
586− /// at `offset` from `column`.
587− fn report(&mut self, problem: interp::Problem, line: usize, column: Option<usize>) {
588− let (line_text, column) = match &self.current_source {
589− // The column is only shown with the line it points into
590− (current, Some(text)) if *current == line => (Some(text.clone()), column),
591− _ => (None, None),
592− };
593− let diagnostic = match problem {
594− interp::Problem::Undefined { name, offset } => {
595− let column = column.map(|c| c + offset);
596− let mut diagnostic = self.undefined(&name, line);
597− if let Some(column) = column {
598− diagnostic = diagnostic.column(column);
599− }
600− diagnostic
601− }
602− interp::Problem::Invalid { message, offset } => {
603− let diagnostic = Diagnostic::error(
604− code::INVALID_EXPRESSION,
605− line,
606− format!("invalid expression: {}", message),
607− );
608− match column {
609− Some(column) => diagnostic.column(column + offset),
610− None => diagnostic,
611− }
612− }
613− interp::Problem::CustomProperty { name, offset } => {
614− let diagnostic = Diagnostic::error(
615− code::INVALID_VALUE,
616− line,
617− interp::custom_property_message(&name),
618− )
619− .subject(format!("${}", name))
620− .suggest(Some(format!("var({})", name)));
621− match column {
622− Some(column) => diagnostic.column(column + offset),
623− None => diagnostic,
624− }
625− }
626− interp::Problem::Record { message, offset } => {
627− let diagnostic = Diagnostic::error(code::INVALID_VALUE, line, message);
628− match column {
629− Some(column) => diagnostic.column(column + offset),
630− None => diagnostic,
631− }
632− }
633− };
634− let diagnostic = match line_text {
635− Some(text) => diagnostic.source(text),
636− None => diagnostic,
637− };
638− self.push_once(diagnostic);
639− }
640−
641− /// Report a diagnostic unless the node being evaluated already did,
642− /// so a line evaluated many times (in a loop, in a function) reports
643− /// each problem once.
644− fn push_once(&mut self, diagnostic: Diagnostic) {
645− let key = (
646− self.current_node,
647− diagnostic.line,
648− diagnostic.column,
649− diagnostic.message.clone(),
650− );
651− if self.reported.insert(key) {
652− self.diagnostics.push(diagnostic);
653− }
654− }
655−
656− /// The error for `$name` with no definition: what the name is if it is
657− /// something else, or the closest defined name.
658− fn undefined(&mut self, name: &str, line: usize) -> Diagnostic {
659− if self.env.bundle(name).is_some() {
660− // Reported here, so not also as unused
661− self.used_defines.insert(name.to_string());
662− return Diagnostic::error(
663− code::UNDEFINED_VARIABLE,
664− line,
665− format!(
666− "'${}' is an attribute bundle, not a value: it goes in an attribute list \
667− as a whole attribute, `[${}]`",
668− name, name
669− ),
670− )
671− .subject(name);
672− }
673− if self.env.function(name).is_some() {
674− self.used_functions.insert(name.to_string());
675− return Diagnostic::error(
676− code::UNDEFINED_VARIABLE,
677− line,
678− format!(
679− "'${}' is a function, not a value: it is called as an element, `@{}`",
680− name, name
681− ),
682− )
683− .subject(name);
684− }
685− // `$i-label` where `$i` is defined: a `-` joins a name
686− if let Some((defined, rest)) = name
687− .match_indices('-')
688− .map(|(i, _)| (&name[..i], &name[i..]))
689− .find(|(before, _)| self.env.value(before).is_some())
690− {
691− return Diagnostic::error(
692− code::UNDEFINED_VARIABLE,
693− line,
694− format!(
695− "undefined variable '${}': a `-` is part of a name. For '${}' followed \
696− by '{}', write `${{{}}}{}`",
697− name, defined, rest, defined, rest
698− ),
699− )
700− .subject(name);
701− }
702− let suggestion = suggest_var_name(name, &self.env.value_names());
703− let message = match (&suggestion, self.let_lines.get(name)) {
704− (Some(closest), _) => format!(
705− "undefined variable '${}', did you mean '${}'?",
706− name, closest
707− ),
708− // Defined, but not where this line can see it
709− (None, Some(_)) => format!(
710− "undefined variable '${}': a `@let` defines it, but not where this line \
711− can see it. A definition is visible from its line to the end of its \
712− block, and a function's body sees what is defined above the function",
713− name
714− ),
715− (None, None) => format!("undefined variable '${}'", name),
716− };
717− Diagnostic::error(code::UNDEFINED_VARIABLE, line, message)
718− .subject(name)
719− .suggest(suggestion)
720− }
721−
722− /// Note the node being evaluated, for diagnostics.
723− fn enter(&mut self, node: &syntax::Node) {
724− self.current_line = node.span.line;
725− self.current_node = node.id;
726− let text =
727− (node.line_count <= 1).then(|| format!("{}{}", " ".repeat(node.indent), node.source));
728− self.current_source = (node.span.line, text);
729− }
730−
731− /// Parse a file's text into a tree whose ids don't clash with the
732− /// trees parsed before it.
733− fn parse_tree(&mut self, text: &str) -> Rc<Tree> {
734− let tree = syntax::parse_from(text, self.next_id);
735− self.next_id = tree.end_id;
736− Rc::new(tree)
737− }
738−
739− /// Read a file (once), relative to the current file.
740− fn read_file(&mut self, resolved: &Path) -> std::io::Result<String> {
741− if let Some(cached) = self.file_cache.get(resolved) {
742− return Ok(cached.clone());
743− }
744− let text = std::fs::read_to_string(resolved)?;
745− self.file_cache.insert(resolved.to_path_buf(), text.clone());
746− Ok(text)
747− }
748−
749− fn resolve(&self, filename: &str) -> PathBuf {
750− match &self.base_path {
751− Some(base) => base.join(filename),
752− None => PathBuf::from(filename),
753− }
754 }
144 }
755}
145}
756
146
@@ -759,19 +149,15 @@ impl ParseContext {
759const PRELUDE: &str = include_str!("std.hl");
149const PRELUDE: &str = include_str!("std.hl");
760
150
761fn load_prelude(ctx: &mut ParseContext) {
151fn load_prelude(ctx: &mut ParseContext) {
762− // The library's definitions are in a block of their own, around the
152+ let mut prelude = Parser {
763− // file's, so the file's own `@let` of the same name hides them
153+ lines: preprocess(PRELUDE),
764− ctx.env.push();
154+ pos: 0,
765− let tree = ctx.parse_tree(PRELUDE);
155+ };
766− collect_namespace(&tree.nodes, None, &mut HashSet::new(), ctx);
156+ let _ = prelude.parse_children(0, ctx);
767− ctx.current_file = Some("std.hl".to_string());
768− let _ = Evaluator.eval_block(&tree.nodes, ctx);
769− ctx.current_file = None;
770 // Library definitions aren't the file's own: never report them unused.
157 // Library definitions aren't the file's own: never report them unused.
771 ctx.fn_lines.clear();
158 ctx.fn_lines.clear();
772 ctx.define_lines.clear();
159 ctx.define_lines.clear();
773 ctx.let_lines.clear();
160 ctx.let_lines.clear();
774− ctx.env.push();
775}
161}
776
162
777pub fn parse(input: &str) -> ParseResult {
163pub fn parse(input: &str) -> ParseResult {
@@ -779,18 +165,22 @@ pub fn parse(input: &str) -> ParseResult {
779}
165}
780
166
781pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
167pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
168+ let lines = preprocess(input);
169+ let mut parser = Parser { lines, pos: 0 };
782 let mut ctx = ParseContext {
170 let mut ctx = ParseContext {
783 current_line: 0,
171 current_line: 0,
784− page: None,
172+ scoped_functions: std::collections::HashSet::new(),
785− page_at: None,
173+ page_title: None,
174+ lang: None,
175+ favicon: None,
786 meta_tags: Vec::new(),
176 meta_tags: Vec::new(),
787 head_blocks: Vec::new(),
177 head_blocks: Vec::new(),
788− head_at: None,
178+ variables: HashMap::new(),
789− env: Env::default(),
179+ defines: HashMap::new(),
790− defined_functions: HashMap::new(),
180+ functions: HashMap::new(),
181+ keyframes: Vec::new(),
791 css_vars: Vec::new(),
182 css_vars: Vec::new(),
792 custom_css: Vec::new(),
183 custom_css: Vec::new(),
793− scoped_css: Vec::new(),
794 og_tags: Vec::new(),
184 og_tags: Vec::new(),
795 diagnostics: Vec::new(),
185 diagnostics: Vec::new(),
796 base_path: base_path.map(|p| p.to_path_buf()),
186 base_path: base_path.map(|p| p.to_path_buf()),
@@ -804,85 +194,30 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
804 let_lines: HashMap::new(),
194 let_lines: HashMap::new(),
805 fn_lines: HashMap::new(),
195 fn_lines: HashMap::new(),
806 define_lines: HashMap::new(),
196 define_lines: HashMap::new(),
807− visited: HashSet::new(),
197+ canonical: None,
808− next_id: 0,
198+ base_url: None,
809− trees: Vec::new(),
199+ import_stack: Vec::new(),
810− included_by: Rc::default(),
811− namespace: HashSet::new(),
812− current_source: (0, None),
813− reported: HashSet::new(),
814− current_node: 0,
815− current_file: None,
816− too_deep: false,
817− in_bundle: false,
818− in_page: false,
819 };
200 };
820 load_prelude(&mut ctx);
201 load_prelude(&mut ctx);
821− let tree = ctx.parse_tree(input);
202+ let nodes = parser.parse_children(0, &mut ctx);
822− ctx.diagnostics.extend(tree.diagnostics.iter().cloned());
203+ validate_tree(&nodes, None, &mut ctx.diagnostics);
823− ctx.trees.push((tree.clone(), None));
204+ check_unused(&mut ctx);
824− collect_namespace(
825− &tree.nodes,
826− base_path.map(Path::to_path_buf),
827− &mut HashSet::new(),
828− &mut ctx,
829− );
830− let nodes = Evaluator.eval_block(&tree.nodes, &mut ctx);
831− check_unevaluated(&mut ctx);
832− check_slot_places(&mut ctx);
833− validate_tree(&nodes, None, false, &mut ctx.diagnostics);
834− // A library's definitions are for the files that include it
835− let library = is_library_file(&tree, base_path, &mut ctx, &mut Vec::new());
836− let exported: HashSet<String> = if library {
837− tree.nodes
838− .iter()
839− .filter_map(|node| match node.directive().map(|d| &d.args) {
840− Some(DirectiveArgs::Let(def)) => Some(def.name.clone()),
841− _ => None,
842− })
843− .collect()
844− } else {
845− HashSet::new()
846− };
847− check_unused(&mut ctx, &exported);
848− if let (None, Some(line)) = (&ctx.page, ctx.head_at)
849− && !library
850− {
851− ctx.diagnostics.push(Diagnostic::warning(
852− code::NO_EFFECT,
853− line,
854− "a file without @page is a fragment, which has no <head>: @meta and @head are \
855− left out of it (give the file a @page, or put them in the page that includes it)"
856− .to_string(),
857− ));
858− }
859− dedupe(&mut ctx.diagnostics);
860− // What the file itself defines at its top level
861− let file = ctx.env.frames.last().cloned().unwrap_or_default();
862− let mut variables = HashMap::new();
863− let mut defines = HashMap::new();
864− for (name, binding) in file.iter() {
865− match binding {
866− Binding::Value(value) => {
867− variables.insert(name.clone(), value.to_string());
868− }
869− Binding::Bundle(attrs) => {
870− defines.insert(name.clone(), attrs.to_vec());
871− }
872− Binding::Function(_) => {}
873− }
874− }
875 ParseResult {
205 ParseResult {
876 document: Document {
206 document: Document {
877− page: ctx.page,
207+ page_title: ctx.page_title,
208+ lang: ctx.lang,
209+ favicon: ctx.favicon,
878 meta_tags: ctx.meta_tags,
210 meta_tags: ctx.meta_tags,
879 head_blocks: ctx.head_blocks,
211 head_blocks: ctx.head_blocks,
880− variables,
212+ variables: ctx.variables,
881− defines,
213+ defines: ctx.defines,
214+ keyframes: ctx.keyframes,
882 css_vars: ctx.css_vars,
215 css_vars: ctx.css_vars,
883 custom_css: ctx.custom_css,
216 custom_css: ctx.custom_css,
884− scoped_css: ctx.scoped_css,
885 og_tags: ctx.og_tags,
217 og_tags: ctx.og_tags,
218+ canonical: ctx.canonical,
219+ base_url: ctx.base_url,
220+ preload_hints: collect_image_preload_hints(&nodes),
886 nodes,
221 nodes,
887 },
222 },
888 diagnostics: ctx.diagnostics,
223 diagnostics: ctx.diagnostics,
@@ -890,2814 +225,1999 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
890 }
225 }
891}
226}
892
227
893−// ---------------------------------------------------------------------------
228+/// Scan nodes for @image elements and generate preload hints for early images.
894−// Code that never runs: an untaken branch, an uncalled function, a loop over
229+fn collect_image_preload_hints(nodes: &[Node]) -> Vec<crate::ast::PreloadHint> {
895−// an empty list
230+ let mut hints = Vec::new();
896−// ---------------------------------------------------------------------------
231+ // Only preload the first few images (above-the-fold heuristic)
232+ collect_images_recursive(nodes, &mut hints, 3);
233+ hints
234+}
897
235
898−/// Collect the functions defined anywhere in `nodes` and in the files they
236+fn collect_images_recursive(nodes: &[Node], hints: &mut Vec<crate::ast::PreloadHint>, max: usize) {
899−/// include (by a literal path), into `ctx.namespace`.
900−fn collect_namespace(
901− nodes: &[syntax::Node],
902− base: Option<PathBuf>,
903− seen: &mut HashSet<PathBuf>,
904− ctx: &mut ParseContext,
905−) {
906− let mut includes = Vec::new();
907 for node in nodes {
237 for node in nodes {
908− node.walk(&mut |node| match node.directive().map(|d| &d.args) {
238+ if hints.len() >= max {
909− Some(DirectiveArgs::Let(def)) if matches!(def.form, LetForm::Function(_)) => {
239+ return;
910− ctx.namespace.insert(def.name.clone());
911− }
912− Some(DirectiveArgs::Text(Some(path))) if node.is_directive("include") => {
913− includes.push(path.raw.clone());
914− }
915− _ => {}
916− });
917− }
918− for path in includes {
919− let path = path.trim_matches('"');
920− if path.contains('$') {
921− continue;
922 }
240 }
923− let resolved = match &base {
241+ if let Node::Element(elem) = node {
924− Some(base) => base.join(path),
242+ if elem.kind == ElementKind::Image
925− None => PathBuf::from(path),
243+ && let Some(ref src) = elem.argument
926− };
244+ && !src.is_empty()
927− if !seen.insert(resolved.clone()) {
245+ && !src.starts_with("data:")
928− continue;
246+ && !src.starts_with('#')
247+ {
248+ hints.push(crate::ast::PreloadHint {
249+ href: src.clone(),
250+ as_type: "image".to_string(),
251+ crossorigin: false,
252+ });
253+ }
254+ collect_images_recursive(&elem.children, hints, max);
929 }
255 }
930− let Ok(text) = ctx.read_file(&resolved) else {
931− continue;
932− };
933− let tree = syntax::parse(&text);
934− let parent = resolved.parent().map(Path::to_path_buf);
935− collect_namespace(&tree.nodes, parent, seen, ctx);
936 }
256 }
937}
257}
938
258
939−/// Check the lines that were never evaluated: element and function names,
259+// ---------------------------------------------------------------------------
940−/// and the attributes of built-in elements (those that don't depend on a
260+// Preprocessing: strip comments/blanks, collapse @raw blocks
941−/// variable). Names that come from data are only checked when evaluated.
261+// ---------------------------------------------------------------------------
942−fn check_unevaluated(ctx: &mut ParseContext) {
262+
943− let trees = std::mem::take(&mut ctx.trees);
263+fn preprocess(input: &str) -> Vec<Line> {
944− let mut seen = HashSet::new();
264+ let raw_lines: Vec<&str> = input.lines().collect();
945− for (tree, chain) in &trees {
265+ let mut lines = Vec::new();
946− let before = ctx.diagnostics.len();
266+ let mut i = 0;
947− for node in &tree.nodes {
267+
948− check_unevaluated_node(node, ctx);
268+ while i < raw_lines.len() {
949− }
269+ let line = raw_lines[i];
950− let mut found = ctx.diagnostics.split_off(before);
270+ let trimmed = line.trim();
951− if let Some(chain) = chain {
271+
952− for d in &mut found {
272+ if trimmed.is_empty() || trimmed.starts_with("--") {
953− d.message = format!("{}\n in {}", d.message, chain);
273+ i += 1;
954− }
274+ continue;
955 }
275 }
956− // A file included twice is checked twice: report each problem once
957− found.retain(|d| seen.insert((d.line, d.message.clone())));
958− ctx.diagnostics.extend(found);
959− }
960− ctx.trees = trees;
961−}
962
276
963−fn check_unevaluated_node(node: &syntax::Node, ctx: &mut ParseContext) {
277+ let indent = line.len() - line.trim_start().len();
964− if !ctx.visited.contains(&node.id) {
278+
965− let line = node.span.line;
279+ // Inline `@markdown` and `@script` blocks keep their bodies verbatim:
966− // A reference from code that doesn't run still counts as a use
280+ // in Markdown, blank lines separate paragraphs, `---` is a rule (not
967− track_var_refs(&node.source, &mut ctx.used_variables);
281+ // a comment) and code indentation matters; in JavaScript, newlines,
968− let before = ctx.diagnostics.len();
282+ // `{...}` and `$` must reach the output untouched.
969− match &node.kind {
283+ let verbatim_body = trimmed == "@markdown"
970− NodeKind::Element(element) => {
284+ || trimmed == "@script"
971− for head in &element.chain {
285+ || trimmed.starts_with("@script ")
972− check_head(head, line, ctx);
286+ || trimmed.starts_with("@script[");
287+ if verbatim_body {
288+ lines.push(Line {
289+ indent,
290+ content: LineContent::Normal(trimmed.to_string()),
291+ line_num: i + 1,
292+ });
293+ let body_start = i + 1;
294+ let mut body_end = body_start;
295+ let mut j = body_start;
296+ while j < raw_lines.len() {
297+ let l = raw_lines[j];
298+ if l.trim().is_empty() {
299+ j += 1;
300+ continue;
973 }
301 }
974− check_unevaluated_content(node, element, ctx);
302+ if l.len() - l.trim_start().len() <= indent {
975− if let Some(text) = &element.text {
303+ break;
976− check_inline_heads(text, line, ctx);
977 }
304 }
305+ j += 1;
306+ body_end = j;
978 }
307 }
979− NodeKind::Text(text) => check_inline_heads(text, line, ctx),
308+ let body = &raw_lines[body_start..body_end];
980− // `@page`'s attributes are checked like an element's (a layout's
309+ let body_indent = body
981− // `@page` is in a function that may not run in this file)
310+ .iter()
982− NodeKind::Directive(syntax::Directive {
311+ .filter(|l| !l.trim().is_empty())
983− args:
312+ .map(|l| l.len() - l.trim_start().len())
984− DirectiveArgs::Page {
313+ .min()
985− attrs: Some(list), ..
314+ .unwrap_or(0);
986− },
315+ if !body.is_empty() {
987− ..
316+ let text: Vec<&str> = body
988− }) => {
989− let words: Vec<String> = crate::vocab::PAGE_WORDS
990 .iter()
317 .iter()
991− .map(|w| w.to_string())
318+ .map(|l| l.get(body_indent..).unwrap_or("").trim_end())
992 .collect();
319 .collect();
993− ctx.in_page = true;
320+ lines.push(Line {
994− check_attrs(&list.attrs, line, ctx, &words);
321+ indent: indent + 1,
995− ctx.in_page = false;
322+ content: LineContent::Raw(text.join("\n")),
996− check_page_tokens(&list.attrs, line, ctx);
323+ line_num: body_start + 1,
324+ });
325+ }
326+ i = body_end.max(body_start);
327+ continue;
328+ }
329+
330+ // Handle @raw """..."""
331+ if let Some(raw_rest) = trimmed.strip_prefix("@raw") {
332+ let after_raw = raw_rest.trim_start();
333+ if let Some(after_open) = after_raw.strip_prefix("\"\"\"") {
334+ // Single-line: @raw """content"""
335+ if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
336+ let content = &after_open[..after_open.len() - 3];
337+ lines.push(Line {
338+ indent,
339+ content: LineContent::Raw(content.to_string()),
340+ line_num: i + 1,
341+ });
342+ i += 1;
343+ continue;
344+ }
345+
346+ // Multiline: collect until closing """
347+ let mut raw_content = String::new();
348+ if !after_open.is_empty() {
349+ raw_content.push_str(after_open);
350+ raw_content.push('\n');
351+ }
352+ i += 1;
353+ while i < raw_lines.len() {
354+ if raw_lines[i].trim() == "\"\"\"" {
355+ i += 1;
356+ break;
357+ }
358+ raw_content.push_str(raw_lines[i]);
359+ raw_content.push('\n');
360+ i += 1;
361+ }
362+
363+ lines.push(Line {
364+ indent,
365+ content: LineContent::Raw(raw_content.trim_end_matches('\n').to_string()),
366+ line_num: i,
367+ });
368+ continue;
997 }
369 }
998− _ => {}
999 }
370 }
1000− let text = written_line(node);
371+
1001− for d in &mut ctx.diagnostics[before..] {
372+ // Join continuation lines for multi-line attribute brackets
1002− d.source_line.get_or_insert_with(|| text.as_str().into());
373+ let first_line_num = i + 1;
374+ let mut full = trimmed.to_string();
375+ while open_attr_depth(&full) > 0 && i + 1 < raw_lines.len() {
376+ i += 1;
377+ let next = raw_lines[i].trim();
378+ if next.is_empty() || next.starts_with("--") {
379+ continue;
380+ }
381+ full.push(' ');
382+ full.push_str(next);
1003 }
383 }
384+
385+ lines.push(Line {
386+ indent,
387+ content: LineContent::Normal(full),
388+ line_num: first_line_num,
389+ });
390+ i += 1;
1004 }
391 }
1005− for child in &node.children {
1006− check_unevaluated_node(child, ctx);
1007− }
1008−}
1009
392
1010−/// Check the content passed to the calls of a line that was never
393+ lines
1011−/// evaluated: its `@slot NAME` blocks, and, for a function without
394+}
1012−/// `@children`, any other content.
395+
1013−fn check_unevaluated_content(
396+/// Bracket depth left open at the end of `line`, counting only attribute
1014− node: &syntax::Node,
397+/// lists: a `[` that starts the line or follows an `@name` token (optionally
1015− element: &syntax::ElementLine,
398+/// with one more word, as in `@let name [`). Brackets in text content, such
1016− ctx: &mut ParseContext,
399+/// as `@text [bold] Use [ to open`, are ignored.
1017−) {
400+fn open_attr_depth(line: &str) -> i32 {
1018− let calls: Vec<Option<(String, Rc<FnDef>)>> = element
401+ if !line.starts_with('@') && !line.starts_with('[') {
1019− .chain
402+ return 0;
1020− .iter()
403+ }
1021− .map(|head| {
404+ let bytes = line.as_bytes();
1022− let function = ctx.defined_functions.get(&head.name)?;
405+ let mut depth: i32 = 0;
1023− Some((head.name.clone(), function.clone()))
406+ for (i, &b) in bytes.iter().enumerate() {
1024− })
407+ match b {
1025− .collect();
408+ b'[' if depth > 0 => depth += 1,
1026− check_fillers(node, element, &calls, ctx);
409+ b'[' => {
1027− let last = calls.len() - 1;
410+ // Is this the start of an attribute list?
1028− for (i, call) in calls.iter().enumerate() {
411+ let before = line[..i].trim_end();
1029− let Some((name, function)) = call else {
412+ let last_directive = before
1030− continue;
413+ .rsplit([']', '>'])
1031− };
414+ .next()
1032− if function.has_children || function.wraps_itself {
415+ .unwrap_or("")
1033− continue;
416+ .trim();
1034− }
417+ let tokens: Vec<&str> = last_directive.split_whitespace().collect();
1035− let content = if i == last {
418+ let starts_list = match tokens.as_slice() {
1036− element.text.is_some() || holds_content(&node.children, ctx)
419+ [] => before.is_empty() || before.ends_with('>'),
1037− } else {
420+ [name] => name.starts_with('@'),
1038− element.chain[i + 1].name != "slot" || !slot_is_built_in(ctx)
421+ [name, _] => name.starts_with('@'),
1039− };
422+ _ => false,
1040− if content {
423+ };
1041− let head = &element.chain[i];
424+ if starts_list {
1042− ctx.push_once(no_children(
425+ depth = 1;
1043− name,
426+ } else if before.ends_with(']') {
1044− node.span.line,
427+ // Text after a closed attribute list: stop scanning.
1045− name_column(head, node.span.line),
428+ return 0;
1046− ));
1047− }
1048− }
1049−}
1050−
1051−/// Check a head that was never evaluated: its name, and its attributes
1052−/// (for a call, see [`check_call`]).
1053−fn check_head(head: &syntax::Head, line: usize, ctx: &mut ParseContext) {
1054− let is_function =
1055− ctx.namespace.contains(&head.name) || ctx.defined_functions.contains_key(&head.name);
1056− if is_function {
1057− ctx.used_functions.insert(head.name.clone());
1058− if let Some(function) = ctx.defined_functions.get(&head.name).cloned()
1059− && !function.wraps_itself
1060− {
1061− check_call(head, &function, line, ctx);
1062− }
1063− return;
1064− }
1065− let kind = match parse_element_kind(&head.name, line, ctx) {
1066− Ok(kind) => kind,
1067− Err(e) => {
1068− ctx.diagnostics.push(e);
1069− return;
1070− }
1071− };
1072− if let Some(list) = &head.attrs {
1073− check_attrs(&list.attrs, line, ctx, &[]);
1074− // A word for laying out children on an element that doesn't lay
1075− // them out, as where the code runs (see `validate_tree`)
1076− if !kind.layout().is_container() && !has_no_element(&kind) {
1077− for attr in &list.attrs {
1078− let base = crate::vocab::base_attribute(&attr.key);
1079− if !attr.html
1080− && attr.choice.is_none()
1081− && attr.prefixed.is_none()
1082− && crate::vocab::CONTAINER_ATTRIBUTES.contains(&base)
1083− && !crate::vocab::split_prefixes(&attr.key)
1084− .0
1085− .contains(&"children:")
1086− {
1087− ctx.push_once(not_a_container(&kind, line, base).column(attr.span.column));
1088 }
429 }
1089 }
430 }
431+ b']' if depth > 0 => depth -= 1,
432+ _ => {}
1090 }
433 }
1091 }
434 }
435+ depth
1092}
436}
1093
437
1094−/// The attributes of a list that is never evaluated that can be checked
438+// ---------------------------------------------------------------------------
1095−/// together: not bundles or `if()`s. Names are checked; a value with a
439+// Parser
1096−/// variable in it depends on what the variable holds, so only a literal
440+// ---------------------------------------------------------------------------
1097−/// value is (the other is checked as an empty value).
1098−/// `@page`'s own rules (see `check_page_attr`) for a list that isn't
1099−/// evaluated, as written: a value made from a variable counts as given.
1100−fn check_page_tokens(tokens: &[syntax::Attr], line: usize, ctx: &mut ParseContext) {
1101− for token in tokens {
1102− match &token.choice {
1103− Some(choice) => {
1104− for branch in &choice.branches {
1105− check_page_tokens(branch.attrs(), line, ctx);
1106− }
1107− }
1108− // Under prefixes there are only styles
1109− None if token.key.starts_with('$') || token.prefixed.is_some() => {}
1110− None => {
1111− let value = token.value.as_deref().map(|v| v.trim_matches('"'));
1112− let value = match value {
1113− Some(v) if v.contains('$') => Some("$"),
1114− other => other,
1115− };
1116− check_page_attr(
1117− &token.key,
1118− token.html,
1119− value,
1120− line,
1121− Some(token.span.column),
1122− ctx,
1123− );
1124− }
1125− }
1126− }
1127−}
1128
441
1129−fn literal_attrs(tokens: &[syntax::Attr]) -> Vec<syntax::Attr> {
442+impl Parser {
1130− // A prefixed group is checked with its prefixes, as far as its own
443+ fn parse_children(&mut self, min_indent: usize, ctx: &mut ParseContext) -> Vec<Node> {
1131− // attributes can be; a prefixed bundle or `if()` depends on what it holds
444+ let mut nodes = Vec::new();
1132− let group = |a: &syntax::Attr| {
445+
1133− let prefixed = a.prefixed.as_ref()?;
446+ while self.pos < self.lines.len() {
1134− let syntax::Branch::Group { list, trailing } = &prefixed.target else {
447+ let indent = self.lines[self.pos].indent;
1135− return None;
448+ if indent < min_indent {
1136− };
449+ break;
1137− let mut list = list.clone();
1138− list.attrs = literal_attrs(&list.attrs);
1139− let mut a = a.clone();
1140− a.prefixed = Some(Box::new(syntax::Prefixed {
1141− target: syntax::Branch::Group {
1142− list,
1143− trailing: trailing.clone(),
1144− },
1145− ..(**prefixed).clone()
1146− }));
1147− Some(a)
1148− };
1149− let groups = tokens.iter().filter_map(group);
1150− tokens
1151− .iter()
1152− .filter(|a| {
1153− let bundle = a.key.starts_with('$') && a.value.is_none() && !a.html;
1154− a.choice.is_none() && a.prefixed.is_none() && !bundle
1155− })
1156− .map(|a| {
1157− let mut a = a.clone();
1158− let variable = |v: &String| v.contains('$');
1159− if !a.key.contains('$') && a.value.as_ref().is_some_and(variable) {
1160− a.value = Some(String::new());
1161− a.raw = format!("{} ", a.key);
1162 }
450 }
1163− a
1164− })
1165− .chain(groups)
1166− .collect()
1167−}
1168
451
1169−/// Check a call that was never evaluated: how it passes its parameters
452+ match self.parse_line(ctx) {
1170−/// (none written `name=value`, and every one without a default passed,
453+ Ok(Some(new_nodes)) => nodes.extend(new_nodes),
1171−/// when no bundle or `if()` could pass it), and its other attributes, like
454+ Ok(None) => {}
1172−/// those of an element.
455+ Err(e) => {
1173−fn check_call(head: &syntax::Head, function: &FnDef, line: usize, ctx: &mut ParseContext) {
456+ // Error recovery: record the diagnostic and continue parsing
1174− let name = head.name.as_str();
457+ // to report multiple errors in a single pass
1175− let attrs = head.attrs.as_ref().map_or(&[][..], |list| &list.attrs[..]);
458+ let source = if self.pos > 0 && self.pos <= self.lines.len() {
1176− written_parameter_forms(name, function, attrs, line, ctx);
459+ match &self.lines[self.pos.saturating_sub(1)].content {
1177− if let Some(list) = &head.attrs {
460+ LineContent::Normal(s) => Some(s.clone()),
1178− let params: Vec<String> = function.params.iter().map(|p| p.name.clone()).collect();
461+ LineContent::Raw(s) => Some(s.clone()),
1179− check_attrs(&list.attrs, line, ctx, &params);
462+ }
1180− }
463+ } else {
1181− let unknown = attrs
464+ None
1182− .iter()
465+ };
1183− .any(|a| a.choice.is_some() || (a.key.starts_with('$') && a.value.is_none()));
466+ ctx.diagnostics.push(Diagnostic {
1184− if unknown {
467+ line: e.line,
1185− return;
468+ column: None,
1186− }
469+ message: e.message,
1187− for param in function.params.iter().filter(|p| p.default.is_none()) {
470+ severity: Severity::Error,
1188− if !attrs.iter().any(|a| a.key == param.name) {
471+ source_line: source,
1189− let column = name_column(head, line);
472+ });
1190− ctx.push_once(missing_parameter(name, &param.name, line, column));
473+ // Skip forward past any deeper-indented children of the errored line
474+ while self.pos < self.lines.len() && self.lines[self.pos].indent > indent {
475+ self.pos += 1;
476+ }
477+ }
478+ }
1191 }
479 }
1192− }
1193−}
1194
480
1195−/// Report each parameter a call's list writes `name=value`, with the value
481+ nodes
1196−/// as written and its column; returns their names, so the evaluated call
1197−/// doesn't report them again.
1198−fn written_parameter_forms(
1199− name: &str,
1200− function: &FnDef,
1201− attrs: &[syntax::Attr],
1202− line: usize,
1203− ctx: &mut ParseContext,
1204−) -> Vec<String> {
1205− let mut reported = Vec::new();
1206− for attr in attrs {
1207− if !(attr.html && function.is_param(&attr.key)) {
1208− continue;
1209− }
1210− let at = if attr.span.line == 0 {
1211− line
1212− } else {
1213− attr.span.line
1214− };
1215− let value = attr.value.as_deref().unwrap_or("");
1216− let diagnostic = parameter_form(name, &attr.key, value, at).column(attr.span.column);
1217− ctx.push_once(match &ctx.current_source {
1218− (current, Some(text)) if *current == at => diagnostic.source(text.clone()),
1219− _ => diagnostic,
1220− });
1221− reported.push(attr.key.clone());
1222 }
482 }
1223− reported
1224−}
1225
483
1226−fn check_inline_heads(text: &syntax::Text, line: usize, ctx: &mut ParseContext) {
484+ fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
1227− for segment in &text.segments {
485+ let line_num = self.lines[self.pos].line_num;
1228− if let Segment::Inline(inline) = segment {
486+ ctx.current_line = line_num;
1229− check_head(&inline.head, line, ctx);
487+ if let LineContent::Normal(text) = &self.lines[self.pos].content
1230− if let Some(text) = &inline.text {
488+ && let Some(filter) = old_filter_syntax(text)
1231− check_inline_heads(text, line, ctx);
489+ {
1232− }
490+ ctx.diagnostics.push(Diagnostic {
491+ line: line_num,
492+ column: None,
493+ message: format!(
494+ "`${}|{}` filters are functions now: write `${{{}(${})}}` (run `htmlang upgrade`)",
495+ filter.0, filter.1, filter.1, filter.0
496+ ),
497+ severity: Severity::Warning,
498+ source_line: Some(text.clone()),
499+ });
1233 }
500 }
1234− }
501+ let current_indent = self.lines[self.pos].indent;
1235−}
1236
502
1237−// ---------------------------------------------------------------------------
503+ // Handle raw content
1238−// Evaluation
504+ if let LineContent::Raw(s) = &self.lines[self.pos].content {
1239−// ---------------------------------------------------------------------------
505+ let content = s.clone();
1240−
506+ self.pos += 1;
1241−/// The `@else` lines that continue the `@if` or `@each` before `from`, and
507+ return Ok(Some(vec![Node::Raw(content)]));
1242−/// the position after them.
1243−fn else_branches(block: &[syntax::Node], from: usize, is_if: bool) -> (Vec<&syntax::Node>, usize) {
1244− let mut branches = Vec::new();
1245− let mut next = from;
1246− let mut i = from;
1247− while i < block.len() {
1248− let node = &block[i];
1249− i += 1;
1250− if node.is_trivia() {
1251− continue;
1252 }
508 }
1253− let condition = match node.directive().map(|d| &d.args) {
509+
1254− Some(DirectiveArgs::Else { condition }) => condition.is_some(),
510+ // Normal content — clone to release borrow. Raw is already handled
1255− _ => break,
511+ // above; a let-else pattern avoids a panic path if new LineContent
512+ // variants are added in the future.
513+ let LineContent::Normal(content) = &self.lines[self.pos].content else {
514+ return Err(ParseError {
515+ line: line_num,
516+ message: "internal: unexpected line content variant".to_string(),
517+ });
1256 };
518 };
1257− if condition && !is_if {
519+ let content = content.clone();
1258− break;
520+ self.pos += 1;
1259− }
521+
1260− branches.push(node);
522+ // --- Directives ---
1261− next = i;
523+
1262− if !condition {
524+ // Removed syntax gets a pointer to its replacement — unless the
1263− break;
525+ // name is a user function (e.g. `@let divider`).
526+ if content.starts_with('@')
527+ && !ctx.functions.contains_key(extract_element_name(&content))
528+ && let Some(hint) = removed_syntax_hint(&content)
529+ {
530+ return Err(ParseError {
531+ line: line_num,
532+ message: hint,
533+ });
1264 }
534 }
1265− }
1266− (branches, next)
1267−}
1268
535
1269−impl Evaluator {
536+ // @page [lang en, favicon /f.png, canonical URL, base URL] Title
1270− fn eval_block(&mut self, block: &[syntax::Node], ctx: &mut ParseContext) -> Vec<Node> {
537+ if let Some(rest) = content.strip_prefix("@page ") {
1271− let mut nodes = Vec::new();
538+ let rest = rest.trim_start();
1272− let mut i = 0;
539+ let title = if rest.starts_with('[') {
1273− while i < block.len() {
540+ let (attrs, title) = parse_attr_brackets_no_validate(rest, line_num, ctx)?;
1274− let node = &block[i];
541+ for attr in attrs {
1275− i += 1;
542+ let value = attr.value.unwrap_or_default();
1276− if node.is_trivia() {
543+ match attr.key.as_str() {
1277− continue;
544+ "lang" => ctx.lang = Some(value),
1278− }
545+ "favicon" => ctx.favicon = Some(value),
1279− let result = match node.directive().map(|d| d.name()) {
546+ "canonical" => ctx.canonical = Some(value),
1280− Some(name @ ("if" | "each")) => {
547+ "base" => ctx.base_url = Some(value),
1281− let (branches, next) = else_branches(block, i, name == "if");
548+ other => ctx.diagnostics.push(Diagnostic {
1282− i = next;
549+ line: line_num,
1283− if name == "if" {
550+ column: None,
1284− Ok(Some(self.eval_if(node, &branches, ctx)))
551+ message: format!(
1285− } else {
552+ "unknown @page attribute '{}' (expected lang, favicon, canonical or base)",
1286− self.eval_each(node, branches.first().copied(), ctx)
553+ other
554+ ),
555+ severity: Severity::Warning,
556+ source_line: Some(content.clone()),
557+ }),
1287 }
558 }
1288 }
559 }
1289− // Reported as a syntax error
560+ title
1290− Some("else") => {
561+ } else {
1291− ctx.visited.insert(node.id);
562+ rest.to_string()
1292− Ok(None)
1293− }
1294− _ => self.eval(node, ctx),
1295 };
563 };
1296− match result {
564+ ctx.page_title = Some(substitute_vars(title.trim(), &ctx.variables));
1297− Ok(Some(new_nodes)) => nodes.extend(new_nodes),
565+ return Ok(None);
1298− Ok(None) => {}
1299− // Record the error and go on, to report several in one pass
1300− Err(mut e) => {
1301− e.source_line
1302− .get_or_insert_with(|| node.source.as_str().into());
1303− ctx.diagnostics.push(e);
1304− }
1305− }
1306 }
566 }
1307− nodes
1308− }
1309
567
1310− /// Evaluate a block in its own scope: what it defines is visible to
1311− /// the end of the block, and not after it.
1312− fn eval_scoped(&mut self, block: &[syntax::Node], ctx: &mut ParseContext) -> Vec<Node> {
1313− ctx.env.push();
1314− let nodes = self.eval_block(block, ctx);
1315− ctx.env.pop();
1316− nodes
1317− }
1318
568
1319− /// `@if` with its `@else if` / `@else` branches. Every condition is
569+ if let Some(rest) = content.strip_prefix("@let ") {
1320− /// checked (and its errors reported); the first that holds picks the
570+ let rest = rest.trim();
1321− /// branch.
571+
1322− fn eval_if(
572+ // A `"""` value opens a multi-line string, whose indented lines
1323− &mut self,
573+ // are its content — not a function body.
1324− node: &syntax::Node,
574+ let opens_triple_quote = rest.split_once(' ').is_some_and(|(_, v)| {
1325− branches: &[&syntax::Node],
575+ let v = v.trim();
1326− ctx: &mut ParseContext,
576+ v.strip_prefix("= ").unwrap_or(v).trim_start().starts_with("\"\"\"")
1327− ) -> Vec<Node> {
577+ });
1328− let mut chosen = None;
578+ // Check if next lines are indented (function/component definition)
1329− for branch in std::iter::once(node).chain(branches.iter().copied()) {
579+ let has_body = !opens_triple_quote
1330− ctx.visited.insert(branch.id);
580+ && self.pos < self.lines.len()
1331− ctx.enter(branch);
581+ && self.lines[self.pos].indent > current_indent;
1332− let holds = match branch.directive().map(|d| &d.args) {
582+
1333− Some(DirectiveArgs::Condition(condition))
583+ if has_body {
1334− | Some(DirectiveArgs::Else {
584+ // Function definition: @let name $param1 $param2=default
1335− condition: Some(condition),
585+ let parts: Vec<&str> = rest.split_whitespace().collect();
1336− }) => {
586+ if parts.is_empty() {
1337− !condition.raw.is_empty()
587+ return Err(ParseError {
1338− && ctx.condition(
588+ line: line_num,
1339− &condition.raw,
589+ message: "@let with body requires a name".to_string(),
1340− branch.span.line,
590+ });
1341− Some(condition.span.column),
591+ }
1342− )
592+ let name = parts[0].to_string();
593+ let mut params = Vec::new();
594+ let mut defaults = HashMap::new();
595+ for part in &parts[1..] {
596+ let part = part.strip_prefix('$').unwrap_or(part);
597+ if let Some((param_name, default_val)) = part.split_once('=') {
598+ params.push(param_name.to_string());
599+ defaults.insert(param_name.to_string(), default_val.to_string());
600+ } else {
601+ params.push(part.to_string());
602+ }
1343 }
603 }
1344− Some(DirectiveArgs::Else { condition: None }) => true,
1345− // A syntax error, already reported
1346− _ => false,
1347− };
1348− if holds && chosen.is_none() {
1349− chosen = Some(&branch.children);
1350− }
1351− }
1352− chosen.map_or_else(Vec::new, |body| self.eval_scoped(body, ctx))
1353− }
1354
604
1355− fn eval(
605+ // Collect body lines (all lines indented deeper than @let)
1356− &mut self,
606+ let mut body_lines = Vec::new();
1357− node: &syntax::Node,
607+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1358− ctx: &mut ParseContext,
608+ body_lines.push(self.lines[self.pos].clone());
1359− ) -> Result<Option<Vec<Node>>, ParseError> {
609+ self.pos += 1;
1360− ctx.visited.insert(node.id);
1361− ctx.enter(node);
1362− match &node.kind {
1363− NodeKind::Blank | NodeKind::Comment => Ok(None),
1364− // `@code` and `@textarea` show their body as text; `@script`'s
1365− // goes into the page as it is
1366− NodeKind::Verbatim(body) if body.escaped => {
1367− let text = body.text.trim_matches('\n').to_string();
1368− Ok(Some(vec![Node::Text(vec![TextSegment::Plain(text)])]))
1369− }
1370− NodeKind::Verbatim(body) => Ok(Some(vec![Node::Raw(body.text.clone())])),
1371− NodeKind::Directive(directive) => {
1372− let mut nodes = self.eval_directive(node, directive, ctx)?;
1373− // A directive that takes no body: the lines indented under
1374− // it (a syntax error) are evaluated as its siblings. A
1375− // @let's body belongs to it even under a value (also an
1376− // error), so it is never evaluated in place.
1377− let takes_body = directive.spec.body != BodyKind::None;
1378− if !takes_body {
1379− let siblings = self.eval_block(&node.children, ctx);
1380− nodes.get_or_insert_with(Vec::new).extend(siblings);
1381 }
610 }
1382− Ok(nodes)
1383− }
1384− NodeKind::Element(element) => self.eval_element(node, element, ctx).map(Some),
1385− NodeKind::Text(text) => {
1386− let mut nodes = vec![Node::Text(text_segments(text, ctx))];
1387− // Text takes no body: lines indented under it are its
1388− // siblings
1389− nodes.extend(self.eval_block(&node.children, ctx));
1390− Ok(Some(nodes))
1391− }
1392− }
1393− }
1394
611
1395− fn eval_directive(
612+ // An @style block at the top of the body is scoped to the
1396− &mut self,
613+ // function: its rules apply inside a `.hl-NAME` wrapper.
1397− node: &syntax::Node,
614+ let (body_lines, style_lines) = split_style_block(body_lines);
1398− directive: &syntax::Directive,
615+ if !style_lines.is_empty() {
1399− ctx: &mut ParseContext,
616+ let scope_class = format!("hl-{}", name);
1400− ) -> Result<Option<Vec<Node>>, ParseError> {
617+ let scoped_css: String = style_lines
1401− let line_num = node.span.line;
618+ .iter()
1402− let content = &node.source;
619+ .filter_map(|line| match &line.content {
1403− match (directive.name(), &directive.args) {
620+ LineContent::Normal(s) if !s.trim().is_empty() => {
1404− // @page [lang=en, favicon /f.png, background #f8fafc] Title
621+ Some(format!(".{} {}\n", scope_class, s.trim()))
1405− ("page", DirectiveArgs::Page { attrs, title }) => {
622+ }
1406− let page = read_page(attrs.as_ref(), title.as_ref(), line_num, ctx);
623+ _ => None,
1407− // A page has one root: a second `@page` (a layout called
624+ })
1408− // twice, or one called after the page's own) is an error
625+ .collect();
1409− let here = match &ctx.current_file {
626+ if !scoped_css.is_empty() {
1410− Some(file) => format!("line {} of {}", line_num, file),
627+ ctx.custom_css.push(scoped_css);
1411− None => format!("line {}", line_num),
628+ }
1412− };
629+ ctx.scoped_functions.insert(name.clone());
1413− if let Some(first) = &ctx.page_at {
1414− let again = if *first == here {
1415− "this @page runs a second time (in a loop, or in a function called \
1416− twice)"
1417− .to_string()
1418− } else {
1419− format!("a second @page (this page already has one, on {})", first)
1420− };
1421− let diagnostic = Diagnostic::error(
1422− code::DUPLICATE_PAGE,
1423− line_num,
1424− format!(
1425− "{}: a page has one @page, the root element that styles its <body>",
1426− again
1427− ),
1428− )
1429− .source(content.clone())
1430− .subject("@page");
1431− ctx.diagnostics.push(diagnostic);
1432− } else {
1433− ctx.page_at = Some(here);
1434− ctx.page = Some(page);
1435 }
630 }
1436− Ok(None)
631+
632+ ctx.fn_lines.entry(name.clone()).or_insert(line_num);
633+ ctx.functions.insert(
634+ name,
635+ FnDef {
636+ params,
637+ defaults,
638+ body_lines,
639+ },
640+ );
641+ return Ok(None);
1437 }
642 }
1438
643
1439− ("let", DirectiveArgs::Let(def)) => {
644+ if let Some((name, value)) = rest.split_once(' ') {
1440− let name = def.name.as_str();
645+ let value = value.trim();
1441− // Values, bundles and functions share one namespace: a
646+ // `@let name = EXPR` computes its value (see expr.rs); any
1442− // definition replaces whatever the name meant in its block
647+ // other value is literal text with `$var` interpolation.
1443− match &def.form {
648+ if let Some(expression) = value.strip_prefix('=') {
1444− LetForm::Function(function) => {
649+ let value = ctx
1445− self.define_function(name, &function.params, node, ctx);
650+ .eval(expression.trim(), line_num)
1446− }
651+ .map(|v| v.to_string())
1447− // `@let name = EXPR` computes its value (see expr.rs)
652+ .unwrap_or_default();
1448− LetForm::Computed(expression) => {
653+ if name.starts_with("--") {
1449− let value = ctx
654+ ctx.css_vars.push((name.to_string(), value.clone()));
1450− .eval(&expression.raw, line_num, Some(expression.span.column))
1451− .unwrap_or_else(Value::empty);
1452− set_variable(name, value, line_num, ctx);
1453− }
1454− // Attribute bundle: @let name [attr1, attr2, ...]
1455− LetForm::Bundle(list) => {
1456− ctx.in_bundle = true;
1457− let attrs = parse_attr_list(&list.attrs, line_num, ctx, true, &[]);
1458− ctx.in_bundle = false;
1459− ctx.env.define(name, Binding::Bundle(Rc::new(attrs)));
1460− ctx.define_lines.entry(name.to_string()).or_insert(line_num);
1461− }
1462− // `@let size 16px`, `@let fruits apple, banana`,
1463− // `@let post $posts.0`: see `slot_value`
1464− LetForm::Value(value) => {
1465− let (value, _) =
1466− ctx.slot_value(&value.raw, value.span.line, Some(value.span.column));
1467− set_variable(name, value, line_num, ctx);
1468− }
1469− // Quoted text: `@let arrow "→ "`
1470− LetForm::Quoted(value) => {
1471− let raw = format!("\"{}\"", value.raw);
1472− let column = value.span.column.saturating_sub(1);
1473− let (text, quoted, _) =
1474− ctx.fill_value(&raw, value.span.line, Some(column), Sink::Text);
1475− let value = quoted.map_or(Value::Str(text), Value::Quoted);
1476− set_variable(name, value, line_num, ctx);
1477 }
655 }
656+ ctx.variables.insert(name.to_string(), value);
657+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
658+ return Ok(None);
1478 }
659 }
1479− Ok(None)
1480− }
1481
660
1482− // @meta NAME VALUE; `og:` names become Open Graph property tags
661+ // Attribute bundle: @let name [attr1, attr2, ...]
1483− ("meta", DirectiveArgs::Text(Some(arg))) => {
662+ if value.starts_with('[') {
1484− let Some((name, value)) = arg.raw.split_once(char::is_whitespace) else {
663+ let (attrs, _) = parse_attr_brackets(value, line_num, ctx)?;
1485− ctx.diagnostics.push(
664+ ctx.defines.insert(name.to_string(), attrs);
1486− Diagnostic::error(
665+ ctx.define_lines.entry(name.to_string()).or_insert(line_num);
1487− code::MISSING_ARGUMENT,
1488− line_num,
1489− format!("@meta needs a name and a value: `@meta {} VALUE`", arg.raw),
1490− )
1491− .source(content.clone()),
1492− );
1493 return Ok(None);
666 return Ok(None);
667+ }
668+
669+ // Multi-line @let with triple quotes: @let name """..."""
670+ let value_str;
671+ let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
672+ if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
673+ // Single-line triple-quote: @let name """value"""
674+ value_str = after_open[..after_open.len() - 3].to_string();
675+ value_str.as_str()
676+ } else {
677+ // Multi-line: collect indented body lines until closing """
678+ let mut lines_buf = String::new();
679+ if !after_open.is_empty() {
680+ lines_buf.push_str(after_open);
681+ lines_buf.push('\n');
682+ }
683+ while self.pos < self.lines.len() {
684+ match &self.lines[self.pos].content {
685+ LineContent::Normal(s) if s.trim() == "\"\"\"" => {
686+ self.pos += 1;
687+ break;
688+ }
689+ LineContent::Normal(s) => {
690+ lines_buf.push_str(s);
691+ lines_buf.push('\n');
692+ }
693+ LineContent::Raw(s) => {
694+ lines_buf.push_str(s);
695+ lines_buf.push('\n');
696+ }
697+ }
698+ self.pos += 1;
699+ }
700+ value_str = lines_buf.trim_end_matches('\n').to_string();
701+ value_str.as_str()
702+ }
703+ } else if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
704+ // Support quoted string interpolation: @let greeting "Hello $name"
705+ &value[1..value.len() - 1]
706+ } else {
707+ value
1494 };
708 };
1495− let value_column = arg.span.column + arg.raw.len() - value.trim_start().len();
709+ track_var_refs(value, &mut ctx.used_variables);
1496− // An HTML attribute value: quoted text loses its quotes
710+ let value = substitute_vars(value, &ctx.variables);
1497− let (value, _, _) =
711+ if name.starts_with("--") {
1498− ctx.fill_value(value.trim(), line_num, Some(value_column), Sink::Text);
712+ // CSS custom property
1499− // The same tag twice says nothing more (a layout called
713+ ctx.css_vars.push((name.to_string(), value.clone()));
1500− // twice): it is written once
1501− let (tags, tag) = match name.trim().strip_prefix("og:") {
1502− Some(property) => (&mut ctx.og_tags, (property.to_string(), value)),
1503− None => (&mut ctx.meta_tags, (name.trim().to_string(), value)),
1504− };
1505− if !tags.contains(&tag) {
1506− tags.push(tag);
1507 }
714 }
1508− ctx.head_at.get_or_insert(line_num);
715+ ctx.variables.insert(name.to_string(), value);
1509− Ok(None)
716+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
1510 }
717 }
718+ return Ok(None);
719+ }
1511
720
1512− ("head", _) => {
721+ // @meta NAME VALUE; `og:` names become Open Graph property tags
1513− let text = verbatim_content(node);
722+ if let Some(rest) = content.strip_prefix("@meta ") {
1514− if !text.trim().is_empty() {
723+ let rest = rest.trim();
1515− ctx.head_blocks.push(text.trim().to_string());
724+ if let Some((name, value)) = rest.split_once(' ') {
1516− ctx.head_at.get_or_insert(line_num);
725+ let value = substitute_vars(value.trim(), &ctx.variables);
726+ match name.trim().strip_prefix("og:") {
727+ Some(property) => ctx.og_tags.push((property.to_string(), value)),
728+ None => ctx.meta_tags.push((name.trim().to_string(), value)),
1517 }
729 }
1518− Ok(None)
1519 }
730 }
731+ return Ok(None);
732+ }
1520
733
1521− // @style: raw CSS
734+
1522− ("style", _) => {
735+ if content == "@head" || content.starts_with("@head ") {
1523− let text = verbatim_content(node);
736+ // Collect indented body lines as raw head content
1524− // Only the @style at the top of a function's body is
737+ let mut head_content = String::new();
1525− // scoped; anywhere else `&` is `:scope`, the whole page
738+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1526− if has_top_level_ampersand(&text) {
739+ match &self.lines[self.pos].content {
1527− ctx.push_once(Diagnostic::warning(
740+ LineContent::Normal(s) => {
1528− code::INVALID_VALUE,
741+ head_content.push_str(s.trim());
1529− node.span.line,
742+ head_content.push('\n');
1530− "`&` in this @style is the whole page: only an @style at the top of a \
743+ }
1531− function's body is scoped to the function, with `&` its root element"
744+ LineContent::Raw(s) => {
1532− .to_string(),
745+ head_content.push_str(s);
1533− ));
746+ head_content.push('\n');
747+ }
1534 }
748 }
1535− if !text.trim().is_empty() {
749+ self.pos += 1;
1536− ctx.custom_css.push(text.trim().to_string());
750+ }
751+ let trimmed = head_content.trim().to_string();
752+ if !trimmed.is_empty() {
753+ ctx.head_blocks.push(trimmed);
754+ }
755+ return Ok(None);
756+ }
757+
758+ // --- @style block (raw CSS) ---
759+ if content.trim() == "@style" {
760+ let mut style_content = String::new();
761+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
762+ match &self.lines[self.pos].content {
763+ LineContent::Normal(s) => {
764+ style_content.push_str(s.trim());
765+ style_content.push('\n');
766+ }
767+ LineContent::Raw(s) => {
768+ style_content.push_str(s);
769+ style_content.push('\n');
770+ }
1537 }
771 }
1538− Ok(None)
772+ self.pos += 1;
773+ }
774+ let trimmed = style_content.trim().to_string();
775+ if !trimmed.is_empty() {
776+ ctx.custom_css.push(trimmed);
1539 }
777 }
778+ return Ok(None);
779+ }
1540
780
1541− // @markdown: an indented block, or a file
781+
1542− ("markdown", DirectiveArgs::Text(file)) => {
782+ // --- @markdown block or file (convert markdown to HTML) ---
1543− let Some(file) = file else {
783+ if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
1544− let md_lines: Vec<String> = verbatim_text(&node.children)
784+ let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
1545− .lines()
785+ if arg.is_empty() {
1546− .map(String::from)
786+ // Inline markdown block: indented children
1547− .collect();
787+ let mut md_lines = Vec::new();
1548− return Ok(Some(vec![Node::Raw(markdown_to_html(&md_lines))]));
788+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
789+ match &self.lines[self.pos].content {
790+ LineContent::Normal(s) => md_lines.push(s.clone()),
791+ LineContent::Raw(s) => md_lines.extend(s.lines().map(String::from)),
792+ }
793+ self.pos += 1;
794+ }
795+ let html = markdown_to_html(&md_lines);
796+ return Ok(Some(vec![Node::Raw(html)]));
797+ } else {
798+ // External markdown file: @markdown file.md
799+ let filename = substitute_vars(arg, &ctx.variables);
800+ let resolved = match &ctx.base_path {
801+ Some(base) => base.join(&filename),
802+ None => PathBuf::from(&filename),
1549 };
803 };
1550− let filename = ctx.interpolate_text(&file.raw, line_num, Some(file.span.column));
804+ let md_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1551− let resolved = ctx.resolve(&filename);
805+ cached.clone()
1552− let md_text = match ctx.read_file(&resolved) {
806+ } else {
1553− Ok(text) => text,
807+ match std::fs::read_to_string(&resolved) {
1554− Err(e) => {
808+ Ok(text) => {
1555− ctx.diagnostics.push(
809+ ctx.file_cache.insert(resolved.clone(), text.clone());
1556− Diagnostic::error(
810+ text
1557− code::UNREADABLE_FILE,
811+ }
1558− line_num,
812+ Err(e) => {
1559− format!("cannot read markdown '{}': {}", filename, e),
813+ ctx.diagnostics.push(Diagnostic {
1560− )
814+ line: line_num,
1561− .source(content.clone()),
815+ column: None,
1562− );
816+ message: format!("cannot read markdown '{}': {}", filename, e),
1563− return Ok(None);
817+ severity: Severity::Error,
818+ source_line: Some(content.clone()),
819+ });
820+ return Ok(None);
821+ }
1564 }
822 }
1565 };
823 };
1566 ctx.included_files.push(resolved);
824 ctx.included_files.push(resolved);
1567 let md_lines: Vec<String> = md_text.lines().map(|l| l.to_string()).collect();
825 let md_lines: Vec<String> = md_text.lines().map(|l| l.to_string()).collect();
1568− Ok(Some(vec![Node::Raw(markdown_to_html(&md_lines))]))
826+ let html = markdown_to_html(&md_lines);
827+ return Ok(Some(vec![Node::Raw(html)]));
1569 }
828 }
829+ }
1570
830
1571− ("include", DirectiveArgs::Text(Some(path))) => {
1572− self.eval_include(node.id, path, line_num, content, ctx)
1573− }
1574
831
1575− ("data", DirectiveArgs::Data { name, source, .. }) => {
832+
1576− self.eval_data(name, source, line_num, content, ctx);
833+ // --- @assert directive (compile-time assertions) ---
1577− Ok(None)
834+
835+ if let Some(rest) = content.strip_prefix("@assert ") {
836+ let rest = rest.trim();
837+ if !ctx.condition(rest, line_num) {
838+ ctx.diagnostics.push(Diagnostic {
839+ line: line_num,
840+ column: None,
841+ message: format!("assertion failed: {}", rest),
842+ severity: Severity::Error,
843+ source_line: Some(content.clone()),
844+ });
1578 }
845 }
846+ return Ok(None);
847+ }
848+
849+ if let Some(rest) = content.strip_prefix("@include ") {
850+ let rest = rest.trim();
851+ let (filename, alias) = if let Some((file_part, alias_part)) = rest.rsplit_once(" as ")
852+ {
853+ (
854+ file_part.trim().to_string(),
855+ Some(alias_part.trim().to_string()),
856+ )
857+ } else {
858+ (rest.to_string(), None)
859+ };
860+ // Strip quotes from filename
861+ let filename =
862+ if filename.starts_with('"') && filename.ends_with('"') && filename.len() >= 2 {
863+ filename[1..filename.len() - 1].to_string()
864+ } else {
865+ filename
866+ };
867+ let filename = substitute_vars(&filename, &ctx.variables);
1579
868
1580− // @raw: the rest of its line, or its indented body, verbatim
869+ // Glob support: if filename contains *, expand to multiple imports
1581− ("raw", _) => {
870+ if filename.contains('*') {
1582− let text = verbatim_content(node);
871+ let base_dir = match &ctx.base_path {
1583− let text = text.trim_end_matches('\n');
872+ Some(base) => base.clone(),
1584− Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())]))
873+ None => PathBuf::from("."),
874+ };
875+ let pattern_path = Path::new(&filename);
876+ let (glob_dir, glob_pattern) = match pattern_path.parent() {
877+ Some(dir) if !dir.as_os_str().is_empty() => (
878+ base_dir.join(dir),
879+ pattern_path
880+ .file_name()
881+ .unwrap_or_default()
882+ .to_string_lossy()
883+ .to_string(),
884+ ),
885+ _ => (base_dir.clone(), filename.clone()),
886+ };
887+ let matched_files = match std::fs::read_dir(&glob_dir) {
888+ Ok(entries) => {
889+ let mut files: Vec<PathBuf> = entries
890+ .flatten()
891+ .filter(|e| {
892+ let name = e.file_name().to_string_lossy().to_string();
893+ glob_match(&glob_pattern, &name)
894+ })
895+ .map(|e| e.path())
896+ .collect();
897+ files.sort();
898+ files
899+ }
900+ Err(e) => {
901+ ctx.diagnostics.push(Diagnostic {
902+ line: line_num,
903+ column: None,
904+ message: format!(
905+ "cannot read directory for glob '{}': {}",
906+ filename, e
907+ ),
908+ severity: Severity::Error,
909+ source_line: Some(content.clone()),
910+ });
911+ return Ok(None);
912+ }
913+ };
914+ if matched_files.is_empty() {
915+ ctx.diagnostics.push(Diagnostic {
916+ line: line_num,
917+ column: None,
918+ message: format!("no files matched glob pattern '{}'", filename),
919+ severity: Severity::Warning,
920+ source_line: Some(content.clone()),
921+ });
922+ return Ok(None);
923+ }
924+ let mut matched_nodes = Vec::new();
925+ for file_path in matched_files {
926+ let rel_name = file_path
927+ .strip_prefix(&base_dir)
928+ .unwrap_or(&file_path)
929+ .to_string_lossy()
930+ .to_string();
931+ // Synthesize an @include line for each matched file
932+ let import_line = match &alias {
933+ Some(pfx) => {
934+ let stem = file_path
935+ .file_stem()
936+ .unwrap_or_default()
937+ .to_string_lossy()
938+ .to_string();
939+ format!("@include \"{}\" as {}.{}", rel_name, pfx, stem)
940+ }
941+ None => format!("@include \"{}\"", rel_name),
942+ };
943+ let synth_lines = preprocess(&import_line);
944+ let mut synth_parser = Parser {
945+ lines: synth_lines,
946+ pos: 0,
947+ };
948+ matched_nodes.extend(synth_parser.parse_children(0, ctx));
949+ }
950+ return Ok(Some(matched_nodes));
1585 }
951 }
1586
952
1587− // A header with a syntax error (reported), or a directive
953+ let resolved = match &ctx.base_path {
1588− // without its argument
954+ Some(base) => base.join(&filename),
1589− _ => Ok(None),
955+ None => PathBuf::from(&filename),
1590− }
956+ };
1591− }
1592
957
1593− fn eval_include(
958+ if ctx.include_stack.contains(&resolved) || ctx.import_stack.contains(&resolved) {
1594− &mut self,
959+ let cycle_chain = format_include_chain(&ctx.import_stack);
1595− id: usize,
960+ ctx.diagnostics.push(Diagnostic {
1596− path: &syntax::Arg,
961+ line: line_num,
1597− line_num: usize,
962+ column: None,
1598− content: &str,
963+ message: format!(
1599− ctx: &mut ParseContext,
1600− ) -> Result<Option<Vec<Node>>, ParseError> {
1601− let (filename, column) = match path.raw.strip_prefix('"').and_then(|f| f.strip_suffix('"'))
1602− {
1603− Some(quoted) => (quoted, path.span.column + 1),
1604− None => (path.raw.as_str(), path.span.column),
1605− };
1606− let filename = ctx.interpolate_text(filename, line_num, Some(column));
1607− let resolved = ctx.resolve(&filename);
1608−
1609− if ctx.include_stack.contains(&resolved) {
1610− let cycle_chain = format_include_chain(&ctx.include_stack);
1611− ctx.diagnostics.push(
1612− Diagnostic::error(
1613− code::CIRCULAR_INCLUDE,
1614− line_num,
1615− format!(
1616 "circular include '{}' (cycle: {} → {})",
964 "circular include '{}' (cycle: {} → {})",
1617 filename, cycle_chain, filename
965 filename, cycle_chain, filename
1618 ),
966 ),
1619− )
967+ severity: Severity::Error,
1620− .source(content),
968+ source_line: Some(content.clone()),
1621− );
969+ });
1622− return Ok(None);
1623− }
1624−
1625− let imported_text = match ctx.read_file(&resolved) {
1626− Ok(text) => text,
1627− Err(e) => {
1628− ctx.diagnostics.push(
1629− Diagnostic::error(
1630− code::UNREADABLE_FILE,
1631− line_num,
1632− format!("cannot include '{}': {}", filename, e),
1633− )
1634− .source(content),
1635− );
1636 return Ok(None);
970 return Ok(None);
1637 }
971 }
1638− };
1639−
1640− ctx.included_files.push(resolved.clone());
1641− ctx.include_stack.push(resolved.clone());
1642− let saved_base = ctx.base_path.clone();
1643− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1644−
1645− let diag_count_before = ctx.diagnostics.len();
1646− let import_chain = format_include_chain(&ctx.include_stack);
1647− let tree = ctx.parse_tree(&imported_text);
1648− ctx.diagnostics.extend(tree.diagnostics.iter().cloned());
1649− ctx.trees.push((tree.clone(), Some(import_chain.clone())));
1650− let files = Rc::make_mut(&mut ctx.included_by).entry(id).or_default();
1651− if !files.iter().any(|(_, chain)| *chain == import_chain) {
1652− files.push((tree.clone(), import_chain.clone()));
1653− }
1654− let saved_file = ctx.current_file.replace(import_chain.clone());
1655− let defined_before: HashSet<String> = ctx
1656− .let_lines
1657− .keys()
1658− .chain(ctx.define_lines.keys())
1659− .chain(ctx.fn_lines.keys())
1660− .cloned()
1661− .collect();
1662− let included_nodes = self.eval_block(&tree.nodes, ctx);
1663− ctx.current_file = saved_file;
1664− // An included file's definitions aren't reported unused here, where
1665− // their lines aren't this file's: a library's are there to be
1666− // picked from, and any other file reports its own when checked
1667− let keep = |name: &String, _: &mut usize| defined_before.contains(name);
1668− ctx.let_lines.retain(keep);
1669− ctx.define_lines.retain(keep);
1670− ctx.fn_lines.retain(keep);
1671−
1672− // Annotate new diagnostics with import chain
1673− for d in &mut ctx.diagnostics[diag_count_before..] {
1674− d.message = format!("{}\n in {}", d.message, import_chain);
1675− }
1676
972
1677− ctx.base_path = saved_base;
973+ let imported_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1678− ctx.include_stack.pop();
974+ cached.clone()
1679− Ok(Some(included_nodes))
975+ } else {
1680− }
976+ match std::fs::read_to_string(&resolved) {
1681−
977+ Ok(text) => {
1682− /// @data $name file.json values as $name.key
978+ ctx.file_cache.insert(resolved.clone(), text.clone());
1683− /// @data $name dir/*.json a list of the files' records
979+ text
1684− /// @data $name [...] / {...} inline JSON
980+ }
1685− /// @data $name env:NAME [DEFAULT] an environment variable
981+ Err(e) => {
1686− fn eval_data(
982+ ctx.diagnostics.push(Diagnostic {
1687− &mut self,
983+ line: line_num,
1688− prefix: &str,
984+ column: None,
1689− source: &syntax::Arg,
985+ message: format!("cannot include '{}': {}", filename, e),
1690− line_num: usize,
986+ severity: Severity::Error,
1691− content: &str,
987+ source_line: Some(content.clone()),
1692− ctx: &mut ParseContext,
988+ });
1693− ) {
989+ return Ok(None);
1694− let prefix = prefix.to_string();
990+ }
1695− let filename = source.raw.as_str();
991+ }
1696− if let Some(env) = filename.strip_prefix("env:") {
1697− let (var, default) = match env.split_once(char::is_whitespace) {
1698− Some((var, default)) => (var, Some(default.trim())),
1699− None => (env, None),
1700 };
992 };
1701− let default_column = default.map(|d| source.span.column + filename.len() - d.len());
1702− let value = std::env::var(var)
1703− .ok()
1704− .or_else(|| default.map(|d| ctx.interpolate_text(d, line_num, default_column)));
1705− if value.is_none() {
1706− ctx.diagnostics.push(
1707− Diagnostic::warning(
1708− code::UNSET_ENVIRONMENT,
1709− line_num,
1710− format!(
1711− "environment variable '{}' is not set and has no default",
1712− var
1713− ),
1714− )
1715− .source(content),
1716− );
1717− }
1718− ctx.bind(&prefix, Value::Str(value.unwrap_or_default()));
1719− return;
1720− }
1721
993
1722− // Inline JSON: @data $links [{"label": "Home", "url": "/"}]
994+ ctx.included_files.push(resolved.clone());
1723− let (json_text, source) = if filename.starts_with(['[', '{']) {
995+ ctx.include_stack.push(resolved.clone());
1724− (filename.to_string(), "the inline data".to_string())
996+ ctx.import_stack.push(resolved.clone());
1725− } else {
997+ let saved_base = ctx.base_path.clone();
1726− let filename = ctx.interpolate_text(filename, line_num, Some(source.span.column));
998+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1727− if filename.contains('*') {
999+
1728− self.load_data_glob(&prefix, &filename, line_num, content, ctx);
1000+ let diag_count_before = ctx.diagnostics.len();
1729− return;
1001+ let mut included_nodes = Vec::new();
1730− }
1002+
1731− let resolved = ctx.resolve(&filename);
1003+ if let Some(ref prefix) = alias {
1732− match std::fs::read_to_string(&resolved) {
1004+ // Snapshot just the *key sets* before parsing — much cheaper
1733− Ok(text) => {
1005+ // than cloning the full HashMaps, since we only need to know
1734− ctx.included_files.push(resolved);
1006+ // which definitions are new afterwards.
1735− (text, format!("'{}'", filename))
1007+ let fn_keys_before: std::collections::HashSet<String> =
1736− }
1008+ ctx.functions.keys().cloned().collect();
1737− Err(e) => {
1009+ let define_keys_before: std::collections::HashSet<String> =
1738− ctx.diagnostics.push(
1010+ ctx.defines.keys().cloned().collect();
1739− Diagnostic::error(
1011+ let var_keys_before: std::collections::HashSet<String> =
1740− code::UNREADABLE_FILE,
1012+ ctx.variables.keys().cloned().collect();
1741− line_num,
1013+
1742− format!("cannot load data '{}': {}", filename, e),
1014+ let imported_lines = preprocess(&imported_text);
1743− )
1015+ let mut imported_parser = Parser {
1744− .source(content),
1016+ lines: imported_lines,
1745− );
1017+ pos: 0,
1746− return;
1018+ };
1019+ let _discarded_nodes = imported_parser.parse_children(0, ctx);
1020+
1021+ // Remove newly added entries and re-insert them under the prefix.
1022+ let new_fn_keys: Vec<String> = ctx
1023+ .functions
1024+ .keys()
1025+ .filter(|k| !fn_keys_before.contains(*k))
1026+ .cloned()
1027+ .collect();
1028+ for name in new_fn_keys {
1029+ if let Some(def) = ctx.functions.remove(&name) {
1030+ ctx.functions.insert(format!("{}.{}", prefix, name), def);
1031+ }
1747 }
1032 }
1748− }
1033+ let new_define_keys: Vec<String> = ctx
1749− };
1034+ .defines
1750− match parse_json_with_error(&json_text) {
1035+ .keys()
1751− Ok(json) => ctx.bind(&prefix, json_value(json)),
1036+ .filter(|k| !define_keys_before.contains(*k))
1752− Err(detail) => ctx.diagnostics.push(
1037+ .cloned()
1753− Diagnostic::error(
1038+ .collect();
1754− code::INVALID_JSON,
1039+ for name in new_define_keys {
1755− line_num,
1040+ if let Some(attrs) = ctx.defines.remove(&name) {
1756− format!("invalid JSON in {}: {}", source, detail),
1041+ ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
1757− )
1042+ }
1758− .source(content),
1759− ),
1760− }
1761− }
1762−
1763− /// `@data $name dir/*.json`: a list with one record per matching file
1764− /// (in name order), whose `file` is the file's name without extension.
1765− fn load_data_glob(
1766− &mut self,
1767− name: &str,
1768− pattern: &str,
1769− line_num: usize,
1770− content: &str,
1771− ctx: &mut ParseContext,
1772− ) {
1773− let base = ctx.base_path.clone().unwrap_or_else(|| PathBuf::from("."));
1774− let pattern_path = Path::new(pattern);
1775− let dir = match pattern_path.parent() {
1776− Some(dir) if !dir.as_os_str().is_empty() => base.join(dir),
1777− _ => base,
1778− };
1779− let file_pattern = pattern_path
1780− .file_name()
1781− .unwrap_or_default()
1782− .to_string_lossy()
1783− .to_string();
1784− let mut files: Vec<PathBuf> = match std::fs::read_dir(&dir) {
1785− Ok(entries) => entries
1786− .flatten()
1787− .map(|e| e.path())
1788− .filter(|p| {
1789− p.file_name()
1790− .is_some_and(|n| glob_match(&file_pattern, &n.to_string_lossy()))
1791− })
1792− .collect(),
1793− Err(e) => {
1794− ctx.diagnostics.push(
1795− Diagnostic::new(
1796− code::UNREADABLE_FILE,
1797− Severity::Error,
1798− line_num,
1799− format!("cannot read directory for '{}': {}", pattern, e),
1800− )
1801− .source(content.to_string()),
1802− );
1803− return;
1804− }
1805− };
1806− files.sort();
1807− let mut records = Vec::new();
1808− for file in files {
1809− let stem = file
1810− .file_stem()
1811− .unwrap_or_default()
1812− .to_string_lossy()
1813− .to_string();
1814− let text = match std::fs::read_to_string(&file) {
1815− Ok(text) => text,
1816− Err(e) => {
1817− ctx.diagnostics.push(
1818− Diagnostic::new(
1819− code::UNREADABLE_FILE,
1820− Severity::Error,
1821− line_num,
1822− format!("cannot load data '{}': {}", file.display(), e),
1823− )
1824− .source(content.to_string()),
1825− );
1826− continue;
1827 }
1043 }
1828− };
1044+ let new_var_keys: Vec<String> = ctx
1829− match parse_json_with_error(&text) {
1045+ .variables
1830− Ok(JsonValue::Object(mut pairs)) => {
1046+ .keys()
1831− if !pairs.iter().any(|(key, _)| key == "file") {
1047+ .filter(|k| !var_keys_before.contains(*k))
1832− pairs.push(("file".to_string(), JsonValue::Str(stem)));
1048+ .cloned()
1049+ .collect();
1050+ for name in new_var_keys {
1051+ if let Some(val) = ctx.variables.remove(&name)
1052+ && !name.starts_with("__")
1053+ {
1054+ ctx.variables.insert(format!("{}.{}", prefix, name), val);
1833 }
1055 }
1834− records.push(JsonValue::Object(pairs));
1835 }
1056 }
1836− Ok(_) => ctx.diagnostics.push(
1057+ } else {
1837− Diagnostic::new(
1058+ let included_lines = preprocess(&imported_text);
1838− code::INVALID_JSON,
1059+ let mut included_parser = Parser {
1839− Severity::Error,
1060+ lines: included_lines,
1840− line_num,
1061+ pos: 0,
1841− format!("'{}' should hold a JSON object", file.display()),
1062+ };
1842− )
1063+ included_nodes = included_parser.parse_children(0, ctx);
1843− .source(content.to_string()),
1844− ),
1845− Err(detail) => ctx.diagnostics.push(
1846− Diagnostic::new(
1847− code::INVALID_JSON,
1848− Severity::Error,
1849− line_num,
1850− format!("invalid JSON in '{}': {}", file.display(), detail),
1851− )
1852− .source(content.to_string()),
1853− ),
1854 }
1064 }
1855− ctx.included_files.push(file);
1856− }
1857− ctx.bind(name, json_value(JsonValue::Array(records)));
1858− }
1859
1065
1860− /// `@each $item in LIST` or `@each $item, $index in LIST`, with the
1066+ // Annotate new diagnostics with import chain
1861− /// `@else` that follows it. LIST is a value (see
1067+ let import_chain = format_include_chain(&ctx.include_stack);
1862− /// [`ParseContext::slot_value`]): a list written with commas, a range,
1068+ for d in &mut ctx.diagnostics[diag_count_before..] {
1863− /// or one `$name` or `${...}` that holds a list. Each item is bound
1069+ d.message = format!("{}\n in {}", d.message, import_chain);
1864− /// whole to `$item`, and each iteration is a block of its own.
1865− fn eval_each(
1866− &mut self,
1867− node: &syntax::Node,
1868− empty_branch: Option<&syntax::Node>,
1869− ctx: &mut ParseContext,
1870− ) -> Result<Option<Vec<Node>>, ParseError> {
1871− ctx.visited.insert(node.id);
1872− if let Some(branch) = empty_branch {
1873− ctx.visited.insert(branch.id);
1874− }
1875− ctx.enter(node);
1876− // A malformed header is a syntax error, already reported
1877− let Some(DirectiveArgs::Each(header)) = node.directive().map(|d| &d.args) else {
1878− return Ok(None);
1879− };
1880− let body = &node.children;
1881− let empty: &[syntax::Node] = empty_branch.map_or(&[], |b| &b.children);
1882− let (item, index) = (header.item.as_str(), header.index.as_deref());
1883− let list = &header.list;
1884− // `[...]` is an attribute list or JSON elsewhere, not a list here
1885− let bracketed = list.raw.starts_with('[') && list.raw.ends_with(']');
1886− let (source, filled) = match bracketed {
1887− true => (Value::empty(), false),
1888− false => ctx.slot_value(&list.raw, list.span.line, Some(list.span.column)),
1889− };
1890− let not_a_list = |message: String, ctx: &ParseContext| {
1891− let diagnostic = Diagnostic::error(code::INVALID_LOOP, list.span.line, message)
1892− .column(list.span.column)
1893− .subject(list.raw.as_str());
1894− match &ctx.current_source {
1895− (current, Some(text)) if *current == list.span.line => {
1896− diagnostic.source(text.clone())
1897− }
1898− _ => diagnostic,
1899 }
1070 }
1900− };
1901− if bracketed {
1902− let inner = list.raw[1..list.raw.len() - 1].trim().to_string();
1903− let diagnostic = not_a_list(
1904− format!(
1905− "@each takes a list written without brackets, `@each ${} in {}`; \
1906− JSON data goes in `@data`",
1907− item, inner
1908− ),
1909− ctx,
1910− )
1911− .suggest(Some(inner));
1912− ctx.push_once(diagnostic);
1913− }
1914− let items: Vec<Value> = match source {
1915− Value::List(list) => list.items.to_vec(),
1916− // Reported (an undefined `$name`, brackets): there is nothing
1917− // to repeat
1918− _ if !filled => Vec::new(),
1919− Value::Record(_) => {
1920− let diagnostic = not_a_list(
1921− format!(
1922− "@each repeats for the items of a list, and `{}` is a record: \
1923− loop over a list of records, or use its fields",
1924− list.raw
1925− ),
1926− ctx,
1927− );
1928− ctx.push_once(diagnostic);
1929− Vec::new()
1930− }
1931− // Empty text (a field a record doesn't have) is no items; any
1932− // other value is one
1933− value if value.to_string().is_empty() => Vec::new(),
1934− value => vec![value],
1935− };
1936
1071
1937− if body.iter().all(syntax::Node::is_trivia) {
1072+ ctx.base_path = saved_base;
1938− return Ok(Some(Vec::new()));
1073+ ctx.include_stack.pop();
1074+ ctx.import_stack.pop();
1075+ return Ok(Some(included_nodes));
1939 }
1076 }
1940− if items.is_empty() {
1941− return Ok(Some(self.eval_scoped(empty, ctx)));
1942− }
1943− let mut nodes = Vec::new();
1944− for (i, value) in items.into_iter().enumerate() {
1945− ctx.env.push();
1946− ctx.bind(item, value);
1947− if let Some(index) = index {
1948− ctx.bind(index, Value::Num(i as f64));
1949− }
1950− nodes.extend(self.eval_block(body, ctx));
1951− ctx.env.pop();
1952− }
1953− Ok(Some(nodes))
1954− }
1955
1077
1956− fn define_function(
1957− &mut self,
1958− name: &str,
1959− params: &[syntax::Param],
1960− node: &syntax::Node,
1961− ctx: &mut ParseContext,
1962− ) {
1963− let line_num = node.span.line;
1964− let body = &node.children;
1965− // An @style block at the top of the body is scoped to the
1966− // function: its rules apply inside a `.hl-fn-NAME-HASH` wrapper (a
1967− // generated class never has a second `-`, so the two can't meet).
1968− // The hash is the stylesheet's, so two functions of one name with
1969− // two stylesheets, in two files, don't style each other's elements
1970− // in a page that has both. It is written into the page when the
1971− // function is called.
1972− let (style, body): (Vec<&syntax::Node>, Vec<&syntax::Node>) =
1973− body.iter().partition(|node| node.is_directive("style"));
1974− let scoped = (!style.is_empty()).then(|| {
1975− let css: String = style
1976− .iter()
1977− .map(|node| {
1978− ctx.visited.insert(node.id);
1979− verbatim_content(node)
1980− })
1981− .collect();
1982− let digits = crate::codegen::CLASS_DIGITS;
1983− let hash = crate::codegen::hash_digits(crate::codegen::stable_hash(&css), digits);
1984− let class = format!("hl-fn-{}-{}", name, hash);
1985− // Nested under the scope class, so any CSS works (multi-line
1986− // rules, at-rules)
1987− let css = if css.trim().is_empty() {
1988− String::new()
1989− } else {
1990− format!(".{} {{\n{}}}", class, css)
1991− };
1992− (class, css)
1993− });
1994
1078
1995− // A default is filled in only when a call leaves its parameter
1079+ // --- @svg (inline SVG from file) ---
1996− // out, so the names in it count as used here
1997− for default in params.iter().filter_map(|p| p.default.as_ref()) {
1998− track_var_refs(default, &mut ctx.used_variables);
1999− }
2000− ctx.fn_lines.entry(name.to_string()).or_insert(line_num);
2001− let wraps_itself = ElementKind::from_name(name).is_some()
2002− && body.iter().any(|node| calls_unguarded(node, name));
2003− if wraps_itself {
2004− ctx.push_once(
2005− Diagnostic::error(
2006− code::RECURSIVE_CALL,
2007− line_num,
2008− format!(
2009− "@{0} in the body of `@let @{0}` calls the function itself, not the \
2010− element @{0}, so it would never stop: give the function another name \
2011− (such as @site-{0})",
2012− name
2013− ),
2014− )
2015− .subject(name),
2016− );
2017− }
2018− for node in &body {
2019− node.walk(&mut |node| {
2020− if let Some(d) = marker_in_text(node) {
2021− ctx.push_once(d);
2022− }
2023− });
2024− }
2025− let body: Vec<syntax::Node> = body.into_iter().cloned().collect();
2026− let (slots, has_children) = declared_slots(&body, ctx);
2027− let function = Rc::new(FnDef {
2028− params: params.to_vec(),
2029− line: line_num,
2030− source: ctx.current_source.1.clone(),
2031− node: node.id,
2032− body,
2033− file: ctx.current_file.clone(),
2034− slots,
2035− has_children,
2036− scoped,
2037− env: ctx.env.clone(),
2038− wraps_itself,
2039− });
2040− ctx.defined_functions
2041− .insert(name.to_string(), function.clone());
2042− ctx.env.define(name, Binding::Function(function));
2043− }
2044
1080
2045− /// An element line: a chain of elements and function calls, the last of
1081+ if let Some(rest) = content.strip_prefix("@svg ") {
2046− /// which gets the indented children.
1082+ let rest = rest.trim();
2047− fn eval_element(
1083+ // @svg file.svg OR @svg [attrs] file.svg
2048− &mut self,
1084+ let (attrs_part, filename) = if rest.starts_with('[') {
2049− node: &syntax::Node,
1085+ if let Some(bracket_end) = rest.find(']') {
2050− element: &syntax::ElementLine,
1086+ let attrs_str = &rest[..=bracket_end];
2051− ctx: &mut ParseContext,
1087+ let file = rest[bracket_end + 1..].trim();
2052− ) -> Result<Vec<Node>, ParseError> {
1088+ (Some(attrs_str.to_string()), file.to_string())
2053− // An unclosed attribute list is a syntax error, already reported
1089+ } else {
2054− if element
1090+ (None, rest.to_string())
2055− .chain
1091+ }
2056− .iter()
2057− .any(|h| h.attrs.as_ref().is_some_and(|a| !a.closed))
2058− {
2059− return Ok(Vec::new());
2060− }
2061− let line_num = node.span.line;
2062− let last = element.chain.len() - 1;
2063− // Each head is read where it is written, left to right. The
2064− // children belong to the last one, so the chain is built right to
2065− // left, each link wrapping the next.
2066− let mut links = Vec::new();
2067− for (i, head) in element.chain.iter().enumerate() {
2068− let text = if i == last {
2069− element.text.as_ref()
2070 } else {
1092 } else {
2071− None
1093+ (None, rest.to_string())
2072 };
1094 };
2073− links.push(resolve(head, text, line_num, &node.source, ctx)?);
2074− }
2075− let calls: Vec<Option<(String, Rc<FnDef>)>> = links
2076− .iter()
2077− .map(|link| match link {
2078− Resolved::Call(call) => Some((call.name.clone(), call.function.clone())),
2079− Resolved::Element(_) => None,
2080− })
2081− .collect();
2082− let mut current = self.eval_scoped(&node.children, ctx);
2083− // After the lines under the call ran, so the files they include
2084− // are read
2085− check_fillers(node, element, &calls, ctx);
2086− for link in links.into_iter().rev() {
2087− current = self.complete(link, current, ctx)?;
2088− }
2089− Ok(current)
2090− }
2091
1095
2092− /// Give a resolved `@name` its children: an element holds them, and a
1096+ let filename = substitute_vars(&filename, &ctx.variables);
2093− /// function's body puts them where its `@children` is.
1097+ let resolved = match &ctx.base_path {
2094− fn complete(
1098+ Some(base) => base.join(&filename),
2095− &mut self,
1099+ None => PathBuf::from(&filename),
2096− resolved: Resolved,
1100+ };
2097− children: Vec<Node>,
2098− ctx: &mut ParseContext,
2099− ) -> Result<Vec<Node>, ParseError> {
2100− match resolved {
2101− Resolved::Element(mut element) => {
2102− element.children.extend(children);
2103− let line = element.line_num;
2104− Ok(vec![inline_svg(Node::Element(element), line, ctx)])
2105− }
2106− Resolved::Call(call) => self.expand_fn_call(*call, children, ctx),
2107− }
2108− }
2109
1101
2110− fn expand_fn_call(
1102+ match std::fs::read_to_string(&resolved) {
2111− &mut self,
1103+ Ok(svg_content) => {
2112− call: Call,
1104+ let mut svg = svg_content.trim().to_string();
2113− all_caller_children: Vec<Node>,
1105+ // Apply attributes (width, height, color/fill, class)
2114− ctx: &mut ParseContext,
1106+ if let Some(ref attrs_str) = attrs_part {
2115− ) -> Result<Vec<Node>, ParseError> {
1107+ let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
2116− let Call {
1108+ for attr in &attrs {
2117− name,
1109+ match attr.key.as_str() {
2118− function: fn_def,
1110+ "width" => {
2119− args,
1111+ if let Some(ref val) = attr.value {
2120− values,
1112+ svg = set_svg_attr(&svg, "width", val);
2121− text: trailing_text,
1113+ }
2122− written_form,
1114+ }
2123− written_keys,
1115+ "height" => {
2124− line: line_num,
1116+ if let Some(ref val) = attr.value {
2125− name_at,
1117+ svg = set_svg_attr(&svg, "height", val);
2126− source: content,
1118+ }
2127− } = call;
1119+ }
2128− let (name, content) = (name.as_str(), content.as_str());
1120+ "color" | "fill" => {
2129−
1121+ if let Some(ref val) = attr.value {
2130− // Separate the caller's `@slot NAME` blocks from its other
1122+ svg = set_svg_attr(&svg, "fill", val);
2131− // children. A block for a slot the function doesn't declare was
1123+ }
2132− // reported where it is written (see `check_fillers`), like one
1124+ }
2133− // without a name.
1125+ "class" => {
2134− let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
1126+ if let Some(ref val) = attr.value {
2135− let mut caller_children = Vec::new();
1127+ svg = set_svg_attr(&svg, "class", val);
2136− for child in all_caller_children {
1128+ }
2137− if let Node::Element(elem) = child {
1129+ }
2138− match elem.kind {
1130+ "id" => {
2139− ElementKind::Slot(slot_name) => {
1131+ if let Some(ref val) = attr.value {
2140− slot_contents
1132+ svg = set_svg_attr(&svg, "id", val);
2141− .entry(slot_name)
1133+ }
2142− .or_default()
1134+ }
2143− .extend(elem.children);
1135+ _ => {}
1136+ }
1137+ }
2144 }
1138 }
2145− kind => caller_children.push(Node::Element(Element { kind, ..elem })),
1139+ return Ok(Some(vec![Node::Raw(svg)]));
1140+ }
1141+ Err(e) => {
1142+ ctx.diagnostics.push(Diagnostic {
1143+ line: line_num,
1144+ column: None,
1145+ message: format!("cannot load SVG '{}': {}", filename, e),
1146+ severity: Severity::Error,
1147+ source_line: Some(content.clone()),
1148+ });
1149+ return Ok(None);
2146 }
1150 }
2147− continue;
2148 }
1151 }
2149− caller_children.push(child);
2150− }
2151− // Text after the call, as in `@card [color red] New`, is content:
2152− // it goes first among the caller's children, and like them it is
2153− // evaluated where the call is written.
2154− if let Some(text) = &trailing_text {
2155− caller_children.insert(0, Node::Text(text_segments(text, ctx)));
2156− }
2157− // Content goes where the body's `@children` is: without one, it
2158− // would be dropped
2159− if !fn_def.has_children && !caller_children.is_empty() {
2160− let (column, text) = match &name_at {
2161− Some((column, text)) => (Some(*column), text.as_str()),
2162− None => (None, content),
2163− };
2164− ctx.push_once(no_children(name, line_num, column).source(text));
2165 }
1152 }
2166
1153
2167− if fn_def.wraps_itself {
2168− return Ok(Vec::new());
2169− }
2170− // Only the body is inside the call, so a function may appear in the
2171− // content its caller passes it. A function may call itself, under
2172− // a condition that stops it; one that doesn't stop goes too deep.
2173− if ctx.too_deep {
2174− return Ok(Vec::new());
2175− }
2176− if ctx.fn_call_stack.len() >= MAX_CALL_DEPTH {
2177− ctx.too_deep = true;
2178− return Err(too_deep(name, &ctx.fn_call_stack, line_num).source(content));
2179− }
2180− let caller = (
2181− ctx.current_line,
2182− ctx.current_source.clone(),
2183− ctx.current_node,
2184− );
2185− ctx.fn_call_stack.push(name.to_string());
2186
1154
2187− // The body runs where the function is defined: it sees what was
1155+ // --- @data (load JSON file into variables) ---
2188− // visible there, the function itself (so it can call itself), and
1156+
2189− // its parameters, bound by name in the order they are declared. A
1157+ // @data file.json top-level keys become variables
2190− // default is filled in at the call, seeing the same, with the
1158+ // @data $name file.json values as $name.key
2191− // parameters before it.
1159+ // @data $name dir/*.json each file as $name.STEM.key; $name
2192− let caller_env = std::mem::replace(&mut ctx.env, fn_def.env.clone());
1160+ // lists the stems, $name._count counts them
2193− ctx.env.push();
1161+ // @data $name env:NAME [DEFAULT] an environment variable
2194− ctx.env.define(name, Binding::Function(fn_def.clone()));
1162+ if let Some(rest) = content.strip_prefix("@data ") {
2195− let mut consumed = vec![false; args.len()];
1163+ let rest = rest.trim();
2196− for param in &fn_def.params {
1164+ let (prefix, filename) = match rest.strip_prefix('$') {
2197− let mut passed = None;
1165+ Some(named) => match named.split_once(char::is_whitespace) {
2198− for (j, arg) in args.iter().enumerate() {
1166+ Some((name, source)) => (name.to_string(), source.trim().to_string()),
2199− if arg.key != param.name {
2200− continue;
2201− }
2202− consumed[j] = true;
2203− // The later one wins, as for any attribute: one written
2204− // after a bundle overrides it, and only one written twice
2205− // in the call's own list is reported
2206− if passed.is_some() && written_keys.iter().filter(|k| **k == param.name).count() > 1
2207− {
2208− ctx.push_once(
2209− Diagnostic::warning(
2210− code::DUPLICATE_ATTRIBUTE,
2211− line_num,
2212− format!(
2213− "parameter '{}' of @{} is passed twice: the later one wins",
2214− param.name, name
2215− ),
2216− )
2217− .source(content)
2218− .subject(param.name.as_str()),
2219− );
2220− }
2221− // Written `name=value` in the call's own list is reported
2222− // as written; this is one from a bundle
2223− if arg.html && !written_form.contains(&arg.key) {
2224− let value = arg.value.as_deref().unwrap_or("");
2225− ctx.push_once(parameter_form(name, &arg.key, value, line_num).source(content));
2226− }
2227− passed = Some(j);
2228− }
2229− let value = match passed {
2230− // A parameter's name alone is true: `@post-card [featured]`
2231− Some(j) if args[j].value.is_none() => Value::Bool(true),
2232− // What the call passed, with its type: a record, a list,
2233− // quoted text
2234− Some(j) => values[j].clone().unwrap_or_else(|| {
2235− let arg = &args[j];
2236− match &arg.quoted {
2237− Some(quoted) => Value::Quoted(quoted.clone()),
2238− None => Value::Str(arg.value.clone().unwrap_or_default()),
2239− }
2240− }),
2241− None => match &param.default {
2242− Some(default) => {
2243− let before = ctx.diagnostics.len();
2244− let value = fill_default(&fn_def, param, default, ctx);
2245− point_at_call(ctx, before, &fn_def, name, line_num, content);
2246− value
2247− }
2248 None => {
1167 None => {
2249− let (column, text) = match &name_at {
1168+ return Err(ParseError {
2250− Some((column, text)) => (Some(*column), text.as_str()),
1169+ line: line_num,
2251− None => (None, content),
1170+ message: "@data requires: @data $name SOURCE or @data file.json"
2252− };
1171+ .to_string(),
2253− ctx.push_once(
1172+ });
2254− missing_parameter(name, &param.name, line_num, column).source(text),
2255− );
2256− Value::empty()
2257 }
1173 }
2258 },
1174 },
1175+ None => (String::new(), rest.to_string()),
2259 };
1176 };
2260− ctx.bind(&param.name, value);
2261− }
2262− // Arguments that aren't parameters are attributes for the
2263− // function's root element, so a function can be styled like an
2264− // element: `@card [background red] New`.
2265− let forwarded: Vec<Attribute> = args
2266− .iter()
2267− .zip(&consumed)
2268− .filter(|&(_, &used)| !used)
2269− .map(|(a, _)| a.clone())
2270− .collect();
2271−
2272− // Evaluate the body with the parameters in scope, as lines of the
2273− // file that defines the function
2274− let caller_file = std::mem::replace(&mut ctx.current_file, fn_def.file.clone());
2275− let before = ctx.diagnostics.len();
2276− let mut body_nodes = self.eval_block(&fn_def.body, ctx);
2277− ctx.current_file = caller_file;
2278− point_at_call(ctx, before, &fn_def, name, line_num, content);
2279
1177
2280− // Back to the caller's names and call stack
1178+ if let Some(env) = filename.strip_prefix("env:") {
2281− ctx.env = caller_env;
1179+ let (var, default) = match env.split_once(char::is_whitespace) {
2282− ctx.fn_call_stack.pop();
1180+ Some((var, default)) => (var, Some(default.trim())),
2283− if ctx.fn_call_stack.is_empty() {
1181+ None => (env, None),
2284− ctx.too_deep = false;
1182+ };
2285− }
1183+ if prefix.is_empty() {
2286− (ctx.current_line, ctx.current_source, ctx.current_node) = caller;
1184+ return Err(ParseError {
1185+ line: line_num,
1186+ message: format!("@data env:{} needs a name: @data $name env:{}", var, var),
1187+ });
1188+ }
1189+ let value = std::env::var(var)
1190+ .ok()
1191+ .or_else(|| default.map(|d| substitute_vars(d, &ctx.variables)));
1192+ if value.is_none() {
1193+ ctx.diagnostics.push(Diagnostic {
1194+ line: line_num,
1195+ column: None,
1196+ message: format!(
1197+ "environment variable '{}' is not set and has no default",
1198+ var
1199+ ),
1200+ severity: Severity::Warning,
1201+ source_line: Some(content.clone()),
1202+ });
1203+ }
1204+ ctx.variables.insert(prefix, value.unwrap_or_default());
1205+ return Ok(None);
1206+ }
2287
1207
2288− // What the body wrote is the call's
1208+ let filename = substitute_vars(&filename, &ctx.variables);
2289− belongs_to_call(&mut body_nodes, name, line_num);
1209+ if filename.contains('*') {
1210+ if prefix.is_empty() {
1211+ return Err(ParseError {
1212+ line: line_num,
1213+ message: format!("@data {} needs a name: @data $name {}", filename, filename),
1214+ });
1215+ }
1216+ self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
1217+ return Ok(None);
1218+ }
2290
1219
2291− // Replace @children with caller's children and @slot with slot content
1220+ let filename = substitute_vars(&filename, &ctx.variables);
2292− let mut result_nodes =
1221+ let resolved = match &ctx.base_path {
2293− replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
1222+ Some(base) => base.join(&filename),
1223+ None => PathBuf::from(&filename),
1224+ };
2294
1225
2295− // Attributes that aren't parameters style the function's root
1226+ let json_text = match std::fs::read_to_string(&resolved) {
2296− // element, and a scoped @style's class goes on it too.
1227+ Ok(text) => text,
2297− let scope_class = fn_def.scoped.as_ref().map(|(class, _)| class.clone());
1228+ Err(e) => {
2298− if !forwarded.is_empty() || scope_class.is_some() {
1229+ ctx.diagnostics.push(Diagnostic {
2299− let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
1230+ line: line_num,
2300− Node::Element(e) => Some(e),
1231+ column: None,
2301− _ => None,
1232+ message: format!("cannot load data '{}': {}", filename, e),
2302− });
1233+ severity: Severity::Error,
2303− let root = match (roots.next(), roots.next()) {
1234+ source_line: Some(content.clone()),
2304− (Some(root), None) => Some(root),
1235+ });
2305− _ => None,
1236+ return Ok(None);
1237+ }
2306 };
1238 };
2307− let mut problems = Vec::new();
1239+
2308− match root {
1240+ match parse_json_with_error(&json_text) {
2309− Some(root) => {
1241+ Ok(json) => {
2310− // A call's `class=` adds to the root's own classes
1242+ if prefix.is_empty() {
2311− for attr in forwarded {
1243+ // No prefix: top-level object keys become variables directly
2312− let existing = (attr.html && attr.key == "class")
1244+ if let JsonValue::Object(pairs) = &json {
2313− .then(|| root.attrs.iter_mut().find(|a| a.html && a.key == "class"))
1245+ for (key, val) in pairs {
2314− .flatten();
1246+ let mut sub = HashMap::new();
2315− match existing {
1247+ flatten_json(key, val, &mut sub);
2316− Some(class) => {
1248+ for (k, v) in sub {
2317− let mut value = class.value.take().unwrap_or_default();
1249+ ctx.variables.insert(k, v);
2318− for name in attr.value.as_deref().unwrap_or("").split_whitespace() {
2319− if !value.split_whitespace().any(|c| c == name) {
2320− value.push(' ');
2321− value.push_str(name);
2322− }
2323 }
1250 }
2324− class.value = Some(value.trim().to_string());
2325 }
1251 }
2326− None => root.attrs.push(attr),
1252+ } else {
1253+ ctx.diagnostics.push(Diagnostic {
1254+ line: line_num,
1255+ column: None,
1256+ message: "@data without prefix requires a JSON object at top level"
1257+ .to_string(),
1258+ severity: Severity::Error,
1259+ source_line: Some(content.clone()),
1260+ });
2327 }
1261 }
2328− }
1262+ } else {
2329− // The scoped `@style` goes into the page once, when the
1263+ let mut sub = HashMap::new();
2330− // function is first called
1264+ flatten_json(&prefix, &json, &mut sub);
2331− if let Some((_, css)) = &fn_def.scoped
1265+ for (k, v) in sub {
2332− && !css.is_empty()
1266+ ctx.variables.insert(k, v);
2333− && !ctx.scoped_css.contains(css)
2334− {
2335− ctx.scoped_css.push(css.clone());
2336− }
2337− if let Some(class) = scope_class {
2338− match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
2339− Some(attr) => {
2340− let existing = attr.value.take().unwrap_or_default();
2341− attr.value =
2342− Some(format!("{} {}", existing, class).trim().to_string());
2343− }
2344− None => root.attrs.push(Attribute {
2345− key: "class".to_string(),
2346− value: Some(class),
2347− html: true,
2348− quoted: None,
2349− }),
2350 }
1267 }
2351 }
1268 }
2352 }
1269 }
2353− None => {
1270+ Err(detail) => {
2354− if !forwarded.is_empty() {
1271+ ctx.diagnostics.push(Diagnostic {
2355− problems.push(format!(
1272+ line: line_num,
2356− "attributes {} on @{} are not parameters, and its body has no single \
1273+ column: None,
2357− root element to receive them",
1274+ message: format!("invalid JSON in '{}': {}", filename, detail),
2358− forwarded
1275+ severity: Severity::Error,
2359− .iter()
1276+ source_line: Some(content.clone()),
2360− .map(|a| format!("'{}'", a.key))
1277+ });
2361− .collect::<Vec<_>>()
2362− .join(", "),
2363− name
2364− ));
2365− }
2366− if scope_class.is_some() {
2367− problems.push(format!(
2368− "@{} has a scoped @style, but its body has no single root element \
2369− to scope it to",
2370− name
2371− ));
2372− }
2373 }
1278 }
2374 }
1279 }
2375− for message in problems {
1280+
2376− ctx.diagnostics.push(
1281+ ctx.included_files.push(resolved);
2377− Diagnostic::error(code::NO_SINGLE_ROOT, line_num, message)
1282+ return Ok(None);
2378− .source(content)
2379− .subject(name),
2380− );
2381− }
2382 }
1283 }
2383
1284
2384− Ok(result_nodes)
2385− }
2386−}
2387
1285
2388−/// A `@name` read where it is written: a built-in element, or a call.
1286+ // --- @if / @else ---
2389−enum Resolved {
2390− Element(Element),
2391− Call(Box<Call>),
2392−}
2393
1287
2394−/// A call of a function, with its arguments evaluated where it is written.
1288+ if let Some(rest) = content.strip_prefix("@if ") {
2395−struct Call {
1289+ let result = ctx.condition(rest.trim(), line_num);
2396− name: String,
2397− function: Rc<FnDef>,
2398− args: Vec<Attribute>,
2399− /// The value of each argument that passes a parameter, with its type
2400− /// (see [`ParseContext::slot_value`]), in the order of `args`.
2401− values: Vec<Option<Value>>,
2402− /// Text after the attributes, which is content.
2403− text: Option<syntax::Text>,
2404− /// Parameters written `name=value`, already reported.
2405− written_form: Vec<String>,
2406− /// The keys written in the call's own list, not from a bundle.
2407− written_keys: Vec<String>,
2408− line: usize,
2409− /// The column of the call's name and the line it is on, for a missing
2410− /// parameter.
2411− name_at: Option<(usize, String)>,
2412− /// The line as written, for diagnostics.
2413− source: String,
2414−}
2415
1290
2416−/// What `@name` is where it is written: a call of the function `name`
1291+ // Collect then-body lines
2417−/// when one is defined, else the built-in element. A whole line, each link
1292+ let mut then_lines = Vec::new();
2418−/// of a chain and an inline element in text all resolve here.
1293+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
2419−fn resolve(
1294+ then_lines.push(self.lines[self.pos].clone());
2420− head: &syntax::Head,
1295+ self.pos += 1;
2421− text: Option<&syntax::Text>,
1296+ }
2422− line: usize,
2423− source: &str,
2424− ctx: &mut ParseContext,
2425−) -> Result<Resolved, ParseError> {
2426− let Some(function) = ctx.env.function(&head.name).cloned() else {
2427− return parse_single_element(head, text, line, ctx).map(Resolved::Element);
2428− };
2429− ctx.used_functions.insert(head.name.clone());
2430− let params: Vec<String> = function.params.iter().map(|p| p.name.clone()).collect();
2431− let written = head.attrs.as_ref().map_or(&[][..], |list| &list.attrs[..]);
2432− let written_form = written_parameter_forms(&head.name, &function, written, line, ctx);
2433− let written_keys = written.iter().map(|a| a.key.clone()).collect();
2434− // Parameters are bound by name; every other attribute goes to the
2435− // root element, and is checked like any attribute
2436− let (args, values) = parse_attrs(written, line, ctx, true, &params)
2437− .into_iter()
2438− .unzip();
2439− let name_at = name_column(head, line).zip(match &ctx.current_source {
2440− (current, Some(text)) if *current == line => Some(text.clone()),
2441− _ => None,
2442− });
2443− Ok(Resolved::Call(Box::new(Call {
2444− name: head.name.clone(),
2445− function,
2446− args,
2447− values,
2448− text: text.cloned(),
2449− written_form,
2450− written_keys,
2451− line,
2452− name_at,
2453− source: source.to_string(),
2454− })))
2455−}
2456
1297
2457−/// A line of another file (the standard library, an included file) means
1298+ // Build branches: [(condition_result, body_lines), ...]
2458−/// nothing in this one: the problems reported since `before` while
1299+ let mut branches: Vec<(bool, Vec<Line>)> = vec![(result, then_lines)];
2459−/// evaluating the function (its body, a default) are moved to the call.
2460−fn point_at_call(
2461− ctx: &mut ParseContext,
2462− before: usize,
2463− function: &FnDef,
2464− name: &str,
2465− line: usize,
2466− source: &str,
2467−) {
2468− if function.file == ctx.current_file {
2469− return;
2470− }
2471− let defined_in = function
2472− .file
2473− .as_deref()
2474− .unwrap_or("the file being compiled");
2475− for d in &mut ctx.diagnostics[before..] {
2476− d.message = format!(
2477− "{}\n in @{} (line {} of {})",
2478− d.message, name, d.line, defined_in
2479− );
2480− d.line = line;
2481− d.column = None;
2482− d.subject = None;
2483− d.suggestion = None;
2484− d.source_line = Some(source.into());
2485− }
2486−}
2487
1300
2488−/// A call nested deeper than [`MAX_CALL_DEPTH`].
1301+ // Check for @else if / @else chains at same indent
2489−fn too_deep(name: &str, stack: &[String], line: usize) -> Diagnostic {
1302+ loop {
2490− let message = if stack.iter().any(|n| n == name) {
1303+ if self.pos >= self.lines.len() || self.lines[self.pos].indent != current_indent {
2491− format!(
1304+ break;
2492− "recursive function call to @{} goes more than {} calls deep: a function that \
1305+ }
2493− calls itself needs a condition that stops it (`@if`)",
1306+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
2494− name, MAX_CALL_DEPTH
1307+ let trimmed = s.trim();
2495− )
1308+ if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
2496− } else {
1309+ self.pos += 1; // consume @else if
2497− format!(
1310+ let cond_result =
2498− "function calls nest more than {} deep at @{} (starting with {})",
1311+ ctx.condition(else_if_cond.trim(), self.lines[self.pos - 1].line_num);
2499− MAX_CALL_DEPTH,
1312+ let mut body = Vec::new();
2500− name,
1313+ while self.pos < self.lines.len()
2501− stack
1314+ && self.lines[self.pos].indent > current_indent
2502− .iter()
1315+ {
2503− .take(3)
1316+ body.push(self.lines[self.pos].clone());
2504− .map(|n| format!("@{}", n))
1317+ self.pos += 1;
2505− .collect::<Vec<_>>()
1318+ }
2506− .join(" → ")
1319+ branches.push((cond_result, body));
2507− )
1320+ } else if trimmed == "@else" {
2508− };
1321+ self.pos += 1; // consume @else
2509− Diagnostic::error(code::RECURSIVE_CALL, line, message).subject(name)
1322+ let mut body = Vec::new();
2510−}
1323+ while self.pos < self.lines.len()
2511−
1324+ && self.lines[self.pos].indent > current_indent
2512−/// Mark what a function's body wrote as the call's: it gets the call's
1325+ {
2513−/// line, so what is reported about it points at the call, and the name of
1326+ body.push(self.lines[self.pos].clone());
2514−/// the function, unless a call inside the body already claimed it.
1327+ self.pos += 1;
2515−fn belongs_to_call(nodes: &mut [Node], function: &str, line: usize) {
1328+ }
2516− fn claim(element: &mut Element, function: &str, line: usize) {
1329+ // @else is always true (fallback)
2517− element.line_num = line;
1330+ branches.push((true, body));
2518− element.function.get_or_insert_with(|| function.to_string());
1331+ break;
2519− belongs_to_call(&mut element.children, function, line);
1332+ } else {
2520− }
1333+ break;
2521− for node in nodes {
2522− match node {
2523− Node::Element(element) => claim(element, function, line),
2524− Node::Text(segments) => {
2525− for segment in segments {
2526− if let TextSegment::Inline(element) = segment {
2527− claim(element, function, line);
2528 }
1334 }
1335+ } else {
1336+ break;
2529 }
1337 }
2530 }
1338 }
2531− Node::Raw(_) => {}
1339+
1340+ // Pick the first branch whose condition is true
1341+ let body_lines = branches
1342+ .into_iter()
1343+ .find(|(cond, _)| *cond)
1344+ .map(|(_, lines)| lines)
1345+ .unwrap_or_default();
1346+
1347+ if body_lines.is_empty() {
1348+ return Ok(None);
1349+ }
1350+
1351+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1352+ let adjusted: Vec<Line> = body_lines
1353+ .iter()
1354+ .map(|l| Line {
1355+ indent: l.indent - min_indent,
1356+ content: l.content.clone(),
1357+ line_num: l.line_num,
1358+ })
1359+ .collect();
1360+
1361+ // Scope variables: @let inside @if doesn't leak out
1362+ let saved_vars = ctx.variables.clone();
1363+ let mut body_parser = Parser {
1364+ lines: adjusted,
1365+ pos: 0,
1366+ };
1367+ let nodes = body_parser.parse_children(0, ctx);
1368+ ctx.variables = saved_vars;
1369+ return Ok(Some(nodes));
2532 }
1370 }
2533− }
2534−}
2535
1371
2536−/// What an inline `{@name ...}` stands for in its line of text: an
1372+
2537−/// element; the text of a function whose body is text; or, for a body with
1373+ if content.trim() == "@else" || content.trim().starts_with("@else if ") {
2538−/// several roots, its nodes as a fragment.
1374+ return Err(ParseError {
2539−fn inline_segments(mut nodes: Vec<Node>, line: usize) -> Vec<TextSegment> {
1375+ line: line_num,
2540− if let [Node::Element(_)] = nodes.as_slice()
1376+ message: "@else without matching @if".to_string(),
2541− && let Some(Node::Element(element)) = nodes.pop()
1377+ });
2542− {
1378+ }
2543− return vec![TextSegment::Inline(element)];
1379+
2544− }
1380+ // --- @each loop ---
2545− if nodes.iter().all(|n| matches!(n, Node::Text(_))) {
1381+
2546− let mut segments = Vec::new();
1382+ if let Some(rest) = content.strip_prefix("@each ") {
2547− for node in nodes {
1383+ let rest = rest.trim();
2548− if let Node::Text(run) = node {
1384+ // Support: @each $var in list OR @each $var, $index in list
2549− if !segments.is_empty() {
1385+ // OR @each $name, $url in Alice /alice, Bob /bob (destructuring)
2550− segments.push(TextSegment::Plain(" ".to_string()));
1386+ let (var_names, list_str) = if let Some((before_in, after_in)) = rest.split_once(" in ")
1387+ {
1388+ let before_in = before_in.trim();
1389+ let vars: Vec<String> = before_in
1390+ .split(',')
1391+ .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
1392+ .collect();
1393+ (vars, after_in.trim().to_string())
1394+ } else {
1395+ return Err(ParseError {
1396+ line: line_num,
1397+ message: "@each requires: @each $var in list".to_string(),
1398+ });
1399+ };
1400+ let var_name = var_names[0].clone();
1401+ let index_var = var_names.get(1).cloned();
1402+
1403+ // Strip a trailing `[page N]` pagination suffix so it doesn't end
1404+ // up in the last item; it's parsed from the raw line below.
1405+ let list_str = match list_str.rfind("[page ") {
1406+ Some(pos) if list_str.trim_end().ends_with(']') => {
1407+ list_str[..pos].trim_end().to_string()
1408+ }
1409+ _ => list_str,
1410+ };
1411+ track_var_refs(&list_str, &mut ctx.used_variables);
1412+ let list_str = substitute_vars(&list_str, &ctx.variables);
1413+ // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
1414+ let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
1415+ let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
1416+ (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
1417+ } else {
1418+ (rest.trim(), 1i64)
1419+ };
1420+ if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
1421+ {
1422+ numeric_range(start, end, step)
1423+ } else {
1424+ list_str
1425+ .split(',')
1426+ .map(|s| s.trim().to_string())
1427+ .filter(|s| !s.is_empty())
1428+ .collect()
2551 }
1429 }
2552− segments.extend(run);
1430+ } else {
1431+ list_str
1432+ .split(',')
1433+ .map(|s| s.trim().to_string())
1434+ .filter(|s| !s.is_empty())
1435+ .collect()
1436+ };
1437+
1438+ // Pagination: check for [page N] or [page N per P] suffix
1439+ // Parse from the original rest before substitution
1440+ let (items, pagination_info) = {
1441+ // Check if list_str ended with a page directive (already consumed in items)
1442+ // Instead, check the raw rest for [page ...] syntax
1443+ let raw_rest = rest;
1444+ let page_size = if let Some(page_pos) = raw_rest.find("[page ") {
1445+ let after = &raw_rest[page_pos + 6..];
1446+ if let Some(close) = after.find(']') {
1447+ let page_spec = after[..close].trim();
1448+ page_spec.parse::<usize>().ok()
1449+ } else {
1450+ None
1451+ }
1452+ } else {
1453+ None
1454+ };
1455+
1456+ if let Some(size) = page_size {
1457+ let current_page: usize = ctx
1458+ .variables
1459+ .get("_page")
1460+ .and_then(|v| v.parse().ok())
1461+ .unwrap_or(1)
1462+ .max(1);
1463+ let size = size.max(1);
1464+ let total_items = items.len();
1465+ let total_pages = total_items.div_ceil(size);
1466+ let start = (current_page - 1).saturating_mul(size);
1467+ let end = start.saturating_add(size).min(total_items);
1468+ let page_items: Vec<String> = if start < total_items {
1469+ items[start..end].to_vec()
1470+ } else {
1471+ Vec::new()
1472+ };
1473+ (
1474+ page_items,
1475+ Some((current_page, total_pages, size, total_items)),
1476+ )
1477+ } else {
1478+ (items, None)
1479+ }
1480+ };
1481+
1482+ // Collect body lines
1483+ let mut body_lines = Vec::new();
1484+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1485+ body_lines.push(self.lines[self.pos].clone());
1486+ self.pos += 1;
2553 }
1487 }
2554− }
2555− return segments;
2556− }
2557− vec![TextSegment::Inline(Element {
2558− kind: ElementKind::Fragment,
2559− attrs: Vec::new(),
2560− argument: None,
2561− children: nodes,
2562− line_num: line,
2563− function: None,
2564− })]
2565−}
2566
1488
2567−/// Fill in the default of a parameter that a call leaves out, like the
1489+ // Check for @else block (empty-state fallback)
2568−/// value of an attribute passed for it: variables are filled in, one
1490+ let mut else_lines = Vec::new();
2569−/// `$name` keeps its type and one `"..."` is quoted text. It sees what the
1491+ if self.pos < self.lines.len()
2570−/// body sees: the names visible where the function is defined, and the
1492+ && self.lines[self.pos].indent == current_indent
2571−/// parameters before it. Its problems are reported at the definition, once.
1493+ && let LineContent::Normal(ref s) = self.lines[self.pos].content
2572−fn fill_default(
1494+ && s.trim() == "@else"
2573− fn_def: &FnDef,
1495+ {
2574− param: &syntax::Param,
1496+ self.pos += 1; // consume @else
2575− default: &str,
1497+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
2576− ctx: &mut ParseContext,
1498+ else_lines.push(self.lines[self.pos].clone());
2577−) -> Value {
1499+ self.pos += 1;
2578− // A default that uses a later parameter is an error in the
1500+ }
2579− // definition; the later one isn't bound yet, so don't report it again
1501+ }
2580− let later = fn_def
2581− .params
2582− .iter()
2583− .skip_while(|p| p.name != param.name)
2584− .skip(1);
2585− let names = interp::names(default);
2586− if later.clone().any(|p| names.contains(&p.name.as_str())) {
2587− return Value::empty();
2588− }
2589− let line = param.span.line;
2590− let at_head = line == fn_def.line && fn_def.source.is_some();
2591− let column =
2592− at_head.then(|| param.span.column + (param.span.end - param.span.start) - default.len());
2593− let caller = (
2594− std::mem::replace(
2595− &mut ctx.current_source,
2596− (fn_def.line, fn_def.source.clone()),
2597− ),
2598− std::mem::replace(&mut ctx.current_node, fn_def.node),
2599− );
2600− let (value, _) = ctx.slot_value(default, line, column);
2601− (ctx.current_source, ctx.current_node) = caller;
2602− value
2603−}
2604
1502
2605−/// Read `@page`'s attribute list and title. The page is the root element,
1503+ if body_lines.is_empty() {
2606−/// and its attributes are checked like any element's: `key=value` (and a
1504+ return Ok(None);
2607−/// bare boolean HTML attribute) go on `<html>`, styles on `<body>`, and
2608−/// `favicon FILE` is its one word of htmlang's own.
2609−fn read_page(
2610− attrs: Option<&syntax::AttrList>,
2611− title: Option<&syntax::Arg>,
2612− line: usize,
2613− ctx: &mut ParseContext,
2614−) -> Page {
2615− let mut page = Page::default();
2616− if let Some(list) = attrs {
2617− let words: Vec<String> = crate::vocab::PAGE_WORDS
2618− .iter()
2619− .map(|w| w.to_string())
2620− .collect();
2621− ctx.in_page = true;
2622− let read = parse_attr_list(&list.attrs, line, ctx, true, &words);
2623− ctx.in_page = false;
2624− // How many of each name have been read, to find where the next
2625− // one is written
2626− let mut seen: HashMap<String, usize> = HashMap::new();
2627− for attr in read {
2628− let boolean = attr.value.is_none() && crate::vocab::is_html_flag(&attr.key);
2629− // Where it is written, when it is written in the list itself
2630− let nth = seen.entry(attr.key.clone()).or_default();
2631− let column = list
2632− .attrs
2633− .iter()
2634− .filter(|token| token.key == attr.key)
2635− .nth(*nth)
2636− .map(|token| token.span.column);
2637− *nth += 1;
2638− let value = attr
2639− .quoted
2640− .as_ref()
2641− .map(|q| q.text.clone())
2642− .or(attr.value.clone());
2643− if !check_page_attr(&attr.key, attr.html, value.as_deref(), line, column, ctx) {
2644− continue;
2645 }
1505 }
2646− if words.contains(&attr.key) {
1506+
2647− if page.favicon.is_some() {
1507+ // If list is empty, render @else body
2648− let diagnostic = Diagnostic::warning(
1508+ if items.is_empty() {
2649− code::DUPLICATE_ATTRIBUTE,
1509+ if else_lines.is_empty() {
2650− line,
1510+ return Ok(None);
2651− "duplicate attribute 'favicon': the later one wins".to_string(),
2652− )
2653− .subject("favicon");
2654− ctx.diagnostics.push(at_attribute(diagnostic, column, ctx));
2655 }
1511 }
2656− // The file itself goes into the page when it can be read
1512+ let min_indent = else_lines.iter().map(|l| l.indent).min().unwrap_or(0);
2657− // (relative to the file); otherwise it is linked
1513+ let adjusted: Vec<Line> = else_lines
2658− page.favicon = value.map(|path| match std::fs::read(ctx.resolve(&path)) {
1514+ .iter()
2659− Ok(data) => crate::codegen::data_uri(&path, &data),
1515+ .map(|l| Line {
2660− Err(_) => path,
1516+ indent: l.indent - min_indent,
2661− });
1517+ content: l.content.clone(),
2662− } else if attr.html || boolean {
1518+ line_num: l.line_num,
2663− page.html_attrs.push(attr);
1519+ })
2664− } else {
1520+ .collect();
2665− page.styles.push(attr);
1521+ let mut body_parser = Parser {
1522+ lines: adjusted,
1523+ pos: 0,
1524+ };
1525+ let saved_vars = ctx.variables.clone();
1526+ let nodes = body_parser.parse_children(0, ctx);
1527+ ctx.variables = saved_vars;
1528+ return Ok(Some(nodes));
2666 }
1529 }
2667− }
2668− }
2669− page.title = match title {
2670− Some(title) => ctx.interpolate_text(&title.raw, title.span.line, Some(title.span.column)),
2671− None => String::new(),
2672− };
2673− page
2674−}
2675
1530
2676−/// `@page`'s own rules for one of its attributes, checked where the
1531+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
2677−/// `@page` runs and where it doesn't (a layout that isn't called): its
1532+ let adjusted: Vec<Line> = body_lines
2678−/// word is written `favicon FILE`, and `inline` belongs to `@image`.
1533+ .iter()
2679−/// Returns whether the attribute can be used (a `favicon` with its file,
1534+ .map(|l| Line {
2680−/// or any other attribute but `inline`).
1535+ indent: l.indent - min_indent,
2681−fn check_page_attr(
1536+ content: l.content.clone(),
2682− key: &str,
1537+ line_num: l.line_num,
2683− html: bool,
1538+ })
2684− value: Option<&str>,
1539+ .collect();
2685− line: usize,
1540+
2686− column: Option<usize>,
1541+ let saved_vars = ctx.variables.clone();
2687− ctx: &mut ParseContext,
1542+ let mut all_nodes = Vec::new();
2688−) -> bool {
1543+
2689− let report = |ctx: &mut ParseContext, diagnostic: Diagnostic| {
1544+ // Inject pagination variables if pagination is active
2690− let diagnostic = at_attribute(diagnostic, column, ctx);
1545+ if let Some((page, total_pages, page_size, total_items)) = pagination_info {
2691− ctx.diagnostics.push(diagnostic);
1546+ ctx.variables.insert("_page".to_string(), page.to_string());
2692− };
1547+ ctx.variables
2693− if crate::vocab::PAGE_WORDS.contains(&key) {
1548+ .insert("_total_pages".to_string(), total_pages.to_string());
2694− if html {
1549+ ctx.variables
2695− report(
1550+ .insert("_page_size".to_string(), page_size.to_string());
2696− ctx,
1551+ ctx.variables
2697− Diagnostic::error(
1552+ .insert("_total_items".to_string(), total_items.to_string());
2698− code::PARAMETER_FORM,
1553+ }
2699− line,
1554+
2700− format!(
1555+ let has_extra_vars = var_names.len() > 2
2701− "'{0}' is @page's own word, not an HTML attribute: write `{0} {1}`",
1556+ || (var_names.len() == 2 && items.first().is_some_and(|it| it.contains(' ')));
2702− key,
1557+
2703− value.filter(|v| !v.is_empty()).unwrap_or("FILE")
1558+ for (i, item) in items.iter().enumerate() {
2704− ),
1559+ // Always expose $_index for the current iteration
2705− )
1560+ ctx.variables.insert("_index".to_string(), i.to_string());
2706− .subject(format!("{}=", key))
1561+ if has_extra_vars {
2707− .suggest(Some(format!("{} ", key))),
1562+ // Destructuring: split item by spaces and assign to each variable
2708− );
1563+ let parts: Vec<&str> = item.splitn(var_names.len(), ' ').collect();
2709− }
1564+ for (vi, vn) in var_names.iter().enumerate() {
2710− if value.is_none_or(|v| v.trim().is_empty()) {
1565+ let val = parts.get(vi).unwrap_or(&"").to_string();
2711− report(
1566+ ctx.variables.insert(vn.clone(), val);
2712− ctx,
1567+ }
2713− Diagnostic::error(
1568+ } else {
2714− code::MISSING_VALUE,
1569+ ctx.variables.insert(var_name.clone(), item.clone());
2715− line,
1570+ if let Some(ref idx_name) = index_var {
2716− format!("'{0}' needs its file: `{0} favicon.png`", key),
1571+ ctx.variables.insert(idx_name.clone(), i.to_string());
2717− )
1572+ }
2718− .subject(key),
1573+ }
2719− );
1574+ let mut body_parser = Parser {
2720− return false;
1575+ lines: adjusted.clone(),
2721− }
1576+ pos: 0,
2722− return true;
1577+ };
2723− }
1578+ let nodes = body_parser.parse_children(0, ctx);
2724− if !html && crate::vocab::base_attribute(key) == "inline" {
1579+ all_nodes.extend(nodes);
2725− report(
1580+ }
2726− ctx,
2727− Diagnostic::error(
2728− code::UNEXPECTED_ARGUMENT,
2729− line,
2730− "`inline` puts an image's file into the page, and @page has none: it only \
2731− goes on @image"
2732− .to_string(),
2733− )
2734− .subject("inline"),
2735− );
2736− return false;
2737− }
2738− true
2739−}
2740
1581
2741−/// A parameter passed `name=value`, the form of an HTML attribute.
1582+ ctx.variables = saved_vars;
2742−fn parameter_form(function: &str, key: &str, value: &str, line: usize) -> Diagnostic {
1583+ return Ok(Some(all_nodes));
2743− Diagnostic::error(
1584+ }
2744− code::PARAMETER_FORM,
2745− line,
2746− format!(
2747− "'{}' is a parameter of @{}: parameters are written `name value`, so write `{} {}`",
2748− key, function, key, value
2749− ),
2750− )
2751− .subject(format!("{}=", key))
2752− .suggest(Some(format!("{} ", key)))
2753−}
2754
1585
2755−/// A call that leaves out a parameter without a default.
2756−fn missing_parameter(
2757− function: &str,
2758− param: &str,
2759− line: usize,
2760− column: Option<usize>,
2761−) -> Diagnostic {
2762− let diagnostic = Diagnostic::error(
2763− code::MISSING_PARAMETER,
2764− line,
2765− format!(
2766− "@{} needs '{}': the parameter has no default, so pass it as `{} VALUE`",
2767− function, param, param
2768− ),
2769− )
2770− .subject(param);
2771− match column {
2772− Some(column) => diagnostic.column(column),
2773− None => diagnostic,
2774− }
2775−}
2776
1586
2777−/// The column of a call's `@name`, when it is on the call's line.
2778−fn name_column(head: &syntax::Head, line: usize) -> Option<usize> {
2779− (head.name_span.line == line).then_some(head.name_span.column)
2780−}
2781
1587
2782−/// Define `$name`, or, for `--name`, the CSS custom property on `:root`
1588+ // --- @warn / @debug ---
2783−/// (which gets quoted text with its quotes, and is read with
1589+
2784−/// `var(--name)`, never as a variable). `@let name.field value` gives the
1590+ if let Some(rest) = content.strip_prefix("@warn ") {
2785−/// record `$name` that field (or the list `$name` a new item at that
1591+ let msg = substitute_vars(rest.trim(), &ctx.variables);
2786−/// index), as a new value in this block.
1592+ ctx.diagnostics.push(Diagnostic {
2787−fn set_variable(name: &str, value: Value, line_num: usize, ctx: &mut ParseContext) {
1593+ line: line_num,
2788− if name.starts_with("--") {
1594+ column: None,
2789− let css = value
1595+ message: msg,
2790− .quoted_css()
1596+ severity: Severity::Warning,
2791− .map_or_else(|| value.to_string(), str::to_string);
1597+ source_line: Some(content.clone()),
2792− let at_line = |diagnostic: Diagnostic, ctx: &ParseContext| match &ctx.current_source {
1598+ });
2793− (current, Some(text)) if *current == line_num => diagnostic.source(text.clone()),
1599+ return Ok(None);
2794− _ => diagnostic,
2795− };
2796− // `:root` is one place for the whole page: a declaration inside a
2797− // block (an element, a loop, a function's body) would not be
2798− // kept to it, and each run would replace the last
2799− if ctx.env.frames.len() > FILE_FRAMES {
2800− let diagnostic = Diagnostic::error(
2801− code::INVALID_DEFINITION,
2802− line_num,
2803− format!(
2804− "`@let {name}` declares {name} on :root, for the whole page, so it goes at \
2805− the top level of the file: to set it on an element and what is inside \
2806− it, write `[{name} value]` on that element"
2807− ),
2808− )
2809− .subject(name);
2810− ctx.push_once(at_line(diagnostic, ctx));
2811− return;
2812 }
1600 }
2813− match css_breakout(&css) {
1601+
2814− Some(reason) => {
1602+
2815− let diagnostic = Diagnostic::error(
1603+ // --- @keyframes directive ---
2816− code::INVALID_VALUE,
1604+
2817− line_num,
1605+ if let Some(rest) = content.strip_prefix("@keyframes ") {
2818− format!(
1606+ let name = rest.trim().to_string();
2819− "the value of '{}' has {}: `{}` can't be written into the page's CSS",
1607+ if name.is_empty() {
2820− name, reason, css
1608+ return Err(ParseError {
2821− ),
1609+ line: line_num,
2822− )
1610+ message: "@keyframes requires a name".to_string(),
2823− .subject(css.as_str());
1611+ });
2824− ctx.push_once(at_line(diagnostic, ctx));
2825 }
1612 }
2826− None => match ctx.css_vars.iter_mut().find(|(n, _)| n == name) {
1613+ // Collect body lines (indented deeper)
2827− Some(declared) => {
1614+ let mut body = String::new();
2828− let diagnostic = Diagnostic::warning(
1615+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
2829− code::DUPLICATE_ATTRIBUTE,
1616+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
2830− line_num,
1617+ let trimmed = s.trim();
2831− format!(
1618+ // Support htmlang-style: from [opacity 0] / to [opacity 1] / 50% [transform scale(1.5)]
2832− "{name} is declared again: :root has one value for the whole page, \
1619+ if let Some(kf_css) = parse_keyframe_line(trimmed) {
2833− so every element gets this one, also above this line (a theme \
1620+ body.push_str(&kf_css);
2834− redefines a token on an element: `[dark:{name} {css}]`)"
1621+ } else {
2835− ),
1622+ body.push_str(trimmed);
2836− )
2837− .subject(name);
2838− declared.1 = css;
2839− ctx.push_once(at_line(diagnostic, ctx));
2840− }
2841− None => ctx.css_vars.push((name.to_string(), css)),
2842− },
2843− }
2844− return;
2845− }
2846− let mut path = name.split('.');
2847− let root = path.next().unwrap_or(name);
2848− let fields: Vec<&str> = path.collect();
2849− let value = match fields.is_empty() {
2850− true => value,
2851− false => match with_field(ctx.env.value(root).cloned(), root, &fields, value) {
2852− Ok(value) => value,
2853− Err(message) => {
2854− let diagnostic =
2855− Diagnostic::error(code::INVALID_DEFINITION, line_num, message).subject(name);
2856− let diagnostic = match &ctx.current_source {
2857− (current, Some(text)) if *current == line_num => {
2858− diagnostic.source(text.clone())
2859 }
1623 }
2860− _ => diagnostic,
2861− };
2862− ctx.push_once(diagnostic);
2863− // Reported: the name keeps what it held
2864− ctx.let_lines.entry(root.to_string()).or_insert(line_num);
2865− return;
2866− }
2867− },
2868− };
2869− ctx.bind(root, value);
2870− ctx.let_lines.entry(root.to_string()).or_insert(line_num);
2871−}
2872−
2873−/// `value` (the value of `$name`) with the field at `path` set to `new`:
2874−/// a record's field, or the item of a list at an index it has. A name
2875−/// without a value, or one whose value is empty (a field a record doesn't
2876−/// have), becomes a record; any other value has no fields to set.
2877−fn with_field(
2878− value: Option<Value>,
2879− name: &str,
2880− path: &[&str],
2881− new: Value,
2882−) -> Result<Value, String> {
2883− let Some((field, rest)) = path.split_first() else {
2884− return Ok(new);
2885− };
2886− let inner = format!("{}.{}", name, field);
2887− match value {
2888− Some(Value::Record(fields)) => {
2889− let mut fields = fields.as_ref().clone();
2890− match fields.iter().position(|(key, _)| key == field) {
2891− Some(i) => {
2892− let old = std::mem::replace(&mut fields[i].1, Value::empty());
2893− fields[i].1 = with_field(Some(old), &inner, rest, new)?;
2894 }
1624 }
2895− None => fields.push((field.to_string(), with_field(None, &inner, rest, new)?)),
1625+ self.pos += 1;
2896 }
1626 }
2897− Ok(Value::Record(Rc::new(fields)))
1627+ ctx.keyframes.push((name, body));
2898− }
1628+ return Ok(None);
2899− Some(Value::List(list)) => {
2900− let count = list.items.len();
2901− let Some(i) = field.parse::<usize>().ok().filter(|&i| i < count) else {
2902− return Err(format!(
2903− "`${}` is {}, so `.{}` isn't one of its items (they are `.0` to `.{}`)",
2904− name,
2905− Value::List(list).describe(),
2906− field,
2907− count.saturating_sub(1)
2908− ));
2909− };
2910− let mut items = list.items.as_ref().clone();
2911− let old = std::mem::replace(&mut items[i], Value::empty());
2912− items[i] = with_field(Some(old), &inner, rest, new)?;
2913− // Its items changed, so it no longer prints as written
2914− Ok(Value::list(items))
2915 }
1629 }
2916− None => Ok(Value::Record(Rc::new(vec![(
2917− field.to_string(),
2918− with_field(None, &inner, rest, new)?,
2919− )]))),
2920− Some(value) if value.to_string().is_empty() => with_field(None, name, path, new),
2921− Some(value) => Err(format!(
2922− "`@let {}.{}` sets a field of a record, but `${}` is {} (`{}`), which has no fields",
2923− name,
2924− path.join("."),
2925− name,
2926− value.describe(),
2927− value
2928− )),
2929− }
2930−}
2931
1630
2932−/// What a verbatim directive holds (`@head`, `@style`, `@raw`): the rest
2933−/// of its line (`@style .a { color: red }`), or else its indented block.
2934−/// The two together are a syntax error, reported by the tree; the line
2935−/// wins.
2936−fn verbatim_content(node: &syntax::Node) -> String {
2937− match node.directive().map(|d| &d.args) {
2938− Some(DirectiveArgs::Text(Some(line))) => format!("{}\n", line.raw),
2939− _ => verbatim_text(&node.children),
2940− }
2941−}
2942
1631
2943−/// The text of a verbatim body (`@head`, `@style`, `@markdown`, `@raw`).
1632+ // --- @extends (template inheritance) ---
2944−fn verbatim_text(children: &[syntax::Node]) -> String {
2945− let mut text = String::new();
2946− for child in children {
2947− if let NodeKind::Verbatim(body) = &child.kind {
2948− text.push_str(&body.text);
2949− text.push('\n');
2950− }
2951− }
2952− text
2953−}
2954
1633
2955−// ---------------------------------------------------------------------------
1634+ if let Some(rest) = content.strip_prefix("@extends ") {
2956−// @children replacement
1635+ let filename = substitute_vars(rest.trim(), &ctx.variables);
2957−// ---------------------------------------------------------------------------
1636+ let resolved = match &ctx.base_path {
1637+ Some(base) => base.join(&filename),
1638+ None => PathBuf::from(&filename),
1639+ };
2958
1640
2959−fn replace_children_and_slots(
1641+ if ctx.include_stack.contains(&resolved) {
2960− nodes: Vec<Node>,
1642+ ctx.diagnostics.push(Diagnostic {
2961− caller_children: &[Node],
1643+ line: line_num,
2962− slot_contents: &HashMap<String, Vec<Node>>,
1644+ column: None,
2963−) -> Vec<Node> {
1645+ message: format!("circular extends '{}'", filename),
2964− let mut result = Vec::new();
1646+ severity: Severity::Error,
2965− for node in nodes {
1647+ source_line: Some(content.clone()),
2966− match node {
1648+ });
2967− Node::Element(elem) if elem.kind == ElementKind::Children => {
1649+ return Ok(None);
2968− if caller_children.is_empty() && !elem.children.is_empty() {
2969− // Use @children's own children as default/fallback content
2970− result.extend(elem.children);
2971− } else {
2972− result.extend(caller_children.iter().cloned());
2973− }
2974 }
1650 }
2975− Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) =>
1651+
2976− {
1652+ let extends_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
2977− if let ElementKind::Slot(ref name) = elem.kind {
1653+ cached.clone()
2978− if let Some(content) = slot_contents.get(name) {
1654+ } else {
2979− result.extend(content.iter().cloned());
1655+ match std::fs::read_to_string(&resolved) {
1656+ Ok(text) => {
1657+ ctx.file_cache.insert(resolved.clone(), text.clone());
1658+ text
2980 }
1659 }
2981− // If no content provided for this slot, use the slot's own children as default
1660+ Err(e) => {
2982− else if !elem.children.is_empty() {
1661+ ctx.diagnostics.push(Diagnostic {
2983− result.extend(elem.children);
1662+ line: line_num,
1663+ column: None,
1664+ message: format!("cannot extend '{}': {}", filename, e),
1665+ severity: Severity::Error,
1666+ source_line: Some(content.clone()),
1667+ });
1668+ return Ok(None);
2984 }
1669 }
2985 }
1670 }
1671+ };
1672+
1673+ // Collect slot blocks defined in the extending file. Everything
1674+ // else becomes the default content, filling the layout's
1675+ // @children.
1676+ let mut slot_contents: HashMap<String, Vec<Line>> = HashMap::new();
1677+ let mut default_lines: Vec<Line> = Vec::new();
1678+ while self.pos < self.lines.len() {
1679+ let line_indent = self.lines[self.pos].indent;
1680+ if line_indent < current_indent {
1681+ break;
1682+ }
1683+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1684+ let trimmed = s.trim();
1685+ if let Some(slot_name) = trimmed.strip_prefix("@slot ") {
1686+ let slot_name = slot_name.trim().to_string();
1687+ self.pos += 1;
1688+ let mut slot_lines = Vec::new();
1689+ while self.pos < self.lines.len()
1690+ && self.lines[self.pos].indent > line_indent
1691+ {
1692+ slot_lines.push(self.lines[self.pos].clone());
1693+ self.pos += 1;
1694+ }
1695+ slot_contents.insert(slot_name, slot_lines);
1696+ continue;
1697+ }
1698+ }
1699+ default_lines.push(self.lines[self.pos].clone());
1700+ self.pos += 1;
2986 }
1701 }
2987− Node::Element(mut elem) => {
1702+ let default_nodes = if default_lines.is_empty() {
2988− elem.children =
1703+ Vec::new()
2989− replace_children_and_slots(elem.children, caller_children, slot_contents);
1704+ } else {
2990− result.push(Node::Element(elem));
1705+ let min_indent = default_lines.iter().map(|l| l.indent).min().unwrap_or(0);
2991− }
1706+ let adjusted: Vec<Line> = default_lines
2992− other => result.push(other),
1707+ .iter()
2993− }
1708+ .map(|l| Line {
2994− }
1709+ indent: l.indent - min_indent,
2995− result
1710+ content: l.content.clone(),
2996−}
1711+ line_num: l.line_num,
1712+ })
1713+ .collect();
1714+ let mut default_parser = Parser {
1715+ lines: adjusted,
1716+ pos: 0,
1717+ };
1718+ default_parser.parse_children(0, ctx)
1719+ };
2997
1720
2998−// ---------------------------------------------------------------------------
1721+ // Parse slot contents into nodes
2999−// Where @slot and @children are written
1722+ let mut slot_nodes: HashMap<String, Vec<Node>> = HashMap::new();
3000−// ---------------------------------------------------------------------------
1723+ for (name, lines) in &slot_contents {
1724+ if lines.is_empty() {
1725+ continue;
1726+ }
1727+ let min_indent = lines.iter().map(|l| l.indent).min().unwrap_or(0);
1728+ let adjusted: Vec<Line> = lines
1729+ .iter()
1730+ .map(|l| Line {
1731+ indent: l.indent - min_indent,
1732+ content: l.content.clone(),
1733+ line_num: l.line_num,
1734+ })
1735+ .collect();
1736+ let mut slot_parser = Parser {
1737+ lines: adjusted,
1738+ pos: 0,
1739+ };
1740+ let nodes = slot_parser.parse_children(0, ctx);
1741+ slot_nodes.insert(name.clone(), nodes);
1742+ }
3001
1743
3002−/// The files each `@include` line brought in, by the line's node id, with
1744+ // Parse the base layout file
3003−/// the include chain that leads to each.
1745+ ctx.included_files.push(resolved.clone());
3004−type IncludedBy = HashMap<usize, Vec<(Rc<Tree>, String)>>;
1746+ ctx.include_stack.push(resolved.clone());
1747+ let saved_base = ctx.base_path.clone();
1748+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
3005
1749
3006−/// The lines of the files an `@include` line brings in, and the include
1750+ let extends_lines = preprocess(&extends_text);
3007−/// chain of each (for diagnostics).
1751+ let mut extends_parser = Parser {
3008−type Included<'a, 'f> = &'f dyn Fn(&syntax::Node) -> Vec<(&'a [syntax::Node], Option<&'a str>)>;
1752+ lines: extends_lines,
1753+ pos: 0,
1754+ };
1755+ let layout_nodes = extends_parser.parse_children(0, ctx);
3009
1756
3010−/// Whether `name` is a function here: defined so far, or anywhere in the
1757+ ctx.base_path = saved_base;
3011−/// file and the files it includes.
1758+ ctx.include_stack.pop();
3012−fn is_function(name: &str, ctx: &ParseContext) -> bool {
3013− ctx.env.function(name).is_some() || ctx.namespace.contains(name)
3014−}
3015
1759
3016−/// Whether `name` is one slot name: letters, digits, `-` and `_`,
1760+ // Fill @slot placeholders and @children in the layout
3017−/// starting with a letter (like a function's name).
1761+ let result_nodes = replace_children_and_slots(layout_nodes, &default_nodes, &slot_nodes);
3018−fn is_slot_name(name: &str) -> bool {
1762+ return Ok(Some(result_nodes));
3019− name.chars().next().is_some_and(char::is_alphabetic)
1763+ }
3020− && name
3021− .chars()
3022− .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
3023−}
3024
1764
3025−/// What a `@slot` or `@children` line is written in.
3026−#[derive(Clone, Copy)]
3027−enum Place<'a> {
3028− /// The top of a file or of a function's body, or a built-in element
3029− /// (by its name).
3030− Element(Option<&'a str>),
3031− /// A call: its content.
3032− Call,
3033−}
3034
1765
3035−/// Where a line is, for [`slot_uses`].
1766+ // --- Function call ---
3036−#[derive(Clone, Copy)]
3037−struct At<'a> {
3038− /// Inside a function's body.
3039− body: bool,
3040− place: Place<'a>,
3041− /// Outside a body: the call (and its line) whose content it is in.
3042− call: Option<(&'a str, usize)>,
3043− /// The include chain of the file it is in, when that isn't the file
3044− /// being compiled.
3045− file: Option<&'a str>,
3046−}
3047
1767
3048−impl At<'_> {
1768+ if content.starts_with('@') {
3049− /// The top of a file, or of a function's body.
1769+ let name = extract_element_name(&content);
3050− fn top(body: bool) -> Self {
1770+ if ctx.functions.contains_key(name) {
3051− At {
1771+ ctx.used_functions.insert(name.to_string());
3052− body,
1772+ let nodes = self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
3053− place: Place::Element(None),
1773+ return Ok(Some(nodes));
3054− call: None,
1774+ }
3055− file: None,
3056 }
1775 }
3057− }
3058−}
3059
1776
3060−/// What [`slot_uses`] knows about the names and files it walks.
1777+ // --- Elements ---
3061−struct Scan<'a, 'f> {
3062− is_function: &'f dyn Fn(&str) -> bool,
3063− included: Included<'a, 'f>,
3064− /// Visit the bodies of the functions defined in the lines too.
3065− into_lets: bool,
3066−}
3067−
3068−/// A `@slot` or `@children` written in a file, and where.
3069−struct SlotUse<'a> {
3070− node: &'a syntax::Node,
3071− head: &'a syntax::Head,
3072− /// The text after it, which is a slot's name; `None` when it isn't
3073− /// the last head of its chain.
3074− text: Option<&'a syntax::Text>,
3075− /// Written inline in a line of text, `{@slot x}`.
3076− inline: bool,
3077− at: At<'a>,
3078−}
3079
1778
3080−impl SlotUse<'_> {
1779+ if content.starts_with('@') || content.starts_with('[') {
3081− fn is_slot(&self) -> bool {
1780+ let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
3082− self.head.name == "slot"
1781+ return Ok(Some(vec![node]));
3083− }
1782+ }
3084
1783
3085− /// The slot's name as written.
1784+ // --- Bare text ---
3086− fn name(&self) -> &str {
3087− self.text.map_or("", |t| t.raw.trim())
3088− }
3089
1785
3090− /// A `@slot NAME` that marks a place in a function's body (not one
1786+ let var_warnings =
3091− /// that fills a slot of a call).
1787+ check_undefined_vars(&content, &ctx.variables, line_num, current_indent);
3092− fn declares(&self) -> bool {
1788+ ctx.diagnostics.extend(var_warnings);
3093− self.is_slot() && !self.inline && !matches!(self.at.place, Place::Call)
1789+ track_var_refs(&content, &mut ctx.used_variables);
1790+ let segments = parse_text_segments(&content, ctx);
1791+ Ok(Some(vec![Node::Text(segments)]))
3094 }
1792 }
3095−}
3096
1793
3097−/// Where each `@slot` and `@children` in `block` is written. A `@slot`
1794+ /// `@data $name dir/*.json`: load every matching file as
3098−/// directly under a call (under its `@if`, `@else` and `@each` too) fills
1795+ /// `$name.STEM.key`, list the stems in `$name` and count them.
3099−/// one of its slots; anywhere else in a function's body it declares one.
1796+ fn load_data_glob(
3100−/// An included file's lines are where its `@include` is.
1797+ &mut self,
3101−fn slot_uses<'a>(
1798+ name: &str,
3102− block: &'a [syntax::Node],
1799+ pattern: &str,
3103− at: At<'a>,
1800+ line_num: usize,
3104− scan: &Scan<'a, '_>,
1801+ content: &str,
3105− out: &mut Vec<SlotUse<'a>>,
1802+ ctx: &mut ParseContext,
3106−) {
3107− fn inline<'a>(
3108− text: &'a syntax::Text,
3109− node: &'a syntax::Node,
3110− at: At<'a>,
3111− scan: &Scan<'a, '_>,
3112− out: &mut Vec<SlotUse<'a>>,
3113 ) {
1803 ) {
3114− for segment in &text.segments {
1804+ let base = ctx.base_path.clone().unwrap_or_else(|| PathBuf::from("."));
3115− if let Segment::Inline(inline_element) = segment {
1805+ let pattern_path = Path::new(pattern);
3116− let head = &inline_element.head;
1806+ let dir = match pattern_path.parent() {
3117− if matches!(head.name.as_str(), "slot" | "children")
1807+ Some(dir) if !dir.as_os_str().is_empty() => base.join(dir),
3118− && !(scan.is_function)(&head.name)
1808+ _ => base,
3119− {
1809+ };
3120− out.push(SlotUse {
1810+ let file_pattern = pattern_path
3121− node,
1811+ .file_name()
3122− head,
1812+ .unwrap_or_default()
3123− text: inline_element.text.as_ref(),
1813+ .to_string_lossy()
3124− inline: true,
1814+ .to_string();
3125− at,
1815+ let mut files: Vec<PathBuf> = match std::fs::read_dir(&dir) {
1816+ Ok(entries) => entries
1817+ .flatten()
1818+ .map(|e| e.path())
1819+ .filter(|p| {
1820+ p.file_name()
1821+ .is_some_and(|n| glob_match(&file_pattern, &n.to_string_lossy()))
1822+ })
1823+ .collect(),
1824+ Err(e) => {
1825+ ctx.diagnostics.push(Diagnostic {
1826+ line: line_num,
1827+ column: None,
1828+ message: format!("cannot read directory for '{}': {}", pattern, e),
1829+ severity: Severity::Error,
1830+ source_line: Some(content.to_string()),
1831+ });
1832+ return;
1833+ }
1834+ };
1835+ files.sort();
1836+ let mut stems = Vec::new();
1837+ for file in files {
1838+ let stem = file
1839+ .file_stem()
1840+ .unwrap_or_default()
1841+ .to_string_lossy()
1842+ .to_string();
1843+ let text = match std::fs::read_to_string(&file) {
1844+ Ok(text) => text,
1845+ Err(e) => {
1846+ ctx.diagnostics.push(Diagnostic {
1847+ line: line_num,
1848+ column: None,
1849+ message: format!("cannot load data '{}': {}", file.display(), e),
1850+ severity: Severity::Error,
1851+ source_line: Some(content.to_string()),
3126 });
1852 });
1853+ continue;
3127 }
1854 }
3128− if let Some(text) = &inline_element.text {
1855+ };
3129− inline(text, node, at, scan, out);
1856+ match parse_json_with_error(&text) {
1857+ Ok(json) => {
1858+ let mut vars = HashMap::new();
1859+ flatten_json(&format!("{}.{}", name, stem), &json, &mut vars);
1860+ ctx.variables.extend(vars);
1861+ stems.push(stem);
3130 }
1862 }
1863+ Err(detail) => ctx.diagnostics.push(Diagnostic {
1864+ line: line_num,
1865+ column: None,
1866+ message: format!("invalid JSON in '{}': {}", file.display(), detail),
1867+ severity: Severity::Error,
1868+ source_line: Some(content.to_string()),
1869+ }),
3131 }
1870 }
1871+ ctx.included_files.push(file);
3132 }
1872 }
1873+ ctx.variables
1874+ .insert(format!("{}._count", name), stems.len().to_string());
1875+ ctx.variables.insert(name.to_string(), stems.join(", "));
3133 }
1876 }
3134− for node in block {
1877+
3135− match &node.kind {
1878+ fn expand_fn_call(
3136− NodeKind::Directive(directive) => match &directive.args {
1879+ &mut self,
3137− DirectiveArgs::Let(def) if matches!(def.form, LetForm::Function(_)) => {
1880+ name: &str,
3138− if scan.into_lets {
1881+ content: &str,
3139− let top = At {
1882+ current_indent: usize,
3140− file: at.file,
1883+ line_num: usize,
3141− ..At::top(true)
1884+ ctx: &mut ParseContext,
3142− };
1885+ ) -> Result<Vec<Node>, ParseError> {
3143− slot_uses(&node.children, top, scan, out);
1886+ // Recursive function cycle detection
3144− }
1887+ if ctx.fn_call_stack.contains(&name.to_string()) {
3145− }
1888+ return Err(ParseError {
3146− // A value's body is an error of its own
1889+ line: line_num,
3147− DirectiveArgs::Let(_) => {}
1890+ message: format!(
3148− _ if directive.spec.body == BodyKind::Verbatim => {}
1891+ "recursive function call to @{} (call stack: {})",
3149− // `@if`, `@else` and `@each` put their lines in place, and
1892+ name,
3150− // `@include` its file's; the lines under a directive that
1893+ ctx.fn_call_stack.join(" -> ")
3151− // takes no body are its siblings
1894+ ),
3152− _ => {
1895+ });
3153− for (lines, file) in (scan.included)(node) {
1896+ }
3154− slot_uses(lines, At { file, ..at }, scan, out);
1897+ ctx.fn_call_stack.push(name.to_string());
3155− }
1898+
3156− slot_uses(&node.children, at, scan, out);
1899+ // Parse [param value, ...] arguments
3157− }
1900+ let rest = &content[1 + name.len()..];
3158− },
1901+ let rest = rest.trim_start();
3159− NodeKind::Element(line) => {
1902+
3160− let mut at = at;
1903+ let (args, trailing_text) = if rest.starts_with('[') {
3161− let last = line.chain.len() - 1;
1904+ parse_attr_brackets_no_validate(rest, line_num, ctx)?
3162− for (i, head) in line.chain.iter().enumerate() {
1905+ } else {
3163− let text = if i == last { line.text.as_ref() } else { None };
1906+ (Vec::new(), rest.to_string())
3164− let function = (scan.is_function)(&head.name);
1907+ };
3165− if !function && matches!(head.name.as_str(), "slot" | "children") {
1908+
3166− out.push(SlotUse {
1909+ // Clone function definition (releases borrow on ctx). If the function is
3167− node,
1910+ // missing (shouldn't normally happen — callers check ctx.functions.contains_key
3168− head,
1911+ // first — but we avoid panicking on malformed state).
3169− text,
1912+ let fn_def = match ctx.functions.get(name) {
3170− inline: false,
1913+ Some(def) => def.clone(),
3171− at,
1914+ None => {
3172− });
1915+ ctx.fn_call_stack.pop();
3173− } else if let Some(text) = text {
1916+ return Err(ParseError {
3174− inline(text, node, at, scan, out);
1917+ line: line_num,
3175− }
1918+ message: format!("undefined function @{}", name),
3176− if function {
1919+ });
3177− at.place = Place::Call;
1920+ }
3178− if !at.body {
1921+ };
3179− at.call = Some((&head.name, node.span.line));
1922+
3180− }
1923+ // Parse caller's children, separating named slots from default children
3181− } else {
1924+ let all_caller_children = self.parse_children(current_indent + 1, ctx);
3182− at.place = Place::Element(Some(&head.name));
1925+ let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
3183− }
1926+ let mut caller_children = Vec::new();
3184− }
1927+ for child in all_caller_children {
3185− slot_uses(&node.children, at, scan, out);
1928+ if let Node::Element(ref elem) = child
1929+ && let ElementKind::Slot(ref slot_name) = elem.kind
1930+ && !slot_name.is_empty()
1931+ {
1932+ slot_contents
1933+ .entry(slot_name.clone())
1934+ .or_default()
1935+ .extend(elem.children.clone());
1936+ continue;
3186 }
1937 }
3187− NodeKind::Text(text) => {
1938+ caller_children.push(child);
3188− inline(text, node, at, scan, out);
1939+ }
3189− slot_uses(&node.children, at, scan, out);
1940+ // Text after the call, as in `@badge [color red] New`, is content:
1941+ // it goes first among the caller's children.
1942+ let trailing_text = trailing_text.trim();
1943+ if !trailing_text.is_empty() {
1944+ caller_children.insert(0, Node::Text(parse_text_segments(trailing_text, ctx)));
1945+ }
1946+
1947+ // Save variable state, inject function parameters
1948+ let saved_vars = ctx.variables.clone();
1949+ let mut consumed = vec![false; args.len()];
1950+ for (i, param) in fn_def.params.iter().enumerate() {
1951+ let named = args.iter().position(|a| a.key == *param);
1952+ // Positional fallback, unless that argument is named for a
1953+ // different parameter.
1954+ let positional = args
1955+ .get(i)
1956+ .filter(|a| !fn_def.params.contains(&a.key) && a.value.is_some())
1957+ .map(|_| i);
1958+ let value = named
1959+ .filter(|&j| args[j].value.is_some())
1960+ .or(positional)
1961+ .and_then(|j| {
1962+ consumed[j] = true;
1963+ args[j].value.clone()
1964+ })
1965+ .or_else(|| fn_def.defaults.get(param).cloned())
1966+ .unwrap_or_default();
1967+ if let Some(j) = named {
1968+ consumed[j] = true;
3190 }
1969 }
3191− NodeKind::Blank | NodeKind::Comment | NodeKind::Verbatim(_) => {}
1970+ ctx.variables.insert(param.clone(), value);
3192 }
1971 }
3193− }
1972+ // Arguments that aren't parameters are attributes for the
3194−}
1973+ // function's root element, so a function can be styled like an
1974+ // element: `@badge [background red] New`.
1975+ let forwarded: Vec<Attribute> = args
1976+ .iter()
1977+ .zip(&consumed)
1978+ .filter(|&(_, &used)| !used)
1979+ .map(|(a, _)| a.clone())
1980+ .collect();
3195
1981
3196−/// The slots a function's body declares, and whether it has a
1982+ // Normalize body indentation so it parses from indent 0
3197−/// `@children`, when it is defined. The files its `@include` lines bring
1983+ let min_indent = fn_def
3198−/// in count too: they are read now, since they run only at a call.
1984+ .body_lines
3199−fn declared_slots(body: &[syntax::Node], ctx: &mut ParseContext) -> (Vec<String>, bool) {
1985+ .iter()
3200− let mut files = HashMap::new();
1986+ .map(|l| l.indent)
3201− read_includes(
1987+ .min()
3202− body,
1988+ .unwrap_or(0);
3203− ctx.base_path.clone(),
1989+ let adjusted: Vec<Line> = fn_def
3204− &mut Vec::new(),
1990+ .body_lines
3205− ctx,
1991+ .iter()
3206− &mut files,
1992+ .map(|l| Line {
3207− );
1993+ indent: l.indent - min_indent,
3208− let included = |node: &syntax::Node| match files.get(&node.id) {
1994+ content: l.content.clone(),
3209− Some(tree) => vec![(&tree.nodes[..], None)],
1995+ line_num: l.line_num,
3210− None => Vec::new(),
1996+ })
3211− };
1997+ .collect();
3212− let is_function = |name: &str| is_function(name, ctx);
1998+
3213− slots_of(body, &is_function, &included)
1999+ // Parse body with params in scope
3214−}
2000+ let mut body_parser = Parser {
2001+ lines: adjusted,
2002+ pos: 0,
2003+ };
2004+ let body_nodes = body_parser.parse_children(0, ctx);
2005+
2006+ // Restore variables and call stack
2007+ ctx.variables = saved_vars;
2008+ ctx.fn_call_stack.pop();
2009+
2010+ // Replace @children with caller's children and @slot with slot content
2011+ let mut result_nodes =
2012+ replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
3215
2013
3216−/// Read and parse the files the `@include` lines in `nodes` bring in (by
2014+ if !forwarded.is_empty() {
3217−/// a literal path, relative to `base`), and the files those include, by
2015+ let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
3218−/// the node id of each `@include` line.
2016+ Node::Element(e) => Some(e),
3219−fn read_includes(
2017+ _ => None,
3220− nodes: &[syntax::Node],
2018+ });
3221− base: Option<PathBuf>,
2019+ match (roots.next(), roots.next()) {
3222− stack: &mut Vec<PathBuf>,
2020+ (Some(root), None) => root.attrs.extend(forwarded),
3223− ctx: &mut ParseContext,
2021+ _ => ctx.diagnostics.push(Diagnostic {
3224− files: &mut HashMap<usize, Rc<Tree>>,
2022+ line: line_num,
3225−) {
2023+ column: None,
3226− let mut includes = Vec::new();
2024+ message: format!(
3227− for node in nodes {
2025+ "attributes {} on @{} are not parameters, and its body has no single \
3228− node.walk(&mut |node| {
2026+ root element to receive them",
3229− if let Some(DirectiveArgs::Text(Some(path))) = node.directive().map(|d| &d.args)
2027+ forwarded
3230− && node.is_directive("include")
2028+ .iter()
3231− {
2029+ .map(|a| format!("'{}'", a.key))
3232− includes.push((node.id, path.raw.clone()));
2030+ .collect::<Vec<_>>()
2031+ .join(", "),
2032+ name
2033+ ),
2034+ severity: Severity::Warning,
2035+ source_line: Some(content.to_string()),
2036+ }),
3233 }
2037 }
3234− });
3235− }
3236− for (id, path) in includes {
3237− let path = path.trim_matches('"');
3238− if path.contains('$') {
3239− continue;
3240 }
2038 }
3241− let resolved = match &base {
2039+
3242− Some(base) => base.join(path),
2040+ // A function with a scoped @style wraps its output in the scope class
3243− None => PathBuf::from(path),
2041+ if ctx.scoped_functions.contains(name) {
3244− };
2042+ let wrapper = Element {
3245− if stack.contains(&resolved) {
2043+ kind: ElementKind::El,
3246− continue;
2044+ attrs: vec![Attribute {
2045+ key: "class".to_string(),
2046+ value: Some(format!("hl-{}", name)),
2047+ html: true,
2048+ }],
2049+ argument: None,
2050+ children: result_nodes,
2051+ line_num,
2052+ };
2053+ result_nodes = vec![Node::Element(wrapper)];
3247 }
2054 }
3248− let Ok(text) = ctx.read_file(&resolved) else {
2055+
3249− continue;
2056+ Ok(result_nodes)
3250− };
3251− let tree = ctx.parse_tree(&text);
3252− stack.push(resolved.clone());
3253− let parent = resolved.parent().map(Path::to_path_buf);
3254− read_includes(&tree.nodes, parent, stack, ctx, files);
3255− stack.pop();
3256− files.insert(id, tree);
3257 }
2057 }
3258−}
3259
2058
3260−/// The slots a function's body declares (`@slot NAME`, in order), and
2059+ fn parse_element_line(
3261−/// whether it has a `@children` for the content of a call. A `@slot`
2060+ &mut self,
3262−/// directly under a call in the body fills that call's slot instead, so
2061+ content: &str,
3263−/// `is_function` says which names are functions. The files the body
2062+ current_indent: usize,
3264−/// includes aren't read.
2063+ line_num: usize,
3265−pub fn function_slots(
2064+ ctx: &mut ParseContext,
3266− body: &[syntax::Node],
2065+ ) -> Result<Node, ParseError> {
3267− is_function: &dyn Fn(&str) -> bool,
2066+ let segments = split_chain(content);
3268−) -> (Vec<String>, bool) {
3269− slots_of(body, is_function, &|_| Vec::new())
3270−}
3271
2067
3272−fn slots_of<'a>(
2068+ // Parse each segment into an Element
3273− body: &'a [syntax::Node],
2069+ let mut elements: Vec<Element> = Vec::new();
3274− is_function: &dyn Fn(&str) -> bool,
2070+ for seg in &segments {
3275− included: Included<'a, '_>,
2071+ let elem = parse_single_element(seg.trim(), line_num, ctx)?;
3276−) -> (Vec<String>, bool) {
2072+ elements.push(elem);
3277− let scan = Scan {
3278− is_function,
3279− included,
3280− into_lets: false,
3281− };
3282− let mut uses = Vec::new();
3283− slot_uses(body, At::top(true), &scan, &mut uses);
3284− let mut slots: Vec<String> = Vec::new();
3285− for slot in uses.iter().filter(|u| u.declares()) {
3286− let name = slot.name();
3287− if is_slot_name(name) && !slots.iter().any(|s| s == name) {
3288− slots.push(name.to_string());
3289 }
2073 }
3290− }
3291− let has_children = uses.iter().any(|u| !u.is_slot() && !u.inline);
3292− (slots, has_children)
3293−}
3294
2074
3295−/// The line as written, indented, when it is one physical line, so a
2075+ // Parse indented children (belong to the innermost element)
3296−/// column points into it.
2076+ let children = self.parse_children(current_indent + 1, ctx);
3297−fn written_line(node: &syntax::Node) -> String {
3298− match node.line_count <= 1 {
3299− true => format!("{}{}", " ".repeat(node.indent), node.source),
3300− false => node.source.clone(),
3301− }
3302−}
3303
2077
3304−/// The lines of the files an `@include` line brought in when it ran, from
2078+ // Build chain right-to-left: rightmost gets children, each wraps the next
3305−/// `included_by`.
2079+ let mut current_children = children;
3306−fn ran_includes<'a>(
2080+ for mut elem in elements.into_iter().rev() {
3307− included_by: &'a IncludedBy,
2081+ elem.children.extend(current_children);
3308− node: &syntax::Node,
2082+ current_children = vec![Node::Element(elem)];
3309−) -> Vec<(&'a [syntax::Node], Option<&'a str>)> {
2083+ }
3310− match included_by.get(&node.id) {
3311− Some(files) => files
3312− .iter()
3313− .map(|(tree, chain)| (&tree.nodes[..], Some(chain.as_str())))
3314− .collect(),
3315− None => Vec::new(),
3316− }
3317−}
3318
2084
3319−/// Check where every `@slot` and `@children` of the file and the files it
2085+ current_children.into_iter().next().ok_or(ParseError {
3320−/// includes is written, whether or not it runs: a slot has one name, a
2086+ line: line_num,
3321−/// function's body marks places with them, and a call fills its slots
2087+ message: "element chain produced no nodes (this is a parser bug)".to_string(),
3322−/// with `@slot` blocks directly under it. An included file's lines are
3323−/// where its `@include` is (in a body, under a call), so an `@include`
3324−/// that never ran leaves its file unchecked.
3325−fn check_slot_places(ctx: &mut ParseContext) {
3326− let trees = std::mem::take(&mut ctx.trees);
3327− let included_by = ctx.included_by.clone();
3328− let is_function = |name: &str| is_function(name, ctx);
3329− let scan = Scan {
3330− is_function: &is_function,
3331− included: &|node| ran_includes(&included_by, node),
3332− into_lets: true,
3333− };
3334− let mut uses = Vec::new();
3335− for (tree, _) in trees.iter().filter(|(_, chain)| chain.is_none()) {
3336− slot_uses(&tree.nodes, At::top(false), &scan, &mut uses);
3337− }
3338− let found: Vec<Diagnostic> = uses
3339− .iter()
3340− .filter_map(|slot| {
3341− let mut d = slot_problem(slot)?;
3342− if d.column.is_none() {
3343− d = d.column(slot.head.name_span.column);
3344− }
3345− d = d.source(written_line(slot.node));
3346− if let Some(file) = slot.at.file {
3347− d.message = format!("{}\n in {}", d.message, file);
3348− }
3349− Some(d)
3350 })
2088 })
3351− .collect();
3352− ctx.diagnostics.extend(found);
3353− ctx.trees = trees;
3354−}
3355−
3356−/// What is wrong with a `@slot` or `@children` where it is written.
3357−fn slot_problem(slot: &SlotUse) -> Option<Diagnostic> {
3358− let line = slot.node.span.line;
3359− let what = if slot.is_slot() {
3360− match slot.name() {
3361− "" => "@slot".to_string(),
3362− name => format!("@slot {}", name),
3363− }
3364− } else {
3365− "@children".to_string()
3366− };
3367− if slot.inline {
3368− return Some(Diagnostic::error(
3369− code::MISPLACED_SLOT,
3370− line,
3371− format!(
3372− "{} goes on a line of its own: inline in text it marks and fills nothing",
3373− what
3374− ),
3375− ));
3376− }
3377− if let Some(list) = &slot.head.attrs {
3378− return Some(
3379− Diagnostic::error(
3380− code::UNEXPECTED_ARGUMENT,
3381− line,
3382− format!(
3383− "{} takes no attributes: it is replaced by content, so style the \
3384− element around it",
3385− what
3386− ),
3387− )
3388− .column(list.span.column),
3389− );
3390− }
3391− if slot.is_slot() {
3392− let name = slot.name();
3393− if name.is_empty() {
3394− return Some(Diagnostic::error(
3395− code::MISSING_ARGUMENT,
3396− line,
3397− "@slot needs a name: `@slot footer` (the caller's content without a \
3398− name goes where `@children` is)"
3399− .to_string(),
3400− ));
3401− }
3402− if !is_slot_name(name) {
3403− let joined = name.split_whitespace().collect::<Vec<_>>().join("-");
3404− let suggestion = is_slot_name(&joined).then_some(joined);
3405− let column = slot.text.map(|t| t.span.column);
3406− let d = Diagnostic::error(
3407− code::INVALID_SLOT_NAME,
3408− line,
3409− format!(
3410− "'{}' is not a slot name: a slot's name is one word of letters, digits, \
3411− `-` and `_`, starting with a letter, and its content goes on the lines \
3412− under it",
3413− name
3414− ),
3415− )
3416− .subject(name)
3417− .suggest(suggestion);
3418− return Some(match column {
3419− Some(column) => d.column(column),
3420− None => d,
3421− });
3422− }
3423− if matches!(slot.at.place, Place::Call) || slot.at.body {
3424− return None;
3425− }
3426− let message = match (slot.at.call, slot.at.place) {
3427− (Some((call, call_line)), Place::Element(Some(element))) => format!(
3428− "{} is inside @{}, so it fills nothing: a @slot block that fills a slot of \
3429− @{} (line {}) goes directly under the call",
3430− what, element, call, call_line
3431− ),
3432− _ => format!(
3433− "{} is outside a function's body: `@slot NAME` marks a place in a body \
3434− (`@let @name`), and directly under a call it fills one",
3435− what
3436− ),
3437− };
3438− return Some(Diagnostic::error(code::MISPLACED_SLOT, line, message));
3439− }
3440− if slot.at.body {
3441− return None;
3442 }
2089 }
3443− let message = match slot.at.call {
3444− Some((call, _)) => format!(
3445− "@children is outside a function's body, so it stands for nothing: the content \
3446− for @{} is written directly under the call",
3447− call
3448− ),
3449− None => "@children is outside a function's body: it marks where a body puts the \
3450− content of each call"
3451− .to_string(),
3452− };
3453− Some(Diagnostic::error(code::MISPLACED_SLOT, line, message))
3454−}
3455−
3456−/// A `@slot NAME` block that fills a slot of a call.
3457−struct Filler<'a> {
3458− node: &'a syntax::Node,
3459− text: &'a syntax::Text,
3460− /// The include chain of the file it is in, when that isn't the file
3461− /// being compiled.
3462− file: Option<&'a str>,
3463}
2090}
3464
2091
3465−/// Whether `@slot` is the built-in element here, and not a function
2092+// ---------------------------------------------------------------------------
3466−/// defined so far that took its name.
2093+// @children replacement
3467−fn slot_is_built_in(ctx: &ParseContext) -> bool {
2094+// ---------------------------------------------------------------------------
3468− ctx.env.function("slot").is_none()
3469−}
3470
2095
3471−/// The `@slot NAME` blocks directly under a call, in `block` (under its
2096+fn replace_children_and_slots(
3472−/// `@if`, `@else` and `@each` too, and in the files its `@include` lines
2097+ nodes: Vec<Node>,
3473−/// brought in).
2098+ caller_children: &[Node],
3474−fn fillers<'a>(
2099+ slot_contents: &HashMap<String, Vec<Node>>,
3475− block: &'a [syntax::Node],
2100+) -> Vec<Node> {
3476− file: Option<&'a str>,
2101+ let mut result = Vec::new();
3477− included: &'a IncludedBy,
2102+ for node in nodes {
3478− out: &mut Vec<Filler<'a>>,
2103+ match node {
3479−) {
2104+ Node::Element(elem) if elem.kind == ElementKind::Children => {
3480− for node in block {
2105+ if caller_children.is_empty() && !elem.children.is_empty() {
3481− match &node.kind {
2106+ // Use @children's own children as default/fallback content
3482− NodeKind::Directive(directive)
2107+ result.extend(elem.children);
3483− if matches!(directive.name(), "if" | "else" | "each") =>
2108+ } else {
3484− {
2109+ result.extend(caller_children.iter().cloned());
3485− fillers(&node.children, file, included, out);
3486− }
3487− NodeKind::Directive(directive) if directive.name() == "include" => {
3488− for (lines, file) in ran_includes(included, node) {
3489− fillers(lines, file, included, out);
3490 }
2110 }
3491 }
2111 }
3492− NodeKind::Element(line) if line.chain.len() == 1 && line.chain[0].name == "slot" => {
2112+ Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) =>
3493− if let Some(text) = &line.text {
2113+ {
3494− out.push(Filler { node, text, file });
2114+ if let ElementKind::Slot(ref name) = elem.kind {
2115+ if let Some(content) = slot_contents.get(name) {
2116+ result.extend(content.iter().cloned());
2117+ }
2118+ // If no content provided for this slot, use the slot's own children as default
2119+ else if !elem.children.is_empty() {
2120+ result.extend(elem.children);
2121+ }
3495 }
2122 }
3496 }
2123 }
3497− _ => {}
2124+ Node::Element(mut elem) => {
3498− }
2125+ elem.children =
3499− }
2126+ replace_children_and_slots(elem.children, caller_children, slot_contents);
3500−}
2127+ result.push(Node::Element(elem));
3501−
3502−/// Whether `block`, the lines under a call, holds content other than
3503−/// `@slot` blocks (under `@if`, `@else` and `@each` too): text, an
3504−/// element, raw HTML, Markdown or an included file. For code that doesn't
3505−/// run.
3506−fn holds_content(block: &[syntax::Node], ctx: &ParseContext) -> bool {
3507− block.iter().any(|node| match &node.kind {
3508− NodeKind::Directive(directive) => match directive.name() {
3509− "if" | "else" | "each" => holds_content(&node.children, ctx),
3510− name => matches!(name, "raw" | "markdown" | "include"),
3511− },
3512− NodeKind::Element(line) => {
3513− line.chain[0].name != "slot" || !slot_is_built_in(ctx) || line.chain.len() > 1
3514− }
3515− NodeKind::Text(_) => true,
3516− _ => false,
3517− })
3518−}
3519−
3520−/// Check the `@slot NAME` blocks passed to each call of a line against
3521−/// the slots its function declares. `calls` has the function of each head
3522−/// of the chain that is a call.
3523−fn check_fillers(
3524− node: &syntax::Node,
3525− element: &syntax::ElementLine,
3526− calls: &[Option<(String, Rc<FnDef>)>],
3527− ctx: &mut ParseContext,
3528−) {
3529− if !slot_is_built_in(ctx) {
3530− return;
3531− }
3532− let included = ctx.included_by.clone();
3533− let last = element.chain.len() - 1;
3534− for (i, call) in calls.iter().enumerate() {
3535− let Some((name, function)) = call else {
3536− continue;
3537− };
3538− let mut found = Vec::new();
3539− if i == last {
3540− fillers(&node.children, None, &included, &mut found);
3541− } else if i + 1 == last
3542− && element.chain[last].name == "slot"
3543− && let Some(text) = &element.text
3544− {
3545− // `@card > @slot footer`
3546− found.push(Filler {
3547− node,
3548− text,
3549− file: None,
3550− });
3551− }
3552− for filler in found {
3553− let slot = filler.text.raw.trim();
3554− if !is_slot_name(slot) || function.slots.iter().any(|s| s == slot) {
3555− continue;
3556− }
3557− let mut d = unknown_slot(name, slot, &function.slots, filler.text.span.line)
3558− .column(filler.text.span.column)
3559− .source(written_line(filler.node));
3560− if let Some(file) = filler.file {
3561− d.message = format!("{}\n in {}", d.message, file);
3562 }
2128 }
3563− ctx.push_once(d);
2129+ other => result.push(other),
3564 }
2130 }
3565 }
2131 }
3566−}
2132+ result
3567−
3568−/// A `@slot NAME` block for a slot the function doesn't declare.
3569−fn unknown_slot(function: &str, slot: &str, slots: &[String], line: usize) -> Diagnostic {
3570− let candidates: Vec<&str> = slots.iter().map(String::as_str).collect();
3571− let suggestion = suggest_closest(slot, &candidates);
3572− let message = match (slots.is_empty(), suggestion) {
3573− (true, _) => format!(
3574− "@{} has no slot '{}': its body declares no slots (`@slot NAME`)",
3575− function, slot
3576− ),
3577− (false, Some(close)) => format!(
3578− "@{} has no slot '{}', did you mean '{}'? (its slots: {})",
3579− function,
3580− slot,
3581− close,
3582− slots.join(", ")
3583− ),
3584− (false, None) => format!(
3585− "@{} has no slot '{}' (its slots: {})",
3586− function,
3587− slot,
3588− slots.join(", ")
3589− ),
3590− };
3591− Diagnostic::error(code::UNKNOWN_SLOT, line, message)
3592− .subject(slot)
3593− .suggest(suggestion)
3594−}
3595−
3596−/// Content passed to a function whose body has no `@children`.
3597−fn no_children(function: &str, line: usize, column: Option<usize>) -> Diagnostic {
3598− let d = Diagnostic::error(
3599− code::UNEXPECTED_CONTENT,
3600− line,
3601− format!(
3602− "@{0} takes no content: its body has no `@children`, so the text and lines \
3603− passed to it would be dropped (pass text as a parameter, `@{0} [name value]`, \
3604− or add `@children` to the body)",
3605− function
3606− ),
3607− )
3608− .subject(function);
3609− match column {
3610− Some(column) => d.column(column),
3611− None => d,
3612− }
3613}
2133}
3614
2134
3615// ---------------------------------------------------------------------------
2135// ---------------------------------------------------------------------------
3616// Element parsing
2136// Element parsing
3617// ---------------------------------------------------------------------------
2137// ---------------------------------------------------------------------------
3618
2138
3619−/// Build the element for `@name [attrs] text` (on its own line, in a chain,
2139+fn extract_element_name(content: &str) -> &str {
3620−/// or inline in text).
2140+ let without_at = &content[1..];
2141+ match without_at.find([' ', '[']) {
2142+ Some(i) => &without_at[..i],
2143+ None => without_at,
2144+ }
2145+}
2146+
3621fn parse_single_element(
2147fn parse_single_element(
3622− head: &syntax::Head,
2148+ content: &str,
3623− text: Option<&syntax::Text>,
3624 line_num: usize,
2149 line_num: usize,
3625 ctx: &mut ParseContext,
2150 ctx: &mut ParseContext,
3626) -> Result<Element, ParseError> {
2151) -> Result<Element, ParseError> {
3627− let kind = parse_element_kind(&head.name, line_num, ctx)?;
2152+ let (kind, rest) = if content.starts_with('[') {
3628− let mut attrs = head.attrs.as_ref().map_or_else(Vec::new, |list| {
2153+ // Implicit @el
3629− parse_attr_list(&list.attrs, line_num, ctx, true, &[])
2154+ (ElementKind::El, content.to_string())
3630− });
2155+ } else if let Some(without_at) = content.strip_prefix('@') {
3631−
2156+ match without_at.find([' ', '[']) {
3632− // The leading argument: for an element whose row names a leading
2157+ Some(i) => {
3633− // attribute (`@link`'s href, `@image`'s src, `@form`'s action, ...),
2158+ let kind_str = &without_at[..i];
3634− // the first token of the text fills it, split before anything is
2159+ let rest = if without_at.as_bytes()[i] == b'[' {
3635− // filled in, and the rest is content (an error on an element without
2160+ without_at[i..].to_string()
3636− // content). `@slot`'s whole text is its name. Any other element's text
2161+ } else {
3637− // is content, parsed like any line of text, as in `@el [padding 8]
2162+ without_at[i + 1..].to_string()
3638− // Hello` or `@h2 Meet {@text htmlang}`; its layout decides how it
2163+ };
3639− // combines with the lines under it.
2164+ match parse_element_kind(kind_str, line_num) {
3640− let mut children = Vec::new();
2165+ Ok(kind) => (kind, rest),
3641− let mut argument = None;
2166+ Err(mut e) => {
3642− let leading = kind.arg().attributes();
2167+ // Also suggest user-defined functions
3643− match text {
2168+ if let Some(fn_suggestion) = suggest_fn_name(kind_str, ctx) {
3644− Some(text) if !leading.is_empty() => {
2169+ e.message =
3645− let (token, rest) = text.split_leading();
2170+ format!("{}, or did you mean @{}?", e.message, fn_suggestion);
3646− let given = attrs
2171+ }
3647− .iter()
2172+ return Err(e);
3648− .find(|a| a.html && leading.contains(&a.key.as_str()))
3649− .map(|a| (a.key.clone(), a.value.clone().unwrap_or_default()));
3650− if let Some((key, value)) = given {
3651− // The attribute wins; the text is content, as if the
3652− // element had no leading argument
3653− ctx.push_once(leading_twice(&kind, &token, &key, &value, text, ctx));
3654− if !takes_no_text(&kind) {
3655− children.push(Node::Text(text_segments(text, ctx)));
3656− }
3657− } else if let Some(d) = bad_leading_token(&kind, &token, &text.raw, ctx) {
3658− // Nothing is taken as the argument; the line is content
3659− ctx.push_once(d);
3660− if !takes_no_text(&kind) {
3661− children.push(Node::Text(text_segments(text, ctx)));
3662− }
3663− } else {
3664− let column = Some(token.span.column);
3665− if let Some(d) = text_as_url(&kind, &token, ctx) {
3666− ctx.push_once(d);
3667− }
3668− argument = Some(
3669− ctx.fill_value(&token.raw, text.span.line, column, Sink::Text)
3670− .0,
3671− );
3672− if let Some(rest) = rest {
3673− if let Some(d) = chain_after_argument(&kind, &token.raw, &rest, ctx) {
3674− ctx.push_once(d);
3675− }
3676− if takes_no_text(&kind) {
3677− let d = after_the_leading_argument(&kind, &token.raw, &rest);
3678− let d = at_attribute(d, Some(rest.span.column), ctx);
3679− ctx.push_once(d);
3680− } else {
3681− children.push(Node::Text(text_segments(&rest, ctx)));
3682 }
2173 }
3683 }
2174 }
3684 }
2175 }
2176+ None => match parse_element_kind(without_at, line_num) {
2177+ Ok(kind) => (kind, String::new()),
2178+ Err(mut e) => {
2179+ if let Some(fn_suggestion) = suggest_fn_name(without_at, ctx) {
2180+ e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion);
2181+ }
2182+ return Err(e);
2183+ }
2184+ },
3685 }
2185 }
3686− Some(text) if matches!(kind, ElementKind::Slot(_)) => {
2186+ } else {
3687− argument =
2187+ return Err(ParseError {
3688− Some(ctx.interpolate_text(&text.raw, text.span.line, Some(text.span.column)));
2188+ line: line_num,
3689− }
2189+ message: format!("expected @element or [attrs], got: {}", content),
3690− Some(text) => children.push(Node::Text(text_segments(text, ctx))),
2190+ });
3691− // `@link [href=/about]`: the attribute is the leading argument, so
2191+ };
3692− // both forms compile the same (`@image [src=a.svg, inline]`). Not
2192+
3693− // for `@source`, whose attribute depends on where it is.
2193+ // Parse optional [attrs]
3694− None if leading.len() == 1 => {
2194+ let (attrs, rest) = if rest.starts_with('[') {
3695− if let Some(i) = attrs.iter().position(|a| a.html && a.key == leading[0]) {
2195+ parse_attr_brackets(&rest, line_num, ctx)?
3696− argument = Some(attrs.remove(i).value.unwrap_or_default());
2196+ } else {
3697− }
2197+ (Vec::new(), rest)
2198+ };
2199+
2200+ let rest = rest.trim().to_string();
2201+ track_var_refs(&rest, &mut ctx.used_variables);
2202+
2203+ // For @link, first token of rest is URL, remainder is inline text
2204+ let mut children = Vec::new();
2205+ let argument = if rest.is_empty() {
2206+ None
2207+ } else if kind == ElementKind::Link {
2208+ let rest_sub = substitute_vars(&rest, &ctx.variables);
2209+ if let Some((url, text)) = rest_sub.split_once(' ') {
2210+ let text = text.trim();
2211+ if !text.is_empty() {
2212+ children.push(Node::Text(parse_text_segments(text, ctx)));
2213+ }
2214+ Some(url.to_string())
2215+ } else {
2216+ Some(rest_sub)
3698 }
2217 }
3699− None => {}
2218+ } else {
3700− }
2219+ Some(substitute_vars(&rest, &ctx.variables))
2220+ };
3701
2221
3702 // For @slot, the argument is the slot name
2222 // For @slot, the argument is the slot name
3703 let kind = if let ElementKind::Slot(_) = kind {
2223 let kind = if let ElementKind::Slot(_) = kind {
@@ -3706,352 +2226,196 @@ fn parse_single_element(
3706 kind
2226 kind
3707 };
2227 };
3708
2228
2229+ // Every other element treats its argument as leading text content, as
2230+ // in `@el [padding 8] Hello` or `@paragraph Read {@link /more more}`.
2231+ let argument = match argument {
2232+ Some(_) if !argument_is_special(&kind) && !renders_argument_as_text(&kind) => {
2233+ children.insert(0, Node::Text(parse_text_segments(&rest, ctx)));
2234+ None
2235+ }
2236+ other => other,
2237+ };
2238+
3709 Ok(Element {
2239 Ok(Element {
3710 kind,
2240 kind,
3711 attrs,
2241 attrs,
3712 argument,
2242 argument,
3713 children,
2243 children,
3714 line_num,
2244 line_num,
3715− function: None,
3716 })
2245 })
3717}
2246}
3718
2247
3719−/// Whether `node` or a line inside it calls `@name` outside any `@if`
2248+/// Elements whose argument is not text content: a URL, a source, an action,
3720−/// (in a chain, or inline in text).
2249+/// or a slot name.
3721−fn calls_unguarded(node: &syntax::Node, name: &str) -> bool {
2250+fn argument_is_special(kind: &ElementKind) -> bool {
3722− if node.is_directive("if") || node.is_directive("else") {
2251+ matches!(
3723− return false;
2252+ kind,
3724− }
2253+ ElementKind::Link | ElementKind::Image | ElementKind::Script | ElementKind::Slot(_)
3725− let text_calls = |text: &syntax::Text| {
2254+ ) || kind
3726− text.segments
2255+ .spec()
3727− .iter()
2256+ .is_some_and(|spec| matches!(spec.arg, TagArg::Attr(_)))
3728− .any(|s| matches!(s, Segment::Inline(inline) if inline.head.name == name))
2257+}
3729− };
2258+
3730− let here = match &node.kind {
2259+/// The directive names (without `@`) the parser recognizes, for tools such
3731− NodeKind::Element(element) => {
2260+/// as the language server.
3732− element.chain.iter().any(|head| head.name == name)
2261+pub fn known_directives() -> &'static [&'static str] {
3733− || element.text.as_ref().is_some_and(text_calls)
2262+ KNOWN_DIRECTIVES
3734− }
2263+}
3735− NodeKind::Text(text) => text_calls(text),
2264+
3736− _ => false,
2265+const KNOWN_DIRECTIVES: &[&str] = &[
3737− };
2266+ "page",
3738− here || node
2267+ "let",
3739− .children
2268+ "include",
3740− .iter()
2269+ "raw",
3741− .any(|child| calls_unguarded(child, name))
2270+ "keyframes",
3742−}
2271+ "if",
3743−
2272+ "else",
3744−/// `@td [padding 8] @children`: `@children` and `@slot NAME` mark where a
2273+ "each",
3745−/// call's content goes only on a line of their own; after an element's
2274+ "meta",
3746−/// attributes, or in a verbatim body, they are text.
2275+ "head",
3747−fn marker_in_text(node: &syntax::Node) -> Option<Diagnostic> {
2276+ "style",
3748− let is_marker = |text: &str| {
2277+ "warn",
3749− let text = text.trim();
2278+ "extends",
3750− text == "@children" || text == "@slot" || text.starts_with("@slot ")
2279+ "assert",
3751− };
2280+ "markdown",
3752− let mut line = node.span.line;
2281+ "data",
3753− let mut source = written_line(node);
2282+ "svg",
3754− let (marker, message) = match &node.kind {
2283+];
3755− NodeKind::Element(element) => {
3756− let text = element.text.as_ref()?;
3757− if !is_marker(&text.raw) {
3758− return None;
3759− }
3760− let name = &element.chain.last()?.name;
3761− let marker = text.raw.trim().to_string();
3762− let message = format!(
3763− "`{marker}` after @{name}'s attributes is text: it marks where a call's content \
3764− goes only on a line of its own, so write it on the next line, indented under \
3765− @{name}"
3766− );
3767− (marker, message)
3768− }
3769− NodeKind::Verbatim(verbatim) if verbatim.escaped => {
3770− let (i, written) = verbatim
3771− .text
3772− .lines()
3773− .enumerate()
3774− .find(|(_, l)| is_marker(l))?;
3775− let marker = written.trim().to_string();
3776− line += i;
3777− source = written.to_string();
3778− let message = format!(
3779− "`{marker}` in a verbatim body is text: the body is written as it is, so it can't \
3780− hold a call's content (pass the text as a parameter instead)"
3781− );
3782− (marker, message)
3783− }
3784− _ => return None,
3785− };
3786− Some(
3787− Diagnostic::error(code::MISPLACED_SLOT, line, message)
3788− .subject(marker)
3789− .source(source),
3790− )
3791−}
3792−
3793−/// A first word that can't be a leading argument: an inline element
3794−/// (`@link {@b x} y`), or a quote that isn't closed (`@link "a b`).
3795−fn bad_leading_token(
3796− kind: &ElementKind,
3797− token: &syntax::Arg,
3798− text: &str,
3799− ctx: &ParseContext,
3800−) -> Option<Diagnostic> {
3801− let what = leading_name(kind);
3802− let name = kind.name();
3803− let message = if token.raw.starts_with("{@") {
3804− format!(
3805− "@{0} takes its {1} first, before any text: write `@{0} VALUE {2}`, or give it as \
3806− `[{1}=VALUE]`",
3807− name,
3808− what,
3809− text.trim()
3810− )
3811− } else if token.raw.starts_with('"') && syntax::quoted_string(&token.raw).is_none() {
3812− format!(
3813− "@{}'s {} starts with a `\"` that isn't closed: close it (`\"...\"`), or write \
3814− `\\\"` for a quote in the value",
3815− name, what
3816− )
3817− } else {
3818− return None;
3819− };
3820− let d = Diagnostic::error(code::UNEXPECTED_ARGUMENT, token.span.line, message)
3821− .subject(token.raw.clone());
3822− Some(at_attribute(d, Some(token.span.column), ctx))
3823−}
3824
2284
3825−/// A first word taken as a URL that reads as text: a word with a capital
2285+fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
3826−/// and nothing a URL or a file name has, as in `@link [hx-get=/panel] Open
2286+ if let Some(kind) = ElementKind::from_name(s) {
3827−/// panel`, whose href would be `Open`. It still is (a relative URL can be
2287+ return Ok(kind);
3828−/// such a word), with a warning.
3829−fn text_as_url(kind: &ElementKind, token: &syntax::Arg, ctx: &ParseContext) -> Option<Diagnostic> {
3830− const URLS: &[&str] = &["href", "src", "srcset", "action", "data"];
3831− let word = token.raw.as_str();
3832− let reads_as_text = word.starts_with(char::is_uppercase)
3833− && !word.contains([
3834− '/', '.', ':', '#', '?', '=', '&', '%', '~', '$', '{', '\\', '"',
3835− ]);
3836− if !reads_as_text || !kind.arg().attributes().iter().any(|a| URLS.contains(a)) {
3837− return None;
3838 }
2288 }
3839− let name = kind.name();
2289+ if let Some(hint) = removed_syntax_hint(&format!("@{}", s)) {
3840− let attr = leading_name(kind);
2290+ return Err(ParseError {
3841− let text = if takes_no_text(kind) {
2291+ line: line_num,
3842− String::new()
2292+ message: hint,
3843− } else {
2293+ });
3844− format!(": write the {attr} before the text, or put the text on the lines under it")
3845− };
3846− let message = format!(
3847− "@{name} takes '{word}', its first word, as its {attr}{text}. A relative URL that \
3848− starts with a capital is written `./{word}`"
3849− );
3850− let d = Diagnostic::warning(code::TEXT_AS_URL, token.span.line, message).subject(word);
3851− Some(at_attribute(d, Some(token.span.column), ctx))
3852−}
3853−
3854−/// `@link /a > @image logo.png`: a `>` chains only after an element's
3855−/// name or its `[attributes]`, so after a leading argument it is text.
3856−/// Said when what follows names an element or a function.
3857−fn chain_after_argument(
3858− kind: &ElementKind,
3859− token: &str,
3860− rest: &syntax::Text,
3861− ctx: &ParseContext,
3862−) -> Option<Diagnostic> {
3863− let after = rest.raw.strip_prefix('>')?;
3864− let name_start = after.trim_start().strip_prefix('@')?;
3865− if after.len() == after.trim_start().len() {
3866− return None;
3867 }
2294 }
3868− let name: String = name_start
2295+ let all_known: Vec<&str> = ElementKind::all_names()
3869− .chars()
2296+ .chain(KNOWN_DIRECTIVES.iter().copied())
3870− .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
3871 .collect();
2297 .collect();
3872− if ElementKind::from_name(&name).is_none() && ctx.env.function(&name).is_none() {
2298+ let message = match suggest_closest(s, &all_known) {
3873− return None;
2299+ Some(closest) => format!("unknown element @{}, did you mean @{}?", s, closest),
3874− }
2300+ None => format!("unknown element @{}", s),
3875− let attr = leading_name(kind);
3876− let d = Diagnostic::warning(
3877− code::UNEXPECTED_ARGUMENT,
3878− rest.span.line,
3879− format!(
3880− "`{}` after @{}'s {} is text, not a chain: `>` chains after an element's name or \
3881− its [attributes], so write `@{} [{}={}] {}`",
3882− rest.raw.trim_end(),
3883− kind.name(),
3884− attr,
3885− kind.name(),
3886− attr,
3887− token,
3888− rest.raw.trim_end()
3889− ),
3890− )
3891− .subject(rest.raw.trim_end());
3892− Some(at_attribute(d, Some(rest.span.column), ctx))
3893−}
3894−
3895−/// A leading argument given twice, as the first token and as its
3896−/// attribute: `@link [href=/a] About`.
3897−fn leading_twice(
3898− kind: &ElementKind,
3899− token: &syntax::Arg,
3900− key: &str,
3901− value: &str,
3902− text: &syntax::Text,
3903− ctx: &ParseContext,
3904−) -> Diagnostic {
3905− let name = kind.name();
3906− let what = leading_name(kind);
3907− // The value as one token: quoted when it is empty or has a space in it
3908− let value = if !value.is_empty() && syntax::leading_token_len(value) == value.len() {
3909− value.to_string()
3910− } else {
3911− format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
3912− };
3913− let written = if takes_no_text(kind) {
3914− format!("@{} {}", name, value)
3915− } else {
3916− format!("@{} {} {}", name, value, text.raw)
3917 };
2301 };
3918− let d = Diagnostic::error(
2302+ Err(ParseError {
3919− code::DUPLICATE_ATTRIBUTE,
2303+ line: line_num,
3920− text.span.line,
2304+ message,
3921− format!(
2305+ })
3922− "@{} takes its first word '{}' as its {}, and it also has {}=: give it once, \
3923− as `{}`",
3924− name, token.raw, what, key, written
3925− ),
3926− )
3927− .subject(token.raw.clone());
3928− at_attribute(d, Some(token.span.column), ctx)
3929−}
3930−
3931−/// The attribute an element's leading argument fills, for messages.
3932−fn leading_name(kind: &ElementKind) -> &'static str {
3933− match kind.arg() {
3934− TagArg::Source => "srcset in @picture or src elsewhere",
3935− arg => arg.attribute(false).unwrap_or("argument"),
3936− }
3937−}
3938−
3939−/// Whether text after an element's leading argument has nowhere to go:
3940−/// an element without content (`@image`, `@source`, ...), or `@optgroup`,
3941−/// which holds only `@option` lines, so `@optgroup Citrus fruits` would
3942−/// otherwise make `Citrus` the label and drop `fruits`.
3943−fn takes_no_text(kind: &ElementKind) -> bool {
3944− kind.layout() == Layout::Void || kind.is_tag("optgroup")
3945}
2306}
3946
2307
3947−/// Text after the leading argument of an element without content:
2308+/// Directives and element names removed when the language was simplified,
3948−/// `@image logo.png Our logo`.
2309+/// with what replaces each. `htmlang upgrade` rewrites all of them.
3949−fn after_the_leading_argument(kind: &ElementKind, token: &str, rest: &syntax::Text) -> Diagnostic {
2310+const REMOVED_SYNTAX: &[(&str, &str)] = &[
3950− let name = kind.name();
2311+ ("@fn", "use `@let name $param` with an indented body"),
3951− let attr = leading_name(kind);
2312+ ("@define", "use `@let name [attributes]`"),
3952− let alt = if matches!(kind, ElementKind::Image) || kind.is_tag("area") {
2313+ ("@mixin", "use `@let name [attributes]`"),
3953− " For a text alternative, write `alt=...`."
2314+ ("@unless", "use `@if not <condition>`"),
3954− } else {
2315+ ("@for", "use `@each $i in 1..10`"),
3955− ""
2316+ ("@repeat", "use `@each $_ in 1..N`"),
3956− };
2317+ (
3957− let holds = if kind.layout() == Layout::Void {
2318+ "@switch",
3958− "it has no content"
2319+ "use `@match`, with `@let __switch [...]` inside a case for its attributes",
3959− } else {
2320+ ),
3960− "it holds only the elements on the lines under it"
2321+ ("@use", "use `@include`"),
3961− };
2322+ (
3962− let bare = syntax::quoted_string(token).unwrap_or(token);
2323+ "@import",
3963− Diagnostic::error(
2324+ "use `@include` (a file with only definitions emits no content)",
3964− code::UNEXPECTED_ARGUMENT,
2325+ ),
3965− rest.span.line,
2326+ ("@with", "use `@let alias $source`"),
3966− format!(
2327+ (
3967− "@{0} takes one word after its attributes, its {1} ('{2}'), and {5}, so '{3}' \
2328+ "@layout",
3968− would go nowhere. A value with a space in it is quoted: `\"{6} {3}\"`.{4}",
2329+ "use `@extends` (page content outside `@slot` blocks fills `@children`)",
3969− name, attr, token, rest.raw, alt, holds, bare
2330+ ),
3970− ),
2331+ ("@scope", "write the `@scope` rule in an `@style` block"),
3971− )
2332+ (
3972− .subject(rest.raw.clone())
2333+ "@starting-style",
3973−}
2334+ "write the `@starting-style` rule in an `@style` block",
2335+ ),
2336+ ("@css-property", "write an `@property` rule in an `@style` block"),
2337+ ("@lang", "use `@page [lang ...] Title`"),
2338+ ("@favicon", "use `@page [favicon ...] Title`"),
2339+ ("@canonical", "use `@page [canonical ...] Title`"),
2340+ ("@base", "use `@page [base ...] Title`"),
2341+ ("@og", "use `@meta og:NAME VALUE`"),
2342+ ("@debug", "use `@warn`"),
2343+ ("@match", "use `@if $x == a` / `@else if $x == b` / `@else`"),
2344+ ("@case", "use `@if $x == a` / `@else if $x == b` / `@else`"),
2345+ ("@default", "use `@else` in an `@if` chain"),
2346+ ("@theme", "use a `@let --name value` line per token"),
2347+ (
2348+ "@json-ld",
2349+ "put a `<script type=\"application/ld+json\">` in `@head`",
2350+ ),
2351+ ("@font-face", "write the `@font-face` rule in an `@style` block"),
2352+ (
2353+ "@manifest",
2354+ "write a manifest.json file and link it from `@head`",
2355+ ),
2356+ ("@breakpoint", "write the media query in an `@style` block"),
2357+ ("@deprecated", "remove it"),
2358+ (
2359+ "@collection",
2360+ "use `@data $name dir/*.json` (each file becomes `$name.STEM.key`)",
2361+ ),
2362+ ("@env", "use `@data $name env:NAME [default]`"),
2363+ (
2364+ "@fetch",
2365+ "download the data before building and use `@data $name file.json`",
2366+ ),
2367+ (
2368+ "@translations",
2369+ "put each locale's strings in a JSON file and use `@data $t locales/$lang.json`",
2370+ ),
2371+ ("@defer", "remove it: the content is already in the page"),
2372+ ("@log", "use `@warn`"),
2373+ (
2374+ "@component",
2375+ "use `@let`: an `@style` block in a function body is scoped to it",
2376+ ),
2377+ ("@col", "use `@column`"),
2378+ ("@p", "use `@paragraph`"),
2379+ ("@img", "use `@image`"),
2380+ ("@li", "use `@item`"),
2381+ ("@btn", "use `@button`"),
2382+ ("@ul", "use `@list`"),
2383+ ("@divider", "use `@hr`"),
2384+ ("@opt", "use `@option`"),
2385+];
3974
2386
3975−/// The element named `name`, or an "unknown element" error that suggests
2387+/// Attributes that became standard-library bundles or plain CSS.
3976−/// the closest element, directive or function.
2388+fn removed_attribute_hint(name: &str) -> Option<&'static str> {
3977−fn parse_element_kind(
2389+ match name {
3978− name: &str,
2390+ "skeleton" => Some("use the `$skeleton` bundle"),
3979− line_num: usize,
2391+ "no-scrollbar" => Some("use the `$no-scrollbar` bundle"),
3980− ctx: &mut ParseContext,
2392+ "gradient" => Some("use `background linear-gradient(...)`"),
3981−) -> Result<ElementKind, ParseError> {
2393+ "animate" => Some("use `animation`"),
3982− if let Some(kind) = ElementKind::from_name(name) {
2394+ "inset-area" => Some("use `position-area`"),
3983− return Ok(kind);
2395+ "gap-x" => Some("use `column-gap`"),
3984− }
2396+ "gap-y" => Some("use `row-gap`"),
3985− // A known name in a place it can't go: say why instead of suggesting
2397+ "shadow" => Some("use `box-shadow`"),
3986− // the name itself.
2398+ "blur" => Some("use `filter blur(...)`"),
3987− let misplaced = if crate::ast::directive(name).is_some() {
2399+ "backdrop-blur" => Some("use `backdrop-filter blur(...)`"),
3988− Some(format!(
2400+ "truncate" => Some("use the `$truncate` bundle"),
3989− "unknown element @{}: @{} is a directive, which goes at the start of its own line",
2401+ "critical" => Some("remove it"),
3990− name, name
2402+ _ => None,
3991− ))
3992− } else if ctx.env.bundle(name).is_some() {
3993− Some(format!(
3994− "unknown element @{}: '{}' is an attribute bundle, used as `[${}]` \
3995− (a function is defined with `@let @{}`)",
3996− name, name, name, name
3997− ))
3998− } else if ctx.env.value(name).is_some() {
3999− Some(format!(
4000− "unknown element @{}: '{}' is a value, used as `${}` \
4001− (a function is defined with `@let @{}`)",
4002− name, name, name, name
4003− ))
4004− } else if ctx.namespace.contains(name) {
4005− ctx.used_functions.insert(name.to_string());
4006− Some(format!(
4007− "unknown element @{}: the function @{} isn't visible here. A definition is \
4008− visible from its line to the end of its block, and a function's body sees what \
4009− is defined above the function, so define @{} above this line, outside the block \
4010− it is in",
4011− name, name, name
4012− ))
4013− } else if matches!(name, "svg" | "math" | "template") {
4014− Some(format!(
4015− "unknown element @{}: htmlang doesn't write <{}>; put its markup in the page with \
4016− @raw{}",
4017− name,
4018− name,
4019− if name == "svg" {
4020− " (or an SVG file with `@image [inline] file.svg`)"
4021− } else {
4022− ""
4023− }
4024− ))
4025− } else {
4026− None
4027− };
4028− if let Some(message) = misplaced {
4029− return Err(Diagnostic::error(code::UNKNOWN_ELEMENT, line_num, message).subject(name));
4030− }
4031− let all_known: Vec<&str> = ElementKind::all_names()
4032− .chain(DIRECTIVES.iter().map(|d| d.name))
4033− .collect();
4034− // An HTML element htmlang writes under its own name (`@a` is `@link`).
4035− let written_otherwise = crate::ast::HTML_NAMES_WRITTEN_OTHERWISE
4036− .iter()
4037− .find(|(html, _)| *html == name)
4038− .map(|(_, htmlang)| *htmlang);
4039− let closest = written_otherwise.or_else(|| suggest_closest(name, &all_known));
4040− let mut message = match (closest, written_otherwise) {
4041− (Some(closest), Some(_)) => format!(
4042− "unknown element @{}, did you mean @{}? (@{} writes <{}>)",
4043− name, closest, closest, name
4044− ),
4045− (Some(closest), None) => format!("unknown element @{}, did you mean @{}?", name, closest),
4046− (None, _) => format!("unknown element @{}", name),
4047− };
4048− let function = suggest_fn_name(name, ctx);
4049− if let Some(function) = &function {
4050− message = format!("{}, or did you mean @{}?", message, function);
4051 }
2403 }
4052− Err(Diagnostic::error(code::UNKNOWN_ELEMENT, line_num, message)
2404+}
4053− .subject(name)
2405+
4054− .suggest(closest.map(str::to_string).or(function)))
2406+/// If `content` starts with removed syntax, the error message for it.
2407+fn removed_syntax_hint(content: &str) -> Option<String> {
2408+ let trimmed = content.trim_start();
2409+ REMOVED_SYNTAX.iter().find_map(|(name, replacement)| {
2410+ let rest = trimmed.strip_prefix(name)?;
2411+ if !(rest.is_empty() || rest.starts_with([' ', '['])) {
2412+ return None;
2413+ }
2414+ Some(format!(
2415+ "`{}` was removed: {} (run `htmlang upgrade` to rewrite it automatically)",
2416+ name, replacement
2417+ ))
2418+ })
4055}
2419}
4056
2420
4057/// Format include/import stack as a readable chain for error messages.
2421/// Format include/import stack as a readable chain for error messages.
@@ -4122,37 +2486,34 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
4122 best
2486 best
4123}
2487}
4124
2488
4125−/// Suggest the closest user-defined function name for typos, from the
2489+/// Suggest the closest user-defined function name for typos.
4126−/// functions defined so far and those defined anywhere in the file.
4127fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
2490fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
4128 let input_chars: Vec<char> = input.chars().collect();
2491 let input_chars: Vec<char> = input.chars().collect();
4129 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
2492 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
4130− let mut best: Option<(usize, &String)> = None;
2493+ let mut best: Option<String> = None;
4131− let visible: Vec<&String> = ctx.defined_functions.keys().collect();
2494+ let mut best_dist = usize::MAX;
4132− for name in visible.into_iter().chain(ctx.namespace.iter()) {
2495+ for name in ctx.functions.keys() {
4133 let nlen = name.chars().count();
2496 let nlen = name.chars().count();
4134 if nlen.abs_diff(input_chars.len()) > max_allowed {
2497 if nlen.abs_diff(input_chars.len()) > max_allowed {
4135 continue;
2498 continue;
4136 }
2499 }
4137 let name_chars: Vec<char> = name.chars().collect();
2500 let name_chars: Vec<char> = name.chars().collect();
4138 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
2501 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
4139− if dist <= max_allowed && best.is_none_or(|b| (dist, name) < b) {
2502+ if dist < best_dist && dist <= max_allowed {
4140− best = Some((dist, name));
2503+ best_dist = dist;
2504+ best = Some(name.clone());
4141 }
2505 }
4142 }
2506 }
4143− best.map(|(_, name)| name.clone())
2507+ best
4144}
2508}
4145
2509
4146/// Suggest the closest variable name for undefined `$var` references.
2510/// Suggest the closest variable name for undefined `$var` references.
4147−fn suggest_var_name(input: &str, vars: &[&str]) -> Option<String> {
2511+fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<String> {
4148 let input_chars: Vec<char> = input.chars().collect();
2512 let input_chars: Vec<char> = input.chars().collect();
4149 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
2513 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
4150 let mut best: Option<String> = None;
2514 let mut best: Option<String> = None;
4151 let mut best_dist = usize::MAX;
2515 let mut best_dist = usize::MAX;
4152− let mut vars = vars.to_vec();
2516+ for name in vars.keys() {
4153− // The same answer every time, whatever the order of the names
4154− vars.sort_unstable();
4155− for name in vars {
4156 let nlen = name.chars().count();
2517 let nlen = name.chars().count();
4157 if nlen.abs_diff(input_chars.len()) > max_allowed {
2518 if nlen.abs_diff(input_chars.len()) > max_allowed {
4158 continue;
2519 continue;
@@ -4161,2162 +2522,1041 @@ fn suggest_var_name(input: &str, vars: &[&str]) -> Option<String> {
4161 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
2522 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
4162 if dist < best_dist && dist <= max_allowed {
2523 if dist < best_dist && dist <= max_allowed {
4163 best_dist = dist;
2524 best_dist = dist;
4164− best = Some(name.to_string());
2525+ best = Some(name.clone());
4165 }
2526 }
4166 }
2527 }
4167 best
2528 best
4168}
2529}
4169
2530
4170−// ---------------------------------------------------------------------------
2531+/// Check for undefined `$var` references and return "did you mean?" diagnostics.
4171−// Attribute parsing
2532+/// `indent` is the line's leading whitespace, which `input` has had trimmed;
4172−// ---------------------------------------------------------------------------
2533+/// reported columns and source lines include it.
4173−
2534+fn check_undefined_vars(
4174−/// The quoted strings of a CSS value, without their quotes.
2535+ input: &str,
4175−fn css_strings(value: &str) -> Vec<&str> {
2536+ vars: &HashMap<String, String>,
4176− let mut strings = Vec::new();
2537+ line_num: usize,
4177− let mut start = None;
2538+ indent: usize,
4178− let mut escaped = false;
2539+) -> Vec<Diagnostic> {
4179− for (i, c) in value.char_indices() {
2540+ let mut warnings = Vec::new();
4180− match (c, start) {
2541+ if !input.contains('$') {
4181− _ if escaped => escaped = false,
2542+ return warnings;
4182− ('\\', Some(_)) => escaped = true,
2543+ }
4183− ('"', None) => start = Some(i + 1),
2544+ let chars: Vec<char> = input.chars().collect();
4184− ('"', Some(from)) => {
2545+ let mut i = 0;
4185− strings.push(&value[from..i]);
2546+ while i < chars.len() {
4186− start = None;
2547+ if chars[i] == '$'
2548+ && i + 1 < chars.len()
2549+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
2550+ {
2551+ let col = i;
2552+ let start = i + 1;
2553+ let mut end = start;
2554+ while end < chars.len()
2555+ && (chars[end].is_alphanumeric()
2556+ || chars[end] == '-'
2557+ || chars[end] == '_'
2558+ || chars[end] == '.')
2559+ {
2560+ end += 1;
4187 }
2561 }
4188− _ => {}
2562+ while end > start && chars[end - 1] == '.' {
2563+ end -= 1;
2564+ }
2565+ let name: String = chars[start..end].iter().collect();
2566+ if !name.is_empty()
2567+ && !vars.contains_key(&name)
2568+ && let Some(closest) = suggest_var_name(&name, vars)
2569+ {
2570+ warnings.push(Diagnostic {
2571+ line: line_num,
2572+ column: Some(indent + col),
2573+ message: format!(
2574+ "undefined variable '${}', did you mean '${}'?",
2575+ name, closest
2576+ ),
2577+ severity: Severity::Warning,
2578+ source_line: Some(format!("{}{}", " ".repeat(indent), input)),
2579+ });
2580+ }
2581+ i = end;
2582+ } else {
2583+ i += 1;
4189 }
2584 }
4190 }
2585 }
4191− strings
2586+ warnings
4192}
2587}
4193
2588
4194−/// An `if(...)` in a CSS value that isn't CSS's if(): CSS writes each
2589+// ---------------------------------------------------------------------------
4195−/// branch `CONDITION: VALUE`, so an if() without a `:` is something else.
2590+// Attribute parsing
4196−/// Quoted CSS strings are text, not calls.
2591+// ---------------------------------------------------------------------------
4197−fn not_css_if(value: &str) -> Option<&str> {
2592+
4198− let mut quote = None;
2593+/// Attributes that expect purely numeric values (px-based) or values with CSS units.
4199− let mut prev = None;
2594+const NUMERIC_ATTRS: &[&str] = &[
4200− let mut chars = value.char_indices();
2595+ "spacing",
4201− while let Some((at, c)) = chars.next() {
2596+ "gap",
4202− match quote {
2597+ "padding",
4203− Some(_) if c == '\\' => {
2598+ "padding-x",
4204− chars.next();
2599+ "padding-y",
2600+ "padding-top",
2601+ "padding-bottom",
2602+ "padding-left",
2603+ "padding-right",
2604+ "min-width",
2605+ "max-width",
2606+ "min-height",
2607+ "max-height",
2608+ "rounded",
2609+ "size",
2610+ "column-gap",
2611+ "row-gap",
2612+ "top",
2613+ "right",
2614+ "bottom",
2615+ "left",
2616+ "letter-spacing",
2617+];
2618+
2619+const CSS_UNIT_SUFFIXES: &[&str] = &[
2620+ "px", "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm",
2621+ "mm", "in", "pt", "pc", "fr",
2622+];
2623+
2624+fn has_css_unit(value: &str) -> bool {
2625+ CSS_UNIT_SUFFIXES.iter().any(|u| value.ends_with(u))
2626+ || value.starts_with("var(")
2627+ || value.starts_with("calc(")
2628+ || value.starts_with("clamp(")
2629+ || value.starts_with("min(")
2630+ || value.starts_with("max(")
2631+}
2632+
2633+/// Attributes that accept numeric OR keyword values.
2634+const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
2635+const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
2636+
2637+fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext) {
2638+ let base_key = crate::vocab::base_attribute(attr.key.as_str());
2639+
2640+ if let Some(val) = &attr.value {
2641+ if NUMERIC_ATTRS.contains(&base_key) {
2642+ // All space-separated parts must be numeric or have a CSS unit
2643+ for part in val.split_whitespace() {
2644+ if part.parse::<f64>().is_err() && !has_css_unit(part) {
2645+ ctx.diagnostics.push(Diagnostic {
2646+ line: line_num,
2647+ column: None,
2648+ message: format!(
2649+ "'{}' expects a numeric value (with optional unit), got '{}'",
2650+ attr.key, val
2651+ ),
2652+ severity: Severity::Warning,
2653+ source_line: None,
2654+ });
2655+ return;
2656+ }
4205 }
2657 }
4206− Some(q) if c == q => quote = None,
2658+ } else if NUMERIC_OR_KEYWORD_ATTRS.contains(&base_key) {
4207− Some(_) => {}
2659+ let is_keyword = SIZE_KEYWORDS.contains(&val.as_str());
4208− None if matches!(c, '"' | '\'') => quote = Some(c),
2660+ let is_numeric = val.parse::<f64>().is_ok();
4209− None => {
2661+ let has_unit = has_css_unit(val);
4210− let word_before =
2662+ if !is_keyword && !is_numeric && !has_unit {
4211− prev.is_some_and(|p: char| p.is_alphanumeric() || matches!(p, '-' | '_'));
2663+ ctx.diagnostics.push(Diagnostic {
4212− if !word_before && value[at..].starts_with("if(") {
2664+ line: line_num,
4213− let mut depth = 0;
2665+ column: None,
4214− let close = value[at + 2..].char_indices().find_map(|(i, c)| {
2666+ message: format!(
4215− match c {
2667+ "'{}' expects a number or one of [{}], got '{}'",
4216− '(' => depth += 1,
2668+ attr.key,
4217− ')' => depth -= 1,
2669+ SIZE_KEYWORDS.join(", "),
4218− _ => {}
2670+ val
4219− }
2671+ ),
4220− (depth == 0).then_some(at + 2 + i)
2672+ severity: Severity::Warning,
2673+ source_line: None,
2674+ });
2675+ }
2676+ } else if base_key == "opacity" {
2677+ if let Ok(v) = val.parse::<f64>() {
2678+ if !(0.0..=1.0).contains(&v) {
2679+ ctx.diagnostics.push(Diagnostic {
2680+ line: line_num,
2681+ column: None,
2682+ message: format!("'opacity' should be between 0 and 1, got '{}'", val),
2683+ severity: Severity::Warning,
2684+ source_line: None,
4221 });
2685 });
4222− let end = close.map_or(value.len(), |c| c + 1);
4223− if !value[at + 3..end].contains(':') {
4224− return Some(&value[at..end]);
4225− }
4226 }
2686 }
2687+ } else {
2688+ ctx.diagnostics.push(Diagnostic {
2689+ line: line_num,
2690+ column: None,
2691+ message: format!("'opacity' expects a numeric value, got '{}'", val),
2692+ severity: Severity::Warning,
2693+ source_line: None,
2694+ });
4227 }
2695 }
4228− }
2696+ } else if base_key == "z-index" {
4229− prev = Some(c);
2697+ if val.parse::<i32>().is_err() {
4230− }
2698+ ctx.diagnostics.push(Diagnostic {
4231− None
2699+ line: line_num,
4232−}
2700+ column: None,
4233−
2701+ message: format!("'z-index' expects an integer, got '{}'", val),
4234−/// Why a CSS value can't be written into a rule as it is, whatever it
2702+ severity: Severity::Warning,
4235−/// means: a `;` (outside parentheses, where CSS's own `if()` uses it), a
2703+ source_line: None,
4236−/// `{` or a `}` would end the declaration or the rule, and an unclosed
2704+ });
4237−/// quote or parenthesis would run on into the rules after it. Quoted
4238−/// strings are text, and a backslash escapes the character after it, as
4239−/// in CSS. A `</style` anywhere, even in a string, would end the page's
4240−/// `<style>` element and start HTML.
4241−fn css_breakout(value: &str) -> Option<&'static str> {
4242− if value.to_ascii_lowercase().contains("</style") {
4243− return Some("a `</style`, which would end the page's style element");
4244− }
4245− let mut quote = None;
4246− let mut depth = 0usize;
4247− let mut chars = value.chars();
4248− while let Some(c) = chars.next() {
4249− match (quote, c) {
4250− (_, '\\') => {
4251− chars.next();
4252 }
2705 }
4253− (Some(q), c) if c == q => quote = None,
2706+ } else if base_key == "display" {
4254− (Some(_), _) => {}
2707+ const DISPLAY_VALUES: &[&str] = &[
4255− (None, '"' | '\'') => quote = Some(c),
2708+ "none",
4256− (None, '(') => depth += 1,
2709+ "block",
4257− (None, ')') if depth == 0 => return Some("a `)` that closes nothing"),
2710+ "inline",
4258− (None, ')') => depth -= 1,
2711+ "inline-block",
4259− (None, ';') if depth == 0 => return Some("a `;`, which would end the declaration"),
2712+ "flex",
4260− (None, '{' | '}') => return Some("a `{` or `}`, which would end the CSS rule"),
2713+ "inline-flex",
4261− _ => {}
2714+ "grid",
4262− }
2715+ "inline-grid",
4263− }
2716+ "table",
4264− match (quote, depth) {
2717+ "table-row",
4265− (Some('"'), _) => Some("a `\"` that isn't closed"),
2718+ "table-cell",
4266− (Some(_), _) => Some("a `'` that isn't closed"),
2719+ "contents",
4267− (None, 0) => None,
2720+ "flow-root",
4268− (None, _) => Some("a `(` that isn't closed"),
2721+ "list-item",
4269− }
2722+ ];
4270−}
2723+ if !DISPLAY_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4271−
2724+ let suggestion = suggest_closest(val, DISPLAY_VALUES);
4272−/// The hex colors of a CSS value that don't have 3, 4, 6 or 8 hex
2725+ let msg = match suggestion {
4273−/// digits. A `#` word in a CSS value is a color, except inside `url(...)`
2726+ Some(s) => format!("unknown display value '{}', did you mean '{}'?", val, s),
4274−/// and quoted strings.
2727+ None => format!("unknown display value '{}'", val),
4275−fn bad_hex_colors(value: &str) -> Vec<&str> {
2728+ };
4276− let mut bad = Vec::new();
2729+ ctx.diagnostics.push(Diagnostic {
4277− let mut quote = None;
2730+ line: line_num,
4278− let mut in_url = 0usize;
2731+ column: None,
4279− let mut depth = 0usize;
2732+ message: msg,
4280− let mut chars = value.char_indices().peekable();
2733+ severity: Severity::Warning,
4281− while let Some((at, c)) = chars.next() {
2734+ source_line: None,
4282− match (quote, c) {
2735+ });
4283− (_, '\\') => {
4284− chars.next();
4285 }
2736 }
4286− (Some(q), c) if c == q => quote = None,
2737+ } else if base_key == "position" {
4287− (Some(_), _) => {}
2738+ const POSITION_VALUES: &[&str] = &["static", "relative", "absolute", "fixed", "sticky"];
4288− (None, '"' | '\'') => quote = Some(c),
2739+ if !POSITION_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4289− (None, '(') => {
2740+ let suggestion = suggest_closest(val, POSITION_VALUES);
4290− depth += 1;
2741+ let msg = match suggestion {
4291− if value[..at].ends_with("url") && in_url == 0 {
2742+ Some(s) => format!("unknown position value '{}', did you mean '{}'?", val, s),
4292− in_url = depth;
2743+ None => format!("unknown position value '{}'", val),
4293− }
2744+ };
2745+ ctx.diagnostics.push(Diagnostic {
2746+ line: line_num,
2747+ column: None,
2748+ message: msg,
2749+ severity: Severity::Warning,
2750+ source_line: None,
2751+ });
4294 }
2752 }
4295− (None, ')') => {
2753+ } else if base_key == "overflow" || base_key == "overflow-x" || base_key == "overflow-y" {
4296− if in_url == depth {
2754+ const OVERFLOW_VALUES: &[&str] = &["visible", "hidden", "scroll", "auto", "clip"];
4297− in_url = 0;
2755+ if !OVERFLOW_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4298− }
2756+ let suggestion = suggest_closest(val, OVERFLOW_VALUES);
4299− depth = depth.saturating_sub(1);
2757+ let msg = match suggestion {
2758+ Some(s) => format!("unknown overflow value '{}', did you mean '{}'?", val, s),
2759+ None => format!("unknown overflow value '{}'", val),
2760+ };
2761+ ctx.diagnostics.push(Diagnostic {
2762+ line: line_num,
2763+ column: None,
2764+ message: msg,
2765+ severity: Severity::Warning,
2766+ source_line: None,
2767+ });
4300 }
2768 }
4301− (None, '#') if in_url == 0 => {
2769+ } else if base_key == "text-align" {
4302− let end = value[at + 1..]
2770+ const TEXT_ALIGN_VALUES: &[&str] =
4303− .find(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_'))
2771+ &["left", "right", "center", "justify", "start", "end"];
4304− .map_or(value.len(), |n| at + 1 + n);
2772+ if !TEXT_ALIGN_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4305− let hex = &value[at + 1..end];
2773+ let suggestion = suggest_closest(val, TEXT_ALIGN_VALUES);
4306− if !(matches!(hex.len(), 3 | 4 | 6 | 8)
2774+ let msg = match suggestion {
4307− && hex.chars().all(|c| c.is_ascii_hexdigit()))
2775+ Some(s) => format!("unknown text-align value '{}', did you mean '{}'?", val, s),
4308− {
2776+ None => format!("unknown text-align value '{}'", val),
4309− bad.push(&value[at..end]);
2777+ };
4310− }
2778+ ctx.diagnostics.push(Diagnostic {
2779+ line: line_num,
2780+ column: None,
2781+ message: msg,
2782+ severity: Severity::Warning,
2783+ source_line: None,
2784+ });
4311 }
2785 }
4312− _ => {}
2786+ } else if base_key == "cursor" {
4313− }
2787+ const CURSOR_VALUES: &[&str] = &[
4314− }
2788+ "auto",
4315− bad
2789+ "default",
4316−}
2790+ "none",
4317−
2791+ "pointer",
4318−/// A diagnostic about an attribute, with its column and source line when
2792+ "wait",
4319−/// it is on the line being evaluated.
2793+ "text",
4320−fn at_attribute(diagnostic: Diagnostic, column: Option<usize>, ctx: &ParseContext) -> Diagnostic {
2794+ "move",
4321− match (&ctx.current_source, column) {
2795+ "not-allowed",
4322− ((current, Some(text)), Some(column)) if *current == diagnostic.line => {
2796+ "crosshair",
4323− diagnostic.column(column).source(text.clone())
2797+ "grab",
4324− }
2798+ "grabbing",
4325− _ => diagnostic,
2799+ "help",
4326− }
2800+ "progress",
4327−}
2801+ "col-resize",
4328−
2802+ "row-resize",
4329−/// Check a style's value. Values go to the CSS as written, so only what
2803+ "n-resize",
4330−/// can't be wrong in any CSS is checked: a value that would break out of
2804+ "s-resize",
4331−/// its rule is an error (and it is left out); a hex color without 3, 4, 6
2805+ "e-resize",
4332−/// or 8 digits, an `if()` that isn't CSS's and a quoted font stack are
2806+ "w-resize",
4333−/// warnings. Returns whether the value can be written.
2807+ "zoom-in",
4334−fn check_css_value(
2808+ "zoom-out",
4335− attr: &Attribute,
2809+ "context-menu",
4336− base: &str,
2810+ "cell",
4337− line: usize,
2811+ "copy",
4338− column: Option<usize>,
2812+ "alias",
4339− ctx: &mut ParseContext,
2813+ "no-drop",
4340−) -> bool {
2814+ ];
4341− let Some(value) = &attr.value else {
2815+ if !CURSOR_VALUES.contains(&val.as_str())
4342− return true;
2816+ && !val.starts_with("url(")
4343− };
2817+ && !val.starts_with("var(")
4344− if let Some(reason) = css_breakout(value) {
4345− let diagnostic = Diagnostic::error(
4346− code::INVALID_VALUE,
4347− line,
4348− format!(
4349− "the value of '{}' has {}: `{}` can't be written into the page's CSS",
4350− attr.key, reason, value
4351− ),
4352− )
4353− .subject(value.as_str());
4354− ctx.diagnostics.push(at_attribute(diagnostic, column, ctx));
4355− return false;
4356− }
4357− if crate::vocab::is_custom_property(base) {
4358− return true;
4359− }
4360−
4361− let warn = |ctx: &mut ParseContext, diagnostic: Diagnostic| {
4362− let diagnostic = at_attribute(diagnostic, column, ctx);
4363− ctx.diagnostics.push(diagnostic);
4364− };
4365− // In CSS, a quoted font-family is one family, commas and all
4366− if base == "font-family"
4367− && let Some(family) = css_strings(value).into_iter().find(|s| s.contains(','))
4368− {
4369− let stack = family
4370− .split(',')
4371− .map(str::trim)
4372− .collect::<Vec<_>>()
4373− .join("\\, ");
4374− warn(
4375− ctx,
4376− Diagnostic::warning(
4377− code::INVALID_VALUE,
4378− line,
4379− format!(
4380− "font-family \"{}\" is one family, whose name has a comma in it: \
4381− for a font stack, write `font-family {}`",
4382− family, stack
4383− ),
4384− )
4385− .subject(format!("\"{}\"", family))
4386− .suggest(Some(stack)),
4387− );
4388− }
4389− if let Some(call) = not_css_if(value) {
4390− warn(
4391− ctx,
4392− Diagnostic::warning(
4393− code::INVALID_VALUE,
4394− line,
4395− format!(
4396− "'{}' is not CSS's if(), whose branches are written \
4397− `if(CONDITION: VALUE; else: VALUE)`",
4398− call
4399− ),
4400− )
4401− .subject(call),
4402− );
4403− }
4404− for color in bad_hex_colors(value) {
4405− warn(
4406− ctx,
4407− Diagnostic::warning(
4408− code::INVALID_COLOR,
4409− line,
4410− format!(
4411− "invalid hex color '{}': a hex color has 3, 4, 6 or 8 hex digits",
4412− color
4413− ),
4414− )
4415− .subject(color),
4416− );
4417− }
4418− true
4419−}
4420−
4421−/// An unknown prefix: the message, the text it is about, and what to write
4422−/// instead, when something is close enough.
4423−struct PrefixProblem {
4424− message: String,
4425− subject: String,
4426− suggestion: Option<String>,
4427−}
4428−
4429−/// What is wrong with the prefix `rest` starts with, when the known
4430−/// prefixes are already split off it and what is left still starts with
4431−/// one: a word and a `:` (`hovr:`), or a word and a parenthesised argument
4432−/// and a `:` (`nth-chld(2):`). Selector prefixes are CSS's pseudo-classes
4433−/// and pseudo-elements under their CSS names, so an unknown one gets the
4434−/// closest name as a suggestion. Only names with the same first letter are
4435−/// suggested: `odd:` is two edits from `md:`, which it doesn't mean.
4436−fn unknown_prefix(rest: &str) -> Option<PrefixProblem> {
4437− use crate::vocab::{self, Pseudo};
4438− let name_len = rest.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))?;
4439− let name = &rest[..name_len];
4440− if name.is_empty() {
4441− return None;
4442− }
4443− if rest[name_len..].starts_with('(') {
4444− // `name(...):`: a pseudo-class with an argument
4445− let close = vocab::closing_paren(rest, name_len)?;
4446− if !rest[close + 1..].starts_with(':') {
4447− return None;
4448− }
4449− let written = &rest[..close + 2];
4450− if matches!(vocab::pseudo(name), Some(Pseudo::Class | Pseudo::Element)) {
4451− let bare = format!("{}:", name);
4452− return Some(PrefixProblem {
4453− message: format!(
4454− "'{}': `{}` takes no argument: write `{}`",
4455− written,
4456− vocab::pseudo_selector(&bare).unwrap_or_default(),
4457− bare
4458− ),
4459− subject: written.to_string(),
4460− suggestion: Some(bare),
4461− });
4462− }
4463− let functional: Vec<&str> = vocab::functional_pseudos()
4464− .into_iter()
4465− .filter(|p| p.chars().next() == name.chars().next())
4466− .collect();
4467− let suggestion = suggest_closest(name, &functional)
4468− .filter(|&closest| closest != name)
4469− .map(|closest| format!("{}(", closest));
4470− let written = format!("{}(", name);
4471− let message = match &suggestion {
4472− Some(closest) => format!("unknown prefix '{}', did you mean '{}'?", written, closest),
4473− None => format!("unknown prefix '{}'", written),
4474− };
4475− return Some(PrefixProblem {
4476− message,
4477− subject: written,
4478− suggestion,
4479− });
4480− }
4481− if !rest[name_len..].starts_with(':') {
4482− return None;
4483− }
4484− let written = &rest[..name_len + 1];
4485− // `has:`: its argument is missing
4486− if vocab::pseudo(name) == Some(Pseudo::Function) {
4487− return Some(PrefixProblem {
4488− message: format!(
4489− "'{}': `:{}()` takes an argument, in parentheses, as in CSS: `{}(...):`",
4490− written, name, name
4491− ),
4492− subject: written.to_string(),
4493− suggestion: None,
4494− });
4495− }
4496− let prefixes = vocab::all_prefixes();
4497− let candidates: Vec<&str> = prefixes
4498− .iter()
4499− .map(String::as_str)
4500− .filter(|p| p.chars().next() == written.chars().next())
4501− .collect();
4502− // The closest name that starts with the same letter (`hvoer:` →
4503− // `hover:`), else the first that starts with the word written and a `-`
4504− // (`first:` → `first-child:`)
4505− let suggestion = suggest_closest(written, &candidates)
4506− .or_else(|| {
4507− let longer = format!("{}-", name);
4508− candidates.iter().copied().find(|p| p.starts_with(&longer))
4509− })
4510− .filter(|&closest| closest != written)
4511− .map(str::to_string);
4512− let message = match &suggestion {
4513− Some(closest) => format!("unknown prefix '{}', did you mean '{}'?", written, closest),
4514− None => format!("unknown prefix '{}'", written),
4515− };
4516− Some(PrefixProblem {
4517− message,
4518− subject: written.to_string(),
4519− suggestion,
4520− })
4521−}
4522−
4523−/// Check an attribute's element prefix (`@td:padding 8`), when it has one:
4524−/// it names a built-in element that has an HTML tag of its own (not a
4525−/// function, and not `@el`, whose `<div>` other elements write too), there
4526−/// is one, not with `children:`, and what it styles is a CSS property, not
4527−/// one of htmlang's words, which compile against each element's layout and
4528−/// its parent's. Returns false, after reporting, when the attribute can't
4529−/// be written.
4530−fn check_element_prefixes(
4531− attr: &Attribute,
4532− prefixes: &[&str],
4533− base: &str,
4534− line: usize,
4535− column: Option<usize>,
4536− ctx: &mut ParseContext,
4537−) -> bool {
4538− use crate::ast::ElementKind;
4539− use crate::vocab;
4540− let elements: Vec<&str> = prefixes
4541− .iter()
4542− .copied()
4543− .filter(|p| vocab::element_prefix(p).is_some())
4544− .collect();
4545− let Some(&prefix) = elements.first() else {
4546− return true;
4547− };
4548− let fail = |ctx: &mut ParseContext,
4549− code: &'static str,
4550− message: String,
4551− subject: &str,
4552− suggestion: Option<String>| {
4553− let diagnostic = Diagnostic::error(code, line, message)
4554− .subject(subject)
4555− .suggest(suggestion);
4556− let diagnostic = at_attribute(diagnostic, column, ctx);
4557− ctx.diagnostics.push(diagnostic);
4558− false
4559− };
4560− let name = vocab::element_prefix(prefix).unwrap_or_default();
4561− if is_function(name, ctx) {
4562− return fail(
4563− ctx,
4564− code::INVALID_PREFIX,
4565− format!(
4566− "'{}': @{} is a function, and `{}` would pick elements out by their HTML tag, \
4567− which a function doesn't have: style what @{} writes in its body, or pass it \
4568− attributes",
4569− attr.key, name, prefix, name
4570− ),
4571− prefix,
4572− None,
4573− );
4574− }
4575− match ElementKind::from_name(name) {
4576− None => {
4577− let own: Vec<&str> = ElementKind::all_names()
4578− .filter(|n| ElementKind::from_name(n).is_some_and(|k| k.own_tag().is_some()))
4579− .collect();
4580− // An HTML name htmlang writes under another (`@a:` is `@link:`)
4581− let (written_otherwise, shared) = match crate::ast::HTML_NAMES_WRITTEN_OTHERWISE
4582− .iter()
4583− .find(|(html, _)| *html == name)
4584 {
2818 {
4585− Some((_, htmlang)) if own.contains(htmlang) => (Some(*htmlang), false),
2819+ let suggestion = suggest_closest(val, CURSOR_VALUES);
4586− Some(_) => (None, true),
2820+ let msg = match suggestion {
4587− None => (None, false),
2821+ Some(s) => format!("unknown cursor value '{}', did you mean '{}'?", val, s),
4588− };
2822+ None => format!("unknown cursor value '{}'", val),
4589− // `@each:`, `@markdown:`: a directive, which is no element
2823+ };
4590− if crate::ast::directive(name).is_some() {
2824+ ctx.diagnostics.push(Diagnostic {
4591− return fail(
2825+ line: line_num,
4592− ctx,
2826+ column: None,
4593− code::UNKNOWN_PREFIX,
2827+ message: msg,
4594− format!(
2828+ severity: Severity::Warning,
4595− "unknown element @{} in '{}': @{} is a directive, and an element \
2829+ source_line: None,
4596− prefix names an element with an HTML tag of its own, such as `@td:`",
2830+ });
4597− name, prefix, name
4598− ),
4599− prefix,
4600− None,
4601− );
4602 }
2831 }
4603− // `@div:`, `@span:`: several elements write it
2832+ } else if base_key == "font-weight" {
4604− if shared {
2833+ const WEIGHT_VALUES: &[&str] = &[
4605− return fail(
2834+ "normal", "bold", "bolder", "lighter", "100", "200", "300", "400", "500", "600",
4606− ctx,
2835+ "700", "800", "900",
4607− code::UNKNOWN_PREFIX,
2836+ ];
4608− format!(
2837+ if !WEIGHT_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4609− "unknown element @{} in '{}': htmlang writes <{}> for several \
2838+ ctx.diagnostics.push(Diagnostic {
4610− elements, so no element prefix picks it out",
2839+ line: line_num,
4611− name, prefix, name
2840+ column: None,
2841+ message: format!(
2842+ "'font-weight' expects a weight keyword or number 100-900, got '{}'",
2843+ val
4612 ),
2844 ),
4613− prefix,
2845+ severity: Severity::Warning,
4614− None,
2846+ source_line: None,
4615− );
2847+ });
4616 }
2848 }
4617− // A name with the same first letter first (`@tdd:` is `@td:`)
2849+ } else if base_key == "color" || base_key == "background" {
4618− let same_letter: Vec<&str> = own
2850+ // Validate named CSS colors (only if not hex, rgb, hsl, var, etc.)
4619− .iter()
2851+ if !val.starts_with('#')
4620− .copied()
2852+ && !val.starts_with("rgb")
4621− .filter(|n| n.chars().next() == name.chars().next())
2853+ && !val.starts_with("hsl")
4622− .collect();
2854+ && !val.starts_with("var(")
4623− let closest = written_otherwise
2855+ && !val.starts_with("linear-gradient")
4624− .or_else(|| suggest_closest(name, &same_letter))
2856+ && !val.starts_with("radial-gradient")
4625− .or_else(|| suggest_closest(name, &own));
2857+ && !val.starts_with("conic-gradient")
4626− let message = match (closest, written_otherwise) {
2858+ && !val.starts_with("oklch")
4627− (Some(closest), Some(_)) => format!(
2859+ && !val.starts_with("oklab")
4628− "unknown element @{} in '{}', did you mean '@{}:'? (@{} writes <{}>)",
2860+ && !val.starts_with("color(")
4629− name, prefix, closest, closest, name
2861+ && !val.starts_with("light-dark(")
4630− ),
2862+ && !val.contains("url(")
4631− (Some(closest), None) => format!(
2863+ && !val.contains(' ')
4632− "unknown element @{} in '{}', did you mean '@{}:'?",
2864+ // skip shorthand multi-value
4633− name, prefix, closest
2865+ {
4634− ),
2866+ const NAMED_COLORS: &[&str] = &[
4635− (None, _) => format!("unknown element @{} in '{}'", name, prefix),
2867+ "transparent",
4636− };
2868+ "currentcolor",
4637− let suggestion = closest.map(|c| format!("@{}:", c));
2869+ "inherit",
4638− fail(ctx, code::UNKNOWN_PREFIX, message, prefix, suggestion)
2870+ "initial",
4639− }
2871+ "unset",
4640− Some(kind) if kind.own_tag().is_none() => {
2872+ "black",
4641− let reason = match kind.tag() {
2873+ "white",
4642− None => format!(
2874+ "red",
4643− "@{} writes no element of its own, so there is nothing for `{}` to style",
2875+ "green",
4644− name, prefix
2876+ "blue",
4645− ),
2877+ "yellow",
4646− Some(_) if kind.is_verbatim() => {
2878+ "orange",
4647− format!("@{} isn't shown, so there is nothing to style", name)
2879+ "purple",
4648− }
2880+ "pink",
4649− Some("span") => format!(
2881+ "brown",
4650− "@{} writes a <span>, as every line of text in a row or column does, so \
2882+ "gray",
4651− `{}` can't pick it out",
2883+ "grey",
4652− name, prefix
2884+ "cyan",
4653− ),
2885+ "magenta",
4654− Some(tag) => {
2886+ "lime",
4655− let others: Vec<String> = ElementKind::all_names()
2887+ "olive",
4656− .filter(|n| {
2888+ "navy",
4657− *n != name
2889+ "teal",
4658− && ElementKind::from_name(n).and_then(|k| k.tag()) == Some(tag)
2890+ "aqua",
4659− })
2891+ "fuchsia",
4660− .map(|n| format!("@{}", n))
2892+ "maroon",
4661− .collect();
2893+ "silver",
4662− let others = match others.split_last() {
2894+ "coral",
4663− Some((last, [])) => last.clone(),
2895+ "salmon",
4664− Some((last, rest)) => format!("{} and {}", rest.join(", "), last),
2896+ "tomato",
4665− None => String::new(),
2897+ "crimson",
2898+ "firebrick",
2899+ "darkred",
2900+ "indigo",
2901+ "violet",
2902+ "plum",
2903+ "orchid",
2904+ "thistle",
2905+ "lavender",
2906+ "gold",
2907+ "khaki",
2908+ "wheat",
2909+ "tan",
2910+ "sienna",
2911+ "chocolate",
2912+ "peru",
2913+ "beige",
2914+ "ivory",
2915+ "linen",
2916+ "snow",
2917+ "seashell",
2918+ "mintcream",
2919+ "skyblue",
2920+ "steelblue",
2921+ "royalblue",
2922+ "dodgerblue",
2923+ "cornflowerblue",
2924+ "slategray",
2925+ "slategrey",
2926+ "dimgray",
2927+ "dimgrey",
2928+ "lightgray",
2929+ "lightgrey",
2930+ "darkgray",
2931+ "darkgrey",
2932+ "gainsboro",
2933+ "whitesmoke",
2934+ ];
2935+ if !NAMED_COLORS.contains(&val.to_lowercase().as_str()) {
2936+ let lower = val.to_lowercase();
2937+ let suggestion = suggest_closest(&lower, NAMED_COLORS);
2938+ let msg = match suggestion {
2939+ Some(s) => format!("unknown color '{}', did you mean '{}'?", val, s),
2940+ None => format!("unknown color '{}'", val),
4666 };
2941 };
4667− format!(
2942+ ctx.diagnostics.push(Diagnostic {
4668− "@{} writes a <{}>, as {} do, so `{}` can't pick it out",
2943+ line: line_num,
4669− name, tag, others, prefix
2944+ column: None,
4670− )
2945+ message: msg,
2946+ severity: Severity::Warning,
2947+ source_line: None,
2948+ });
4671 }
2949 }
4672− };
2950+ }
4673− fail(
4674− ctx,
4675− code::INVALID_PREFIX,
4676− format!(
4677− "'{}': {}. Give those elements their styles themselves, or a bundle \
4678− (`@let name [...]`, used as `[$name]`)",
4679− attr.key, reason
4680− ),
4681− prefix,
4682− None,
4683− )
4684− }
4685− Some(_) if elements.len() > 1 => fail(
4686− ctx,
4687− code::INVALID_PREFIX,
4688− format!(
4689− "'{}': a style takes one element prefix, which styles every such element \
4690− inside this one, at any depth",
4691− attr.key
4692− ),
4693− &attr.key,
4694− None,
4695− ),
4696− Some(_) if prefixes.contains(&vocab::CHILDREN) => fail(
4697− ctx,
4698− code::INVALID_PREFIX,
4699− format!(
4700− "'{}': `children:` and `{}` each say which elements the style is for: write \
4701− one of them",
4702− attr.key, prefix
4703− ),
4704− &attr.key,
4705− None,
4706− ),
4707− Some(_) if vocab::is_htmlang_word(base, attr.value.as_deref()) => {
4708− let value = attr.value.as_deref().map(str::trim);
4709− let css = match (base, value) {
4710− ("spacing", _) => " (`gap`)",
4711− ("wrap", _) => " (`flex-wrap wrap`)",
4712− ("grid-cols", _) => " (`grid-template-columns`)",
4713− ("grid-rows", _) => " (`grid-template-rows`)",
4714− ("col-span", _) => " (`grid-column span N`)",
4715− ("row-span", _) => " (`grid-row span N`)",
4716− ("width", Some("fill")) => " (`flex 1` in a row, `width 100%` otherwise)",
4717− ("height", Some("fill")) => " (`flex 1` in a column, `height 100%` otherwise)",
4718− ("width", _) => " (`flex-shrink 0` in a row, `width fit-content` otherwise)",
4719− ("height", _) => " (`flex-shrink 0` in a column, `height fit-content` otherwise)",
4720− ("center-x", _) => " (`margin-inline auto`)",
4721− ("center-y", _) => " (`margin-block auto`)",
4722− _ => "",
4723− };
4724− let word = match value {
4725− Some(v) if matches!(base, "width" | "height") => format!("{} {}", base, v),
4726− _ => base.to_string(),
4727− };
4728− fail(
4729− ctx,
4730− code::INVALID_PREFIX,
4731− format!(
4732− "'{}': `{}` is one of htmlang's layout words, which compile against each \
4733− element's layout and its parent's, and `{}` styles elements whose \
4734− parents it doesn't know: write `{}` on the elements themselves, or the \
4735− CSS you mean{}",
4736− attr.key, word, prefix, word, css
4737− ),
4738− &attr.key,
4739− None,
4740− )
4741 }
2951 }
4742− Some(_) => true,
4743 }
2952 }
4744}
2953}
4745
2954
4746−/// Check an attribute's prefixes: each is known, their selector prefixes
2955+fn parse_attr_brackets(
4747−/// read left to right with a pseudo-element (`before:`) last, the argument
2956+ input: &str,
4748−/// of `has(...)` or `nth-child(...)` can be written into the CSS, and a word that
2957+ line_num: usize,
4749−/// places an element in its parent (`width fill`, `center-x`, `align-*`)
4750−/// isn't under `children:`, whose styles go on the children. Media,
4751−/// container and width prefixes go anywhere. Returns false, after
4752−/// reporting, when the attribute can't be written.
4753−fn check_prefixes(
4754− attr: &Attribute,
4755− line: usize,
4756− column: Option<usize>,
4757 ctx: &mut ParseContext,
2958 ctx: &mut ParseContext,
4758−) -> bool {
2959+) -> Result<(Vec<Attribute>, String), ParseError> {
4759− use crate::vocab;
2960+ parse_attr_brackets_inner(input, line_num, ctx, true)
4760− let (prefixes, base) = vocab::split_prefixes(&attr.key);
2961+}
4761− let fail =
4762− |ctx: &mut ParseContext, message: String, subject: &str, suggestion: Option<String>| {
4763− let diagnostic = Diagnostic::error(code::INVALID_PREFIX, line, message)
4764− .subject(subject)
4765− .suggest(suggestion);
4766− let diagnostic = at_attribute(diagnostic, column, ctx);
4767− ctx.diagnostics.push(diagnostic);
4768− false
4769− };
4770
2962
4771− if let Some(problem) = unknown_prefix(base) {
2963+fn parse_attr_brackets_no_validate(
4772− let diagnostic = Diagnostic::error(code::UNKNOWN_PREFIX, line, problem.message)
2964+ input: &str,
4773− .subject(problem.subject)
2965+ line_num: usize,
4774− .suggest(problem.suggestion);
2966+ ctx: &mut ParseContext,
4775− let diagnostic = at_attribute(diagnostic, column, ctx);
2967+) -> Result<(Vec<Attribute>, String), ParseError> {
4776− ctx.diagnostics.push(diagnostic);
2968+ parse_attr_brackets_inner(input, line_num, ctx, false)
4777− return false;
4778− }
4779− if !check_element_prefixes(attr, &prefixes, base, line, column, ctx) {
4780− return false;
4781− }
4782− // The argument of `has(...)` or `nth-child(...)` goes into the CSS as
4783− // written
4784− for prefix in &prefixes {
4785− if let Some(reason) = css_breakout(prefix.trim_end_matches(':')) {
4786− return fail(
4787− ctx,
4788− format!(
4789− "the prefix '{}' has {}: it can't be written into the page's CSS",
4790− prefix, reason
4791− ),
4792− prefix,
4793− None,
4794− );
4795− }
4796− }
4797− // `nth-child():` or `has( ):`: CSS has no pseudo-class with an empty
4798− // argument
4799− let empty_argument = |prefix: &str| {
4800− prefix
4801− .strip_suffix("):")
4802− .and_then(|p| p.split_once('('))
4803− .is_some_and(|(_, argument)| argument.trim().is_empty())
4804− };
4805− if let Some(empty) = prefixes.iter().find(|p| empty_argument(p)) {
4806− return fail(
4807− ctx,
4808− format!(
4809− "'{}': `:{}()` takes an argument between its parentheses, as in CSS",
4810− attr.key,
4811− vocab::pseudo_name(empty)
4812− ),
4813− empty,
4814− None,
4815− );
4816− }
4817− // A pseudo-element is the last selector prefix: CSS has no
4818− // `::before:hover`
4819− let selectors: Vec<&str> = prefixes
4820− .iter()
4821− .copied()
4822− .filter(|p| vocab::at_rule_rank(p).is_none())
4823− .collect();
4824− // Only one: an element has one `::before`, which has no `::after`
4825− let elements: Vec<&str> = selectors
4826− .iter()
4827− .copied()
4828− .filter(|p| vocab::is_pseudo_element(p))
4829− .collect();
4830− if elements.len() > 1 {
4831− return fail(
4832− ctx,
4833− format!(
4834− "'{}': `{}` and `{}` each select a pseudo-element, and a style applies to one \
4835− of them",
4836− attr.key, elements[0], elements[1]
4837− ),
4838− &attr.key,
4839− None,
4840− );
4841− }
4842− if let Some(at) = selectors.iter().position(|p| vocab::is_pseudo_element(p))
4843− && at + 1 < selectors.len()
4844− {
4845− let element = selectors[at];
4846− // The same prefixes, with the pseudo-element moved after the other
4847− // selector prefixes
4848− let mut order: Vec<&str> = prefixes.iter().copied().filter(|p| *p != element).collect();
4849− let last_selector = order
4850− .iter()
4851− .rposition(|p| vocab::at_rule_rank(p).is_none())
4852− .map_or(order.len(), |i| i + 1);
4853− order.insert(last_selector, element);
4854− let fixed = format!("{}{}", order.concat(), base);
4855− return fail(
4856− ctx,
4857− format!(
4858− "'{}': `{}` selects a pseudo-element, which comes after the other selector \
4859− prefixes, as in CSS: write `{}`",
4860− attr.key, element, fixed
4861− ),
4862− &attr.key,
4863− Some(fixed),
4864− );
4865− }
4866− // Under `children:` the styles go on the children, whose parent is this
4867− // element: a word that places an element in its parent can't say where
4868− if prefixes.contains(&"children:") && vocab::places_in_parent(base, attr.value.as_deref()) {
4869− // The CSS of `fill` and `shrink` depends on the direction: along
4870− // it, they are flex properties
4871− let (word, css) = match (base, attr.value.as_deref().map(str::trim)) {
4872− ("width", Some("fill")) => (
4873− "width fill",
4874− " (`children:flex 1` in a row, `children:width 100%` otherwise)",
4875− ),
4876− ("height", Some("fill")) => (
4877− "height fill",
4878− " (`children:flex 1` in a column, `children:height 100%` otherwise)",
4879− ),
4880− ("width", Some(_)) => (
4881− "width shrink",
4882− " (`children:flex-shrink 0` in a row, `children:width fit-content` otherwise)",
4883− ),
4884− ("height", Some(_)) => (
4885− "height shrink",
4886− " (`children:flex-shrink 0` in a column, `children:height fit-content` \
4887− otherwise)",
4888− ),
4889− _ => (base, ""),
4890− };
4891− return fail(
4892− ctx,
4893− format!(
4894− "'{}{}': `{}` places an element in its parent, and under `children:` that \
4895− parent is this element: write `{}` on the children themselves, or the CSS \
4896− you mean{}",
4897− attr.key,
4898− attr.value
4899− .as_deref()
4900− .map_or(String::new(), |v| format!(" {}", v.trim())),
4901− word,
4902− word,
4903− css
4904− ),
4905− &attr.key,
4906− None,
4907− );
4908− }
4909− true
4910}
2969}
4911
2970
4912−/// Check an attribute written as a style or a flag (not `key=value`): its
2971+fn parse_attr_brackets_inner(
4913−/// prefix, its name, and, for a style, its value. A name htmlang doesn't
2972+ input: &str,
4914−/// know but CSS could have (`corner-shape`) is passed to the CSS as written,
2973+ line_num: usize,
4915−/// with a warning; custom properties (`--brand`) and vendor-prefixed ones
4916−/// (`-webkit-...`) without one. Anything that can't be written into the
4917−/// page is an error; returns false then, and the attribute is left out.
4918−fn check_attribute(
4919− attr: &Attribute,
4920− line: usize,
4921− column: Option<usize>,
4922− before: Option<&Attribute>,
4923 ctx: &mut ParseContext,
2974 ctx: &mut ParseContext,
4924−) -> bool {
2975+ validate: bool,
4925− use crate::vocab;
2976+) -> Result<(Vec<Attribute>, String), ParseError> {
4926− let (prefixes, base) = vocab::split_prefixes(&attr.key);
2977+ let mut depth = 0;
4927− let fail = |ctx: &mut ParseContext, code: &'static str, message: String, subject: &str| {
2978+ let mut end_pos = 0;
4928− let diagnostic = Diagnostic::error(code, line, message).subject(subject);
4929− let diagnostic = at_attribute(diagnostic, column, ctx);
4930− ctx.diagnostics.push(diagnostic);
4931− false
4932− };
4933
2979
4934− if !check_prefixes(attr, line, column, ctx) {
2980+ for (i, c) in input.char_indices() {
4935− return false;
2981+ match c {
4936− }
2982+ '[' => depth += 1,
4937− let prefixed = !prefixes.is_empty();
2983+ ']' => {
4938− // `md:id=x`: `=` makes an HTML attribute, which has no states
2984+ depth -= 1;
4939− if prefixed && let Some((name, _)) = base.split_once('=') {
2985+ if depth == 0 {
4940− return fail(
2986+ end_pos = i;
4941− ctx,
2987+ break;
4942− code::INVALID_PREFIX,
2988+ }
4943− format!(
4944− "'{}': a prefix applies to a style, and `{}=` is an HTML attribute",
4945− attr.key, name
4946− ),
4947− &attr.key,
4948− );
4949− }
4950− let Some(value) = &attr.value else {
4951− // `inline` puts a file into the page as it compiles, which no
4952− // state or media condition can undo
4953− if base == "inline" && prefixed {
4954− return fail(
4955− ctx,
4956− code::INVALID_PREFIX,
4957− format!(
4958− "'{}': `inline` puts the image's file into the page, which can't depend \
4959− on a prefix",
4960− attr.key
4961− ),
4962− &attr.key,
4963− );
4964− }
4965− if vocab::HTMLANG_FLAGS.contains(&base) {
4966− return true;
4967− }
4968− if vocab::is_html_flag(base) {
4969− if !prefixed {
4970− return true;
4971 }
2989 }
4972− return fail(
2990+ _ => {}
4973− ctx,
4974− code::INVALID_PREFIX,
4975− format!(
4976− "'{}': a prefix applies to a style, and '{}' is an HTML attribute",
4977− attr.key, base
4978− ),
4979− &attr.key,
4980− );
4981− }
4982− if vocab::is_style_attribute(base)
4983− || vocab::is_custom_property(base)
4984− || vocab::is_vendor_property(base)
4985− {
4986− return fail(
4987− ctx,
4988− code::MISSING_VALUE,
4989− format!("'{}' needs a value: `{} VALUE`", attr.key, attr.key),
4990− &attr.key,
4991− );
4992− }
4993− if vocab::is_html_attribute(base) && !prefixed {
4994− return fail(
4995− ctx,
4996− code::HTML_ATTRIBUTE_FORM,
4997− format!(
4998− "'{}' is an HTML attribute that isn't a flag: write `{}=VALUE`",
4999− base, base
5000− ),
5001− base,
5002− );
5003 }
2991 }
5004− return unknown_attribute(attr, base, line, column, before, true, ctx);
5005− };
5006−
5007− if vocab::HTMLANG_FLAGS.contains(&base) {
5008− return fail(
5009− ctx,
5010− code::INVALID_VALUE,
5011− format!(
5012− "'{}' is a flag and takes no value: write `{}` alone",
5013− base, attr.key
5014− ),
5015− &attr.key,
5016− );
5017− }
5018− if vocab::is_style_attribute(base)
5019− || vocab::is_custom_property(base)
5020− || vocab::is_vendor_property(base)
5021− {
5022− return check_css_value(attr, base, line, column, ctx);
5023− }
5024− if vocab::is_html_attribute(base) && !prefixed {
5025− let diagnostic = Diagnostic::error(
5026− code::HTML_ATTRIBUTE_FORM,
5027− line,
5028− format!(
5029− "'{}' is an HTML attribute: write `{}={}`",
5030− attr.key, attr.key, value
5031− ),
5032− )
5033− .subject(attr.key.as_str());
5034− let diagnostic = at_attribute(diagnostic, column, ctx);
5035− ctx.diagnostics.push(diagnostic);
5036− return false;
5037− }
5038− let passes = vocab::is_property_name(base);
5039− unknown_attribute(attr, base, line, column, before, !passes, ctx)
5040− && check_css_value(attr, base, line, column, ctx)
5041−}
5042−
5043−/// Whether an attribute of a bundle could pass a parameter to a function
5044−/// it is spliced into (`@let t [title Hello]`): a name without a prefix
5045−/// that isn't a style or a flag. Such a name is checked where the bundle is
5046−/// used.
5047−fn could_be_parameter(attr: &Attribute) -> bool {
5048− use crate::vocab;
5049− let key = attr.key.as_str();
5050− let flag =
5051− attr.value.is_none() && (vocab::HTMLANG_FLAGS.contains(&key) || vocab::is_html_flag(key));
5052− !attr.html
5053− && !flag
5054− && !vocab::is_style_attribute(key)
5055− && key.starts_with(|c: char| c.is_alphabetic())
5056− && key
5057− .chars()
5058− .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_'))
5059−}
5060−
5061−/// At a call, a name close to one of the function's parameters is a
5062−/// misspelled parameter, not an attribute for its root: report it, and
5063−/// return true (it is left out).
5064−fn misspelled_parameter(
5065− attr: &Attribute,
5066− text_keys: &[String],
5067− line: usize,
5068− column: Option<usize>,
5069− ctx: &mut ParseContext,
5070−) -> bool {
5071− if text_keys.is_empty() || !could_be_parameter(attr) {
5072− return false;
5073 }
2992 }
5074− let params: Vec<&str> = text_keys.iter().map(String::as_str).collect();
5075− let Some(closest) = suggest_closest(&attr.key, &params) else {
5076− return false;
5077− };
5078− let what = if ctx.in_page {
5079− "@page word"
5080− } else {
5081− "parameter"
5082− };
5083− let diagnostic = Diagnostic::error(
5084− code::UNKNOWN_ATTRIBUTE,
5085− line,
5086− format!(
5087− "unknown {} '{}', did you mean '{}'?",
5088− what, attr.key, closest
5089− ),
5090− )
5091− .subject(attr.key.as_str())
5092− .suggest(Some(closest));
5093− let diagnostic = at_attribute(diagnostic, column, ctx);
5094− ctx.diagnostics.push(diagnostic);
5095− true
5096−}
5097
2993
5098−/// An attribute name htmlang doesn't know. A style whose name CSS could
2994+ if depth != 0 {
5099−/// have goes to the CSS as written, with a warning; anything else (a flag,
2995+ return Err(ParseError {
5100−/// a name that isn't a word) is an error. Returns whether it is kept.
2996+ line: line_num,
5101−fn unknown_attribute(
2997+ message: "unclosed '[' in attribute list".to_string(),
5102− attr: &Attribute,
2998+ });
5103− base: &str,
5104− line: usize,
5105− column: Option<usize>,
5106− before: Option<&Attribute>,
5107− error: bool,
5108− ctx: &mut ParseContext,
5109−) -> bool {
5110− // `@td padding 8`: an element prefix without its colon
5111− if let Some(name) = base.strip_prefix('@')
5112− && crate::ast::ElementKind::from_name(name).is_some()
5113− {
5114− let example = match attr.value.as_deref() {
5115− Some(value) => format!("{}:{}", attr.key, value.trim()),
5116− None => format!("{}:padding 8", attr.key),
5117− };
5118− let diagnostic = Diagnostic::error(
5119− code::UNKNOWN_ATTRIBUTE,
5120− line,
5121− format!(
5122− "unknown attribute '{}': an element prefix ends in a colon, as in `{}`",
5123− attr.key, example
5124− ),
5125− )
5126− .subject(base);
5127− let diagnostic = at_attribute(diagnostic, column, ctx);
5128− ctx.diagnostics.push(diagnostic);
5129− return false;
5130− }
5131− // `[card]` where `card` is a bundle: it is used as `$card`
5132− if attr.value.is_none() && ctx.env.bundle(base).is_some() && base == attr.key {
5133− ctx.used_defines.insert(base.to_string());
5134− let diagnostic = Diagnostic::error(
5135− code::UNKNOWN_ATTRIBUTE,
5136− line,
5137− format!(
5138− "unknown attribute '{}': '{}' is an attribute bundle, used as `${}`",
5139− base, base, base
5140− ),
5141− )
5142− .subject(base)
5143− .suggest(Some(format!("${}", base)));
5144− let diagnostic = at_attribute(diagnostic, column, ctx);
5145− ctx.diagnostics.push(diagnostic);
5146− return false;
5147− }
5148− // `hover:title x`: an HTML attribute's name is not a suggestion for
5149− // itself
5150− let suggestion =
5151− suggest_closest(base, &crate::vocab::all_attributes()).filter(|&closest| closest != base);
5152− // `hx-trigger=load, every 2s`: right after a `key=value`, a name that
5153− // isn't close to one htmlang knows is the rest of that value, which
5154− // the comma ended. It is left out.
5155− let cut = before.filter(|b| b.html && b.value.is_some());
5156− let rest = match &attr.value {
5157− Some(value) => format!("{} {}", attr.key, value),
5158− None => attr.key.clone(),
5159− };
5160− let joined = cut.map(|cut| {
5161− let value = cut.value.as_deref().unwrap_or("");
5162− (
5163− format!("{}={}", cut.key, value),
5164− format!("{}={}\\, {}", cut.key, value, rest),
5165− )
5166− });
5167− if let (Some((cut, joined)), None) = (&joined, suggestion) {
5168− let diagnostic = Diagnostic::error(
5169− code::SPLIT_VALUE,
5170− line,
5171− format!(
5172− "unknown attribute '{}' after `{}`: if '{}' is the rest of its value, the comma \
5173− ended it. To keep a comma in a value, write `\\,`: `{}`",
5174− base, cut, rest, joined
5175− ),
5176− )
5177− .subject(base);
5178− let diagnostic = at_attribute(diagnostic, column, ctx);
5179− ctx.diagnostics.push(diagnostic);
5180− return false;
5181− }
5182− let mut message = match (&suggestion, error) {
5183− (Some(closest), true) => {
5184− format!(
5185− "unknown attribute '{}', did you mean '{}'?",
5186− attr.key, closest
5187− )
5188− }
5189− (None, true) => format!("unknown attribute '{}'", attr.key),
5190− (Some(closest), false) => format!(
5191− "unknown CSS property '{}', did you mean '{}'? It is written to the CSS as it is",
5192− base, closest
5193− ),
5194− (None, false) => format!(
5195− "unknown CSS property '{}': it is written to the CSS as it is",
5196− base
5197− ),
5198− };
5199− // `box-shadow 0 1px red, 0 2px blue` or `font-family Inter,
5200− // sans-serif`: the comma ended the attribute, and the rest of the
5201− // value became one
5202− let rest_of_a_value = !base.starts_with(|c: char| c.is_ascii_alphabetic() || c == '-')
5203− || (suggestion.is_none() && attr.value.is_none() && before.is_some());
5204− if joined.is_some() || (rest_of_a_value && error) {
5205− if !message.ends_with('?') {
5206− message.push('.');
5207− }
5208− match &joined {
5209− Some((cut, joined)) => message.push_str(&format!(
5210− " If it is the rest of `{}`, the comma ended it: write `{}`",
5211− cut, joined
5212− )),
5213− None => message
5214− .push_str(" A comma separates attributes: to keep one in a value, write `\\,`"),
5215− }
5216 }
2999 }
5217− let severity = if error {
5218− Severity::Error
5219− } else {
5220− Severity::Warning
5221− };
5222− let diagnostic = Diagnostic::new(code::UNKNOWN_ATTRIBUTE, severity, line, message)
5223− .subject(base)
5224− .suggest(suggestion);
5225− let diagnostic = at_attribute(diagnostic, column, ctx);
5226− ctx.diagnostics.push(diagnostic);
5227− !error
5228−}
5229
3000
5230−/// Evaluate the attributes of a list: bundles are spliced in, `if()`
3001+ let attrs_inner = &input[1..end_pos];
5231−/// chooses, and variables fill values. A variable fills only the value it
3002+ let remaining = input[end_pos + 1..].trim().to_string();
5232−/// is written in: attributes come from bundles, never from text. With
3003+ let attrs = parse_attr_list(attrs_inner, line_num, ctx, validate);
5233−/// `validate`, unknown names and invalid values are reported. `text_keys`
5234−/// are a called function's parameters: their values are text rather than
5235−/// CSS, and they aren't checked as attributes.
5236−fn parse_attr_list(
5237− tokens: &[syntax::Attr],
5238− line_num: usize,
5239− ctx: &mut ParseContext,
5240− validate: bool,
5241− text_keys: &[String],
5242−) -> Vec<Attribute> {
5243− parse_attrs(tokens, line_num, ctx, validate, text_keys)
5244− .into_iter()
5245− .map(|(attr, _)| attr)
5246− .collect()
5247−}
5248
3004
5249−/// [`parse_attr_list`], with the value of each attribute whose key is one
3005+ Ok((attrs, remaining))
5250−/// of `text_keys` (a call's parameters) as a value with its type (see
5251−/// [`ParseContext::slot_value`]), so a parameter can get a list or a
5252−/// record.
5253−fn parse_attrs(
5254− tokens: &[syntax::Attr],
5255− line_num: usize,
5256− ctx: &mut ParseContext,
5257− validate: bool,
5258− text_keys: &[String],
5259−) -> Vec<(Attribute, Option<Value>)> {
5260− let mut read = ReadAttrs::default();
5261− read_attrs(tokens, "", line_num, ctx, validate, text_keys, &mut read);
5262− read.attrs
5263}
3006}
5264
3007
5265−/// The attributes of a list read so far, and the keys written (for the
3008+fn is_valid_hex_color(s: &str) -> bool {
5266−/// duplicate check).
3009+ if !s.starts_with('#') {
5267−#[derive(Default)]
3010+ return true; // Not a hex color, skip
5268−struct ReadAttrs {
3011+ }
5269− attrs: Vec<(Attribute, Option<Value>)>,
3012+ let hex = &s[1..];
5270− seen_keys: Vec<(String, bool)>,
3013+ matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
5271}
3014}
5272
3015
5273−/// [`parse_attrs`] for the attributes of `tokens`, each under the prefixes
3016+fn parse_attr_list(
5274−/// `prefix` (`md:` for those of `md:[...]`), which distribute over a
3017+ input: &str,
5275−/// group, a bundle and the branch an `if()` takes. Only styles take a
5276−/// prefix, so an HTML attribute there is an error.
5277−fn read_attrs(
5278− tokens: &[syntax::Attr],
5279− prefix: &str,
5280 line_num: usize,
3018 line_num: usize,
5281 ctx: &mut ParseContext,
3019 ctx: &mut ParseContext,
5282 validate: bool,
3020 validate: bool,
5283− text_keys: &[String],
3021+) -> Vec<Attribute> {
5284− out: &mut ReadAttrs,
3022+ let mut attrs = Vec::new();
5285−) {
3023+ let mut seen_keys: Vec<String> = Vec::new();
5286− let mut chosen = Vec::new();
5287− choose_attrs(
5288− tokens,
5289− prefix,
5290− line_num,
5291− ctx,
5292− validate,
5293− text_keys,
5294− &mut chosen,
5295− );
5296−
5297− for token in &chosen {
5298− let line = if token.span.line == 0 {
5299− line_num
5300− } else {
5301− token.span.line
5302− };
5303− let (key, value, html) = (token.key.clone(), token.value.clone(), token.html);
5304− let column = |at: usize| Some(token.span.column + at);
5305−
5306− // `md:[...]`, `md:$card`, `md:if(...)`: the prefixes apply to each
5307− // attribute there, and a prefix a parameter can't have
5308− if let Some(prefixed) = &token.prefixed {
5309− if let Some(inner) = group_prefix(token, prefixed, prefix, line, ctx) {
5310− read_attrs(
5311− prefixed.target.attrs(),
5312− &inner,
5313− line_num,
5314− ctx,
5315− validate,
5316− text_keys,
5317− out,
5318− );
5319− }
5320− continue;
5321− }
5322
3024
5323− // `$name` (or `${name}`) alone: an attribute bundle, spliced in
3025+ for part in split_commas(input) {
5324− let bundle = key.strip_prefix('$').filter(|_| value.is_none() && !html);
3026+ let part = part.trim();
5325− let bundle = bundle.and_then(|after| match interp::reference(after, &ctx.env)? {
3027+ if part.is_empty() {
5326− (interp::Reference::Var(name), len) if len == after.len() => Some(name),
5327− _ => None,
5328− });
5329− if let Some(name) = bundle {
5330− if let Some(define_attrs) = ctx.env.bundle(&name).cloned() {
5331− ctx.used_defines.insert(name.clone());
5332− for attr in define_attrs.iter() {
5333− if !prefix.is_empty() {
5334− let prefixed = Attribute {
5335− key: format!("{}{}", prefix, attr.key),
5336− ..attr.clone()
5337− };
5338− if prefixed_parameter(&prefixed, text_keys, line, column(0), validate, ctx)
5339− {
5340− continue;
5341− }
5342− if let Some(attr) =
5343− prefixed_member(attr, prefix, &name, line, column(0), validate, ctx)
5344− {
5345− out.attrs.push((attr, None));
5346− }
5347− continue;
5348− }
5349− let attrs = &out.attrs;
5350− // Checked here, where it is either a parameter or not
5351− let checked_here = validate
5352− && !ctx.in_bundle
5353− && could_be_parameter(attr)
5354− && !text_keys.contains(&attr.key);
5355− if checked_here
5356− && (misspelled_parameter(attr, text_keys, line, column(0), ctx)
5357− || !check_attribute(
5358− attr,
5359− line,
5360− column(0),
5361− attrs.last().map(|(a, _)| a),
5362− ctx,
5363− ))
5364− {
5365− continue;
5366− }
5367− out.attrs.push((attr.clone(), None));
5368− }
5369− } else {
5370− not_a_bundle(&name, line, column(0), ctx);
5371− }
5372− continue;
5373− }
5374− // A name never comes from a variable
5375− if key.contains('$') {
5376− track_var_refs(&key, &mut ctx.used_variables);
5377− ctx.diagnostics.push(
5378− Diagnostic::error(
5379− code::ATTRIBUTE_FROM_VARIABLE,
5380− line,
5381− format!(
5382− "'{}': an attribute's name can't come from a variable. A variable \
5383− fills a value (`padding $size`), and whole attributes come from a \
5384− bundle (`@let name [padding 8]`, used as `[$name]`)",
5385− key
5386− ),
5387− )
5388− .subject(key.as_str()),
5389− );
5390 continue;
3028 continue;
5391 }
3029 }
5392
3030
5393− // Only a style has states: `md:[id=x]`, `md:id=x`
3031+ // $define reference — expand attribute bundle
5394− if html && (!prefix.is_empty() || !crate::vocab::split_prefixes(&key).0.is_empty()) {
3032+ if let Some(name) = part.strip_prefix('$')
5395− let written = format!("{}{}", prefix, key);
3033+ && let Some(define_attrs) = ctx.defines.get(name)
5396− let (name, _) = crate::vocab::split_prefixes(&written);
3034+ {
5397− let diagnostic = Diagnostic::error(
3035+ ctx.used_defines.insert(name.to_string());
5398− code::INVALID_PREFIX,
3036+ attrs.extend(define_attrs.clone());
5399− line,
5400− format!(
5401− "'{}=': a prefix applies to a style, and `{}=` is an HTML attribute, \
5402− which has no states",
5403− written,
5404− &written[name.concat().len()..]
5405− ),
5406− )
5407− .subject(token.key.as_str());
5408− let diagnostic = at_attribute(diagnostic, column(0), ctx);
5409− ctx.diagnostics.push(diagnostic);
5410 continue;
3037 continue;
5411 }
3038 }
5412− let key = if prefix.is_empty() {
5413− key
5414− } else {
5415− format!("{}{}", prefix, key)
5416− };
5417
3039
5418− // A value's variables are filled in. A style's value is CSS, where
3040+ track_var_refs(part, &mut ctx.used_variables);
5419− // quoted text keeps its quotes; an HTML attribute's (and a
3041+
5420− // parameter's) is text, where it loses them.
3042+ // Conditional attribute: `key if condition` or `key value if condition`.
5421− let sink = if html || text_keys.contains(&key) {
3043+ // Conditions are evaluated on the source text, before substitution.
5422− Sink::Text
3044+ let part = match split_trailing_if(part) {
5423− } else {
3045+ (attr, Some(condition)) => {
5424− Sink::Css
3046+ if !ctx.condition(condition, line_num) {
5425− };
3047+ continue;
5426− let mut filled = true;
5427− let mut quoted = None;
5428− let mut typed = None;
5429− let value = match value {
5430− None => None,
5431− // A parameter's value keeps its type
5432− Some(value) if !html && text_keys.contains(&key) => {
5433− let at = column(token.raw.len() - value.len());
5434− let (value, ok) = ctx.slot_value(&value, line, at);
5435− filled = ok;
5436− if let Value::Quoted(q) = &value {
5437− quoted = Some(q.clone());
5438 }
3048 }
5439− let text = value.to_string();
3049+ attr
5440− typed = Some(value);
5441− Some(text)
5442− }
5443− Some(value) => {
5444− let at = column(token.raw.len() - value.len());
5445− let (text, is_quoted, ok) = ctx.fill_value(&value, line, at, sink);
5446− filled = ok;
5447− quoted = is_quoted;
5448− Some(text)
5449 }
3050 }
3051+ (attr, None) => attr,
5450 };
3052 };
5451− let attr = Attribute {
3053+ // A value `if(cond, a, b)` picks `a` or `b` (free text) by `cond`.
5452− key,
3054+ let part = choose_if_value(part, ctx, line_num);
5453− value,
3055+ let part = substitute_vars(&part, &ctx.variables);
5454− html,
3056+
5455− quoted,
3057+ let attr = if let Some((key, value)) = split_html_attribute(&part) {
3058+ Attribute {
3059+ key: key.to_string(),
3060+ value: Some(value.to_string()),
3061+ html: true,
3062+ }
3063+ } else if let Some((key, value)) = part.split_once(' ') {
3064+ let value = value.trim().to_string();
3065+ Attribute {
3066+ key: key.trim().to_string(),
3067+ value: Some(value),
3068+ html: false,
3069+ }
3070+ } else {
3071+ Attribute {
3072+ key: part.to_string(),
3073+ value: None,
3074+ html: false,
3075+ }
5456 };
3076 };
5457− // A value that couldn't be filled in is already reported, and a
5458− // parameter's value is text for the function
5459− let validate = validate && filled && !text_keys.contains(&attr.key);
5460
3077
5461− // A duplicate is reported (compare the full key, so `border` and
3078+ // Warn on duplicate attributes (compare full key so pseudo-class
5462− // `hover:border` differ, and the form, so `width=800` and
3079+ // variants like `border` and `hover:border` are not conflated)
5463− // `width 200` do too), and the later one wins
5464 if validate {
3080 if validate {
5465− let seen = (normal_key(&attr.key), attr.html);
3081+ if seen_keys.contains(&attr.key) {
5466− if out.seen_keys.contains(&seen) {
3082+ ctx.diagnostics.push(Diagnostic {
5467− let written = if attr.html { "=" } else { "" };
3083+ line: line_num,
5468− let message = format!(
3084+ column: None,
5469− "duplicate attribute '{}{}': the later one wins",
3085+ message: format!("duplicate attribute '{}'", attr.key),
5470− attr.key, written
3086+ severity: Severity::Warning,
5471− );
3087+ source_line: None,
5472− let diagnostic = Diagnostic::warning(code::DUPLICATE_ATTRIBUTE, line, message)
3088+ });
5473− .subject(attr.key.as_str());
5474− let diagnostic = at_attribute(diagnostic, column(0), ctx);
5475− ctx.diagnostics.push(diagnostic);
5476 } else {
3089 } else {
5477− out.seen_keys.push(seen);
3090+ seen_keys.push(attr.key.clone());
5478 }
3091 }
5479− }
5480
3092
5481− if validate && misspelled_parameter(&attr, text_keys, line, column(0), ctx) {
3093+ // Color validation for hex colors
5482− continue;
3094+ if !attr.html
5483− }
3095+ && matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
5484− if prefixed_parameter(&attr, text_keys, line, column(0), validate, ctx) {
3096+ && let Some(ref val) = attr.value
5485− continue;
3097+ && val.starts_with('#')
5486− }
3098+ && !is_valid_hex_color(val)
5487− if validate && attr.html && attr.key == "class" {
3099+ {
5488− reserved_class(&attr, line, column(0), ctx);
3100+ ctx.diagnostics.push(Diagnostic {
5489− }
3101+ line: line_num,
5490− // `padding=4`: a style written as an HTML attribute, which the
3102+ column: None,
5491− // browser ignores (`width=` and `height=` are real on some
3103+ message: format!("invalid hex color '{}'", val),
5492− // elements, see `validate_tree`)
3104+ severity: Severity::Warning,
5493− if validate
3105+ source_line: None,
5494− && attr.html
3106+ });
5495− && crate::vocab::is_style_attribute(&attr.key)
3107+ }
5496− && !crate::vocab::HTML_ATTRIBUTES.contains(&attr.key.as_str())
5497− {
5498− let value = token.value.as_deref().unwrap_or("").trim();
5499− let diagnostic = Diagnostic::warning(
5500− code::HTML_ATTRIBUTE_FORM,
5501− line,
5502− format!(
5503− "'{0}={1}' is an HTML attribute, but {0} is a style: write `{0} {1}`",
5504− attr.key, value
5505− ),
5506− )
5507− .subject(token.raw.as_str())
5508− .suggest(Some(format!("{} {}", attr.key, value)));
5509− let diagnostic = at_attribute(diagnostic, column(0), ctx);
5510− ctx.diagnostics.push(diagnostic);
5511 }
3108 }
5512
3109
5513− // A style or a flag: its name is checked, and what can't be
3110+ // Warn on unknown attributes. `key=value` HTML attributes may use any
5514− // written into the page is an error and left out
3111+ // name; everything else must be a known style.
5515− let checked = validate && !attr.html && !(ctx.in_bundle && could_be_parameter(&attr));
3112+ if validate && !attr.html {
5516− let before = out.attrs.last().map(|(a, _)| a);
3113+ let base_key = crate::vocab::base_attribute(attr.key.as_str());
5517− if checked && !check_attribute(&attr, line, column(0), before, ctx) {
3114+ let is_boolean_html =
5518− continue;
3115+ attr.value.is_none() && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&base_key);
3116+ if is_boolean_html || crate::vocab::is_style_attribute(base_key) {
3117+ validate_attr_value(&attr, line_num, ctx);
3118+ } else if crate::vocab::HTML_ATTRIBUTES.contains(&base_key)
3119+ || base_key.starts_with("aria-")
3120+ || base_key.starts_with("data-")
3121+ {
3122+ ctx.diagnostics.push(Diagnostic {
3123+ line: line_num,
3124+ column: None,
3125+ message: format!(
3126+ "'{}' is an HTML attribute: write `{}={}` (run `htmlang upgrade`)",
3127+ attr.key,
3128+ attr.key,
3129+ attr.value.as_deref().unwrap_or("")
3130+ ),
3131+ severity: Severity::Warning,
3132+ source_line: None,
3133+ });
3134+ } else if let Some(hint) = removed_attribute_hint(base_key) {
3135+ ctx.diagnostics.push(Diagnostic {
3136+ line: line_num,
3137+ column: None,
3138+ message: format!(
3139+ "attribute '{}' was removed: {} (run `htmlang upgrade`)",
3140+ base_key, hint
3141+ ),
3142+ severity: Severity::Warning,
3143+ source_line: None,
3144+ });
3145+ } else {
3146+ let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
3147+ let msg = match suggestion {
3148+ Some(closest) => {
3149+ format!(
3150+ "unknown attribute '{}', did you mean '{}'?",
3151+ attr.key, closest
3152+ )
3153+ }
3154+ None => format!("unknown attribute '{}'", attr.key),
3155+ };
3156+ ctx.diagnostics.push(Diagnostic {
3157+ line: line_num,
3158+ column: None,
3159+ message: msg,
3160+ severity: Severity::Warning,
3161+ source_line: None,
3162+ });
3163+ }
5519 }
3164 }
5520
3165
5521− out.attrs.push((attr, typed));
3166+ attrs.push(attr);
5522 }
3167 }
3168+
3169+ attrs
5523}
3170}
5524
3171
5525−/// At a call, a parameter under prefixes (`md:title x`, `md:[title x]`):
3172+/// Split an HTML attribute written `key=value` (`alt=`, `type=email`,
5526−/// a parameter has one value, with no states, so it is reported (with
3173+/// `aria-label=Close menu`). Returns `None` for style attributes.
5527−/// `validate`) and left out; returns true then. A parameter named like a
3174+fn split_html_attribute(part: &str) -> Option<(&str, &str)> {
5528−/// style (`color`) is a style under a prefix, for the root element.
3175+ let first_token = part.split(char::is_whitespace).next()?;
5529−fn prefixed_parameter(
3176+ let eq = first_token.find('=')?;
5530− attr: &Attribute,
3177+ let key = &part[..eq];
5531− text_keys: &[String],
3178+ let valid_key = key.starts_with(|c: char| c.is_ascii_alphabetic())
5532− line: usize,
3179+ && key
5533− column: Option<usize>,
3180+ .chars()
5534− validate: bool,
3181+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
5535− ctx: &mut ParseContext,
3182+ valid_key.then(|| (key, part[eq + 1..].trim()))
5536−) -> bool {
5537− let (prefixes, name) = crate::vocab::split_prefixes(&attr.key);
5538− if attr.html
5539− || prefixes.is_empty()
5540− || !text_keys.iter().any(|k| k == name)
5541− || crate::vocab::is_style_attribute(name)
5542− {
5543− return false;
5544− }
5545− if validate {
5546− let what = if ctx.in_page {
5547− "@page's own word"
5548− } else {
5549− "a parameter"
5550− };
5551− let diagnostic = Diagnostic::error(
5552− code::INVALID_PREFIX,
5553− line,
5554− format!(
5555− "'{}': `{}` is {}, which has one value, and a prefix applies to a style, \
5556− which has states",
5557− attr.key, name, what
5558− ),
5559− )
5560− .subject(attr.key.as_str());
5561− let diagnostic = at_attribute(diagnostic, column, ctx);
5562− ctx.diagnostics.push(diagnostic);
5563− }
5564− true
5565}
3183}
5566
3184
5567−/// The prefixes of a prefixed group, bundle or `if()` inside `outer` (the
3185+fn split_commas(input: &str) -> Vec<&str> {
5568−/// prefixes of the groups it is in), when they are all known; else the
3186+ let mut parts = Vec::new();
5569−/// unknown one is reported. Text after a group's `]` is an error too.
3187+ let mut start = 0;
5570−fn group_prefix(
3188+ let mut depth = 0;
5571− token: &syntax::Attr,
3189+ let mut in_quotes = false;
5572− prefixed: &syntax::Prefixed,
3190+
5573− outer: &str,
3191+ for (i, c) in input.char_indices() {
5574− line: usize,
3192+ match c {
5575− ctx: &mut ParseContext,
3193+ '"' => in_quotes = !in_quotes,
5576−) -> Option<String> {
3194+ _ if in_quotes => {}
5577− let at = |ctx: &ParseContext, diagnostic: Diagnostic, span: syntax::Span| match &ctx
3195+ '[' | '(' => depth += 1,
5578− .current_source
3196+ ']' | ')' => depth -= 1,
5579− {
3197+ ',' if depth == 0 => {
5580− (current, Some(text)) if span.line != 0 && *current == span.line => {
3198+ parts.push(&input[start..i]);
5581− diagnostic.column(span.column).source(text.clone())
3199+ start = i + 1;
5582− }
5583− _ => diagnostic,
5584− };
5585− if let syntax::Branch::Group {
5586− trailing: Some(trailing),
5587− ..
5588− } = &prefixed.target
5589− {
5590− let diagnostic = Diagnostic::error(
5591− code::UNEXPECTED_ARGUMENT,
5592− line,
5593− format!(
5594− "'{}' after the `]` of `{}[...]`: the prefixes apply to the attributes in the \
5595− group, so it goes inside it",
5596− trailing.raw, prefixed.prefix
5597− ),
5598− )
5599− .subject(trailing.raw.as_str());
5600− let diagnostic = at(ctx, diagnostic, trailing.span);
5601− ctx.push_once(diagnostic);
5602− }
5603− let (known, rest) = crate::vocab::split_prefixes(&prefixed.prefix);
5604− if !rest.is_empty() {
5605− let problem = unknown_prefix(rest).unwrap_or_else(|| {
5606− let written = rest.split_inclusive(':').next().unwrap_or(rest);
5607− PrefixProblem {
5608− message: format!("unknown prefix '{}'", written),
5609− subject: written.to_string(),
5610− suggestion: None,
5611 }
3200 }
5612− });
3201+ _ => {}
5613− let diagnostic = Diagnostic::error(code::UNKNOWN_PREFIX, line, problem.message)
3202+ }
5614− .subject(problem.subject)
5615− .suggest(problem.suggestion);
5616− let diagnostic = at(ctx, diagnostic, token.span);
5617− ctx.push_once(diagnostic);
5618− return None;
5619 }
3203 }
5620− Some(format!("{}{}", outer, known.concat()))
3204+ parts.push(&input[start..]);
3205+ parts
5621}
3206}
5622
3207
5623−/// An attribute of the bundle `bundle`, spliced in under `prefix`
3208+// ---------------------------------------------------------------------------
5624−/// (`md:$card`): the same attribute with the prefixes in front, when it can
3209+// Chain splitting (the > operator)
5625−/// take them. An HTML attribute can't; a style's prefixes are checked, as
3210+// ---------------------------------------------------------------------------
5626−/// is a flag or a name that isn't a style (a bundle's parameter), which
3211+
5627−/// under a prefix are attributes like any other.
3212+fn split_chain(content: &str) -> Vec<String> {
5628−fn prefixed_member(
3213+ let mut segments = Vec::new();
5629− attr: &Attribute,
3214+ let mut current = String::new();
5630− prefix: &str,
3215+ let chars: Vec<char> = content.chars().collect();
5631− bundle: &str,
3216+ let mut i = 0;
5632− line: usize,
3217+ let mut bracket_depth = 0;
5633− column: Option<usize>,
3218+
5634− validate: bool,
3219+ while i < chars.len() {
5635− ctx: &mut ParseContext,
3220+ if chars[i] == '[' {
5636−) -> Option<Attribute> {
3221+ bracket_depth += 1;
5637− let key = format!("{}{}", prefix, attr.key);
5638− if attr.html {
5639− if validate {
5640− let diagnostic = Diagnostic::error(
5641− code::INVALID_PREFIX,
5642− line,
5643− format!(
5644− "'{}${}': the bundle has `{}=`, an HTML attribute, and a prefix applies to \
5645− styles, which have states",
5646− prefix, bundle, attr.key
5647− ),
5648− )
5649− .subject(format!("{}${}", prefix, bundle));
5650− let diagnostic = at_attribute(diagnostic, column, ctx);
5651− ctx.diagnostics.push(diagnostic);
5652 }
3222 }
5653− return None;
3223+ if chars[i] == ']' {
5654− }
3224+ bracket_depth -= 1;
5655− let prefixed = Attribute {
5656− key,
5657− ..attr.clone()
5658− };
5659− if validate {
5660− let fine = if attr.value.is_none() || could_be_parameter(attr) {
5661− check_attribute(&prefixed, line, column, None, ctx)
5662− } else {
5663− check_prefixes(&prefixed, line, column, ctx)
5664− };
5665− if !fine {
5666− return None;
5667 }
3225 }
5668− }
5669− Some(prefixed)
5670−}
5671
3226
5672−/// `class=hl-x`: the `hl-` prefix is htmlang's, for its generated classes,
3227+ // Match " > @" outside brackets
5673−/// whose rules would then apply to the element too. It goes into the page
3228+ if bracket_depth == 0
5674−/// as written, so this is a warning.
3229+ && i + 3 < chars.len()
5675−fn reserved_class(attr: &Attribute, line: usize, column: Option<usize>, ctx: &mut ParseContext) {
3230+ && chars[i] == ' '
5676− let classes = attr.value.as_deref().unwrap_or_default();
3231+ && chars[i + 1] == '>'
5677− for class in classes.split_whitespace() {
3232+ && chars[i + 2] == ' '
5678− if class.starts_with(crate::codegen::CLASS_PREFIX) {
3233+ && chars[i + 3] == '@'
5679− let diagnostic = Diagnostic::warning(
3234+ {
5680− code::INVALID_VALUE,
3235+ segments.push(current.trim().to_string());
5681− line,
3236+ current = String::new();
5682− format!(
3237+ i += 3; // skip " > ", keep the "@"
5683− "class '{}' starts with `{}`, which htmlang keeps for the classes it \
3238+ continue;
5684− generates, so their styles could apply to this element: give it \
5685− another name",
5686− class,
5687− crate::codegen::CLASS_PREFIX
5688− ),
5689− )
5690− .subject(class);
5691− let diagnostic = at_attribute(diagnostic, column, ctx);
5692− ctx.diagnostics.push(diagnostic);
5693 }
3239 }
3240+
3241+ current.push(chars[i]);
3242+ i += 1;
5694 }
3243 }
5695−}
5696
3244
5697−/// A style's key as the CSS reads it, for finding duplicates: its at-rule
3245+ if !current.trim().is_empty() {
5698−/// prefixes in one order (`dark:md:` is `md:dark:`), then its selector
3246+ segments.push(current.trim().to_string());
5699−/// prefixes as written, whose order is the selector's.
5700−fn normal_key(key: &str) -> String {
5701− let (prefixes, name) = crate::vocab::split_prefixes(key);
5702− let mut at: Vec<(usize, &str)> = prefixes
5703− .iter()
5704− .filter_map(|p| crate::vocab::at_rule_rank(p).map(|rank| (rank, *p)))
5705− .collect();
5706− at.sort_unstable();
5707− at.dedup();
5708− let mut normal: String = at.iter().map(|(_, p)| *p).collect();
5709− for prefix in prefixes
5710− .iter()
5711− .filter(|p| crate::vocab::at_rule_rank(p).is_none())
5712− {
5713− normal.push_str(prefix);
5714 }
3247 }
5715− normal.push_str(name);
5716− normal
5717−}
5718
3248
5719−/// `[$name]` where `$name` isn't an attribute bundle.
3249+ segments
5720−fn not_a_bundle(name: &str, line: usize, column: Option<usize>, ctx: &mut ParseContext) {
5721− track_var_refs(&format!("${}", name), &mut ctx.used_variables);
5722− let is_value = interp::name_len(name) > 0 && interp::resolve(name, &ctx.env).is_some();
5723− let diagnostic = if is_value {
5724− Diagnostic::error(
5725− code::ATTRIBUTE_FROM_VARIABLE,
5726− line,
5727− format!(
5728− "'${}' is a value, not an attribute: attributes come from a bundle \
5729− (`@let name [padding 8]`, used as `[$name]`), and a value fills an \
5730− attribute's value (`padding ${}`)",
5731− name, name
5732− ),
5733− )
5734− .subject(name)
5735− } else if ctx.env.function(name).is_some() {
5736− ctx.used_functions.insert(name.to_string());
5737− Diagnostic::error(
5738− code::UNDEFINED_VARIABLE,
5739− line,
5740− format!(
5741− "'${}' is a function, not an attribute bundle: it is called as an element, \
5742− `@{}`",
5743− name, name
5744− ),
5745− )
5746− .subject(name)
5747− } else {
5748− let mut bundles = ctx.env.bundle_names();
5749− bundles.sort_unstable();
5750− let suggestion = suggest_closest(name, &bundles);
5751− let message = match suggestion {
5752− Some(closest) => format!(
5753− "undefined attribute bundle '${}', did you mean '${}'?",
5754− name, closest
5755− ),
5756− None => format!("undefined attribute bundle '${}'", name),
5757− };
5758− Diagnostic::error(code::UNDEFINED_VARIABLE, line, message)
5759− .subject(name)
5760− .suggest(suggestion)
5761− };
5762− let diagnostic = match (&ctx.current_source, column) {
5763− ((current, Some(text)), Some(column)) if *current == line => {
5764− diagnostic.column(column).source(text.clone())
5765− }
5766− _ => diagnostic,
5767− };
5768− ctx.diagnostics.push(diagnostic);
5769}
3250}
5770
3251
5771// ---------------------------------------------------------------------------
3252// ---------------------------------------------------------------------------
5772// Text segment parsing (inline {...} elements)
3253// Text segment parsing (inline {...} elements)
5773// ---------------------------------------------------------------------------
3254// ---------------------------------------------------------------------------
5774
3255
5775−/// While a slot is filled in, each escape (see [`syntax::ESCAPES`]) is a
3256+fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment> {
5776−/// private-use placeholder, U+E000 plus its place in the table, so it
3257+ let mut segments = Vec::new();
5777−/// can't start a variable, a quote or an inline element. Afterwards it
3258+ let mut current_text = String::new();
5778−/// becomes what it stands for.
3259+ let chars: Vec<char> = input.chars().collect();
5779−fn protect_escapes(text: &str) -> String {
5780− if !text.contains('\\') {
5781− return text.to_string();
5782− }
5783− let mut out = String::with_capacity(text.len());
5784 let mut i = 0;
3260 let mut i = 0;
5785− while let Some(c) = text[i..].chars().next() {
5786− let len = syntax::escape_len(&text[i..]);
5787− if len == 0 {
5788− out.push(c);
5789− i += c.len_utf8();
5790− continue;
5791− }
5792− let escape = &text[i..i + len];
5793− let index = syntax::ESCAPES.iter().position(|e| *e == escape);
5794− out.extend(index.and_then(|n| char::from_u32(0xE000 + n as u32)));
5795− i += len;
5796− }
5797− out
5798−}
5799−
5800−/// The escape a placeholder stands for, as written (`\,`).
5801−fn escape_of(c: char) -> Option<&'static str> {
5802− let index = (c as u32).checked_sub(0xE000)?;
5803− syntax::ESCAPES.get(index as usize).copied()
5804−}
5805−
5806−fn restore_escapes(text: &str) -> String {
5807− text.chars()
5808− .fold(String::with_capacity(text.len()), |mut out, c| {
5809− match escape_of(c) {
5810− // What the escape stands for: what follows the backslash
5811− Some(escape) => out.push_str(&escape[1..]),
5812− None => out.push(c),
5813− }
5814− out
5815− })
5816−}
5817
3261
5818−/// Inside the quoted strings of a CSS value, `\"` and `\\` mean in CSS what
3262+ while i < chars.len() {
5819−/// they mean in htmlang, so they stay as written there.
3263+ if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '@' {
5820−fn keep_css_string_escapes(protected: &str) -> String {
3264+ // Flush accumulated plain text
5821− let mut out = String::with_capacity(protected.len());
3265+ if !current_text.is_empty() {
5822− let mut inside = false;
3266+ segments.push(TextSegment::Plain(substitute_vars(
5823− for c in protected.chars() {
3267+ &current_text,
5824− match escape_of(c) {
3268+ &ctx.variables,
5825− Some(escape @ ("\\\"" | "\\\\")) if inside => out.push_str(escape),
3269+ )));
5826− _ => {
3270+ current_text.clear();
5827− if c == '"' {
3271+ }
5828− inside = !inside;
3272+
3273+ // Find matching }
3274+ let mut depth = 0;
3275+ let start = i + 1; // after {
3276+ i += 1;
3277+ loop {
3278+ if i >= chars.len() {
3279+ break;
3280+ }
3281+ if chars[i] == '{' {
3282+ depth += 1;
3283+ } else if chars[i] == '}' {
3284+ if depth == 0 {
3285+ break;
3286+ }
3287+ depth -= 1;
5829 }
3288 }
5830− out.push(c);
3289+ i += 1;
5831 }
3290 }
5832− }
5833− }
5834− out
5835−}
5836
3291
5837−/// Evaluate text: fill in the variables of its plain runs and build its
3292+ let inner: String = chars[start..i].iter().collect();
5838−/// inline elements. Each run is one slot, so a value can't make markup.
3293+ i += 1; // skip }
5839−fn text_segments(text: &syntax::Text, ctx: &mut ParseContext) -> Vec<TextSegment> {
3294+
5840− let mut segments = Vec::new();
3295+ match parse_single_element(&inner, ctx.current_line, ctx) {
5841− // The plain text so far, and where it starts
3296+ Ok(elem) => segments.push(TextSegment::Inline(elem)),
5842− let mut plain = String::new();
3297+ Err(e) => {
5843− let mut start: Option<syntax::Span> = None;
3298+ // Keep the braces as literal text, but flag what looks
5844− let flush = |plain: &mut String,
3299+ // like a mistyped inline element.
5845− start: &mut Option<syntax::Span>,
3300+ if inner.trim_start().starts_with('@') {
5846− segments: &mut Vec<TextSegment>,
3301+ ctx.diagnostics.push(Diagnostic {
5847− ctx: &mut ParseContext| {
3302+ line: ctx.current_line,
5848− if !plain.is_empty() {
3303+ column: None,
5849− let at = start.unwrap_or(text.span);
3304+ message: e.message,
5850− let filled = ctx.interpolate_text(plain, at.line, Some(at.column));
3305+ severity: Severity::Warning,
5851− segments.push(TextSegment::Plain(filled));
3306+ source_line: Some(input.to_string()),
5852− plain.clear();
3307+ });
5853− }
5854− *start = None;
5855− };
5856− for segment in &text.segments {
5857− match segment {
5858− Segment::Plain { raw, span } => {
5859− start.get_or_insert(*span);
5860− plain.push_str(raw);
5861− }
5862− Segment::Inline(inline) => {
5863− let line = ctx.current_line;
5864− match resolve(&inline.head, inline.text.as_ref(), line, &text.raw, ctx) {
5865− Ok(resolved) => {
5866− if !inline.closed {
5867− let d = Diagnostic::warning(
5868− code::UNCLOSED_BRACKET,
5869− inline.span.line,
5870− format!(
5871− "unclosed `{{`: the inline @{} runs to the end of the line. \
5872− An inline element ends with `}}` (a brace that is text is \
5873− written `\\{{`)",
5874− inline.head.name
5875− ),
5876− );
5877− let d = at_attribute(d, Some(inline.span.column), ctx);
5878− ctx.push_once(d);
5879− }
5880− flush(&mut plain, &mut start, &mut segments, ctx);
5881− // A function called inline gets no children: its
5882− // content is the text after its attributes
5883− match Evaluator.complete(resolved, Vec::new(), ctx) {
5884− Ok(nodes) => segments.extend(inline_segments(nodes, line)),
5885− Err(mut e) => {
5886− e.source_line
5887− .get_or_insert_with(|| text.raw.as_str().into());
5888− ctx.diagnostics.push(e);
5889− }
5890− }
5891− }
5892− Err(mut e) => {
5893− // A misspelled element is an error inline too (a
5894− // brace that is text is written `\{`); the braces
5895− // stay as written
5896− e.source_line = Some(text.raw.as_str().into());
5897− ctx.diagnostics.push(e);
5898− start.get_or_insert(inline.span);
5899− plain.push('{');
5900− plain.push_str(&inline.raw);
5901− if inline.closed {
5902− plain.push('}');
5903− }
5904 }
3308 }
3309+ current_text.push('{');
3310+ current_text.push_str(&inner);
3311+ current_text.push('}');
5905 }
3312 }
5906 }
3313 }
3314+ } else {
3315+ current_text.push(chars[i]);
3316+ i += 1;
5907 }
3317 }
5908 }
3318 }
5909− flush(&mut plain, &mut start, &mut segments, ctx);
5910− segments
5911−}
5912
3319
5913−/// The variable `raw` is, when it is exactly one `$name` or `${name}`.
3320+ if !current_text.is_empty() {
5914−fn whole_reference(raw: &str, env: &Env) -> Option<String> {
3321+ segments.push(TextSegment::Plain(substitute_vars(
5915− let after = raw.trim().strip_prefix('$')?;
3322+ &current_text,
5916− match interp::reference(after, env)? {
3323+ &ctx.variables,
5917− (interp::Reference::Var(path), len) if len == after.len() => Some(path),
3324+ )));
5918− _ => None,
5919 }
3325 }
3326+
3327+ segments
5920}
3328}
5921
3329
5922−/// The attributes of `tokens` after each whole-attribute `if()` has
3330+// ---------------------------------------------------------------------------
5923−/// chosen (see [`syntax::Choice`]), in order, into `out`. The branch not
3331+// Variable substitution
5924−/// taken isn't evaluated; with `validate`, its attribute names are checked
3332+// ---------------------------------------------------------------------------
5925−/// like code that doesn't run, under the prefixes `prefix` the `if()` is
3333+
5926−/// under (`hover:if(...)`).
3334+// ---------------------------------------------------------------------------
5927−fn choose_attrs(
3335+// Condition evaluation for @if
5928− tokens: &[syntax::Attr],
3336+// ---------------------------------------------------------------------------
5929− prefix: &str,
3337+
5930− line_num: usize,
3338+/// Inclusive integer range from `start` to `end` (counting down when
5931− ctx: &mut ParseContext,
3339+/// `start > end`), stepping by `step`. Stops instead of overflowing.
5932− validate: bool,
3340+fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
5933− text_keys: &[String],
3341+ let mut items = Vec::new();
5934− out: &mut Vec<syntax::Attr>,
3342+ let mut n = start;
5935−) {
3343+ if start <= end {
5936− for token in tokens {
3344+ while n <= end {
5937− let Some(choice) = &token.choice else {
3345+ items.push(n.to_string());
5938− out.push(token.clone());
3346+ match n.checked_add(step) {
5939− continue;
3347+ Some(next) => n = next,
5940− };
3348+ None => break,
5941− let Some(condition) = choice_shape(token, choice, line_num, ctx) else {
5942− continue;
5943− };
5944− let taken = match condition.span.line {
5945− 0 => ctx.condition(&condition.raw, line_num, None),
5946− line => ctx.condition(&condition.raw, line, Some(condition.span.column)),
5947− };
5948− let (chosen, other) = match taken {
5949− true => (choice.branches.first(), choice.branches.get(1)),
5950− false => (choice.branches.get(1), choice.branches.first()),
5951− };
5952− if let Some(other) = other {
5953− track_var_refs(&branch_text(other), &mut ctx.used_variables);
5954− if validate {
5955− check_branch(token, other, prefix, line_num, ctx, text_keys);
5956 }
3349 }
5957 }
3350 }
5958− if let Some(branch) = chosen {
5959− choose_attrs(
5960− branch.attrs(),
5961− prefix,
5962− line_num,
5963− ctx,
5964− validate,
5965− text_keys,
5966− out,
5967− );
5968− }
5969− }
5970−}
5971−
5972−/// Report what is wrong with the shape of `if()`: an empty condition, a
5973−/// number of branches other than one or two, text after a group's `]`.
5974−/// Returns the condition when the `if()` can be evaluated.
5975−fn choice_shape<'a>(
5976− token: &syntax::Attr,
5977− choice: &'a syntax::Choice,
5978− line_num: usize,
5979− ctx: &mut ParseContext,
5980−) -> Option<&'a syntax::Arg> {
5981− let line = if token.span.line == 0 {
5982− line_num
5983 } else {
3351 } else {
5984− token.span.line
3352+ while n >= end {
5985− };
3353+ items.push(n.to_string());
5986− // The line the column points into, when it is the one being evaluated
3354+ match n.checked_sub(step) {
5987− let source = |ctx: &ParseContext, diagnostic: Diagnostic, at: usize| match &ctx.current_source {
3355+ Some(next) => n = next,
5988− (current, Some(text)) if *current == at => diagnostic.source(text.clone()),
3356+ None => break,
5989− _ => diagnostic,
3357+ }
5990− };
5991− for branch in &choice.branches {
5992− if let syntax::Branch::Group {
5993− trailing: Some(trailing),
5994− ..
5995− } = branch
5996− {
5997− let diagnostic = source(
5998− ctx,
5999− Diagnostic::error(
6000− code::UNEXPECTED_ARGUMENT,
6001− trailing.span.line,
6002− format!(
6003− "'{}' after the `]` of a group in if(): a branch is one attribute, \
6004− one `[group]` of attributes or one `$bundle`",
6005− trailing.raw
6006− ),
6007− )
6008− .column(trailing.span.column)
6009− .subject(trailing.raw.as_str()),
6010− trailing.span.line,
6011− );
6012− ctx.push_once(diagnostic);
6013− }
6014− }
6015− match &choice.condition {
6016− Some(condition) if (1..=2).contains(&choice.branches.len()) => Some(condition),
6017− _ => {
6018− let diagnostic = source(
6019− ctx,
6020− Diagnostic::error(
6021− code::INVALID_EXPRESSION,
6022− line,
6023− format!(
6024− "`{}`: if() takes a condition and one or two branches, \
6025− `if(CONDITION, A, B)`",
6026− token.raw
6027− ),
6028− )
6029− .column(token.span.column),
6030− line,
6031− );
6032− ctx.push_once(diagnostic);
6033− None
6034 }
3358 }
6035 }
3359 }
3360+ items
6036}
3361}
6037
3362
6038−/// A branch's text, for the names it uses.
6039−fn branch_text(branch: &syntax::Branch) -> String {
6040− let raws: Vec<&str> = branch.attrs().iter().map(|a| a.raw.as_str()).collect();
6041− raws.join(", ")
6042−}
6043
3363
6044−/// Check attributes that are never evaluated (in code that doesn't run,
6045−/// or the branch an `if()` didn't take), as far as they can be checked:
6046−/// names are, and literal values; a value with a variable depends on what
6047−/// the variable holds. Each branch of an `if()` is checked on its own.
6048−fn check_attrs(tokens: &[syntax::Attr], line: usize, ctx: &mut ParseContext, text_keys: &[String]) {
6049− parse_attr_list(&literal_attrs(tokens), line, ctx, true, text_keys);
6050− check_choices(tokens, "", line, ctx, text_keys);
6051−}
6052
3364
6053−/// [`check_attrs`] for a branch of the `if()` `token`, under the prefixes
3365+/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
6054−/// `prefix` it is under: `hover:if($on, color red, [id=x])` checks
3366+/// Split `key value if condition` into the attribute and its condition
6055−/// `hover:[id=x]`, an error whichever branch is taken.
3367+/// (ignoring ` if ` inside parentheses).
6056−fn check_branch(
3368+fn split_trailing_if(part: &str) -> (&str, Option<&str>) {
6057− token: &syntax::Attr,
3369+ let mut depth = 0;
6058− branch: &syntax::Branch,
3370+ for (i, c) in part.char_indices() {
6059− prefix: &str,
3371+ match c {
6060− line: usize,
3372+ '(' => depth += 1,
6061− ctx: &mut ParseContext,
3373+ ')' => depth -= 1,
6062− text_keys: &[String],
3374+ _ if depth == 0 && part[i..].starts_with(" if ") => {
6063−) {
3375+ return (&part[..i], Some(part[i + 4..].trim()));
6064− if prefix.is_empty() {
3376+ }
6065− check_attrs(branch.attrs(), line, ctx, text_keys);
3377+ _ => {}
6066− return;
3378+ }
6067 }
3379 }
6068− let list = syntax::AttrList {
3380+ (part, None)
6069− attrs: branch.attrs().to_vec(),
3381+}
6070− span: token.span,
3382+
6071− closed: true,
3383+/// Resolve a value written `if(cond, a, b)`: evaluate `cond` and keep the
6072− quoted_close: None,
3384+/// chosen branch's text. Other attributes are returned unchanged.
3385+fn choose_if_value(part: &str, ctx: &mut ParseContext, line: usize) -> String {
3386+ let (head, value) = match part.find(['=', ' ']) {
3387+ Some(pos) => part.split_at(pos + 1),
3388+ None => return part.to_string(),
6073 };
3389 };
6074− let grouped = syntax::Attr {
3390+ let value = value.trim();
6075− key: prefix.to_string(),
3391+ let Some(inner) = value.strip_prefix("if(").and_then(|v| v.strip_suffix(')')) else {
6076− value: None,
3392+ return part.to_string();
6077− html: false,
6078− raw: token.raw.clone(),
6079− span: token.span,
6080− choice: None,
6081− prefixed: Some(Box::new(syntax::Prefixed {
6082− prefix: prefix.to_string(),
6083− prefix_span: token.span,
6084− target: syntax::Branch::Group {
6085− list,
6086− trailing: None,
6087− },
6088− })),
6089 };
3393 };
6090− check_attrs(std::slice::from_ref(&grouped), line, ctx, text_keys);
3394+ let args = split_if_args(inner);
3395+ if args.len() != 3 {
3396+ return part.to_string();
3397+ }
3398+ let branch = if ctx.condition(args[0].trim(), line) {
3399+ args[1]
3400+ } else {
3401+ args[2]
3402+ };
3403+ format!("{}{}", head, branch.trim())
6091}
3404}
6092
3405
6093−/// [`check_attrs`] for the branches of each `if()` of `tokens`, which are
3406+fn split_if_args(input: &str) -> Vec<&str> {
6094−/// under the prefixes `prefix`, also inside a prefixed group or as a
3407+ let mut parts = Vec::new();
6095−/// prefixed `if()`.
3408+ let mut start = 0;
6096−fn check_choices(
3409+ let mut depth = 0;
6097− tokens: &[syntax::Attr],
3410+ for (i, c) in input.char_indices() {
6098− prefix: &str,
3411+ match c {
6099− line: usize,
3412+ '(' => depth += 1,
6100− ctx: &mut ParseContext,
3413+ ')' => depth -= 1,
6101− text_keys: &[String],
3414+ ',' if depth == 0 => {
6102−) {
3415+ parts.push(&input[start..i]);
6103− for token in tokens {
3416+ start = i + 1;
6104− if let Some(choice) = &token.choice
6105− && choice_shape(token, choice, line, ctx).is_some()
6106− {
6107− for branch in &choice.branches {
6108− check_branch(token, branch, prefix, line, ctx, text_keys);
6109 }
3417 }
6110− }
3418+ _ => {}
6111− if let Some(prefixed) = &token.prefixed {
6112− let inner = format!("{}{}", prefix, prefixed.prefix);
6113− check_choices(prefixed.target.attrs(), &inner, line, ctx, text_keys);
6114 }
3419 }
6115 }
3420 }
3421+ parts.push(&input[start..]);
3422+ parts
6116}
3423}
6117
3424
6118// ---------------------------------------------------------------------------
3425// ---------------------------------------------------------------------------
6119// Post-parse validation (context-dependent warnings)
3426// Post-parse validation (context-dependent warnings)
6120// ---------------------------------------------------------------------------
3427// ---------------------------------------------------------------------------
6121
3428
6122−fn element_kind_name(kind: &ElementKind) -> String {
3429+/// Attributes that only make sense on container elements (@row, @column, @el).
6123− format!("@{}", kind.name())
3430+const CONTAINER_ONLY_ATTRS: &[&str] = &[
6124−}
3431+ "spacing",
6125−
3432+ "gap",
6126−/// HTML elements whose start tag ends an open `<p>`: written inside a
3433+ "wrap",
6127−/// `@paragraph`, the browser moves them out of it. (`div` isn't here:
3434+ "grid",
6128−/// inside text, htmlang writes its `<div>`s as `<span>`s.)
3435+ "grid-cols",
6129−#[rustfmt::skip]
3436+ "grid-rows",
6130−const ENDS_A_PARAGRAPH: &[&str] = &[
3437+ "container",
6131− "address", "article", "aside", "blockquote", "dd", "details", "dialog", "dl", "dt",
3438+ "container-name",
6132− "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6",
3439+ "container-type",
6133− "header", "hgroup", "hr", "li", "main", "menu", "nav", "ol", "p", "pre", "search",
6134− "section", "summary", "table", "ul",
6135];
3440];
6136
3441
6137−/// The HTML element `kind` is written as, if the browser would take it out
3442+fn element_kind_name(kind: &ElementKind) -> String {
6138−/// of a `<p>`.
3443+ format!("@{}", kind.name())
6139−fn ends_a_paragraph(kind: &ElementKind) -> Option<&'static str> {
6140− let html = match kind {
6141− ElementKind::Paragraph => "p",
6142− ElementKind::Tag(spec) => spec.html,
6143− _ => return None,
6144− };
6145− ENDS_A_PARAGRAPH.contains(&html).then_some(html)
6146}
3444}
6147
3445
6148−/// Stricter checks for `htmlang lint`, on top of the diagnostics every
3446+fn is_container(kind: &ElementKind) -> bool {
6149−/// compile reports: deep nesting, empty layout containers, and buttons
3447+ matches!(kind, ElementKind::Row | ElementKind::Column | ElementKind::El)
6150−/// without an explicit `type`.
3448+ || kind.spec().is_some_and(|spec| spec.container)
6151−pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
6152− fn walk(nodes: &[Node], depth: usize, out: &mut Vec<Diagnostic>) {
6153− for node in nodes {
6154− let Node::Element(elem) = node else { continue };
6155− let start = out.len();
6156− let mut warn = |code: &'static str, message: String| {
6157− out.push(Diagnostic::warning(code, elem.line_num, message))
6158− };
6159− if depth > 10 {
6160− warn(
6161− code::DEEP_NESTING,
6162− format!(
6163− "deeply nested element ({} levels): consider simplifying",
6164− depth
6165− ),
6166− );
6167− }
6168− // A function's body may draw with an empty element (a spacer,
6169− // a dot): only one written in the page is flagged
6170− if elem.kind.layout().is_container()
6171− && elem.children.is_empty()
6172− && elem.function.is_none()
6173− {
6174− warn(
6175− code::EMPTY_CONTAINER,
6176− format!("empty container (@{}) has no children", elem.kind.name()),
6177− );
6178− }
6179− if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
6180− warn(
6181− code::MISSING_BUTTON_TYPE,
6182− "@button missing 'type' attribute (defaults to submit)".to_string(),
6183− );
6184− }
6185− in_function_body(elem, &mut out[start..]);
6186− walk(&elem.children, depth + 1, out);
6187− }
6188− }
6189− let mut out = Vec::new();
6190− walk(nodes, 0, &mut out);
6191− dedupe(&mut out);
6192− out
6193}
3449}
6194
3450
6195−/// `in_paragraph`: the nodes are inside a `@paragraph` (and not inside a
6196−/// `@button` in it, which the browser keeps whole).
6197fn validate_tree(
3451fn validate_tree(
6198 nodes: &[Node],
3452 nodes: &[Node],
6199 parent_kind: Option<&ElementKind>,
3453 parent_kind: Option<&ElementKind>,
6200− in_paragraph: bool,
6201 diagnostics: &mut Vec<Diagnostic>,
3454 diagnostics: &mut Vec<Diagnostic>,
6202) {
3455) {
6203 for node in nodes {
3456 for node in nodes {
6204− // An element inside a line of text (`{@kbd ...}`, an inline call)
6205− // is checked like one on a line of its own
6206− if let Node::Text(segments) = node {
6207− let inline: Vec<Node> = segments
6208− .iter()
6209− .filter_map(|segment| match segment {
6210− TextSegment::Inline(elem) => Some(Node::Element(elem.clone())),
6211− _ => None,
6212− })
6213− .collect();
6214− validate_tree(&inline, parent_kind, in_paragraph, diagnostics);
6215− }
6216 if let Node::Element(elem) = node {
3457 if let Node::Element(elem) = node {
6217− let start = diagnostics.len();
6218− dropped_by_the_element(elem, diagnostics);
6219− if in_paragraph && let Some(html) = ends_a_paragraph(&elem.kind) {
6220− diagnostics.push(
6221− Diagnostic::warning(
6222− code::BLOCK_IN_PARAGRAPH,
6223− elem.line_num,
6224− format!(
6225− "{0} inside @paragraph: HTML ends a <p> before a <{1}>, so the \
6226− browser moves the <{1}> out of the paragraph. Put {0} after the \
6227− paragraph, or use @el, @row or @grid, which are laid out inline \
6228− in text",
6229− element_kind_name(&elem.kind),
6230− html
6231− ),
6232− )
6233− .subject(elem.kind.name()),
6234− );
6235− }
6236 for attr in &elem.attrs {
3458 for attr in &elem.attrs {
6237 let base = crate::vocab::base_attribute(&attr.key);
3459 let base = crate::vocab::base_attribute(&attr.key);
6238−
3460+ if base == "width"
6239− // htmlang's words for laying out children, on an element
3461+ && attr.value.as_deref() == Some("fill")
6240− // that doesn't lay out its children: left out (codegen)
3462+ && !matches!(parent_kind, Some(ElementKind::Row))
6241− if !attr.html
3463+ {
6242− && crate::vocab::CONTAINER_ATTRIBUTES.contains(&base)
3464+ diagnostics.push(Diagnostic {
6243− && !elem.kind.layout().is_container()
3465+ line: elem.line_num,
6244− && !has_no_element(&elem.kind)
3466+ column: None,
6245− && !crate::vocab::split_prefixes(&attr.key)
3467+ message: "'width fill' works best inside @row; using 100% as fallback"
6246− .0
3468+ .to_string(),
6247− .contains(&"children:")
3469+ severity: Severity::Warning,
3470+ source_line: None,
3471+ });
3472+ }
3473+ if base == "height"
3474+ && attr.value.as_deref() == Some("fill")
3475+ && !matches!(parent_kind, Some(ElementKind::Column))
6248 {
3476 {
6249− diagnostics.push(not_a_container(&elem.kind, elem.line_num, base));
3477+ diagnostics.push(Diagnostic {
3478+ line: elem.line_num,
3479+ column: None,
3480+ message: "'height fill' works best inside @column; using 100% as fallback"
3481+ .to_string(),
3482+ severity: Severity::Warning,
3483+ source_line: None,
3484+ });
3485+ }
3486+
3487+ // Container-only attributes on non-container elements
3488+ if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
3489+ diagnostics.push(Diagnostic {
3490+ line: elem.line_num,
3491+ column: None,
3492+ message: format!(
3493+ "'{}' has no effect on {} (only works on @row, @column, @el)",
3494+ base,
3495+ element_kind_name(&elem.kind)
3496+ ),
3497+ severity: Severity::Warning,
3498+ source_line: None,
3499+ });
6250 }
3500 }
6251
3501
6252 // Form-specific: placeholder only on @input/@textarea
3502 // Form-specific: placeholder only on @input/@textarea
6253 if base == "placeholder"
3503 if base == "placeholder"
6254 && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
3504 && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
6255 {
3505 {
6256− diagnostics.push(Diagnostic::new(
3506+ diagnostics.push(Diagnostic {
6257− code::NO_EFFECT,
3507+ line: elem.line_num,
6258− Severity::Warning,
3508+ column: None,
6259− elem.line_num,
3509+ message: format!(
6260− format!(
6261 "'placeholder' has no effect on {} (only works on @input, @textarea)",
3510 "'placeholder' has no effect on {} (only works on @input, @textarea)",
6262 element_kind_name(&elem.kind)
3511 element_kind_name(&elem.kind)
6263 ),
3512 ),
6264− ));
3513+ severity: Severity::Warning,
3514+ source_line: None,
3515+ });
6265 }
3516 }
6266
3517
6267− // 'for' only on @label and @output
3518+ // 'for' only on @label
6268− if base == "for" && !(elem.kind.is_tag("label") || elem.kind.is_tag("output")) {
3519+ if base == "for" && !elem.kind.is_tag("label") {
6269− diagnostics.push(Diagnostic::new(
3520+ diagnostics.push(Diagnostic {
6270− code::NO_EFFECT,
3521+ line: elem.line_num,
6271− Severity::Warning,
3522+ column: None,
6272− elem.line_num,
3523+ message: format!(
6273− format!(
3524+ "'for' has no effect on {} (only works on @label)",
6274− "'for' has no effect on {} (only works on @label and @output)",
6275 element_kind_name(&elem.kind)
3525 element_kind_name(&elem.kind)
6276 ),
3526 ),
6277− ));
3527+ severity: Severity::Warning,
6278− }
3528+ source_line: None,
6279−
3529+ });
6280− // `width=` and `height=` are HTML attributes of media and
6281− // embedded content only; elsewhere they are styles
6282− if attr.html
6283− && (base == "width" || base == "height")
6284− && !matches!(elem.kind, ElementKind::Image)
6285− && ![
6286− "video", "canvas", "iframe", "embed", "object", "input", "source",
6287− ]
6288− .iter()
6289− .any(|tag| elem.kind.is_tag(tag))
6290− {
6291− let value = attr.value.as_deref().unwrap_or("");
6292− diagnostics.push(
6293− Diagnostic::warning(
6294− code::HTML_ATTRIBUTE_FORM,
6295− elem.line_num,
6296− format!(
6297− "'{0}={1}' has no effect on {2}, which has no {0} attribute: \
6298− the style is written `{0} {1}`",
6299− base,
6300− value,
6301− element_kind_name(&elem.kind)
6302− ),
6303− )
6304− .subject(format!("{}={}", base, value)),
6305− );
6306 }
3530 }
6307
3531
6308 // 'rows'/'cols' only on @textarea
3532 // 'rows'/'cols' only on @textarea
6309− if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea") {
3533+ if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea")
6310− diagnostics.push(Diagnostic::new(
3534+ {
6311− code::NO_EFFECT,
3535+ diagnostics.push(Diagnostic {
6312− Severity::Warning,
3536+ line: elem.line_num,
6313− elem.line_num,
3537+ column: None,
6314− format!(
3538+ message: format!(
6315 "'{}' has no effect on {} (only works on @textarea)",
3539 "'{}' has no effect on {} (only works on @textarea)",
6316 base,
3540 base,
6317 element_kind_name(&elem.kind)
3541 element_kind_name(&elem.kind)
6318 ),
3542 ),
6319− ));
3543+ severity: Severity::Warning,
3544+ source_line: None,
3545+ });
3546+ }
3547+
3548+ // 'ordered' only on @list
3549+ if base == "ordered" && !elem.kind.is_tag("list") {
3550+ diagnostics.push(Diagnostic {
3551+ line: elem.line_num,
3552+ column: None,
3553+ message: format!(
3554+ "'ordered' has no effect on {} (only works on @list)",
3555+ element_kind_name(&elem.kind)
3556+ ),
3557+ severity: Severity::Warning,
3558+ source_line: None,
3559+ });
6320 }
3560 }
6321
3561
6322 // Media-specific attributes only on @video/@audio
3562 // Media-specific attributes only on @video/@audio
@@ -6331,35 +3571,40 @@ fn validate_tree(
6331 | "preload"
3571 | "preload"
6332 ) && !matches!(elem.kind.name(), "video" | "audio")
3572 ) && !matches!(elem.kind.name(), "video" | "audio")
6333 {
3573 {
6334− diagnostics.push(Diagnostic::new(
3574+ diagnostics.push(Diagnostic {
6335− code::NO_EFFECT,
3575+ line: elem.line_num,
6336− Severity::Warning,
3576+ column: None,
6337− elem.line_num,
3577+ message: format!(
6338− format!(
6339 "'{}' has no effect on {} (only works on @video, @audio)",
3578 "'{}' has no effect on {} (only works on @video, @audio)",
6340 base,
3579 base,
6341 element_kind_name(&elem.kind)
3580 element_kind_name(&elem.kind)
6342 ),
3581 ),
6343− ));
3582+ severity: Severity::Warning,
3583+ source_line: None,
3584+ });
6344 }
3585 }
6345 }
3586 }
6346 // Missing alt text on @image
3587 // Missing alt text on @image
6347 if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt")
3588 if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt")
6348 {
3589 {
6349− diagnostics.push(Diagnostic::new(
3590+ diagnostics.push(Diagnostic {
6350− code::MISSING_ALT,
3591+ line: elem.line_num,
6351− Severity::Warning,
3592+ column: None,
6352− elem.line_num,
3593+ message: "@image missing 'alt' attribute (accessibility)".to_string(),
6353− "@image missing 'alt' attribute (accessibility)".to_string(),
3594+ severity: Severity::Warning,
6354− ));
3595+ source_line: None,
3596+ });
6355 }
3597 }
6356− if elem.kind.is_tag("input") && !elem.attrs.iter().any(|a| a.key == "type") {
3598+ if elem.kind.is_tag("input")
6357− diagnostics.push(Diagnostic::new(
3599+ && !elem.attrs.iter().any(|a| a.key == "type")
6358− code::MISSING_INPUT_TYPE,
3600+ {
6359− Severity::Warning,
3601+ diagnostics.push(Diagnostic {
6360− elem.line_num,
3602+ line: elem.line_num,
6361− "@input missing 'type' attribute (defaults to 'text')".to_string(),
3603+ column: None,
6362− ));
3604+ message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
3605+ severity: Severity::Warning,
3606+ source_line: None,
3607+ });
6363 }
3608 }
6364 if matches!(elem.kind, ElementKind::Link) {
3609 if matches!(elem.kind, ElementKind::Link) {
6365 // For @link, argument is the URL, not text content
3610 // For @link, argument is the URL, not text content
@@ -6370,50 +3615,44 @@ fn validate_tree(
6370 .iter()
3615 .iter()
6371 .any(|a| a.key == "aria-label" || a.key == "title")
3616 .any(|a| a.key == "aria-label" || a.key == "title")
6372 {
3617 {
6373− diagnostics.push(Diagnostic::new(
3618+ diagnostics.push(Diagnostic {
6374− code::MISSING_LINK_TEXT,
3619+ line: elem.line_num,
6375− Severity::Warning,
3620+ column: None,
6376− elem.line_num,
3621+ message: "@link has no visible text or aria-label (accessibility)"
6377− "@link has no visible text or aria-label (accessibility)".to_string(),
3622+ .to_string(),
6378− ));
3623+ severity: Severity::Warning,
3624+ source_line: None,
3625+ });
6379 }
3626 }
6380 }
3627 }
6381
3628
6382− // Contrast ratio check for hex color pairs, each pair on the
3629+ // Contrast ratio check for hex color pairs
6383− // same elements: the element's own, or those an element prefix
3630+ {
6384− // (`@td:`) picks out inside it
3631+ let bg_color = elem
6385− let inside = |key: &str| {
3632+ .attrs
6386− crate::vocab::split_prefixes(key)
3633+ .iter()
6387− .0
3634+ .find(|a| crate::vocab::base_attribute(&a.key) == "background")
6388− .into_iter()
3635+ .and_then(|a| a.value.as_deref());
6389− .find_map(crate::vocab::element_prefix)
3636+ let fg_color = elem
6390− .map(str::to_string)
3637+ .attrs
6391− };
3638+ .iter()
6392− let mut targets: Vec<Option<String>> = Vec::new();
3639+ .find(|a| crate::vocab::base_attribute(&a.key) == "color")
6393− for attr in elem.attrs.iter().filter(|a| !a.html) {
3640+ .and_then(|a| a.value.as_deref());
6394− let target = inside(&attr.key);
6395− if !targets.contains(&target) {
6396− targets.push(target);
6397− }
6398− }
6399− for target in targets {
6400− let color = |property: &str| {
6401− elem.attrs
6402− .iter()
6403− .filter(|a| !a.html && inside(&a.key) == target)
6404− .find(|a| crate::vocab::base_attribute(&a.key) == property)
6405− .and_then(|a| a.value.as_deref())
6406− };
6407− let (bg_color, fg_color) = (color("background"), color("color"));
6408 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
3641 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
6409− && let (Some(bg_rgb), Some(fg_rgb)) = (parse_hex_rgb(bg), parse_hex_rgb(fg))
3642+ && let (Some(bg_rgb), Some(fg_rgb)) = (crate::expr::parse_hex_rgb(bg), crate::expr::parse_hex_rgb(fg))
6410 {
3643 {
6411 let ratio = contrast_ratio(bg_rgb, fg_rgb);
3644 let ratio = contrast_ratio(bg_rgb, fg_rgb);
6412 if ratio < 4.5 {
3645 if ratio < 4.5 {
6413− diagnostics.push(Diagnostic::new(code::LOW_CONTRAST, Severity::Warning, elem.line_num, format!(
3646+ diagnostics.push(Diagnostic {
3647+ line: elem.line_num,
3648+ column: None,
3649+ message: format!(
6414 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
3650 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
6415 ratio, fg, bg
3651 ratio, fg, bg
6416− )));
3652+ ),
3653+ severity: Severity::Warning,
3654+ source_line: None,
3655+ });
6417 }
3656 }
6418 }
3657 }
6419 }
3658 }
@@ -6428,34 +3667,45 @@ fn validate_tree(
6428 let has_title = elem.attrs.iter().any(|a| a.key == "title");
3667 let has_title = elem.attrs.iter().any(|a| a.key == "title");
6429 let in_label = parent_kind.is_some_and(|k| k.is_tag("label"));
3668 let in_label = parent_kind.is_some_and(|k| k.is_tag("label"));
6430 if !has_id && !has_aria_label && !has_title && !in_label {
3669 if !has_id && !has_aria_label && !has_title && !in_label {
6431− diagnostics.push(Diagnostic::new(code::MISSING_LABEL, Severity::Warning, elem.line_num, format!(
3670+ diagnostics.push(Diagnostic {
3671+ line: elem.line_num,
3672+ column: None,
3673+ message: format!(
6432 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
3674 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
6433 element_kind_name(&elem.kind)
3675 element_kind_name(&elem.kind)
6434− )));
3676+ ),
3677+ severity: Severity::Warning,
3678+ source_line: None,
3679+ });
6435 }
3680 }
6436 }
3681 }
6437
3682
6438 // @iframe should have title attribute
3683 // @iframe should have title attribute
6439− if elem.kind.is_tag("iframe") && !elem.attrs.iter().any(|a| a.key == "title") {
3684+ if elem.kind.is_tag("iframe")
6440− diagnostics.push(Diagnostic::new(
3685+ && !elem.attrs.iter().any(|a| a.key == "title")
6441− code::MISSING_TITLE,
3686+ {
6442− Severity::Warning,
3687+ diagnostics.push(Diagnostic {
6443− elem.line_num,
3688+ line: elem.line_num,
6444− "@iframe missing 'title' attribute (accessibility)".to_string(),
3689+ column: None,
6445− ));
3690+ message: "@iframe missing 'title' attribute (accessibility)".to_string(),
3691+ severity: Severity::Warning,
3692+ source_line: None,
3693+ });
6446 }
3694 }
6447
3695
6448 // @button should have accessible text
3696 // @button should have accessible text
6449 if elem.kind.is_tag("button") {
3697 if elem.kind.is_tag("button") {
6450− let has_text = !elem.children.is_empty();
3698+ let has_text = elem.argument.is_some() || !elem.children.is_empty();
6451 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
3699 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
6452 if !has_text && !has_aria {
3700 if !has_text && !has_aria {
6453− diagnostics.push(Diagnostic::new(
3701+ diagnostics.push(Diagnostic {
6454− code::MISSING_BUTTON_TEXT,
3702+ line: elem.line_num,
6455− Severity::Warning,
3703+ column: None,
6456− elem.line_num,
3704+ message: "@button has no text content or aria-label (accessibility)"
6457− "@button has no text content or aria-label (accessibility)".to_string(),
3705+ .to_string(),
6458− ));
3706+ severity: Severity::Warning,
3707+ source_line: None,
3708+ });
6459 }
3709 }
6460 }
3710 }
6461
3711
@@ -6468,15 +3718,16 @@ fn validate_tree(
6468 let has_track = elem
3718 let has_track = elem
6469 .children
3719 .children
6470 .iter()
3720 .iter()
6471− .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("track")));
3721+ .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("source")));
6472 if !has_aria && !has_track {
3722 if !has_aria && !has_track {
6473− diagnostics.push(Diagnostic::new(
3723+ diagnostics.push(Diagnostic {
6474− code::MISSING_CAPTIONS,
3724+ line: elem.line_num,
6475− Severity::Warning,
3725+ column: None,
6476− elem.line_num,
3726+ message: "@video should have aria-label or captions for accessibility"
6477− "@video should have aria-label or a captions @track for accessibility"
6478 .to_string(),
3727 .to_string(),
6479− ));
3728+ severity: Severity::Warning,
3729+ source_line: None,
3730+ });
6480 }
3731 }
6481 }
3732 }
6482
3733
@@ -6486,204 +3737,20 @@ fn validate_tree(
6486 && let Ok(n) = val.parse::<i32>()
3737 && let Ok(n) = val.parse::<i32>()
6487 && n > 0
3738 && n > 0
6488 {
3739 {
6489− diagnostics.push(Diagnostic::new(code::POSITIVE_TABINDEX, Severity::Warning, elem.line_num, format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n)));
3740+ diagnostics.push(Diagnostic {
3741+ line: elem.line_num,
3742+ column: None,
3743+ message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n),
3744+ severity: Severity::Warning,
3745+ source_line: None,
3746+ });
6490 }
3747 }
6491− in_function_body(elem, &mut diagnostics[start..]);
6492−
6493− // An element that ends the <p> takes what is inside it out of
6494− // the paragraph too, so only it is reported
6495− let in_paragraph = elem.kind == ElementKind::Paragraph
6496− || (in_paragraph
6497− && ends_a_paragraph(&elem.kind).is_none()
6498− && !elem.kind.is_tag("button"));
6499− validate_tree(&elem.children, Some(&elem.kind), in_paragraph, diagnostics);
6500− }
6501− }
6502−}
6503−
6504−/// What an element has no place for in HTML, which would otherwise be
6505−/// left out of the page: attributes on `@fragment` (which has no element
6506−/// of its own), styles on `@script` (which isn't shown), a body under a
6507−/// `@script` that has a src (which the browser doesn't run), an element
6508−/// under a `@script` (whose body is code), and content in a void element
6509−/// such as `@input`.
6510−fn dropped_by_the_element(elem: &Element, diagnostics: &mut Vec<Diagnostic>) {
6511− let name = elem.kind.name();
6512− let keys = |attrs: Vec<&Attribute>| {
6513− attrs
6514− .iter()
6515− .map(|a| format!("'{}'", a.key))
6516− .collect::<Vec<_>>()
6517− .join(", ")
6518− };
6519− let mut error = |code: &'static str, message: String| {
6520− diagnostics.push(Diagnostic::error(code, elem.line_num, message).subject(name))
6521− };
6522− if elem.kind == ElementKind::Fragment && !elem.attrs.is_empty() {
6523− error(
6524− code::UNEXPECTED_ARGUMENT,
6525− format!(
6526− "@fragment has no element of its own to put attributes on, so {} would go \
6527− nowhere: put them on its children, or use @el",
6528− keys(elem.attrs.iter().collect())
6529− ),
6530− );
6531− }
6532− // The leading argument given twice: an attribute passed to the root
6533− // of a function whose body gives the argument (written on one line,
6534− // it is reported where it is read, and the attribute wins there)
6535− if let Some(argument) = &elem.argument
6536− && let Some(attr) = elem
6537− .attrs
6538− .iter()
6539− .find(|a| a.html && elem.kind.arg().attributes().contains(&a.key.as_str()))
6540− {
6541− error(
6542− code::DUPLICATE_ATTRIBUTE,
6543− format!(
6544− "@{} takes '{}' as its {}, and it also has {}={}: give it once",
6545− name,
6546− argument,
6547− leading_name(&elem.kind),
6548− attr.key,
6549− attr.value.as_deref().unwrap_or("")
6550− ),
6551− );
6552− }
6553− if elem.kind.is_verbatim() {
6554− let styles: Vec<&Attribute> = elem
6555− .attrs
6556− .iter()
6557− .filter(|a| !a.html && !crate::vocab::is_html_flag(&a.key))
6558− .collect();
6559− if !styles.is_empty() {
6560− error(
6561− code::UNEXPECTED_ARGUMENT,
6562− format!(
6563− "@{} isn't shown on the page, so its styles {} would go nowhere",
6564− name,
6565− keys(styles)
6566− ),
6567− );
6568− }
6569− let src = elem.argument.clone().or_else(|| {
6570− elem.attrs
6571− .iter()
6572− .find(|a| a.html && a.key == "src")
6573− .map(|a| a.value.clone().unwrap_or_default())
6574− });
6575− if let Some(src) = src
6576− && !elem.children.is_empty()
6577− {
6578− error(
6579− code::UNEXPECTED_CONTENT,
6580− format!(
6581− "@{0} has both a src ('{1}') and a body: the browser runs only the file, \
6582− so the body would go nowhere. Put the code in {1}, or leave out the src",
6583− name, src
6584− ),
6585− );
6586− } else if let Some(Node::Element(child)) = elem
6587− .children
6588− .iter()
6589− .find(|child| matches!(child, Node::Element(_)))
6590− {
6591− // `@script > @b x`: its body is code, not elements
6592− error(
6593− code::UNEXPECTED_CONTENT,
6594− format!(
6595− "@{} holds its code as the lines under it, kept as written, so @{} would \
6596− go nowhere",
6597− name,
6598− child.kind.name()
6599− ),
6600− );
6601− }
6602− }
6603− if elem.kind != ElementKind::Image && elem.attrs.iter().any(|a| !a.html && a.key == "inline") {
6604− error(
6605− code::UNEXPECTED_ARGUMENT,
6606− format!(
6607− "`inline` puts an image's file into the page, and @{} has none: it only \
6608− works on @image",
6609− name
6610− ),
6611− );
6612− }
6613− if elem.kind.layout() == Layout::Void && !elem.children.is_empty() {
6614− error(
6615− code::UNEXPECTED_CONTENT,
6616− format!(
6617− "@{} takes no content: it is an element without a closing tag, so its text \
6618− and indented lines would go nowhere",
6619− name
6620− ),
6621− );
6622− }
6623−}
6624−
6625−/// `@fragment`, `@script`, `@children` and `@slot`, whose attributes are
6626−/// reported on their own (see `dropped_by_the_element`).
6627−fn has_no_element(kind: &ElementKind) -> bool {
6628− matches!(
6629− kind,
6630− ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_)
6631− ) || kind.is_verbatim()
6632−}
6633−
6634−/// htmlang's word `word` for laying out children (`spacing`, `wrap`,
6635−/// `grid-cols`, `grid-rows`) on an element that doesn't lay out its
6636−/// children: an error, and the word is left out.
6637−fn not_a_container(kind: &ElementKind, line: usize, word: &str) -> Diagnostic {
6638− let name = element_kind_name(kind);
6639− let (what, instead) = match kind.layout() {
6640− Layout::Text => (
6641− "is text, whose lines and children flow together with no gap between them",
6642− "; to space the children out, put them in an @el or @row",
6643− ),
6644− Layout::Void => ("has no content to lay out", ""),
6645− _ => (
6646− "keeps HTML's own layout",
6647− "; for a flex layout of your own, write CSS, such as `display flex, gap 8`",
6648− ),
6649− };
6650− Diagnostic::error(
6651− code::NO_EFFECT,
6652− line,
6653− format!(
6654− "'{0}' can't go on {1}: {1} {2}. `{0}` works on a row, column or grid \
6655− (@el, @row, @grid, @section, ...){3}",
6656− word, name, what, instead
6657− ),
6658− )
6659− .subject(word)
6660−}
6661
3748
6662−/// Diagnostics about an element a function's body wrote are reported at
3749+ validate_tree(&elem.children, Some(&elem.kind), diagnostics);
6663−/// the call (see `belongs_to_call`); say which body the element is in.
6664−fn in_function_body(elem: &Element, diagnostics: &mut [Diagnostic]) {
6665− if let Some(function) = &elem.function {
6666− for d in diagnostics {
6667− d.message = format!("{}\n in the body of @{}", d.message, function);
6668 }
3750 }
6669 }
3751 }
6670}
3752}
6671
3753
6672−/// Report each diagnostic once: a line that runs many times (in a loop, in
6673−/// a function called from one place many times) reports its problems once.
6674−fn dedupe(diagnostics: &mut Vec<Diagnostic>) {
6675− let mut seen = HashSet::new();
6676− diagnostics.retain(|d| {
6677− seen.insert((
6678− d.code,
6679− d.line,
6680− d.column,
6681− d.message.clone(),
6682− d.severity as u8,
6683− ))
6684− });
6685−}
6686−
6687fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
3754fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
6688 fn linearize(c: u8) -> f64 {
3755 fn linearize(c: u8) -> f64 {
6689 let s = c as f64 / 255.0;
3756 let s = c as f64 / 255.0;
@@ -6696,24 +3763,6 @@ fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
6696 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
3763 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
6697}
3764}
6698
3765
6699−/// The red, green and blue of a `#rgb` or `#rrggbb` colour.
6700−fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
6701− let s = s.strip_prefix('#')?;
6702− if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
6703− return None;
6704− }
6705− let channel = |i: usize, len: usize| u8::from_str_radix(&s[i..i + len], 16).ok();
6706− match s.len() {
6707− 3 => Some((
6708− channel(0, 1)? * 17,
6709− channel(1, 1)? * 17,
6710− channel(2, 1)? * 17,
6711− )),
6712− 6 => Some((channel(0, 2)?, channel(2, 2)?, channel(4, 2)?)),
6713− _ => None,
6714− }
6715−}
6716−
6717fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
3766fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
6718 let l1 = relative_luminance(c1.0, c1.1, c1.2);
3767 let l1 = relative_luminance(c1.0, c1.1, c1.2);
6719 let l2 = relative_luminance(c2.0, c2.1, c2.2);
3768 let l2 = relative_luminance(c2.0, c2.1, c2.2);
@@ -6721,154 +3770,168 @@ fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
6721 (lighter + 0.05) / (darker + 0.05)
3770 (lighter + 0.05) / (darker + 0.05)
6722}
3771}
6723
3772
6724−/// `@image [inline] icon.svg` becomes the SVG's markup, with `width`,
3773+// ---------------------------------------------------------------------------
6725−/// `height`, `color` / `fill` and the HTML attributes (`class=`, `id=`,
3774+// Variable substitution
6726−/// `aria-label=`, ...) applied to the `<svg>` tag; any other attribute is
3775+// ---------------------------------------------------------------------------
6727−/// an error. Other nodes are returned unchanged.
3776+
6728−fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
3777+/// Interpolate `$name` variables and `${expr}` expressions into text.
6729− let Node::Element(elem) = &node else {
3778+/// Undefined variables, and expressions that don't evaluate, are left as
6730− return node;
3779+/// written so they show up in the output.
6731− };
3780+fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
6732− let inline = elem.kind == ElementKind::Image
3781+ if !input.contains('$') {
6733− && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
3782+ return input.to_string();
6734− && elem.argument.as_deref().is_some_and(|src| !src.is_empty());
3783+ }
6735− if !inline {
3784+ let is_name_char = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
6736− return node;
3785+ let mut result = String::with_capacity(input.len());
6737− }
3786+ let mut rest = input;
6738− // A raster image: its file, read relative to this one, as a `data:` URI
3787+ while let Some(pos) = rest.find('$') {
6739− if !elem
3788+ result.push_str(&rest[..pos]);
6740− .argument
3789+ let after = &rest[pos + 1..];
6741− .as_deref()
3790+ if after.starts_with('{')
6742− .is_some_and(|src| src.ends_with(".svg"))
3791+ && let Some(close) = matching_brace(after)
6743− {
3792+ {
6744− let Node::Element(mut elem) = node else {
3793+ let source = &after[1..close];
6745− return node;
3794+ let resolve = |name: &str| vars.get(name).cloned();
6746− };
3795+ match crate::expr::eval(source, &resolve) {
6747− let filename = elem.argument.clone().unwrap_or_default();
3796+ Ok(value) => result.push_str(&value.to_string()),
6748− let resolved = ctx.resolve(&filename);
3797+ Err(_) => result.push_str(&rest[pos..pos + 1 + close + 1]),
6749− match std::fs::read(&resolved) {
6750− Ok(data) => {
6751− elem.argument = Some(crate::codegen::data_uri(&filename, &data));
6752− ctx.included_files.push(resolved);
6753 }
3798 }
6754− Err(e) => ctx.diagnostics.push(Diagnostic::new(
3799+ rest = &after[close + 1..];
6755− code::UNREADABLE_FILE,
3800+ continue;
6756− Severity::Error,
6757− line_num,
6758− format!(
6759− "cannot load image '{}' to put it into the page: {}",
6760− filename, e
6761− ),
6762− )),
6763 }
3801 }
6764− return Node::Element(elem);
3802+ let mut end = after.find(|c: char| !is_name_char(c)).unwrap_or(after.len());
6765− }
3803+ while end > 0 && after[..end].ends_with('.') {
6766− let filename = elem.argument.clone().unwrap_or_default();
3804+ end -= 1;
6767− let resolved = match &ctx.base_path {
6768− Some(base) => base.join(&filename),
6769− None => PathBuf::from(&filename),
6770− };
6771− let mut svg = match std::fs::read_to_string(&resolved) {
6772− Ok(text) => text.trim().to_string(),
6773− Err(e) => {
6774− ctx.diagnostics.push(Diagnostic::new(
6775− code::UNREADABLE_FILE,
6776− Severity::Error,
6777− line_num,
6778− format!("cannot load SVG '{}': {}", filename, e),
6779− ));
6780− return node;
6781 }
3805 }
6782− };
3806+ let name = &after[..end];
6783− ctx.included_files.push(resolved);
3807+ match vars.get(name) {
6784− // The file's markup has no class of its own, so a style reaches it only
3808+ Some(value) if !name.is_empty() => result.push_str(value),
6785− // as one of the root's attributes: anything else is reported, never
6786− // left out silently
6787− let mut left_out: Vec<String> = Vec::new();
6788− // The styles go in the root's `style=`, where a length takes its unit
6789− // and `var(--name)` works, as in any CSS
6790− let mut style: Vec<String> = Vec::new();
6791− for attr in &elem.attrs {
6792− let target = match (attr.key.as_str(), attr.html, &attr.value) {
6793− ("inline", false, None) | ("src", true, _) => continue,
6794− // `fill` and `shrink` are layout words, which need a class
6795− ("width" | "height", false, Some(value))
6796− if matches!(value.as_str(), "fill" | "shrink") =>
6797− {
6798− left_out.push(format!("{} {}", attr.key, value));
6799− continue;
6800− }
6801− ("width" | "height", false, Some(value)) => {
6802− let value = crate::vocab::with_px(&attr.key, value);
6803− style.push(format!("{}:{}", attr.key, value));
6804− continue;
6805− }
6806− ("color" | "fill", false, Some(value)) => {
6807− style.push(format!("fill:{}", value));
6808− continue;
6809− }
6810− ("style", true, Some(value)) => {
6811− style.insert(0, value.trim().trim_end_matches(';').to_string());
6812− continue;
6813− }
6814− ("alt", true, _) => {
6815− left_out.push("alt".to_string());
6816− continue;
6817− }
6818− (key, true, Some(_)) => key,
6819− (key, false, None) if crate::vocab::is_html_flag(key) => key,
6820 _ => {
3809 _ => {
6821− left_out.push(attr.key.clone());
3810+ result.push('$');
6822− continue;
3811+ result.push_str(name);
6823 }
3812 }
6824− };
3813+ }
6825− let value = attr.value.as_deref().unwrap_or("");
3814+ rest = &after[end..];
6826− svg = set_svg_attr(&svg, target, &html_escape_md(value));
6827 }
3815 }
6828− if !style.is_empty() {
3816+ result.push_str(rest);
6829− // After the file's own `style=`, which stays: these come later, so
3817+ result
6830− // they win where both set a property
3818+}
6831− let mut value = html_escape_md(&style.join(";"));
3819+
6832− if let Some(own) = svg_attr_value(&svg, "style")
3820+/// The first `$name|filter` (old filter syntax) in `text`, as (name, filter).
6833− .map(|range| svg[range].trim().trim_end_matches(';').to_string())
3821+fn old_filter_syntax(text: &str) -> Option<(String, String)> {
6834− .filter(|own| !own.is_empty())
3822+ const FILTERS: &[&str] = &[
6835− {
3823+ "uppercase", "lowercase", "capitalize", "trim", "length", "reverse", "truncate",
6836− value = format!("{};{}", own, value);
3824+ "replace", "default", "lighten", "darken", "alpha", "mix",
3825+ ];
3826+ let mut rest = text;
3827+ while let Some(pos) = rest.find('$') {
3828+ let after = &rest[pos + 1..];
3829+ let end = after
3830+ .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.')))
3831+ .unwrap_or(after.len());
3832+ if let Some(filter) = after[end..].strip_prefix('|') {
3833+ let name_end = filter.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(filter.len());
3834+ if end > 0 && FILTERS.contains(&&filter[..name_end]) {
3835+ return Some((after[..end].to_string(), filter[..name_end].to_string()));
3836+ }
6837 }
3837 }
6838− svg = set_svg_attr(&svg, "style", &value);
3838+ rest = &after[end..];
6839 }
3839 }
6840− if !left_out.is_empty() {
3840+ None
6841− let alt = left_out.iter().any(|name| name == "alt");
3841+}
6842− let names = left_out
3842+
6843− .iter()
3843+/// Index of the `}` matching the `{` that `s` starts with (skipping
6844− .map(|name| format!("'{}'", name))
3844+/// braces inside string literals).
6845− .collect::<Vec<_>>()
3845+fn matching_brace(s: &str) -> Option<usize> {
6846− .join(", ");
3846+ let mut depth = 0;
6847− let mut message = format!(
3847+ let mut in_string = false;
6848− "{} can't go on an inline SVG: `@image [inline]` puts the file's own markup into the page, which takes `width` and `height` as lengths, `color` or `fill` (as its fill) and HTML attributes (`class=`, `aria-label=`, ...)",
3848+ for (i, c) in s.char_indices() {
6849− names
3849+ match c {
6850− );
3850+ '"' => in_string = !in_string,
6851− if alt {
3851+ _ if in_string => {}
6852− message.push_str("; an SVG has no alt text: name it with `aria-label=` and `role=img`");
3852+ '{' => depth += 1,
3853+ '}' => {
3854+ depth -= 1;
3855+ if depth == 0 {
3856+ return Some(i);
3857+ }
3858+ }
3859+ _ => {}
6853 }
3860 }
6854− ctx.diagnostics.push(Diagnostic::new(
6855− code::UNEXPECTED_ARGUMENT,
6856− Severity::Error,
6857− line_num,
6858− message,
6859− ));
6860 }
3861 }
6861− Node::Raw(svg)
3862+ None
3863+}
3864+
3865+/// Parse a keyframe line in htmlang syntax: `from [opacity 0]` / `50% [transform scale(1.5)]`
3866+fn parse_keyframe_line(line: &str) -> Option<String> {
3867+ let (selector, rest) = if let Some(rest) = line.strip_prefix("from") {
3868+ ("from", rest.trim())
3869+ } else if let Some(rest) = line.strip_prefix("to") {
3870+ ("to", rest.trim())
3871+ } else if let Some(pct_end) = line.find('%') {
3872+ let rest = line[pct_end + 1..].trim();
3873+ let selector = &line[..pct_end + 1];
3874+ (selector, rest)
3875+ } else {
3876+ return None;
3877+ };
3878+
3879+ if !rest.starts_with('[') || !rest.ends_with(']') {
3880+ return None;
3881+ }
3882+
3883+ let inner = &rest[1..rest.len() - 1];
3884+ // Parse comma-separated key-value pairs into CSS
3885+ let mut css = String::new();
3886+ for part in split_commas(inner) {
3887+ let part = part.trim();
3888+ if part.is_empty() {
3889+ continue;
3890+ }
3891+ if let Some((key, value)) = part.split_once(' ') {
3892+ css.push_str(key.trim());
3893+ css.push(':');
3894+ css.push_str(value.trim());
3895+ css.push(';');
3896+ }
3897+ }
3898+
3899+ if css.is_empty() {
3900+ return None;
3901+ }
3902+
3903+ Some(format!("{}{{{}}}", selector, css))
6862}
3904}
6863
3905
6864// ---------------------------------------------------------------------------
3906// ---------------------------------------------------------------------------
6865// Variable usage tracking
3907// Variable usage tracking
6866// ---------------------------------------------------------------------------
3908// ---------------------------------------------------------------------------
6867
3909
6868−/// Record the names a string refers to (by syntax) as used.
3910+/// Scan a string for $name references and record them in the used set.
6869fn track_var_refs(input: &str, used: &mut HashSet<String>) {
3911fn track_var_refs(input: &str, used: &mut HashSet<String>) {
6870− if input.contains('$') {
3912+ if !input.contains('$') {
6871− used.extend(interp::names(input).into_iter().map(String::from));
3913+ return;
3914+ }
3915+ let chars: Vec<char> = input.chars().collect();
3916+ let mut i = 0;
3917+ while i < chars.len() {
3918+ if chars[i] == '$'
3919+ && i + 1 < chars.len()
3920+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
3921+ {
3922+ let start = i + 1;
3923+ let mut end = start;
3924+ while end < chars.len()
3925+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
3926+ {
3927+ end += 1;
3928+ }
3929+ let name: String = chars[start..end].iter().collect();
3930+ used.insert(name);
3931+ i = end;
3932+ } else {
3933+ i += 1;
3934+ }
6872 }
3935 }
6873}
3936}
6874
3937
@@ -6876,63 +3939,49 @@ fn track_var_refs(input: &str, used: &mut HashSet<String>) {
6876// Unused definition warnings
3939// Unused definition warnings
6877// ---------------------------------------------------------------------------
3940// ---------------------------------------------------------------------------
6878
3941
6879−/// Warn about definitions nothing uses, except `exported` ones (the
3942+fn check_unused(ctx: &mut ParseContext) {
6880−/// definitions of a library, for the files that include it).
6881−fn check_unused(ctx: &mut ParseContext, exported: &HashSet<String>) {
6882 // Check unused @let variables
3943 // Check unused @let variables
6883 for (name, &line) in &ctx.let_lines {
3944 for (name, &line) in &ctx.let_lines {
6884− if exported.contains(name) {
3945+ if name.starts_with("--") {
6885− // A library's names are for other files (a custom property
3946+ continue; // CSS vars are always used
6886− // isn't a variable, so it is never listed here)
6887− continue;
6888 }
3947 }
6889 if !ctx.used_variables.contains(name) {
3948 if !ctx.used_variables.contains(name) {
6890− ctx.diagnostics.push(
3949+ ctx.diagnostics.push(Diagnostic {
6891− Diagnostic::new(
3950+ line,
6892− code::UNUSED_VARIABLE,
3951+ column: None,
6893− Severity::Warning,
3952+ message: format!("unused variable '${}' (defined but never referenced)", name),
6894− line,
3953+ severity: Severity::Warning,
6895− format!("unused variable '${}' (defined but never referenced)", name),
3954+ source_line: None,
6896− )
3955+ });
6897− .subject(name.as_str()),
6898− );
6899 }
3956 }
6900 }
3957 }
6901
3958
6902− // Check unused attribute bundles (@let name [...]). A `$name` in code
3959+ // Check unused attribute bundles (@let name [...])
6903− // that doesn't run (an `if()` branch or `@if` not taken) counts too.
6904 for (name, &line) in &ctx.define_lines {
3960 for (name, &line) in &ctx.define_lines {
6905− if !ctx.used_defines.contains(name)
3961+ if !ctx.used_defines.contains(name) {
6906− && !ctx.used_variables.contains(name)
3962+ ctx.diagnostics.push(Diagnostic {
6907− && !exported.contains(name)
3963+ line,
6908− {
3964+ column: None,
6909− ctx.diagnostics.push(
3965+ message: format!(
6910− Diagnostic::new(
3966+ "unused attribute bundle '${}' (defined but never referenced)",
6911− code::UNUSED_BUNDLE,
3967+ name
6912− Severity::Warning,
3968+ ),
6913− line,
3969+ severity: Severity::Warning,
6914− format!(
3970+ source_line: None,
6915− "unused attribute bundle '${}' (defined but never referenced)",
3971+ });
6916− name
6917− ),
6918− )
6919− .subject(name.as_str()),
6920− );
6921 }
3972 }
6922 }
3973 }
6923
3974
6924 // Check unused functions (@let name ... with body)
3975 // Check unused functions (@let name ... with body)
6925 for (name, &line) in &ctx.fn_lines {
3976 for (name, &line) in &ctx.fn_lines {
6926− if !ctx.used_functions.contains(name) && !exported.contains(name) {
3977+ if !ctx.used_functions.contains(name) {
6927− ctx.diagnostics.push(
3978+ ctx.diagnostics.push(Diagnostic {
6928− Diagnostic::new(
3979+ line,
6929− code::UNUSED_FUNCTION,
3980+ column: None,
6930− Severity::Warning,
3981+ message: format!("unused function '@{}' (defined but never called)", name),
6931− line,
3982+ severity: Severity::Warning,
6932− format!("unused function '@{}' (defined but never called)", name),
3983+ source_line: None,
6933− )
3984+ });
6934− .subject(name.as_str()),
6935− );
6936 }
3985 }
6937 }
3986 }
6938}
3987}
@@ -7018,6 +4067,28 @@ fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
7018 }
4067 }
7019}
4068}
7020
4069
4070+/// Split a function body into its content and an @style block at the
4071+/// body's top level (the block's CSS lines, without the `@style` line).
4072+fn split_style_block(body: Vec<Line>) -> (Vec<Line>, Vec<Line>) {
4073+ let top = body.iter().map(|l| l.indent).min().unwrap_or(0);
4074+ let mut content = Vec::new();
4075+ let mut style = Vec::new();
4076+ let mut in_style = false;
4077+ for line in body {
4078+ let is_style_header =
4079+ line.indent == top && matches!(&line.content, LineContent::Normal(s) if s.trim() == "@style");
4080+ if is_style_header {
4081+ in_style = true;
4082+ } else if in_style && line.indent > top {
4083+ style.push(line);
4084+ } else {
4085+ in_style = false;
4086+ content.push(line);
4087+ }
4088+ }
4089+ (content, style)
4090+}
4091+
7021fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
4092fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
7022 pos = skip_ws(chars, pos);
4093 pos = skip_ws(chars, pos);
7023 if pos >= chars.len() {
4094 if pos >= chars.len() {
@@ -7198,21 +4269,75 @@ fn skip_ws(chars: &[char], mut pos: usize) -> usize {
7198 pos
4269 pos
7199}
4270}
7200
4271
7201−/// A JSON value as an htmlang value: an object is a record, an array a
4272+/// Flatten a JSON value into variable assignments.
7202−/// list, a string text (never quoted text), `null` empty. A number keeps
4273+/// - Top-level object: each key becomes `prefix.key`
7203−/// the text it is written with (`1.50`), and reads as a number.
4274+/// - Top-level array: `prefix` becomes comma-separated, `prefix._count` set
7204−fn json_value(json: JsonValue) -> Value {
4275+/// - Array of objects: each item becomes space-separated values for @each destructuring
7205− match json {
4276+fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, String>) {
7206− JsonValue::Null => Value::empty(),
4277+ match value {
7207− JsonValue::Bool(b) => Value::Bool(b),
4278+ JsonValue::Str(s) => {
7208− JsonValue::Number(n) | JsonValue::Str(n) => Value::Str(n),
4279+ vars.insert(prefix.to_string(), s.clone());
7209− JsonValue::Array(items) => Value::list(items.into_iter().map(json_value).collect()),
4280+ }
7210− JsonValue::Object(pairs) => Value::Record(Rc::new(
4281+ JsonValue::Number(n) => {
7211− pairs
4282+ vars.insert(prefix.to_string(), n.clone());
7212− .into_iter()
4283+ }
7213− .map(|(key, value)| (key, json_value(value)))
4284+ JsonValue::Bool(b) => {
7214− .collect(),
4285+ vars.insert(prefix.to_string(), b.to_string());
7215− )),
4286+ }
4287+ JsonValue::Null => {
4288+ vars.insert(prefix.to_string(), String::new());
4289+ }
4290+ JsonValue::Object(pairs) => {
4291+ for (key, val) in pairs {
4292+ flatten_json(&format!("{}.{}", prefix, key), val, vars);
4293+ }
4294+ }
4295+ JsonValue::Array(items) => {
4296+ vars.insert(format!("{}._count", prefix), items.len().to_string());
4297+ // Check if all items are objects with the same keys
4298+ let all_objects = items.iter().all(|v| matches!(v, JsonValue::Object(_)));
4299+ if all_objects && !items.is_empty() {
4300+ // Collect keys from first object for destructuring
4301+ if let JsonValue::Object(first_pairs) = &items[0] {
4302+ let keys: Vec<String> = first_pairs.iter().map(|(k, _)| k.clone()).collect();
4303+ vars.insert(format!("{}._keys", prefix), keys.join(","));
4304+ }
4305+ // Each item becomes space-separated values, items comma-separated
4306+ let csv: Vec<String> = items
4307+ .iter()
4308+ .map(|item| {
4309+ if let JsonValue::Object(pairs) = item {
4310+ pairs
4311+ .iter()
4312+ .map(|(_, v)| json_value_to_string(v))
4313+ .collect::<Vec<_>>()
4314+ .join(" ")
4315+ } else {
4316+ json_value_to_string(item)
4317+ }
4318+ })
4319+ .collect();
4320+ vars.insert(prefix.to_string(), csv.join(","));
4321+ } else {
4322+ // Primitive array: comma-separated
4323+ let csv: Vec<String> = items.iter().map(json_value_to_string).collect();
4324+ vars.insert(prefix.to_string(), csv.join(","));
4325+ }
4326+ // Also set indexed access: prefix.0, prefix.1, etc.
4327+ for (i, item) in items.iter().enumerate() {
4328+ flatten_json(&format!("{}.{}", prefix, i), item, vars);
4329+ }
4330+ }
4331+ }
4332+}
4333+
4334+fn json_value_to_string(v: &JsonValue) -> String {
4335+ match v {
4336+ JsonValue::Str(s) => s.clone(),
4337+ JsonValue::Number(n) => n.clone(),
4338+ JsonValue::Bool(b) => b.to_string(),
4339+ JsonValue::Null => String::new(),
4340+ JsonValue::Array(_) | JsonValue::Object(_) => String::new(),
7216 }
4341 }
7217}
4342}
7218
4343
@@ -7220,29 +4345,6 @@ fn json_value(json: JsonValue) -> Value {
7220// SVG attribute injection helper
4345// SVG attribute injection helper
7221// ---------------------------------------------------------------------------
4346// ---------------------------------------------------------------------------
7222
4347
7223−/// Where the root `<svg ...>` tag's `attr_name="..."` value is, as a byte
7224−/// range of `svg`. Only the opening tag is read, so a child's attribute is
7225−/// never found, and the name must follow whitespace, so `width` doesn't
7226−/// match `stroke-width`.
7227−fn svg_attr_value(svg: &str, attr_name: &str) -> Option<std::ops::Range<usize>> {
7228− let tag_start = svg.find("<svg")?;
7229− let tag_end = svg[tag_start..]
7230− .find('>')
7231− .map(|p| tag_start + p)
7232− .unwrap_or(svg.len());
7233− let tag = &svg[tag_start..tag_end];
7234− let pattern = format!("{}=\"", attr_name);
7235− let (pos, _) = tag.match_indices(&pattern).find(|(pos, _)| {
7236− tag[..*pos]
7237− .chars()
7238− .next_back()
7239− .is_some_and(char::is_whitespace)
7240− })?;
7241− let value_start = tag_start + pos + pattern.len();
7242− let end = svg[value_start..tag_end].find('"')?;
7243− Some(value_start..value_start + end)
7244−}
7245−
7246fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
4348fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
7247 // Only the opening <svg ...> tag is touched, so child attributes such as
4349 // Only the opening <svg ...> tag is touched, so child attributes such as
7248 // `stroke-width` are left alone.
4350 // `stroke-width` are left alone.
@@ -7253,14 +4355,26 @@ fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
7253 .find('>')
4355 .find('>')
7254 .map(|p| tag_start + p)
4356 .map(|p| tag_start + p)
7255 .unwrap_or(svg.len());
4357 .unwrap_or(svg.len());
4358+ let tag = &svg[tag_start..tag_end];
7256
4359
7257− // Replace an existing attribute
4360+ // Replace an existing attribute (must be preceded by whitespace so that
7258− if let Some(existing) = svg_attr_value(svg, attr_name) {
4361+ // `width` doesn't match `stroke-width`).
7259− let mut result = String::with_capacity(svg.len());
4362+ let pattern = format!("{}=\"", attr_name);
7260− result.push_str(&svg[..existing.start]);
4363+ let existing = tag.match_indices(&pattern).find(|(pos, _)| {
7261− result.push_str(value);
4364+ tag[..*pos]
7262− result.push_str(&svg[existing.end..]);
4365+ .chars()
7263− return result;
4366+ .next_back()
4367+ .is_some_and(char::is_whitespace)
4368+ });
4369+ if let Some((pos, _)) = existing {
4370+ let value_start = tag_start + pos + pattern.len();
4371+ if let Some(end) = svg[value_start..tag_end].find('"') {
4372+ let mut result = String::with_capacity(svg.len());
4373+ result.push_str(&svg[..value_start]);
4374+ result.push_str(value);
4375+ result.push_str(&svg[value_start + end..]);
4376+ return result;
4377+ }
7264 }
4378 }
7265
4379
7266 // Otherwise inject it into the opening tag (before a self-closing `/`).
4380 // Otherwise inject it into the opening tag (before a self-closing `/`).
@@ -7583,10 +4697,7 @@ mod tests {
7583 #[test]
4697 #[test]
7584 fn parses_page_directive() {
4698 fn parses_page_directive() {
7585 let r = parse_ok("@page My Title\n");
4699 let r = parse_ok("@page My Title\n");
7586− assert_eq!(
4700+ assert_eq!(r.document.page_title.as_deref(), Some("My Title"));
7587− r.document.page.map(|p| p.title).as_deref(),
7588− Some("My Title")
7589− );
7590 }
4701 }
7591
4702
7592 #[test]
4703 #[test]
crates/htmlang-core/src/std.hl+46−2
@@ -1,9 +1,53 @@
1-- The htmlang standard library, available in every file. It is ordinary
1-- The htmlang standard library, available in every file. It is ordinary
2−-- htmlang: a file's own @let with the same name takes precedence.
2+-- htmlang: a file's own @let with the same name takes precedence, and
3+-- attributes on a call style the component's root element, so
4+-- `@badge [background red] New` works like a built-in element.
5+
6+-- Small pill for counts and statuses.
7+@let badge
8+ @text [display inline-flex, align-items center, padding 2px 8px, font-size 0.75rem, font-weight 600, justify-content center, border-radius 9999px, line-height 1]
9+ @children
10+
11+-- Label with slightly rounded corners.
12+@let tag
13+ @text [display inline-flex, align-items center, padding 2px 8px, font-size 0.75rem, font-weight 600, border-radius 4px]
14+ @children
15+
16+-- Outlined, fully rounded label.
17+@let chip
18+ @text [display inline-flex, align-items center, column-gap 4px, padding 4px 12px, border-radius 9999px, font-size 0.875rem, border 1 currentColor]
19+ @children
20+
21+-- Circular frame for an image or initials.
22+@let avatar
23+ @el [display inline-flex, flex-direction row, align-items center, justify-content center, border-radius 9999px, overflow hidden, flex-shrink 0]
24+ @children
3
25
4-- Takes up the remaining space in a row or column.
26-- Takes up the remaining space in a row or column.
5−@let @spacer
27+@let spacer
6 @el [flex 1]
28 @el [flex 1]
7
29
30+-- Text that shows `tip` when hovered.
31+@let tooltip $tip
32+ @text [position relative, cursor help, title=$tip]
33+ @children
34+
35+-- Horizontally scrolling row that snaps to each child.
36+@let carousel
37+ @row [overflow-x auto, scroll-snap-type x mandatory, scrollbar-width none, children:scroll-snap-align start, children:flex-shrink 0]
38+ @children
39+
40+-- Breadcrumb trail: each crumb is an @item.
41+@let breadcrumb
42+ @nav [aria-label=breadcrumb]
43+ @list [ordered, display flex, flex-direction row, flex-wrap wrap, gap 8px]
44+ @children
45+
46+-- Loading placeholder: [$skeleton]
47+@let skeleton [background linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%), background-size 200% 100%, animation hl-skeleton 1.5s ease-in-out infinite]
48+
8-- Cut text off at one line with an ellipsis: [$truncate]
49-- Cut text off at one line with an ellipsis: [$truncate]
9@let truncate [overflow hidden, text-overflow ellipsis, white-space nowrap]
50@let truncate [overflow hidden, text-overflow ellipsis, white-space nowrap]
51+
52+-- Hide scrollbars but keep scrolling: [$no-scrollbar]
53+@let no-scrollbar [scrollbar-width none]
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−273

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+894−1295

This diff is too big to show here.

src/bin/htmlang_lsp/completion.rs+160−1361

This diff is too big to show here.

src/bin/htmlang_lsp/docs.rs+121−359

This diff is too big to show here.

src/bin/htmlang_lsp/hover.rs+91−349

This diff is too big to show here.

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

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+9−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+531−227

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+1360

This diff is too big to show here.

tests/common/mod.rsdeleted−140

This diff is too big to show here.

tests/docs.rs+20−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+58−2142

This diff is too big to show here.

tests/snapshots.rs+1092−4410

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

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

This diff is too big to show here.

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

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.