on 74fc727 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+7−96
@@ -19,98 +19,22 @@ 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 and formatter.
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`). Its
102 completions come from the compiler's tables; hover and completion text live
31 completions come from the compiler's tables; hover and completion text live
103 in `docs.rs`.
32 in `docs.rs`.
104- `editors/vscode/` — VS Code extension.
33- `editors/vscode/` — VS Code extension.
105- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
34- `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.
35- `tests/regressions.rs` — one test per fixed bug.
110- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`,
36- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`,
111− and every page in `examples/`: any diagnostic fails it.
37+ and every page in `examples/`.
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
38- `examples/` — complete pages: a landing page, a blog, a docs page, and a
115 tour of the whole language (`demo.hl`).
39 tour of the whole language (`demo.hl`).
116
40
@@ -121,9 +45,7 @@ Linux, macOS, and Windows. Your change should pass all three.
121 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
45 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
122 tests alongside the code.
46 tests alongside the code.
1232. Prefer the smallest mechanism: a component belongs in `std.hl`, an HTML
472. 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
48+ 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
49 thread a feature through the parser and codegen when it needs to be, and
128 describe it in the LSP's `docs.rs`.
50 describe it in the LSP's `docs.rs`.
1293. Document it in `DESIGN.md` (the examples there are compiled by the tests).
513. Document it in `DESIGN.md` (the examples there are compiled by the tests).
@@ -135,20 +57,9 @@ Linux, macOS, and Windows. Your change should pass all three.
135
57
136- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
58- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
137- Keep `@` prefixes and bracket-attribute syntax consistent with existing
59- Keep `@` prefixes and bracket-attribute syntax consistent with existing
138− directives. A new `@foo` is a row in `ast::DIRECTIVES`; the parser, the
60+ 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
61- Diagnostics should include `line` and, when practical, `column` and a
146 `source_line` excerpt. Use `Severity::Help` for suggestions, not `Warning`.
62 `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
63- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
153 diagnostic so the compiler keeps going.
64 diagnostic so the compiler keeps going.
154
65
DESIGN.md+153−1183
@@ -5,10 +5,10 @@ tree of elements written one per line, with indentation for nesting:
5
5
6```
6```
7@page Hello
7@page Hello
8−@el [width fill, max-width 640, center-x, padding 40, spacing 16]
8+@el [max-width 640, center-x, padding 40, spacing 16]
9 @h1 Hello
9 @h1 Hello
10 @paragraph
10 @paragraph
11− This page was written in {@b htmlang}.
11+ This page was written in {@text [font-weight bold] htmlang}.
12```
12```
13
13
14Every example block in this file is compiled by the test suite
14Every example block in this file is compiled by the test suite
@@ -19,28 +19,23 @@ Every example block in this file is compiled by the test suite
19- **`@` starts structure, and any other line is content.** An element is `@name`,
19- **`@` starts structure, and any other line is content.** An element is `@name`,
20 and a line without `@` is text.
20 and a line without `@` is text.
21- **htmlang's own vocabulary is about layout.** It comes from elm-ui. An
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,
22+ element is a row or a column, `spacing` sets the gap between its children,
24 and each child says how it sits in its parent (`width fill`, `center-x`,
23 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
24 `align-right`). The layout attributes are the only styling words htmlang
26 adds.
25 adds.
27- **Everything else is CSS or HTML, under its own name.** A style is any CSS
26- **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
27+ property with a CSS value. An element that isn't about layout has its HTML
29− its HTML name, and an HTML attribute is written `key=value`. Nothing is
28+ name, and an HTML attribute is written `key=value`. Nothing is renamed or
30− renamed or abbreviated.
29+ abbreviated.
31- **Each thing is done one way.** `@let` defines every reusable piece, `@data`
30- **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
31 loads data, `@include` brings in a file, and `if()` makes an attribute
33 conditional. A layout is an ordinary function.
32 conditional. A layout is an ordinary function.
34- **The output is what you wrote.** Each page compiles to one self-contained
33- **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
34 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
35 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:
36+ the source doesn't ask for.
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
37- **Everything runs at compile time.** Variables, expressions, loops and
42− conditions are resolved when the page is built. What CSS computes in the
38+ conditions are resolved when the page is built.
43− browser (`var()`, `calc()`, `color-mix()`) stays CSS, written as it is.
44
39
45## Syntax
40## Syntax
46
41
@@ -51,9 +46,7 @@ Every example block in this file is compiled by the test suite
51 children
46 children
52```
47```
53
48
54−Children are indented under their parent, with spaces or with tabs (a
49+Children are indented under their parent. Attributes go inside `[...]`,
55−tab counts as one space, so a file that mixes them is a warning).
56−Attributes go inside `[...]`,
57separated by commas, and the list may span several lines:
50separated by commas, and the list may span several lines:
58
51
59```
52```
@@ -61,7 +54,7 @@ separated by commas, and the list may span several lines:
61 padding 20,
54 padding 20,
62 background white,
55 background white,
63 border-radius 8,
56 border-radius 8,
64− box-shadow 0 2 4 rgba(0,0,0,0.1)
57+ box-shadow 0 2px 4px rgba(0,0,0,0.1)
65]
58]
66 Content
59 Content
67```
60```
@@ -72,149 +65,54 @@ An attribute can take one of three forms:
72 (`spacing 20`, `color red`).
65 (`spacing 20`, `color red`).
73- `key=value` is an **HTML attribute** (`id=main`, `type=email`,
66- `key=value` is an **HTML attribute** (`id=main`, `type=email`,
74 `aria-label=Close`).
67 `aria-label=Close`).
75−- A bare word is a **flag**: a layout attribute such as `center-x`, a
68+- A bare word is a **flag**: a layout attribute such as `center-x`, or a
76− boolean HTML attribute such as `required`, `disabled`, `hidden` or `open`,
69+ boolean HTML attribute such as `required`, `disabled` or `open`.
77− or a `data-` or `hx-` attribute that has no value (`data-open`,
78− `hx-preserve`).
79
70
80−A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else,
71+A comma inside `(...)` or `"..."` doesn't split attributes, so a font stack
81−`\,` keeps a comma in the value:
82−
83−```
84−@el [transition opacity 0.3s\, transform 0.3s, font-family "Open Sans"\, sans-serif]
72+is written `font-family "Inter, 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
73
109### Text
74### Text
110
75
111A line that doesn't start with `@` is text. Text after an element's
76A line that doesn't start with `@` is text. Text after an element's
112−attributes is the element's content: its first line of text, read like any
77+attributes is the element's content. Inside a line of text, `{...}` holds an
113−other. Inside a line of text, `{...}` holds an inline element, and
78+inline element:
114−[escapes](#escapes) and [`$names`](#variables) work:
115
79
116```
80```
117@paragraph
81@paragraph
118− This is {@strong important}, and this is {@link https://example.com a link}.
82+ This is {@text [font-weight bold] important}, and this is {@link https://example.com a link}.
119@text [font-weight bold, font-size 24, color #333] Hello world
83@text [font-weight bold, font-size 24, color #333] Hello world
120−@h2 Welcome to {@text [color #3b82f6] htmlang}
121−@ul
122− @li Read {@link /docs the docs}
123@section [padding 8] Text after the attributes is content too.
84@section [padding 8] Text after the attributes is content too.
124```
85```
125
86
126−How an element's lines combine is its [layout](#rows-columns-and-text)'s
87+A backslash makes the next character literal:
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
88
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 `--` |
89+- `\@` and `\--` let a line of text start with `@` or `--`.
166−| `\$` | `$` | a `$` before a name, which would start a variable |
90+- `\$` writes a `$` that isn't a variable.
167−| `\{`, `\}` | `{`, `}` | braces that don't start or end an inline element |
91+- `\{` writes a brace that doesn't start 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`) |
92+- `\\` writes a backslash.
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
93
178```
94```
179\@htmlang on social media
95\@htmlang on social media
180@let price 5
96@let price 5
181−@paragraph \$price is $price dollars: {@mark \{braces\}}, \[brackets\] and a comma\, too
97+@text \$price is $price dollars
182−@input [type=text, pattern=\d{3}-\d{4}, title=Costs \$5]
183```
98```
184
99
185### Comments
100### Comments
186
101
187−A line whose first word is `--` is a comment: `--` followed by a space, or
188−alone on its line. A comment must be on its own line, because `--` later in
102+A line starting with `--` is a comment. A comment must be on its own line,
189−a line is ordinary text. A comment may sit between the lines of an attribute
103+because `--` 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
104
194```
105```
195-- this is a comment
106-- this is a comment
196@row [spacing 10]
107@row [spacing 10]
197 -- todo: more nav items
108 -- todo: more nav items
198 @link / Home
109 @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```
110```
208
111
209### Chains
112### Chains
210
113
211`>` puts elements that have one child each on one line. The last element in
114`>` puts elements that have one child each on one line. The last element in
212−the chain gets the indented children. A `>` is a chain only between two
115+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
116
219```
117```
220@el [padding 16, background blue, border-radius 8] > @link https://example.com
118@el [padding 16, background blue, border-radius 8] > @link https://example.com
@@ -223,20 +121,10 @@ compiler warns when `> @name` follows a leading argument).
223
121
224### Verbatim bodies
122### Verbatim bodies
225
123
124+`@raw` writes HTML into the output exactly as given, either the rest of its
226−The bodies of `@raw`, `@style`, `@head`, `@markdown`, `@script`, `@code`
125+line or its indented block. The bodies of `@style`, `@head`, `@script` and
227−and `@textarea` are foreign text, kept exactly as written: nothing in them
126+`@markdown` are also kept exactly as written: nothing in them is parsed as
228−is parsed as htmlang, and a `--` line in them is not a comment. `@raw`
127+htmlang, and a `--` line in them is not a comment.
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
128
241```
129```
242@raw <hr class="fancy">
130@raw <hr class="fancy">
@@ -244,99 +132,16 @@ error. Only `@script` takes attributes: an attribute list on `@raw`,
244 <div class="custom-widget">
132 <div class="custom-widget">
245 <span>Hand-written HTML</span>
133 <span>Hand-written HTML</span>
246 </div>
134 </div>
247−@style .note { color: gray; }
248```
135```
249
136
250−### Directives
251−
252−A directive is a built-in word that isn't an element. Each one takes a
253−fixed kind of argument and a fixed kind of body. `@page` is the one
254−directive that is also an element: it stands for the page's root, `<body>`,
255−whose content is the rest of the file (see [Page and head](#page-and-head)).
256−
257−| Directive | Argument | Body |
258−|---|---|---|
259−| `@page` | attributes (styles for `<body>`, `key=value` for `<html>`) and a title | none |
260−| `@let` | a name and a value or bundle, or `@name` and parameters | a function's body |
261−| `@include` | a file | none |
262−| `@data` | a variable and a source | none |
263−| `@meta` | a name and a value | none |
264−| `@if`, `@else`, `@each` | a condition or a loop | htmlang |
265−| `@raw`, `@style`, `@head` | the rest of the line: a one-line body | verbatim, instead of the line |
266−| `@markdown` | a file | verbatim Markdown, instead of the file |
267−
268−An indented line under a directive that takes no body is an error, because
269−it would otherwise silently become a sibling.
270−
271−### Diagnostics
272−
273−The compiler checks the whole file, including code that doesn't run: the
274−branch of an `@if` or an `if()` that isn't taken, the body of a function that is never
275−called and the body of a loop over an empty list. Unknown elements,
276−functions, attributes and prefixes are reported there too. In code that
277−doesn't run, a call is checked against every function in the file and the
278−files it includes; where the code runs, a name is looked up where it is
279−written, so a function's body sees only what is defined above the function
280−(a call to a function defined further down is reported when the body
281−runs, and two functions can't call each other). A `$name` is checked only
282−where the code runs, since any of them may come from data: an undefined
283−variable in a branch that isn't taken, or in a function that is never
284−called, isn't reported.
285−
286−**Names are checked, and values are CSS's.** A misspelled element, prefix,
287−parameter or slot is an error, and so is an undefined variable. A CSS
288−property htmlang doesn't know, but whose name CSS could have, is written to
289−the CSS as it is, with a warning that suggests the closest known name
290−(`text-grow`, or `colr`, which asks "did you mean `color`?"). Values go to
291−the CSS as written, so the browser decides what `z-index auto` or `color
292−rebeccapurple` means; only what is wrong in any CSS is reported (see
293−[CSS properties](#css-properties)).
294−
295−**Nothing you write is left out silently.** An error means something
296−couldn't go into the page: a flag htmlang doesn't know, an attribute
297−`@fragment` has no element for, content for a function without
298−`@children`. A warning means it went into the page as written, but may not
299−be what you meant. A warning is reported once, even from a line that runs
300−many times in a loop or a function.
301−
302−Every diagnostic has a stable code, such as `unknown-element` or
303−`unused-variable`. The command line prints it as `error[unknown-element]`,
304−`--format json` has it in a `code` field, and the editor's quick fixes are
305−keyed on it.
306−
307## Layout
137## Layout
308
138
309−### Rows, columns and text
139+### Rows and columns
310−
311−Every element has one layout, which decides what the lines written inside
312−it are. **In a row or column each line is a child; in a text element lines
313−flow.**
314−
315−| Layout | Elements | What is inside it |
316−|---|---|---|
317−| column | `@el`, and the containers: `@section`, `@nav`, `@form`, `@ul`, `@li`, ... | Each line of text is a child of its own, and the children are laid out top to bottom (side by side with `flex-direction row`) |
318−| row | `@row` | The same, side by side |
319−| grid | `@grid` | Each line of text is a cell |
320−| text | `@paragraph`, `@text`, `@link`, `@h1` … `@h6`, `@button`, `@label`, `@td`, `@strong`, `@em`, ... | The argument, the lines and the children flow as one run of text, joined with spaces |
321−| native | `@table`, `@caption`, `@select`, `@pre`, `@textarea`, `@video`, ... | HTML's own layout, which htmlang leaves alone |
322−| void | `@input`, `@hr`, `@br`, `@image`, `@source` | Nothing: it takes no content |
323−
324−The table under [HTML elements](#html-elements) gives every element's layout. The
325−page itself is a column too: `@page` is the [root element](#page-and-head),
326−`<body>`, so what is written at the top of a page stacks like the children
327−of an `@el`, `height fill` there takes the rest of the window and
328−`center-y` centres in it.
329−
330−```
331−@el [spacing 4]
332− First line
333− Second line
334−@button [type=button]
335− Save
336− changes
337−```
338
140
339−The column shows two lines 4px apart; the button says "Save changes".
141+`@el` is the container, and it lays out its children in a column. `@row`
142+lays them out side by side. Every other container (`@section`, `@nav`,
143+`@article`, `@form`, and so on) is a column too, so `@row` is the only
144+element that changes direction. `@grid` is a CSS grid.
340
145
341These attributes are htmlang's own. They describe how an element lays out
146These attributes are htmlang's own. They describe how an element lays out
342its children, and how it sits in its parent:
147its children, and how it sits in its parent:
@@ -344,31 +149,14 @@ its children, and how it sits in its parent:
344| Attribute | Effect |
149| Attribute | Effect |
345|---|---|
150|---|---|
346| `spacing N` | Gap between children |
151| `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 |
152+| `width fill` / `width shrink` / `width N` | Take the remaining space in a row (the full width in a column), 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 |
153+| `height fill` / `height shrink` / `height N` | Take the remaining space in a column, fit the content, or an exact size |
349−| `center-x`, `center-y` | Center the element in its parent (auto margins) |
154+| `center-x`, `center-y` | Center the element in its parent |
350−| `align-left`, `align-right`, `align-top`, `align-bottom` | Align the element in its parent (an auto margin on the other side) |
155+| `align-left`, `align-right`, `align-top`, `align-bottom` | Align the element in its parent |
351| `wrap` | Let a row wrap onto more lines |
156| `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`) |
157+| `grid-cols N`, `grid-rows N` | Equal grid columns or rows (on `@grid`) |
353| `col-span N`, `row-span N` | Cells a grid child spans |
158| `col-span N`, `row-span N` | Cells a grid child spans |
354
159
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```
160```
373@row [spacing 8]
161@row [spacing 8]
374 @el [width fill, padding 12, background #f3f4f6] Fills the row
162 @el [width fill, padding 12, background #f3f4f6] Fills the row
@@ -377,77 +165,15 @@ and `[flex-shrink 0, width fill]` still grows but never shrinks.
377 @text [center-x] Centered
165 @text [center-x] Centered
378 @el [height fill, background #dbeafe] Fills the rest of the column
166 @el [height fill, background #dbeafe] Fills the rest of the column
379 @text [align-right] Right-aligned
167 @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]
168@grid [grid-cols 3, spacing 8, md:grid-cols 4]
385 @el [col-span 2, background #fef3c7] Two columns wide
169 @el [col-span 2, background #fef3c7] Two columns wide
386 @el [background #fde68a] One
170 @el [background #fde68a] One
387 @el [background #fcd34d] One
171 @el [background #fcd34d] One
388```
172```
389
173
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−
417Everything else about layout is plain CSS: `justify-content`, `align-items`,
174Everything else about layout is plain CSS: `justify-content`, `align-items`,
418`flex 1 1 240px`, `grid-template-areas`, `position`.
175`flex 1 1 240px`, `grid-template-areas`, `position`.
419
176
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−```
450−
451### Overlays: `@in-front` and `@behind`
177### Overlays: `@in-front` and `@behind`
452
178
453These layers work like elm-ui's `inFront` and `behind`. Their children fill
179These layers work like elm-ui's `inFront` and `behind`. Their children fill
@@ -488,114 +214,15 @@ same name and value as in CSS: `padding 20`, `font-weight bold`,
488`border 1 solid #e5e7eb`, `border-radius 8`, `background red`,
214`border 1 solid #e5e7eb`, `border-radius 8`, `background red`,
489`grid-template-areas "a b"`, `display none`.
215`grid-template-areas "a b"`, `display none`.
490
216
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
217+Values mean what they mean in CSS, with one addition: **a bare number in a
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`,
218+length is pixels**. So `padding 20` is `20px`, `border 1 solid red` is
500−`box-shadow 0 2 4 rgba(0,0,0,0.1)` is `0 2px 4px rgba(0,0,0,0.1)`, and
219+`1px solid red`, and `margin 0 auto` is `0 auto`. Values that have a unit,
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
220+keywords and CSS functions pass through as written (`width 50%`,
504−min(100%, 800px)`). Every other property takes numbers as CSS does, so
221+`max-width min(100%, 800px)`), and so do numbers that aren't lengths
505−`opacity 0.5`, `z-index 2`, `line-height 1.5`, `flex 1 1 240px`,
222+(`opacity 0.5`, `z-index 2`, `line-height 1.5`, `flex 1`).
506−`grid-column 1 / 3`, `initial-letter 3` and `border-image-width 2` are
507−written as they are, and so are custom properties (`--cols 3`), which have
508−no type to go by.
509−
510−The length properties are these, and only these:
511−
512−| Group | Properties |
513−|---|---|
514−| Size | `width`, `height`, `min-*` and `max-*` of both, `block-size`, `inline-size` and their `min-`/`max-`, `flex-basis`, `column-width`, `contain-intrinsic-*` |
515−| Space | `margin`, `padding`, `scroll-margin`, `scroll-padding` and every side of them, `gap`, `row-gap`, `column-gap`, `border-spacing` |
516−| Position | `top`, `right`, `bottom`, `left`, `inset` and `inset-*`, `translate`, `transform-origin`, `perspective`, `perspective-origin`, `offset-position`, `offset-anchor`, `offset-distance` |
517−| Border | `border` and every `border-*` shorthand, width and radius (not `border-image-*`), `outline`, `outline-width`, `outline-offset`, `column-rule`, `column-rule-width` |
518−| Shadow | `box-shadow`, `text-shadow` |
519−| Grid | `grid-template-columns`, `grid-template-rows`, `grid-auto-columns`, `grid-auto-rows` |
520−| Background | `background-position` (and `-x`, `-y`), `background-size`, `object-position`, `mask-position`, `mask-size` |
521−| Text | `font-size`, `letter-spacing`, `word-spacing`, `text-indent`, `text-decoration-thickness`, `text-underline-offset`, `vertical-align`, `baseline-shift` |
522−| Other | `shape-margin`, `overflow-clip-margin`, `view-timeline-inset` |
523−
524−Shorthands that mix lengths with numbers of other kinds (`font`,
525−`background`, `mask`, `grid`, `grid-template`, `offset`, `flex`) are not in
526−the table, so the lengths in them are written with their unit:
527−`font 700 16px/1.5 Inter`.
528−
529−Nothing else is added to a value, and a shorthand means what it means in
530−CSS: `outline 2 solid red` is `2px solid red`, while `outline 2 red` names
531−no line style, so CSS draws no outline. `container card / inline-size`
532−and `contain content` are CSS's own shorthands too, and a bare `contain` is
533−an error, like any style without a value. `line-clamp N` alone also adds
534−the `-webkit-box` declarations that browsers still need to cut text off
535−after N lines.
536−
537−A property htmlang doesn't know is written to the CSS as it is, so new CSS
538−(`text-grow per-line`) works before htmlang lists it; the
539−compiler warns and suggests the closest known name, in case it is a typo.
540−Vendor-prefixed properties (`-webkit-tap-highlight-color`) pass without a
541−warning. The value of a property htmlang doesn't know is written exactly as
542−it is, without pixels added. A style needs a value: `[padding]` is an error.
543
223
544−```
224+`line-clamp N` also adds the `-webkit-box` declarations that browsers still
545−@el [-webkit-tap-highlight-color transparent, -webkit-text-stroke 1px black]
225+need to cut text off after N lines.
546− Passed through as written
547−```
548−
549−A value is checked only for what is wrong in any CSS. A `;`, `{` or `}`
550−(outside a quoted string, and for `;` outside parentheses, as in CSS's own
551−`if()`) or an unclosed quote or parenthesis would break out of the rule, and
552−a `</style` (even quoted) would end the page's style element, so each is an
553−error, including in a value that comes from a variable or from `@data`. A hex color that doesn't have 3, 4, 6 or 8 digits is a warning. A
554−style whose value comes out empty, such as a field that a record doesn't
555−have, is left out. Since a value is CSS's, a missing comma between two
556−styles makes one value: `padding 8 color red` is `padding: 8px color red`,
557−which the browser ignores. A style written as an HTML attribute
558−(`padding=8`) is a warning that shows its style form.
559−
560−#### Custom properties
561−
562−A custom property (`--name`) is a CSS property like any other: it goes on
563−any element, under any prefix, and is written into the element's class.
564−Its name is yours, so it is never checked against a list.
565−CSS passes it down, so it holds for the element and everything inside it,
566−where `var(--name)` reads it. Its value is written exactly as it is, since
567−a custom property has no type for pixels to go by: `--hue 200` can feed
568−`hsl(var(--hue) 80% 50%)` and `--cols 3` can feed `repeat(var(--cols),
569−1fr)`. A length therefore takes its unit: `--gap 16px`, not `--gap 16`.
570−
571−```
572−@el [
573− --gap 16px,
574− md:--gap 24px,
575− --hue 200,
576− spacing var(--gap),
577− background hsl(var(--hue) 80% 95%)
578−]
579− @text One
580− @text Two
581−```
582−
583−`@let --name value` declares a custom property for the whole page, on
584−`:root` (see [CSS custom properties](#css-custom-properties));
585−`[--name value]` sets it on one element and everything inside it. So a
586−design token is declared once with `@let`, and a theme redefines it where
587−it changes, once, rather than repeating a colour on every element that
588−uses it:
589−
590−```
591−@let --ink #0f172a
592−@let --surface white
593−@let --subtle #f8fafc
594−@page [color var(--ink), background var(--surface), dark:[--ink #e2e8f0, --surface #0b1220, --subtle #111a2e]] Tokens
595−@section [padding 96 24, background var(--subtle)]
596− @article [padding 24, background var(--surface)]
597− Both follow dark mode, from the one place that sets it.
598−```
599
226
600### Prefixes
227### Prefixes
601
228
@@ -603,201 +230,44 @@ A prefix applies a style only in some condition:
603
230
604| Prefix | Applies |
231| Prefix | Applies |
605|---|---|
232|---|---|
606−| `hover:`, `focus:`, `first-child:`, `checked:`, `user-invalid:`, `open:`, ... | When that CSS pseudo-class matches (`:hover`, `:first-child`, ...) |
233+| `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 |
234+| `first:`, `last:`, `odd:`, `even:`, `nth:EXPR:` | By the element's position among its siblings |
608−| `before:`, `after:`, `marker:`, `placeholder:`, `backdrop:`, ... | To that pseudo-element (`::before`, `::marker`, ...) |
609| `children:` | To each direct child |
235| `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)) |
236+| `before:`, `after:` | To the `::before` / `::after` pseudo-element (together with `content`) |
237+| `has(SELECTOR):` | When the element contains a match |
611| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640, 768, 1024, 1280, 1536px) |
238| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640, 768, 1024, 1280, 1536px) |
612| `cq-sm:` … `cq-2xl:` | From that container width up (the same widths, for an ancestor with `container-type inline-size`) |
239| `cq-sm:` … `cq-2xl:` | From that container width up (the same widths, for an ancestor with `container-type inline-size`) |
613| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Under that media condition |
240| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Under that media condition |
614
241
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```
242```
677−@let team Ada, Grace, Linus
678−@table [border-collapse collapse, width 100%, @th:[padding 8, text-align left], @td:padding 8, @tr:border-bottom 1 solid #e5e7eb]
679− @tr
680− @th Name
681− @th Team
682− @each $name in $team
683− @tr
684− @td $name
685− @td [padding 2] Platform
686−@article [spacing 12, @h2:font-size 20, @code:[padding 1 4, background #f1f5f9], @link:hover:color #2563eb]
687− @markdown
688− ## Notes
689−
690− Run `htmlang check` before you [publish](/publish).
691−```
692−
693−- **The element has a tag of its own.** `@td:` is every `<td>`,
694− `@paragraph:` every `<p>`, `@link:` every `<a>` and `@image:` every
695− `<img>`, the ones `@markdown` and `@raw` write included. An element that
696− shares its tag can't be picked out, so its prefix is an error: `@el`,
697− `@row`, `@grid`, `@in-front` and `@behind` all write `<div>`, and `@text`
698− writes the `<span>` that every line of text in a row or column is too.
699− So is a function's name (`@card:`), since a function writes whatever its
700− body says and has no tag of its own, and a name that isn't an element
701− (`@a:` suggests `@link:`).
702−- **It takes CSS properties.** htmlang's layout words (`spacing`, `wrap`,
703− `grid-cols`, `col-span`, `width fill`, `center-x`, `align-*`) compile
704− against each element's own layout and its parent's, which the prefix
705− doesn't know, so they are errors under it: write them on the elements,
706− or the CSS they stand for (`@li:margin-inline auto`). HTML attributes
707− have no states: `@td:colspan=2` is an error, like `md:colspan=2`.
708−- **It composes with the other prefixes, read left to right.** Selector
709− prefixes after it choose among the elements:
710− `@tr:nth-child(even):background #f8fafc` shades every other row, and
711− `@link:hover:color red` colours a hovered link. Those before it are
712− about the element itself: `hover:@link:color red` recolours its links
713− while it is hovered. Width, media and container prefixes go anywhere
714− (`md:@td:padding 12`). A `[group]` or a `$bundle` takes it like any
715− prefix: `@th:[padding 8, text-align left]`. A style has one element
716− prefix, and not `children:` as well: each says which elements the style
717− is for.
718−- **What an element says about itself wins.** A cell's own `[padding 2]`
719− wins over the table's `@td:padding 8`, and a parent's `children:` wins
720− over it too. The prefix wins over the defaults of the element's kind, so
721− `@main [@h2:margin-block-start 16]` spaces headings, which start with
722− `margin: 0`. Where two elements around one both style it, the nearest
723− wins, whatever the order they are written in: in an `@article
724− [@td:padding 16]` around a `@table [@td:padding 8]`, the table's cells
725− get 8.
726−- **It styles what is inside, not the element itself.** `@ul
727− [@ul:padding-inline-start 16]` indents the lists inside the list, not
728− the list itself.
729−
730−`children:` and an element prefix answer different questions:
731−`children:` is for the direct children, whatever they are, as the parent
732−sees them; `@td:` is for every cell inside, however deep. An element's
733−own attributes are for it alone.
734−
735−Each style under an element prefix compiles to a rule in CSS's `@scope`,
736−on the element's class, in the layer `hl-inside` (see [CSS](#css)):
737−`@scope (.hl-2k9xq0m) { :scope td { padding: 8px } }`. `@scope`'s proximity
738−rule is what makes the nearest element win. `@scope` is Baseline 2025,
739−supported by Chrome and Edge 118, Safari 17.4 and Firefox 146; an older
740−browser ignores these rules, so the elements inside keep the styles they
741−have without them.
742−
743−```
744−@let card [padding 16, border 1 solid #e5e7eb, border-radius 8]
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]]
243+@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
747 @text [color white] Click me
244 @text [color white] Click me
748−@row [spacing 4, children:flex 1, hover:children:opacity 0.8]
245+@row [spacing 4, children:flex 1]
749− @el [nth-child(odd):background #f3f4f6] A
246+ @el [odd:background #f3f4f6] A
750− @el [nth-child(odd):background #f3f4f6] B
247+ @el [odd:background #f3f4f6] B
751− @el [nth-child(odd):background #f3f4f6] C
248+ @el [odd:background #f3f4f6] C
752−@el [before:content "→ ", before:color red, hover:before:color blue]
249+@el [before:content "→ ", before:color red]
753 Item with an arrow
250 Item with an arrow
754−@section [spacing 16, md:$card, hover:if($active, [background #eef2ff, color #3730a3])]
755− A card from md up
756```
251```
757
252
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
253### Conditional attributes
771
254
772−`if(CONDITION, A, B)`, written as a whole attribute, is `A` when the
255+`if(CONDITION, A, B)` chooses `A` or `B` depending on the condition. It works
773−condition holds and `B` otherwise. Each branch is an attribute, a `[group]`
256+as a value or as a whole attribute. If the chosen side is empty, the attribute
774−of attributes or a `$bundle`. An empty branch leaves the attribute out, and
257+is left out, and `B` itself can be omitted.
775−`B` itself can be left out. Several attributes that depend on one condition
776−are one `if()` with a group:
777
258
778```
259```
779@let active true
260@let active true
780−@el [if($active, background blue, background gray), if($active, font-weight bold)]
261+@el [background if($active, blue, gray), if($active, font-weight bold), padding if($active, 12)]
781 Conditionally styled
262 Conditionally styled
782−@link [if($active, [background #eef2ff, font-weight 600, aria-current=page])] /docs Docs
783```
263```
784
264
785−A value that depends on a condition is an [expression](#expressions),
786−`${if(CONDITION, A, B)}`. Its branches are expressions, so a number or a
787−single word is written as it is, and other CSS text is quoted:
788−`padding ${if($active, 24, 0)}`, `color ${if($active, #10b981, "var(--muted)")}`.
789−An `if()` written in a value without `${...}` is CSS's own `if()`, which the
790−browser decides, and it is passed through like any CSS function:
791−`width if(media(width > 40em): 50%; else: 100%)`.
792−
793## HTML
265## HTML
794
266
795−### HTML elements
267+### Elements
796
268
797The layout and text elements have elm-ui's names. Every other element has
269The layout and text elements have elm-ui's names. Every other element has
798−its HTML name: `@strong`, `@em`, `@small`, `@br`, `@sub`, `@caption`,
270+its HTML name.
799−`@tfoot`, `@optgroup`, `@hgroup` and the others in the table of layouts
800−below.
801
271
802| Element | Output | Purpose |
272| Element | Output | Purpose |
803|---|---|---|
273|---|---|---|
@@ -806,82 +276,38 @@ below.
806| `@grid` | div, grid | Grid container (with `grid-cols` or `grid-template-*`) |
276| `@grid` | div, grid | Grid container (with `grid-cols` or `grid-template-*`) |
807| `@in-front` / `@behind` | div, absolute | Overlay layers that fill the parent |
277| `@in-front` / `@behind` | div, absolute | Overlay layers that fill the parent |
808| `@text` | span | Styled inline text |
278| `@text` | span | Styled inline text |
809−| `@paragraph` | p | A paragraph of flowing text |
279+| `@paragraph` | p | Flowing text with inline elements |
810−| `@link URL` | a | Link: the URL is its `href`, and the text after it is its content |
280+| `@link URL` | a | Link, whose content is the text after the URL |
811−| `@image SRC` | img | Image: the one word after its attributes is its `src` |
281+| `@image SRC` | img | Image |
812−| `@fragment` | (none) | Its children, without a wrapper element (so it takes no attributes) |
282+| `@fragment` | (none) | Its children, without a wrapper element |
813−
283+
814−Every element has one [layout](#rows-columns-and-text):
284+The HTML elements, by kind:
815−
285+
816−| Layout | Elements |
286+- **Semantic containers**, all laid out as columns: `@nav`, `@header`,
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` |
287+ `@footer`, `@main`, `@section`, `@article`, `@aside`, `@address`,
819−| row | `@row` |
288+ `@search`, `@form`, `@details` / `@summary`, `@dialog`, `@figure` /
820−| grid | `@grid` |
289+ `@figcaption`, `@blockquote` / `@cite`, `@fieldset` / `@legend`,
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` |
290+ `@noscript`.
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` |
291+- **Text**: `@h1` … `@h6`, `@code`, `@pre`, `@mark`, `@kbd`, `@abbr`,
823−| void | `@image`, `@input`, `@hr`, `@br`, `@wbr`, `@col`, `@source`, `@track`, `@embed`, `@area` |
292+ `@time`.
824−
293+- **Lists and tables**: `@ul` / `@ol` / `@li`, `@dl` / `@dt` / `@dd`,
825−`@fragment`, `@children` and `@slot` have no element of their own: what
294+ `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`.
826−they hold takes the layout of the element they are in. The top of a page is
295+- **Forms**: `@input`, `@button`, `@select` / `@option`, `@textarea`,
827−a column, `<body>` (see [Page and head](#page-and-head)). In a native
296+ `@label`, `@datalist`, `@progress`, `@meter`, `@output`. For `@form URL`,
828−element, and at the top of a fragment, lines of text are separated by a line break,
297+ the URL is the form's `action`.
829−which HTML shows as a space except where whitespace is kept, as in `@pre`
298+- **Media and embeds**: `@picture` / `@source`, `@video SRC`, `@audio SRC`,
830−and `@textarea`.
299+ `@iframe SRC`, `@canvas`, `@script`, `@hr`.
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
300
870Browser default margins on headings, paragraphs, lists and figures are reset
301Browser 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
302+to 0, so `spacing` controls the gaps. List items are columns like other
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
303+containers, so a list shows no markers. To bring them back, write
875`[list-style disc, padding-inline-start 20, children:display list-item]` on
304`[list-style disc, padding-inline-start 20, children:display list-item]` on
876−the list, and `marker:` styles the markers themselves (`::marker`).
305+the list.
877
306
878```
307```
879@ol [spacing 4]
308@ol [spacing 4]
880 @li First
309 @li First
881 @li Second
310 @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
311@form [method=post, spacing 8] /subscribe
886 @label [for=email] Email
312 @label [for=email] Email
887 @input [type=email, name=email, id=email, required]
313 @input [type=email, name=email, id=email, required]
@@ -891,79 +317,12 @@ the list, and `marker:` styles the markers themselves (`::marker`).
891 @text The answer.
317 @text The answer.
892```
318```
893
319
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
320### HTML attributes
957
321
958HTML attributes are written `key=value`: `id=main`, `class=note`,
322HTML attributes are written `key=value`: `id=main`, `class=note`,
959`href=/about`, `type=email`, `alt=Logo`, `target=_blank`,
323`href=/about`, `type=email`, `alt=Logo`, `target=_blank`,
960−`aria-label=Close menu`, `data-id=42`, `hx-get=/items`. Any name works,
324+`aria-label=Close menu`, `data-id=42`. Any name works. Boolean attributes are
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`,
325+written bare: `required`, `disabled`, `checked`, `open`, `popover`. A style
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
967and an HTML attribute can share a name, because the `=` tells them apart:
326and an HTML attribute can share a name, because the `=` tells them apart:
968
327
969```
328```
@@ -983,12 +342,12 @@ and an HTML attribute can share a name, because the `=` tells them apart:
983@let primary #3b82f6
342@let primary #3b82f6
984-- A computed value: `=` makes the rest an expression
343-- A computed value: `=` makes the rest an expression
985@let gap = 8 * 2
344@let gap = 8 * 2
986−-- Quoted text, with $variables filled in (see Variables for its quotes)
345+-- A string, with $variables interpolated
987@let greeting "Hello from $primary"
346@let greeting "Hello from $primary"
988-- An attribute bundle, used as [$card]
347-- An attribute bundle, used as [$card]
989@let card [padding 20, background white, border-radius 8]
348@let card [padding 20, background white, border-radius 8]
990−-- A function: `@` before its name, its parameters in brackets, and a body
349+-- A function: a @let with an indented body
991−@let @panel [title]
350+@let panel $title
992 @el [$card, spacing $gap]
351 @el [$card, spacing $gap]
993 @text [font-weight bold, color $primary] $title
352 @text [font-weight bold, color $primary] $title
994 @children
353 @children
@@ -999,186 +358,23 @@ and an HTML attribute can share a name, because the `=` tells them apart:
999 Attributes after a bundle override it.
358 Attributes after a bundle override it.
1000```
359```
1001
360
1002−The line alone says what a `@let` defines: an `@` before the name makes a
361+A definition applies from its own line onward.
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
362
1138### Functions
363### Functions
1139
364
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
365+A function is called like an element:
1154−line of its own, in a [chain](#chains) (`@el > @card [title Hi]`) or
366+
1155−inline in text (`{@key Ctrl+K}`):
1156−
1157−- Its parameters are passed by name, as attributes: `name value`, or the
367+- Its parameters are passed as attributes. A parameter with a default
1158− name alone for `true`. A parameter with a default can be left out;
368+ (`$tone=info`) can be left out.
1159− leaving out one without a default is an error that names it. `=` writes
369+- Any other attributes style the function's root element, so
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
370+ `@panel [title Hi, padding 40]` works the same as styling a built-in
1165− `[paddin 40]` gets the same warning. A name close to one of the
371+ element.
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
372+- Text after the attributes and the indented children replace `@children`.
1173− content, and replace `@children` in the body. A `@slot NAME` block
373+ A caller's `@slot NAME` block replaces the function's `@slot NAME`, and
1174− directly under the call (or under an `@if`, `@else` or `@each` there)
374+ the slot's own children are the default content.
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
375
1180```
376```
1181−@let @card [title, tone #f9fafb]
377+@let card $title $tone=#f9fafb
1182 @article [padding 16, spacing 8, background $tone, border-radius 8]
378 @article [padding 16, spacing 8, background $tone, border-radius 8]
1183 @row [align-items center]
379 @row [align-items center]
1184 @h3 $title
380 @h3 $title
@@ -1198,64 +394,12 @@ inline in text (`{@key Ctrl+K}`):
1198 @text Updated today
394 @text Updated today
1199```
395```
1200
396
1201−`@children` takes a fallback the same way:
1202−
1203−```
1204−@let @notice
1205− @el [padding 12, background #fef3c7]
1206− @children
1207− @text Nothing to report.
1208−
1209−@notice
1210−@notice Deploys are paused today.
1211−```
1212−
1213−Content that would go nowhere is an error, so a mistake can't drop it:
1214−
1215−- a `@slot NAME` block for a slot the function doesn't have (the message
1216− lists the slots it has);
1217−- text or lines passed to a function whose body has no `@children`;
1218−- a `@slot NAME` block inside an element at the call, instead of directly
1219− under the call;
1220−- `@slot` or `@children` outside a function's body, or inline in text;
1221−- `@children` or `@slot` after an element's attributes (`@td [padding 8]
1222− @children`), where it would be text: it goes on a line of its own.
1223−
1224−A slot's name is one word of letters, digits, `-` and `_`, starting with a
1225−letter, like a function's. Inside a body, a call can pass on what the
1226−function got: `@children` under the call passes the content, and a
1227−`@slot footer` block holding `@slot footer` passes that slot.
1228−
1229−A parameter that is off unless the call names it is a flag: give it the
1230−default `false`, and name it alone to turn it on.
1231−
1232−```
1233−@let @post-card [post, featured false, label "About $post"]
1234− @article [spacing 8, padding ${if($featured, 24, 0)}]
1235− @if $featured
1236− @text [font-weight bold] Featured
1237− @h3 $label
1238−
1239−@post-card [post htmlang, featured]
1240−@post-card [post CSS]
1241−```
1242−
1243An `@style` block at the top of a function body is **scoped** to the
397An `@style` block at the top of a function body is **scoped** to the
1244function. Its rules apply inside the function's root element, and `&` is the
398function. 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
399+root itself. This requires the body to have a single root element.
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
400
1257```
401```
1258−@let @note [kind Note]
402+@let note $kind=Note
1259 @style
403 @style
1260 & { border-left: 4px solid #3b82f6; }
404 & { border-left: 4px solid #3b82f6; }
1261 .title { font-weight: bold; }
405 .title { font-weight: bold; }
@@ -1266,94 +410,26 @@ about a `&` there.
1266@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
410@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
1267```
411```
1268
412
1269−A call in text or in a chain whose body has several roots, or is text,
1270−puts them in its place, like `@fragment`. A function whose body is text
413+A function whose body is text holds multi-line content:
1271−holds multi-line content, and a small function works inside a sentence:
1272
414
1273```
415```
1274−@let @intro
416+@let intro
1275 htmlang is a layout language.
417 htmlang is a layout language.
1276− It compiles to {@strong static HTML}.
418+ It compiles to {@text [font-weight bold] static HTML}.
1277−@let @key
1278− @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
1279− @children
1280
419
1281@paragraph
420@paragraph
1282 @intro
421 @intro
1283−@paragraph
1284− In short: {@intro} Press {@key Ctrl+K} to search.
1285```
422```
1286
423
1287−A function may call itself, under a condition that stops it, so it can
1288−render nested data such as a menu. A parameter passed one `$name` that
1289−holds a record or a list gets the whole value, fields and items
1290−included. Calls nest at most 64 deep: a function that calls itself with
1291−nothing to stop it is an error. Since a body sees only what is defined
1292−above its function, two functions can't call each other.
1293−
1294−```
1295−@data $menu [
1296− {"label": "Guide", "children": [{"label": "Install"}, {"label": "Usage"}]},
1297− {"label": "Reference"}
1298−]
1299−@let @tree [items]
1300− @ul [padding-left 16]
1301− @each $item in $items
1302− @li
1303− @text $item.label
1304− @if $item.children
1305− @tree [items $item.children]
1306−
1307−@tree [items $menu]
1308−```
1309−
1310−The elements a function's body writes are the call's: a problem with
1311−one of them, such as low contrast or `spacing` on an element that isn't a
1312−row, column or grid, is reported at the call, once, and names the function. A
1313−problem in the text of a body is reported on its own line, or at the call
1314−when the function comes from `std.hl` or another file.
1315−
1316−Naming a function after a built-in element or a directive is a warning:
1317−`@let @button` replaces `@button` in every call after it, and a function
1318−named `@if` could never be called. Inside its own body `@button` is the
1319−function too, so a function that wraps the element it is named after,
1320−outside an `@if`, is an error: give it another name.
1321−
1322### CSS custom properties
424### CSS custom properties
1323
425
1324−`@let --name value` declares the CSS custom property `--name` on `:root`,
426+`@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,
427+`var(--name)` refers to it at run time, so a stylesheet can still override
1326−so a length takes its unit (`@let --radius 8px`). A token is read one way,
428+it. `$--name` is its value at compile time, for use in expressions:
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
429
1349```
430```
1350@let --brand #3b82f6
431@let --brand #3b82f6
1351−@let --radius 8px
1352−@page [dark:--brand #60a5fa] Themed
1353−@el [background var(--brand), border-radius var(--radius), border 1 solid color-mix(in srgb, var(--brand), black 10%)] Themed
432+@el [background var(--brand), border 1 solid ${darken($--brand, 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```
433```
1358
434
1359## Expressions
435## Expressions
@@ -1362,40 +438,25 @@ Conditions and computed values (`@let x = ...`) are expressions:
1362
438
1363| | |
439| | |
1364|---|---|
440|---|---|
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 |
441+| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `$record.field`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
1366| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
442| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
1367−| Comparison | `== != < > <= >=`: numbers as numbers, text exactly, lists and records item by item (see [Values](#values)) |
443+| Comparison | `== != < > <= >=` (numeric when both sides are numbers) |
1368−| Logic | `and`, `or`, `not`. `false`, empty text, `0` and an empty list are false |
444+| Logic | `and`, `or`, `not`. An empty value, `false` and `0` are false |
1369−| Choice | `if(CONDITION, A, B)`, where `B` may be left out (empty) |
445+| 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)` |
446| 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) |
447+| Text | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)` (the items of a list, or the characters of a text), `reverse(s)`, `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` |
1372−
448+| Color | `lighten(c, pct)`, `darken(c, pct)`, `alpha(c, a)`, `mix(c1, c2, pct)` |
1373−Colour maths is CSS's, not an expression's: see
1374−[CSS custom properties](#css-custom-properties). A CSS function such as
1375−`color-mix()` goes in an attribute's value as it is, or in a plain
1376−`@let` value (`@let hover color-mix(in srgb, var(--brand), black 8%)`),
1377−not in `@let x = ...` or `${...}`.
1378
449
1379Variables are looked up during evaluation, so a value that contains `==` or
450Variables are looked up during evaluation, so a value that contains `==` or
1380−spaces is still one value. An invalid expression is a compile error, and so
451+spaces 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,
1382−instead of a guess: `uppercase()` of `Fast, simple` (a list, because of its
1383−comma) says so, and the text is written `"Fast, simple"` or `Fast\, simple`.
1384−A variable's name ends at `..`, so `${$from..$to}` is a range.
1385−
1386−Only what decides the result is evaluated: `if()` evaluates the branch it
1387−takes, and `and` and `or` stop at the side that decides. So
1388−`${if($n != 0, 10 / $n, 0)}` and `@if $n != 0 and 10 / $n > 1` work when
1389−`$n` is 0. The rest is still read, and its syntax and function names are
1390−checked.
1391
452
453+In text and attribute values, `$name` inserts a variable, and `${EXPR}`
1392−In text, values and file paths, `${EXPR}` inserts the value of any
454+inserts the value of any expression:
1393−expression (see [Variables](#variables)):
1394
455
1395```
456```
1396@let name htmlang
457@let name htmlang
1397−@let size 12
458+@let base #3b82f6
1398−@text [font-size ${$size + 4}] ${uppercase($name)} has ${length($name)} letters
459+@text [color ${darken($base, 10)}] ${uppercase($name)} has ${length($name)} letters
1399```
460```
1400
461
1401## Control flow
462## Control flow
@@ -1414,15 +475,11 @@ All control flow runs at compile time.
1414 @text Few
475 @text Few
1415```
476```
1416
477
1417−`@each $item in LIST` repeats its body for each item. Its variables are
478+`@each $item in LIST` repeats its body for each item. A list can be a list
1418−written with `$`, like `@data $name`. LIST is a value, read like the value
479+loaded with `@data`, a text split on commas, or a range. A range counts down
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
480+when its start is greater than its end, and `step` sets the increment. An
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
481+optional second variable is the index, starting from 0. `@else` gives the
1425−the content to show when the list is empty.
482+content to show when the list is empty.
1426
483
1427```
484```
1428@let fruits apple, banana, cherry
485@let fruits apple, banana, cherry
@@ -1443,10 +500,10 @@ the content to show when the list is empty.
1443| `@data $name file.json` | Load a JSON file. Objects are records (`$name.key`) and arrays are lists |
500| `@data $name file.json` | Load a JSON file. Objects are records (`$name.key`) and arrays are lists |
1444| `@data $name [...]` / `@data $name {...}` | Inline JSON, which may span several lines |
501| `@data $name [...]` / `@data $name {...}` | Inline JSON, which may span several lines |
1445| `@data $name dir/*.json` | A list with one record per file, in name order. `$item.file` is the file's name |
502| `@data $name dir/*.json` | A list with one record per file, in name order. `$item.file` is the file's name |
1446−| `@data $name env:NAME DEFAULT` | An environment variable, with an optional default |
503+| `@data $name env:NAME [default]` | 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)) |
504+| `@include file.hl` | Insert another file: its content and its definitions. A file that holds only `@let`s outputs nothing |
1448| `@markdown` / `@markdown file.md` | Markdown (an indented body or a file), converted to HTML |
505| `@markdown` / `@markdown file.md` | Markdown (an indented body or a file), converted to HTML |
1449−| `@image [inline] file.svg` | Put the file inside the page: SVG as markup, other images as base64. The file is read next to the page, and one that can't be read is an error. The SVG's root gets `width` and `height` (lengths, not `fill` or `shrink`) and `color` or `fill` (as its `fill`) in its `style=`, after the file's own, where `var(--name)` works, and the HTML attributes (`class=`, `aria-label=`, ...); any other style, and `alt=`, is an error, since the markup has no generated class, and an element prefix (`@image:`) doesn't reach it. `inline` goes only on `@image`, without a prefix |
506+| `@image [inline] file.svg` | Put the file inside the page: SVG as markup (`width`, `height`, `color`, `class=` and `id=` apply to it), other images as base64 |
1450
507
1451Records keep their values whole, even when a value contains spaces or
508Records keep their values whole, even when a value contains spaces or
1452commas:
509commas:
@@ -1464,18 +521,17 @@ commas:
1464@text ${length($team)} people
521@text ${length($team)} people
1465```
522```
1466
523
1467−### Page layouts
524+### Layouts
1468
525
1469A layout is an ordinary function in a file of its own. It marks where content
526A layout is an ordinary function in a file of its own. It marks where content
1470−goes with `@slot NAME` (named blocks) and `@children` (everything in the call
527+goes with `@slot NAME` (named blocks, with default content) and `@children`
1471−that is outside a `@slot` block), each with fallback content indented under
528+(everything in the call that is outside a `@slot` block).
1472−it.
1473
529
1474```
530```
1475-- layout.hl
531-- layout.hl
1476−@let @layout
532+@let layout
1477− @page [lang=en, background #f8fafc] My Site
533+ @page My Site
1478− @el [width fill, max-width 800, center-x, spacing 24]
534+ @el [max-width 800, center-x, spacing 24]
1479 @header
535 @header
1480 @slot header
536 @slot header
1481 @text Default header
537 @text Default header
@@ -1492,65 +548,28 @@ it.
1492 @paragraph This fills @children.
548 @paragraph This fills @children.
1493```
549```
1494
550
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
551## Page and head
1507
552
1508`@page TITLE` makes the output a full HTML document. Without it, the output
553`@page TITLE` makes the output a full HTML document. Without it, the output
1509−is a fragment, which has no `<head>`: `@meta` and `@head` in it are a
554+is a fragment. The attributes of `@page` set `lang`, and `favicon` (the file
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
555+is embedded in the page). `@meta NAME VALUE` adds a meta tag, and names that
1529−Open Graph `property` tags; a `@meta viewport` replaces the usual one.
556+start with `og:` become Open Graph `property` tags. `@head` holds any other
1530−`@head` holds any other raw HTML for the `<head>`, such as a font link, a
557+raw HTML for the `<head>`, such as a font link, a canonical URL or JSON-LD.
1531−canonical URL or JSON-LD, on its line or in its indented block.
1532
558
1533```
559```
1534−@page [lang=en, favicon favicon.png, color #1f2937, dark:background #0b1220, dark:color #e5e7eb] My Site
560+@page [lang en, favicon favicon.png] My Site
1535@meta description A small site
561@meta description A small site
1536@meta og:title My Site
562@meta og:title My Site
1537−@head <link rel="canonical" href="https://example.com/">
1538@head
563@head
1539− <script type="application/ld+json">{"@type": "WebSite"}</script>
564+ <link rel="canonical" href="https://example.com/">
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```
565```
1545
566
1546−Translations are a JSON file per locale: `@data $t locales/$lang.json`
567+Translations are a JSON file per locale: `@data $t locales/$lang.json`.
1547−loads `locales/fr.json` when `$lang` is `fr`.
1548
568
1549## CSS
569## CSS
1550
570
1551`@style` holds raw CSS, including at-rules such as `@keyframes`,
571`@style` holds raw CSS, including at-rules such as `@keyframes`,
1552−`@font-face`, `@property` and `@starting-style`, in its indented block or,
572+`@font-face`, `@property` and `@starting-style`:
1553−for a single rule, on its line:
1554
573
1555```
574```
1556@style
575@style
@@ -1559,61 +578,12 @@ for a single rule, on its line:
1559 to { opacity: 1; }
578 to { opacity: 1; }
1560 }
579 }
1561 .note { color: gray; }
580 .note { color: gray; }
1562−@style .quiet { opacity: 0.6; }
1563@el [animation fade-in 0.3s ease, class=note] Fades in
581@el [animation fade-in 0.3s ease, class=note] Fades in
1564```
582```
1565
583
1566−Each element with styles gets a generated class, and elements with the
584+Each element gets a short generated class for its styles, and elements with
1567−same styles share one. A class is named by its style: `hl-` and seven
585+the same styles share one. The generated rules live in `@layer htmlang`. A
1568−characters of a hash of what it sets (`hl-2k9xq0m`), so one style has one
586+small reset (`box-sizing`, body margin, block images, unstyled links, a
1569−name in every file. A fragment put into a page (`--partial` output that a
587+focus outline) lives in `@layer hl-reset` before it. CSS outside any layer
1570−server returns, or any HTML of one file put into another's page) means
1571−the same there, and its rules restyle nothing of the page's: a class of
1572−both is the same style. Two different styles of one file never share a
1573−name (the later one takes more characters), and `htmlang build` checks
1574−the whole site: two files whose different styles hash to one name are an
1575−error (`class-collision`), which one more attribute on either style
1576−fixes. A function's scoped `@style` is named by the function and a hash
1577−of the stylesheet, `hl-fn-card-1x0a9zq`. The `hl-` prefix is htmlang's:
1578−a class of your own that starts with it gets a warning, since the generated
1579−rules would apply to it too. Every style goes into the class under its own
1580−name, with its value as written (plus pixels, see
1581−[CSS properties](#css-properties)); only the layout words, and
1582−`line-clamp`'s fallback, write more than one declaration.
1583−
1584−An element's defaults go with its own class: its layout (a column is
1585−`display: flex`), the margins browsers give headings, paragraphs, lists,
1586−figures and blockquotes are 0, lists have no markers, `@fieldset` has a
1587−thin border and padding, `@code`, `@kbd` and `@pre` are monospace, `@link`
1588−has no underline and takes its parent's text colour, and `@image` is a
1589−block. They apply only to htmlang's own elements, so HTML that comes from
1590−`@markdown` or `@raw` keeps the browser's defaults: a Markdown list keeps
1591−its bullets and a Markdown link its underline.
1592−
1593−The generated rules live in three cascade layers, after a small reset in
1594−the layer `hl-reset`, and a later layer wins over an earlier one whatever
1595−the specificity:
1596−
1597−| Layer | Holds |
1598−|---|---|
1599−| `hl-reset` | The reset |
1600−| `hl-kind` | The defaults of each element's kind, as `:where(.hl-2k9xq0m)` |
1601−| `hl-inside` | The styles an element gets from an element prefix on an element around it (`@td:padding 8`, see [Styling the elements inside](#styling-the-elements-inside)), and functions' scoped `@style` |
1602−| `htmlang` | Each element's own styles, and its parent's `children:` styles |
1603−
1604−So an element's own styles win over everything else htmlang writes for
1605−it; its parent's `children:` styles and the element prefixes of the
1606−elements around it win over its defaults.
1607−
1608−A page's reset sets `box-sizing: border-box` everywhere and makes
1609−`<body>` a column that fills the window. A fragment (a file
1610−without `@page`, or `--partial` output) goes into a page it doesn't own, so
1611−its reset touches only htmlang's own elements (those with an `hl-` class),
1612−which get `box-sizing: border-box`; no other element on the page is
1613−touched. In both, an element
1614−htmlang lays out stays hidden while it has `hidden`, while an `@dialog` is
1615−closed, and while a popover isn't showing, even though its generated
1616−`display` would otherwise beat the browser's `display: none`. A page with
1617−links, buttons or form fields also gives htmlang's ones a focus outline.
1618−CSS outside any layer takes precedence over every layer, so rules in
588+takes precedence over both, so rules in `@style` or `@raw` override the
1619−`@style` or `@raw` override the generated ones.
589+generated ones.
README.md+29−127
@@ -7,15 +7,15 @@ themselves. Styling uses CSS properties under their CSS names, and every other
7element uses its HTML name.
7element uses its HTML name.
8
8
9```
9```
10−@page [lang=en, background #f8fafc] My Site
10+@page My Site
11@let --brand #3b82f6
11@let --brand #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 @article [padding 20, spacing 8, border 1 solid #e5e7eb, border-radius 8, hover:border-color var(--brand)]
15 @h3 $title
15 @h3 $title
16 @children
16 @children
17
17
18−@el [width fill, max-width 800, center-x, padding 40, spacing 24]
18+@el [max-width 800, center-x, padding 40, spacing 24]
19 @h1 [font-size 32] Hello
19 @h1 [font-size 32] Hello
20 @paragraph
20 @paragraph
21 Built with {@text [font-weight bold, color var(--brand)] htmlang}.
21 Built with {@text [font-weight bold, color var(--brand)] htmlang}.
@@ -32,92 +32,23 @@ unless you write some, and nothing is added that you didn't ask for.
32## The language in brief
32## The language in brief
33
33
34- **`@` starts structure, and any other line is content.** Indentation nests
34- **`@` starts structure, and any other line is content.** Indentation nests
35− elements. Text after an element's attributes is its content, read like
35+ elements. Text after an element's attributes is its content, and `{...}`
36− any line of text, and `{...}` puts an element inside a line of text
36+ 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
37+- **Layout comes from elm-ui.** `@el` lays out its children in a column and
46− containers (`@section`, `@nav`, `@ul`, `@li`, ...) are columns, `@row` is a
38+ `@row` in a row, and every other container is a column too. Layout
47− row and `@grid` a grid: in them each line of text is a child, and
39+ attributes (`spacing`, `width fill`, `center-x`, `align-right`, `wrap`) say
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
40+ how an element sits inside its parent.
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
41- **Styling is CSS.** Any other attribute is a CSS property with its CSS
59 name and value: `padding 20`, `border 1 solid #eee`,
42 name and value: `padding 20`, `border 1 solid #eee`,
60− `grid-template-columns 1fr 2fr`. In a length property, every bare
43+ `grid-template-columns 1fr 2fr`. In lengths, a bare number means pixels.
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
44+ Prefixes make a style conditional: `hover:`, `md:`, `dark:`, `first:`.
72− common pseudo-classes and pseudo-elements under their CSS names
45+- **HTML stays HTML.** Elements have their HTML names (`@nav`, `@ul`, `@form`,
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
46+ `@details`), and HTML attributes are written `key=value` (`id=main`,
103− bare (`required`).
47+ `type=email`) or bare (`required`).
104- **`@let` defines everything.** It defines values, computed values,
48- **`@let` defines everything.** It defines values, computed values,
105− attribute bundles and functions. A function's body puts a call's content
49+ attribute bundles and functions. A layout is just a function with slots.
106− where `@children` is and fills `@slot NAME` from the call's `@slot NAME`
50+- **Data comes in as lists and records.** `@data` loads JSON, and `@each` and
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
51+ `@if` run at compile time.
119− line to the end of its block, and a function sees what is visible where
120− it is defined.
121
52
122## Install
53## Install
123
54
@@ -136,12 +67,12 @@ htmlang build src -o dist # compile a whole site
136
67
137| Command | Purpose |
68| Command | Purpose |
138|---|---|
69|---|---|
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`) |
70+| `build <dir> [-o out] [--minify] [--strict]` | Compile every `.hl` file under a directory (into `out/` by default) |
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 |
71+| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload |
141| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
72| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
142| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
73| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
143| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
74| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
144−| `fmt <file.hl>...` | Format files in place |
75+| `fmt <file.hl>` | Format a file in place |
145| `lsp` | Run the language server over stdio |
76| `lsp` | Run the language server over stdio |
146
77
147Compiling a file directly also takes `-w` / `--watch`, `--dev` (readable
78Compiling a file directly also takes `-w` / `--watch`, `--dev` (readable
@@ -163,50 +94,29 @@ is an HTML attribute:
163```
94```
164
95
165A prefix applies a style only in a state, from a screen width up, or under a
96A prefix applies a style only in a state, from a screen width up, or under a
166−media condition. Prefixes stack, and a prefix before a `[group]` applies to
97+media condition:
167−each style in it:
168
98
169```
99```
170−@el [padding 16, md:[padding 32, font-size 20], background #3b82f6, hover:background #2563eb, dark:[background #1e3a8a, hover:background #1e40af]]
100+@el [padding 16, md:padding 32, background #3b82f6, hover:background #2563eb, dark:background #1e3a8a]
171 @text [color white] Click me
101 @text [color white] Click me
172```
102```
173
103
174−An element prefix, `@td:`, styles every such element inside, so rows a
175−loop writes and HTML from `@markdown` need no attributes of their own:
176−
177−```
178−@table [border-collapse collapse, @th:[padding 8, text-align left], @td:padding 8, @tr:nth-child(even):background #f8fafc]
179− @tr
180− @th Name
181− @tr
182− @td Ada
183−```
184−
185−`@let` defines values, bundles and functions, in one namespace. A function
104+`@let` defines values, bundles and functions. A function is called like an
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
105+element: its parameters are attributes, and any other attributes style its
191−attributes written there.
106+root element.
192
107
193```
108```
194@let --primary #3b82f6
109@let --primary #3b82f6
195@let gap = 8 * 2
110@let gap = 8 * 2
196@let rounded [border-radius 8, overflow hidden]
111@let rounded [border-radius 8, overflow hidden]
197
112
198−@let @cta [label, href #]
113+@let button $label $href=#
199 @link [$rounded, padding 10 16, background var(--primary), color white] $href
114 @link [$rounded, padding 10 16, background var(--primary), color white] $href
200 $label
115 $label
201−@let @key
202− @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
203− @children
204
116
205@row [spacing $gap]
117@row [spacing $gap]
206− @cta [label Sign up]
118+ @button [label Sign up]
207− @cta [label Learn more, href /about, background #64748b]
119+ @button [label Learn more, href /about, background #64748b]
208−@paragraph
209− Press {@key Ctrl+K} to search.
210```
120```
211
121
212Data, loops and conditions run at compile time:
122Data, loops and conditions run at compile time:
@@ -222,7 +132,7 @@ Data, loops and conditions run at compile time:
222@nav
132@nav
223 @row [spacing 16]
133 @row [spacing 16]
224 @each $link in $links
134 @each $link in $links
225− @link [if($link.url == $current, [font-weight bold, aria-current=page])] $link.url $link.label
135+ @link [if($link.url == $current, font-weight bold)] $link.url $link.label
226```
136```
227
137
228The [`examples/`](examples) directory has complete pages (a landing page, a
138The [`examples/`](examples) directory has complete pages (a landing page, a
@@ -235,11 +145,3 @@ The VS Code extension in [`editors/vscode`](editors/vscode) provides syntax
235highlighting, snippets, and the language server (`htmlang-lsp`). The server
145highlighting, snippets, and the language server (`htmlang-lsp`). The server
236gives diagnostics, completions, hover documentation, go to definition, rename
146gives diagnostics, completions, hover documentation, go to definition, rename
237and formatting.
147and 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+99−624
@@ -3,35 +3,19 @@ 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>>,
12 pub css_vars: Vec<(String, String)>,
13 pub css_vars: Vec<(String, String)>,
13 pub custom_css: Vec<String>,
14 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)>,
15 pub og_tags: Vec<(String, String)>,
18 pub nodes: Vec<Node>,
16 pub nodes: Vec<Node>,
19}
17}
20
18
21−/// What `@page` says: the page is the root element. Its styles style
22−/// `<body>`, which lays out the page's top-level elements as a column; its
23−/// HTML attributes (`lang=en`, `dir=rtl`, `class=x`) go on `<html>`.
24−#[derive(Debug, Clone, Default)]
25−pub struct Page {
26− pub title: String,
27− /// `key=value` attributes, and bare boolean HTML attributes, for `<html>`
28− pub html_attrs: Vec<Attribute>,
29− /// Styles, prefixed ones included, for `<body>`
30− pub styles: Vec<Attribute>,
31− /// `favicon FILE`, `@page`'s one word of htmlang's own
32− pub favicon: Option<String>,
33−}
34−
35#[derive(Debug, Clone)]
19#[derive(Debug, Clone)]
36pub enum Node {
20pub enum Node {
37 Element(Element),
21 Element(Element),
@@ -49,40 +33,18 @@ pub enum TextSegment {
49pub struct Element {
33pub struct Element {
50 pub kind: ElementKind,
34 pub kind: ElementKind,
51 pub attrs: Vec<Attribute>,
35 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>,
36 pub argument: Option<String>,
57 pub children: Vec<Node>,
37 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,
38 pub line_num: usize,
61− /// The function whose body wrote it, when it comes from a call.
62− pub function: Option<String>,
63}
39}
64
40
41+
65#[derive(Debug, Clone)]
42#[derive(Debug, Clone)]
66pub struct Attribute {
43pub struct Attribute {
67 pub key: String,
44 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>,
45 pub value: Option<String>,
71 /// Written `key=value`: an HTML attribute rather than a style.
46 /// Written `key=value`: an HTML attribute rather than a style.
72 pub html: bool,
47 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}
48}
87
49
88#[derive(Debug, Clone, PartialEq)]
50#[derive(Debug, Clone, PartialEq)]
@@ -95,6 +57,7 @@ pub enum ElementKind {
95 Link,
57 Link,
96 Image,
58 Image,
97 // Elements generated in a special way
59 // Elements generated in a special way
60+ Script,
98 Fragment,
61 Fragment,
99 // Function bodies: placeholders for the caller's content
62 // Function bodies: placeholders for the caller's content
100 Children,
63 Children,
@@ -113,6 +76,7 @@ impl ElementKind {
113 ElementKind::Paragraph => "paragraph",
76 ElementKind::Paragraph => "paragraph",
114 ElementKind::Link => "link",
77 ElementKind::Link => "link",
115 ElementKind::Image => "image",
78 ElementKind::Image => "image",
79+ ElementKind::Script => "script",
116 ElementKind::Fragment => "fragment",
80 ElementKind::Fragment => "fragment",
117 ElementKind::Children => "children",
81 ElementKind::Children => "children",
118 ElementKind::Slot(_) => "slot",
82 ElementKind::Slot(_) => "slot",
@@ -127,84 +91,11 @@ impl ElementKind {
127 }
91 }
128 }
92 }
129
93
130− /// How the element lays out what is inside it. `@fragment`, `@children`
94+ /// Does this element lay its children out in a flex column (`@el` and
131− /// and `@slot` have no element of their own: their content lands in the
95+ /// the semantic containers)?
132− /// element around them and takes its layout.
96+ pub fn is_column(&self) -> bool {
133− pub fn layout(&self) -> Layout {
97+ matches!(self, ElementKind::El)
134− match self {
98+ || self.spec().is_some_and(|spec| spec.css.contains("flex-direction:column"))
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 }
99 }
209
100
210 /// Is this the table element named `name` (e.g. `"main"`)?
101 /// Is this the table element named `name` (e.g. `"main"`)?
@@ -221,6 +112,7 @@ impl ElementKind {
221 "paragraph" => ElementKind::Paragraph,
112 "paragraph" => ElementKind::Paragraph,
222 "link" => ElementKind::Link,
113 "link" => ElementKind::Link,
223 "image" => ElementKind::Image,
114 "image" => ElementKind::Image,
115+ "script" => ElementKind::Script,
224 "fragment" => ElementKind::Fragment,
116 "fragment" => ElementKind::Fragment,
225 "children" => ElementKind::Children,
117 "children" => ElementKind::Children,
226 "slot" => ElementKind::Slot(String::new()),
118 "slot" => ElementKind::Slot(String::new()),
@@ -231,15 +123,8 @@ impl ElementKind {
231 /// Every element name, for suggestions and completions.
123 /// Every element name, for suggestions and completions.
232 pub fn all_names() -> impl Iterator<Item = &'static str> {
124 pub fn all_names() -> impl Iterator<Item = &'static str> {
233 [
125 [
234− "row",
126+ "row", "el", "text", "paragraph", "link", "image", "script", "fragment",
235− "el",
236− "text",
237− "paragraph",
238− "link",
239− "image",
240− "fragment",
241− "children",
127+ "children", "slot",
242− "slot",
243 ]
128 ]
244 .into_iter()
129 .into_iter()
245 .chain(TAGS.iter().map(|spec| spec.name))
130 .chain(TAGS.iter().map(|spec| spec.name))
@@ -249,90 +134,12 @@ impl ElementKind {
249/// What an element does with the text after its name and attributes.
134/// What an element does with the text after its name and attributes.
250#[derive(Debug, Clone, Copy, PartialEq)]
135#[derive(Debug, Clone, Copy, PartialEq)]
251pub enum TagArg {
136pub enum TagArg {
252− /// Its content: the first line of its text, parsed like any text
137+ /// 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,
138 Child,
256− /// Its leading attribute: the first token fills this HTML attribute
139+ /// Printed as the element's text (`@li 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,
140 Text,
309− /// HTML's own layout, which htmlang leaves alone: tables, `@select`,
141+ /// 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 {
142+ 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}
143}
337
144
338/// How an element compiles: the data behind [`ElementKind::Tag`].
145/// How an element compiles: the data behind [`ElementKind::Tag`].
@@ -342,19 +149,17 @@ pub struct TagSpec {
342 pub name: &'static str,
149 pub name: &'static str,
343 /// HTML element emitted.
150 /// HTML element emitted.
344 pub html: &'static str,
151 pub html: &'static str,
345− /// Default CSS besides the layout's `display` (margin reset, ...).
152+ /// Default CSS (flex column, margin reset, ...).
346 pub css: &'static str,
153 pub css: &'static str,
347 pub arg: TagArg,
154 pub arg: TagArg,
348− /// How it lays out its content.
155+ /// Bare text children are wrapped in `<span>` (flex containers).
349− pub layout: Layout,
156+ pub wraps_text: bool,
350− /// Its text is shown as written (`@code`, `@textarea`). On its line and
157+ /// Accepts layout attributes for its children (`spacing`, ...).
351− /// inline, a `{@...}` is text, not an inline element (escapes and
158+ pub container: bool,
352− /// `$names` still work). Its indented body is verbatim: nothing in it is
159+ /// 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,
160+ pub void: bool,
355− /// Its indented body is foreign text written into the page exactly as
161+ /// 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,
162+ pub inline: bool,
358}
163}
359
164
360impl TagSpec {
165impl TagSpec {
@@ -363,413 +168,83 @@ impl TagSpec {
363 html: "",
168 html: "",
364 css: "",
169 css: "",
365 arg: TagArg::Child,
170 arg: TagArg::Child,
366− layout: Layout::Native,
171+ wraps_text: false,
367− literal: false,
172+ container: false,
173+ void: false,
368− verbatim: false,
174+ inline: false,
369 };
175 };
370}
176}
371
177
372/// Every element besides the core ones in [`ElementKind`].
178/// Every element besides the core ones in [`ElementKind`].
373−#[rustfmt::skip]
374pub static TAGS: &[TagSpec] = &[
179pub static TAGS: &[TagSpec] = &[
375− TagSpec { name: "nav", html: "nav", layout: Layout::Column, ..TagSpec::DEFAULT },
180+ 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 },
181+ 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 },
182+ 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 },
183+ 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 },
184+ 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 },
185+ 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 },
186+ 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 },
187+ 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 },
188+ 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 },
189+ TagSpec { name: "search", html: "search", wraps_text: true, container: true, ..TagSpec::DEFAULT },
385− TagSpec { name: "address", html: "address", layout: Layout::Column, ..TagSpec::DEFAULT },
190+ TagSpec { name: "address", html: "address", wraps_text: true, container: true, ..TagSpec::DEFAULT },
386− TagSpec { name: "noscript", html: "noscript", layout: Layout::Column, ..TagSpec::DEFAULT },
191+ 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 },
192+ 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 },
193+ 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 },
194+ 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 },
195+ 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 },
196+ 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 },
197+ TagSpec { name: "ul", 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 },
198+ TagSpec { name: "ol", html: "ol", css: "margin:0;padding-left:0;list-style:none;", container: true, ..TagSpec::DEFAULT },
394− TagSpec { name: "li", html: "li", layout: Layout::Column, ..TagSpec::DEFAULT },
199+ TagSpec { name: "li", html: "li", css: "display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
395− TagSpec { name: "dl", html: "dl", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
200+ TagSpec { name: "dl", html: "dl", css: "margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
396− TagSpec { name: "dt", html: "dt", layout: Layout::Text, ..TagSpec::DEFAULT },
201+ TagSpec { name: "dt", html: "dt", arg: TagArg::Text, container: true, ..TagSpec::DEFAULT },
397− TagSpec { name: "dd", html: "dd", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
202+ TagSpec { name: "dd", html: "dd", css: "margin:0;display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
398− TagSpec { name: "table", html: "table", layout: Layout::Native, ..TagSpec::DEFAULT },
203+ TagSpec { name: "table", html: "table", ..TagSpec::DEFAULT },
399− TagSpec { name: "thead", html: "thead", layout: Layout::Native, ..TagSpec::DEFAULT },
204+ TagSpec { name: "thead", html: "thead", ..TagSpec::DEFAULT },
400− TagSpec { name: "tbody", html: "tbody", layout: Layout::Native, ..TagSpec::DEFAULT },
205+ TagSpec { name: "tbody", html: "tbody", ..TagSpec::DEFAULT },
401− TagSpec { name: "tr", html: "tr", layout: Layout::Native, ..TagSpec::DEFAULT },
206+ TagSpec { name: "tr", html: "tr", ..TagSpec::DEFAULT },
402− TagSpec { name: "select", html: "select", layout: Layout::Native, ..TagSpec::DEFAULT },
207+ TagSpec { name: "select", html: "select", ..TagSpec::DEFAULT },
403− TagSpec { name: "picture", html: "picture", layout: Layout::Native, ..TagSpec::DEFAULT },
208+ TagSpec { name: "picture", html: "picture", ..TagSpec::DEFAULT },
404− TagSpec { name: "progress", html: "progress", layout: Layout::Native, ..TagSpec::DEFAULT },
209+ TagSpec { name: "progress", html: "progress", ..TagSpec::DEFAULT },
405− TagSpec { name: "meter", html: "meter", layout: Layout::Native, ..TagSpec::DEFAULT },
210+ TagSpec { name: "meter", html: "meter", ..TagSpec::DEFAULT },
406− TagSpec { name: "output", html: "output", layout: Layout::Native, ..TagSpec::DEFAULT },
211+ TagSpec { name: "output", html: "output", ..TagSpec::DEFAULT },
407− TagSpec { name: "canvas", html: "canvas", layout: Layout::Native, ..TagSpec::DEFAULT },
212+ TagSpec { name: "canvas", html: "canvas", container: true, ..TagSpec::DEFAULT },
408− TagSpec { name: "td", html: "td", layout: Layout::Text, ..TagSpec::DEFAULT },
213+ TagSpec { name: "td", html: "td", arg: TagArg::Text, ..TagSpec::DEFAULT },
409− TagSpec { name: "th", html: "th", layout: Layout::Text, ..TagSpec::DEFAULT },
214+ TagSpec { name: "th", html: "th", arg: TagArg::Text, ..TagSpec::DEFAULT },
410− TagSpec { name: "button", html: "button", layout: Layout::Text, ..TagSpec::DEFAULT },
215+ TagSpec { name: "button", html: "button", arg: TagArg::Text, ..TagSpec::DEFAULT },
411− TagSpec { name: "label", html: "label", layout: Layout::Text, ..TagSpec::DEFAULT },
216+ TagSpec { name: "label", html: "label", arg: TagArg::Text, ..TagSpec::DEFAULT },
412− TagSpec { name: "option", html: "option", layout: Layout::Native, ..TagSpec::DEFAULT },
217+ TagSpec { name: "option", html: "option", arg: TagArg::Text, ..TagSpec::DEFAULT },
413− TagSpec { name: "textarea", html: "textarea", layout: Layout::Native, literal: true, ..TagSpec::DEFAULT },
218+ TagSpec { name: "textarea", html: "textarea", arg: TagArg::Text, ..TagSpec::DEFAULT },
414− TagSpec { name: "summary", html: "summary", layout: Layout::Text, ..TagSpec::DEFAULT },
219+ TagSpec { name: "summary", html: "summary", arg: TagArg::Text, ..TagSpec::DEFAULT },
415− TagSpec { name: "cite", html: "cite", layout: Layout::Text, ..TagSpec::DEFAULT },
220+ TagSpec { name: "cite", html: "cite", arg: TagArg::Text, ..TagSpec::DEFAULT },
416− TagSpec { name: "figcaption", html: "figcaption", layout: Layout::Text, ..TagSpec::DEFAULT },
221+ TagSpec { name: "figcaption", html: "figcaption", arg: TagArg::Text, ..TagSpec::DEFAULT },
417− TagSpec { name: "legend", html: "legend", layout: Layout::Text, ..TagSpec::DEFAULT },
222+ TagSpec { name: "legend", html: "legend", arg: TagArg::Text, ..TagSpec::DEFAULT },
418− TagSpec { name: "time", html: "time", layout: Layout::Text, ..TagSpec::DEFAULT },
223+ TagSpec { name: "time", html: "time", arg: TagArg::Text, ..TagSpec::DEFAULT },
419− TagSpec { name: "mark", html: "mark", layout: Layout::Text, ..TagSpec::DEFAULT },
224+ TagSpec { name: "mark", html: "mark", arg: TagArg::Text, ..TagSpec::DEFAULT },
420− TagSpec { name: "abbr", html: "abbr", layout: Layout::Text, ..TagSpec::DEFAULT },
225+ TagSpec { name: "abbr", html: "abbr", arg: TagArg::Text, ..TagSpec::DEFAULT },
421− TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, literal: true, ..TagSpec::DEFAULT },
226+ TagSpec { name: "code", html: "code", 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 },
227+ TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
423− TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", layout: Layout::Native, ..TagSpec::DEFAULT },
228+ TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", ..TagSpec::DEFAULT },
424− TagSpec { name: "h1", html: "h1", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
229+ TagSpec { name: "h1", html: "h1", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
425− TagSpec { name: "h2", html: "h2", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
230+ TagSpec { name: "h2", html: "h2", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
426− TagSpec { name: "h3", html: "h3", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
231+ TagSpec { name: "h3", html: "h3", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
427− TagSpec { name: "h4", html: "h4", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
232+ TagSpec { name: "h4", html: "h4", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
428− TagSpec { name: "h5", html: "h5", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
233+ TagSpec { name: "h5", html: "h5", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
429− TagSpec { name: "h6", html: "h6", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
234+ TagSpec { name: "h6", html: "h6", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
430− TagSpec { name: "input", html: "input", layout: Layout::Void, ..TagSpec::DEFAULT },
235+ TagSpec { name: "input", html: "input", void: true, ..TagSpec::DEFAULT },
431− TagSpec { name: "hr", html: "hr", layout: Layout::Void, ..TagSpec::DEFAULT },
236+ TagSpec { name: "hr", html: "hr", void: true, ..TagSpec::DEFAULT },
432− TagSpec { name: "source", html: "source", arg: TagArg::Source, layout: Layout::Void, ..TagSpec::DEFAULT },
237+ TagSpec { name: "source", html: "source", arg: TagArg::Attr("src"), void: true, ..TagSpec::DEFAULT },
433− TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
238+ TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
434− TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
239+ TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
435− TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
240+ TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), container: true, ..TagSpec::DEFAULT },
436− TagSpec { name: "script", html: "script", arg: TagArg::Attr("src"), layout: Layout::Native, verbatim: true, ..TagSpec::DEFAULT },
241+ TagSpec { name: "grid", html: "div", css: "display:grid;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
437− // Flow containers
438− TagSpec { name: "hgroup", html: "hgroup", layout: Layout::Column, ..TagSpec::DEFAULT },
439− TagSpec { name: "menu", html: "menu", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
440− // Phrasing: text with the browser's own style, and no CSS of htmlang's
441− TagSpec { name: "b", html: "b", layout: Layout::Text, ..TagSpec::DEFAULT },
442− TagSpec { name: "i", html: "i", layout: Layout::Text, ..TagSpec::DEFAULT },
443− TagSpec { name: "strong", html: "strong", layout: Layout::Text, ..TagSpec::DEFAULT },
444− TagSpec { name: "em", html: "em", layout: Layout::Text, ..TagSpec::DEFAULT },
445− TagSpec { name: "small", html: "small", layout: Layout::Text, ..TagSpec::DEFAULT },
446− TagSpec { name: "s", html: "s", layout: Layout::Text, ..TagSpec::DEFAULT },
447− TagSpec { name: "u", html: "u", layout: Layout::Text, ..TagSpec::DEFAULT },
448− TagSpec { name: "sub", html: "sub", layout: Layout::Text, ..TagSpec::DEFAULT },
449− TagSpec { name: "sup", html: "sup", layout: Layout::Text, ..TagSpec::DEFAULT },
450− TagSpec { name: "q", html: "q", layout: Layout::Text, ..TagSpec::DEFAULT },
451− TagSpec { name: "var", html: "var", layout: Layout::Text, ..TagSpec::DEFAULT },
452− TagSpec { name: "samp", html: "samp", layout: Layout::Text, ..TagSpec::DEFAULT },
453− TagSpec { name: "dfn", html: "dfn", layout: Layout::Text, ..TagSpec::DEFAULT },
454− TagSpec { name: "bdi", html: "bdi", layout: Layout::Text, ..TagSpec::DEFAULT },
455− TagSpec { name: "bdo", html: "bdo", layout: Layout::Text, ..TagSpec::DEFAULT },
456− TagSpec { name: "ins", html: "ins", layout: Layout::Text, ..TagSpec::DEFAULT },
457− TagSpec { name: "del", html: "del", layout: Layout::Text, ..TagSpec::DEFAULT },
458− TagSpec { name: "br", html: "br", layout: Layout::Void, ..TagSpec::DEFAULT },
459− TagSpec { name: "wbr", html: "wbr", layout: Layout::Void, ..TagSpec::DEFAULT },
460− // HTML's own display: table parts, option groups, ruby, embedded content
461− TagSpec { name: "caption", html: "caption", layout: Layout::Native, ..TagSpec::DEFAULT },
462− TagSpec { name: "colgroup", html: "colgroup", layout: Layout::Native, ..TagSpec::DEFAULT },
463− TagSpec { name: "col", html: "col", layout: Layout::Void, ..TagSpec::DEFAULT },
464− TagSpec { name: "tfoot", html: "tfoot", layout: Layout::Native, ..TagSpec::DEFAULT },
465− TagSpec { name: "optgroup", html: "optgroup", arg: TagArg::Attr("label"), layout: Layout::Native, ..TagSpec::DEFAULT },
466− TagSpec { name: "ruby", html: "ruby", layout: Layout::Native, ..TagSpec::DEFAULT },
467− TagSpec { name: "rt", html: "rt", layout: Layout::Native, ..TagSpec::DEFAULT },
468− TagSpec { name: "rp", html: "rp", layout: Layout::Native, ..TagSpec::DEFAULT },
469− TagSpec { name: "track", html: "track", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
470− TagSpec { name: "embed", html: "embed", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
471− TagSpec { name: "object", html: "object", arg: TagArg::Attr("data"), layout: Layout::Native, ..TagSpec::DEFAULT },
472− TagSpec { name: "map", html: "map", layout: Layout::Native, ..TagSpec::DEFAULT },
473− TagSpec { name: "area", html: "area", arg: TagArg::Attr("href"), layout: Layout::Void, ..TagSpec::DEFAULT },
474− // htmlang's own
475− TagSpec { name: "grid", html: "div", layout: Layout::Grid, ..TagSpec::DEFAULT },
476− TagSpec { name: "in-front", html: "div", css: "position:absolute;inset:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
242+ TagSpec { name: "in-front", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;", container: true, ..TagSpec::DEFAULT },
477− TagSpec { name: "behind", html: "div", css: "position:absolute;inset:0;z-index:-1;", layout: Layout::Column, ..TagSpec::DEFAULT },
243+ TagSpec { name: "behind", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;z-index:-1;", container: true, ..TagSpec::DEFAULT },
478];
244];
479
245
480−/// HTML elements htmlang writes under another name. The HTML name isn't an
246+/// Elements that print their argument as their text (`@text Hello`,
481−/// element: the unknown-element error for `@a` suggests `@link`. (Not a
247+/// `@li First`).
482−/// second spelling: only the suggestion reads this.)
248+pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
483−pub const HTML_NAMES_WRITTEN_OTHERWISE: &[(&str, &str)] = &[
249+ *kind == ElementKind::Text || kind.spec().is_some_and(|spec| spec.arg == TagArg::Text)
484− ("a", "link"),
485− ("img", "image"),
486− ("span", "text"),
487− ("p", "paragraph"),
488− ("div", "el"),
489−];
490−
491−/// Whether the element named `name` (without the `@`) shows its text as
492−/// written: `{@code {@link /x y}}` prints the braces, and its indented body
493−/// is verbatim text, HTML-escaped. The syntax tree reads this, so every
494−/// tool agrees on it.
495−pub fn has_literal_text(name: &str) -> bool {
496− ElementKind::from_name(name)
497− .and_then(|kind| kind.spec())
498− .is_some_and(|spec| spec.literal)
499−}
500−
501−/// How a directive reads the rest of its line.
502−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503−pub enum ArgGrammar {
504− /// Free text, taken as written (`@include file.hl`, `@meta name value`).
505− /// For a directive with a verbatim body it is the one-line form of the
506− /// body (`@raw <hr>`, `@style .a { color: red }`) or, for `@markdown`,
507− /// the file.
508− Text,
509− /// An optional `[attributes]` list, then text (`@page [lang=en] Title`).
510− AttrsText,
511− /// An expression (`@if $count > 2`).
512− Expression,
513− /// A name, then a value, `= EXPR`, `"text"`, `[attributes]` or the
514− /// parameters of a function (`@let`).
515− Definition,
516− /// Nothing, or `if EXPR` (`@else`, `@else if`).
517− Else,
518− /// `$item in LIST` or `$item, $index in LIST` (`@each`).
519− Loop,
520− /// `$name SOURCE`; inline JSON may span several lines (`@data`).
521− Data,
522−}
523−
524−/// What the lines indented under a directive or element are.
525−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526−pub enum BodyKind {
527− /// Nothing: indented lines are an error.
528− None,
529− /// htmlang: elements, text and directives.
530− Htmlang,
531− /// Foreign text (CSS, HTML, JavaScript, Markdown, a code sample), kept
532− /// exactly as written: nothing in it is parsed, and `--` is not a
533− /// comment. The text on the header's line is its one-line form (or a
534− /// file or URL: `@markdown notes.md`, `@script app.js`); the line and a
535− /// body together are an error. A verbatim directive takes no
536− /// attributes.
537− Verbatim,
538−}
539−
540−/// A directive: a `@name` that isn't an element. The syntax tree
541−/// (`syntax.rs`), the evaluator, the formatter and the editor tools all
542−/// read the directives' shapes from this table.
543−#[derive(Debug, PartialEq)]
544−pub struct DirectiveSpec {
545− /// Name in htmlang source, without the `@`.
546− pub name: &'static str,
547− pub args: ArgGrammar,
548− pub body: BodyKind,
549−}
550−
551−/// Every directive.
552−#[rustfmt::skip]
553−pub static DIRECTIVES: &[DirectiveSpec] = &[
554− DirectiveSpec { name: "page", args: ArgGrammar::AttrsText, body: BodyKind::None },
555− DirectiveSpec { name: "let", args: ArgGrammar::Definition, body: BodyKind::Htmlang },
556− DirectiveSpec { name: "include", args: ArgGrammar::Text, body: BodyKind::None },
557− DirectiveSpec { name: "data", args: ArgGrammar::Data, body: BodyKind::None },
558− DirectiveSpec { name: "meta", args: ArgGrammar::Text, body: BodyKind::None },
559− DirectiveSpec { name: "if", args: ArgGrammar::Expression, body: BodyKind::Htmlang },
560− DirectiveSpec { name: "else", args: ArgGrammar::Else, body: BodyKind::Htmlang },
561− DirectiveSpec { name: "each", args: ArgGrammar::Loop, body: BodyKind::Htmlang },
562− DirectiveSpec { name: "style", args: ArgGrammar::Text, body: BodyKind::Verbatim },
563− DirectiveSpec { name: "head", args: ArgGrammar::Text, body: BodyKind::Verbatim },
564− DirectiveSpec { name: "raw", args: ArgGrammar::Text, body: BodyKind::Verbatim },
565− DirectiveSpec { name: "markdown", args: ArgGrammar::Text, body: BodyKind::Verbatim },
566−];
567−
568−/// The directive named `name` (without the `@`).
569−pub fn directive(name: &str) -> Option<&'static DirectiveSpec> {
570− DIRECTIVES.iter().find(|d| d.name == name)
571−}
572−
573−/// What the lines indented under `@name` are: the directive's body kind,
574−/// or an element's (verbatim for a row that says so: `@script`, and
575−/// `@code` and `@textarea`, whose text is literal; htmlang for every other
576−/// element).
577−pub fn body_kind(name: &str) -> BodyKind {
578− let verbatim = |spec: &TagSpec| spec.verbatim || spec.literal;
579− match directive(name) {
580− Some(spec) => spec.body,
581− None if ElementKind::from_name(name)
582− .and_then(|kind| kind.spec())
583− .is_some_and(verbatim) =>
584− {
585− BodyKind::Verbatim
586− }
587− None => BodyKind::Htmlang,
588− }
589−}
590−
591−#[cfg(test)]
592−mod tests {
593− use super::{ElementKind, Layout};
594−
595− fn layout(name: &str) -> Layout {
596− ElementKind::from_name(name).unwrap().layout()
597− }
598−
599− #[test]
600− fn every_element_has_the_layout_it_is_documented_with() {
601− let text = [
602− "text",
603− "paragraph",
604− "link",
605− "h1",
606− "h2",
607− "h3",
608− "h4",
609− "h5",
610− "h6",
611− "button",
612− "td",
613− "th",
614− "label",
615− "summary",
616− "legend",
617− "figcaption",
618− "dt",
619− "cite",
620− "code",
621− "kbd",
622− "mark",
623− "abbr",
624− "time",
625− "b",
626− "i",
627− "strong",
628− "em",
629− "small",
630− "sub",
631− "sup",
632− "q",
633− "del",
634− ];
635− for name in text {
636− assert_eq!(layout(name), Layout::Text, "@{}", name);
637− }
638− let columns = [
639− "el", "li", "dd", "ul", "ol", "dl", "search", "address", "noscript", "section", "form",
640− "in-front", "behind", "hgroup", "menu",
641− ];
642− for name in columns {
643− assert_eq!(layout(name), Layout::Column, "@{}", name);
644− }
645− assert_eq!(layout("row"), Layout::Row);
646− assert_eq!(layout("grid"), Layout::Grid);
647− for name in [
648− "table", "tr", "select", "option", "picture", "video", "pre", "canvas", "caption",
649− "colgroup", "tfoot", "optgroup", "ruby", "rt", "object",
650− ] {
651− assert_eq!(layout(name), Layout::Native, "@{}", name);
652− }
653− for name in [
654− "image", "input", "hr", "source", "br", "wbr", "col", "track", "embed", "area",
655− ] {
656− assert_eq!(layout(name), Layout::Void, "@{}", name);
657− }
658− }
659−
660− #[test]
661− fn html_names_written_otherwise_are_not_elements() {
662− for (html, htmlang) in super::HTML_NAMES_WRITTEN_OTHERWISE {
663− assert!(ElementKind::from_name(html).is_none(), "@{}", html);
664− let kind = ElementKind::from_name(htmlang).unwrap();
665− let written = match kind {
666− ElementKind::El => "div",
667− ElementKind::Text => "span",
668− ElementKind::Paragraph => "p",
669− ElementKind::Link => "a",
670− ElementKind::Image => "img",
671− _ => panic!("@{} is not one of htmlang's own", htmlang),
672− };
673− assert_eq!(written, *html);
674− }
675− // No two rows share a name, and none is a directive's
676− let mut names: Vec<&str> = ElementKind::all_names().collect();
677− let count = names.len();
678− names.sort_unstable();
679− names.dedup();
680− assert_eq!(names.len(), count, "an element name is listed twice");
681− for name in names {
682− assert!(super::directive(name).is_none(), "@{}", name);
683− }
684− }
685−
686− #[test]
687− fn each_row_names_at_most_one_leading_attribute() {
688− let leading = |name: &str, in_picture: bool| {
689− ElementKind::from_name(name)
690− .unwrap()
691− .leading_attribute(in_picture)
692− };
693− for (name, attr) in [
694− ("link", "href"),
695− ("area", "href"),
696− ("image", "src"),
697− ("script", "src"),
698− ("iframe", "src"),
699− ("video", "src"),
700− ("audio", "src"),
701− ("track", "src"),
702− ("embed", "src"),
703− ("form", "action"),
704− ("object", "data"),
705− ("optgroup", "label"),
706− ] {
707− assert_eq!(leading(name, false), Some(attr), "@{}", name);
708− assert_eq!(leading(name, true), Some(attr), "@{}", name);
709− }
710− assert_eq!(leading("source", false), Some("src"));
711− assert_eq!(leading("source", true), Some("srcset"));
712− for name in ["el", "text", "h1", "button", "slot", "fragment", "picture"] {
713− assert_eq!(leading(name, false), None, "@{}", name);
714− }
715− // @script is an ordinary row whose body is kept as written
716− let script = ElementKind::from_name("script").unwrap();
717− assert!(script.is_verbatim() && script.spec().is_some());
718− assert_eq!(super::body_kind("script"), super::BodyKind::Verbatim);
719− assert_eq!(super::body_kind("el"), super::BodyKind::Htmlang);
720− // @code and @textarea: a verbatim body, shown as text; @pre is an
721− // ordinary element, so `@pre > @code` and wrapper functions work
722− for name in ["code", "textarea"] {
723− assert_eq!(
724− super::body_kind(name),
725− super::BodyKind::Verbatim,
726− "@{}",
727− name
728− );
729− assert!(super::has_literal_text(name), "@{}", name);
730− }
731− assert_eq!(super::body_kind("pre"), super::BodyKind::Htmlang);
732− // Every verbatim directive takes its line as its one-line body or file
733− for spec in super::DIRECTIVES {
734− if spec.body == super::BodyKind::Verbatim {
735− assert_eq!(spec.args, super::ArgGrammar::Text, "@{}", spec.name);
736− }
737− }
738− }
739−
740− #[test]
741− fn an_element_has_a_tag_of_its_own_when_no_other_element_writes_it() {
742− let own = |name: &str| ElementKind::from_name(name).unwrap().own_tag();
743− for shared in [
744− "el", "row", "grid", "in-front", "behind", "text", "script", "fragment",
745− ] {
746− assert_eq!(own(shared), None, "@{}", shared);
747− }
748− assert_eq!(own("td"), Some("td"));
749− assert_eq!(own("link"), Some("a"));
750− assert_eq!(own("paragraph"), Some("p"));
751− assert_eq!(own("image"), Some("img"));
752− // Every tag of its own is written by that one element
753− let names: Vec<&str> = ElementKind::all_names().collect();
754− for name in &names {
755− let Some(tag) = own(name) else { continue };
756− let writers: Vec<&&str> = names
757− .iter()
758− .filter(|other| ElementKind::from_name(other).unwrap().tag() == Some(tag))
759− .collect();
760− assert_eq!(writers, vec![name], "<{}>", tag);
761− }
762− }
763−
764− #[test]
765− fn a_layout_s_display_is_not_repeated_in_the_row_s_css() {
766− for spec in super::TAGS {
767− assert!(
768− !spec.css.contains("display"),
769− "@{}: {}",
770− spec.name,
771− spec.css
772− );
773− }
774− }
775}
250}
crates/htmlang-core/src/codegen.rs+1246−1574
@@ -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 }
211 }
565− if inner.is_empty() {
212+ emit_media_block(
566− continue;
213+ &mut css,
214+ &format!("@media (min-width: {})", bp_width),
215+ &format!("@media(min-width:{})", bp_width),
216+ &bp_pairs,
217+ "",
218+ dev,
219+ );
567− }
220+ }
568− // Each at-rule wraps the next, outermost first
221+
569− for (level, &rank) in at.iter().enumerate().rev() {
222+ // Dark mode rules
570− let pad = if dev {
223+ let dark_pairs: Vec<(&str, &str)> = self
571− " ".repeat(level)
224+ .entries
572− } else {
225+ .iter()
573− String::new()
226+ .filter(|e| !e.dark.is_empty())
574− };
227+ .map(|e| (e.class_name.as_str(), e.dark.as_str()))
575− inner = if dev {
228+ .collect();
576− format!("{pad}{} {{\n{inner}{pad}}}\n", at_rule(rank, true))
229+ emit_media_block(
577− } else {
230+ &mut css,
578− format!("{}{{{inner}}}", at_rule(rank, false))
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,29 +388,29 @@ 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
@@ -647,14 +418,6 @@ struct GenContext {
647 dev: bool,
418 dev: bool,
648 depth: usize,
419 depth: usize,
649 has_interactive: bool,
420 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}
421}
659
422
660impl GenContext {
423impl GenContext {
@@ -684,22 +447,15 @@ pub struct CodegenOptions {
684
447
685/// Generate HTML from a parsed document using the given options.
448/// Generate HTML from a parsed document using the given options.
686pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String {
449pub 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 {
450 let mut html = if opts.partial {
695− generate_partial_inner(doc, opts.dev, &mut classes)
451+ generate_partial_inner(doc, opts.dev)
696 } else {
452 } else {
697− generate_full_inner(doc, opts.dev, &mut classes)
453+ generate_full_inner(doc, opts.dev)
698 };
454 };
699 if opts.minify {
455 if opts.minify {
700 html = minify_html(&html);
456 html = minify_html(&html);
701 }
457 }
702− (html, classes)
458+ html
703}
459}
704
460
705pub fn generate(doc: &Document) -> String {
461pub fn generate(doc: &Document) -> String {
@@ -825,131 +581,191 @@ fn minify_html(html: &str) -> String {
825 result
581 result
826}
582}
827
583
828−fn generate_full_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
584+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();
585 let mut styles = StyleCollector::new();
834 let mut ctx = GenContext {
586 let mut ctx = GenContext {
835 dev,
587 dev,
836 depth: 0,
588 depth: 0,
837 has_interactive: false,
589 has_interactive: false,
838− in_text: false,
839− flow: Flow::default(),
840− in_picture: false,
841− };
842−
843− // The page is the root element: `<body>` is a column (the reset makes
844− // it one, filling the viewport), `@page`'s styles are its class, and
845− // the page's top-level elements are its children
846− let root = Element {
847− kind: ElementKind::El,
848− attrs: page.styles.clone(),
849− argument: None,
850− children: Vec::new(),
851− line_num: 0,
852− function: None,
853 };
590 };
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
591
867 let mut body = String::new();
592 let mut body = String::new();
868− generate_children(
593+
869− &doc.nodes,
594+ for node in &doc.nodes {
870− Some(Layout::Column),
595+ generate_node(node, None, &mut body, &mut styles, &mut ctx);
871− &mut body,
596+ }
872− &mut styles,
597+
873− &mut ctx,
874− );
875
598
876 let element_css = build_element_css(doc, &styles, dev);
599 let element_css = build_element_css(doc, &styles, dev);
877− let nl = if dev { "\n" } else { "" };
878
600
879− let mut meta_html = String::new();
601+ // Build meta tags string
880− // A `@meta viewport` of the page's own replaces the usual one
602+ let meta_html = if doc.meta_tags.is_empty() {
881− if !doc.meta_tags.iter().any(|(name, _)| name == "viewport") {
603+ String::new()
882− meta_html.push_str(&format!(
604+ } else {
883− "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">{}",
605+ let mut m = String::new();
884− nl
885− ));
886− }
887− meta_html.push_str(&format!(
888− "<title>{}</title>{}",
889− html_escape(&page.title),
890− nl
891− ));
892− for (name, content) in &doc.meta_tags {
606+ for (name, content) in &doc.meta_tags {
607+ if dev {
893− meta_html.push_str(&format!(
608+ m.push_str(&format!(
894− "<meta name=\"{}\" content=\"{}\">{}",
609+ "<meta name=\"{}\" content=\"{}\">\n",
895− html_escape(name),
610+ html_escape(name),
896− html_escape(content),
611+ html_escape(content)
897− nl
898− ));
612+ ));
899− }
613+ } else {
900− for (property, content) in &doc.og_tags {
901− meta_html.push_str(&format!(
614+ m.push_str(&format!(
902− "<meta property=\"og:{}\" content=\"{}\">{}",
615+ "<meta name=\"{}\" content=\"{}\">",
903− html_escape(property),
616+ html_escape(name),
904− html_escape(content),
617+ html_escape(content)
905− nl
906− ));
618+ ));
907− }
619+ }
908− if let Some(path) = &page.favicon {
620+ }
909− meta_html.push_str(&format!(
621+ m
910− "<link rel=\"icon\" href=\"{}\">{}",
911− html_escape(path),
912− nl
913− ));
914− }
622+ };
915− for block in &doc.head_blocks {
916− meta_html.push_str(block);
917− meta_html.push_str(nl);
918− }
919
623
920− // `@page`'s HTML attributes go on `<html>`
624+ // Build OG meta tags
625+ let og_html = if doc.og_tags.is_empty() {
626+ String::new()
627+ } else {
921− let mut root_attrs = String::new();
628+ let mut o = String::new();
922− for attr in html_attrs(&page.html_attrs) {
629+ for (property, content) in &doc.og_tags {
923− push_html_attr(&mut root_attrs, attr);
630+ if dev {
631+ o.push_str(&format!(
632+ "<meta property=\"og:{}\" content=\"{}\">\n",
633+ html_escape(property),
634+ html_escape(content)
635+ ));
636+ } else {
637+ o.push_str(&format!(
638+ "<meta property=\"og:{}\" content=\"{}\">",
639+ html_escape(property),
640+ html_escape(content)
641+ ));
642+ }
643+ }
644+ o
924− }
645+ };
646+
647+ // Build head blocks string
648+ let head_html = if doc.head_blocks.is_empty() {
925− let mut body_attrs = String::new();
649+ String::new()
926− emit_class_attr(&mut body_attrs, body_class.as_deref(), None);
650+ } else {
651+ let mut h = String::new();
652+ for block in &doc.head_blocks {
653+ h.push_str(block);
654+ if dev {
655+ h.push('\n');
656+ }
657+ }
658+ h
659+ };
660+
661+ let lang_attr = match &doc.lang {
662+ Some(lang) => format!(" lang=\"{}\"", html_escape(lang)),
663+ None => String::new(),
664+ };
665+
666+ let favicon_html = match &doc.favicon {
667+ Some(path) => {
668+ // Try to read and inline the favicon
669+ if let Ok(data) = std::fs::read(path) {
670+ let mime = if path.ends_with(".ico") {
671+ "image/x-icon"
672+ } else if path.ends_with(".png") {
673+ "image/png"
674+ } else if path.ends_with(".svg") {
675+ "image/svg+xml"
676+ } else {
677+ "image/x-icon"
678+ };
679+ let b64 = base64_encode(&data);
680+ if dev {
681+ format!(
682+ "<link rel=\"icon\" href=\"data:{};base64,{}\">\n",
683+ mime, b64
684+ )
685+ } else {
686+ format!("<link rel=\"icon\" href=\"data:{};base64,{}\">", mime, b64)
687+ }
688+ } else {
689+ // Fall back to href
690+ if dev {
691+ format!("<link rel=\"icon\" href=\"{}\">\n", html_escape(path))
692+ } else {
693+ format!("<link rel=\"icon\" href=\"{}\">", html_escape(path))
694+ }
695+ }
696+ }
697+ None => String::new(),
698+ };
927
699
928 // Focus-visible CSS for interactive elements (accessibility)
700 // Focus-visible CSS for interactive elements (accessibility)
929 let focus_visible_css = if ctx.has_interactive {
701 let focus_visible_css = if ctx.has_interactive {
930− let focusable = ":where(a,button,input,select,textarea):focus-visible";
931 if dev {
702 if dev {
932− format!(
933− "{}{} {{ outline: 2px solid currentColor; outline-offset: 2px; }}\n",
703+ "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 {
704 } else {
937− format!(
938− "{}{}{{outline:2px solid currentColor;outline-offset:2px}}",
705+ "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 }
706 }
942 } else {
707 } else {
943− String::new()
708+ ""
944 };
709 };
945− let reset_css = reset_css(dev, true, &focus_visible_css);
946− classes.extend(styles.classes());
947
710
711+ let reset_css = reset_css(dev, focus_visible_css);
712+
713+ match &doc.page_title {
714+ Some(title) => {
715+ if dev {
948− format!(
716+ format!(
949− "<!DOCTYPE html>{nl}<html{root_attrs}>{nl}<head>{nl}<meta charset=\"utf-8\">{nl}\
717+ "\
950− {meta_html}<style>{nl}{reset_css}{element_css}</style>{nl}</head>{nl}\
718+<!DOCTYPE html>
951− <body{body_attrs}>{nl}{body}</body>{nl}</html>{nl}",
719+<html{lang_attr}>
952− )
720+<head>
721+<meta charset=\"utf-8\">
722+<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
723+<title>{title}</title>
724+{meta_html}{og_html}{favicon_html}{head_html}\
725+<style>
726+{reset_css}{element_css}\
727+</style>
728+</head>
729+<body>
730+{body}\
731+</body>
732+</html>
733+",
734+ title = html_escape(title),
735+ lang_attr = lang_attr,
736+ meta_html = meta_html,
737+ favicon_html = favicon_html,
738+ head_html = head_html,
739+ og_html = og_html,
740+ reset_css = reset_css,
741+ element_css = element_css,
742+ body = body,
743+ )
744+ } else {
745+ format!(
746+ "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{meta_html}{og_html}{favicon_html}{head_html}<style>{reset_css}{element_css}</style></head><body>{body}</body></html>",
747+ title = html_escape(title),
748+ lang_attr = lang_attr,
749+ meta_html = meta_html,
750+ og_html = og_html,
751+ favicon_html = favicon_html,
752+ head_html = head_html,
753+ reset_css = reset_css,
754+ element_css = element_css,
755+ body = body,
756+ )
757+ }
758+ }
759+ None => {
760+ if element_css.is_empty() {
761+ body
762+ } else if dev {
763+ format!("<style>\n{}</style>\n{}", element_css, body)
764+ } else {
765+ format!("<style>{}</style>{}", element_css, body)
766+ }
767+ }
768+ }
953}
769}
954
770
955/// Assemble every CSS block the document needs (font faces, custom
771/// Assemble every CSS block the document needs (font faces, custom
@@ -958,16 +774,17 @@ fn generate_full_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> S
958fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
774fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
959 let mut element_css = String::new();
775 let mut element_css = String::new();
960
776
961− // Collect the CSS custom properties declared with `@let --name` so they
777+ // Collect all CSS custom properties (explicit `@let --name` / `@theme`
778+ // tokens, plus any auto-extracted repeats) so they can be emitted in a
962− // can be emitted in a single `:root` block below.
779+ // single `:root` block below.
963 let mut root_vars: Vec<(String, String)> = Vec::new();
780 let mut root_vars: Vec<(String, String)> = Vec::new();
964 for (name, value) in &doc.css_vars {
781 for (name, value) in &doc.css_vars {
965 root_vars.push((name.clone(), value.clone()));
782 root_vars.push((name.clone(), value.clone()));
966 }
783 }
967
784
968− // Generated rules always go in htmlang's layers, so unlayered user CSS
785+ // Generated rules always go in `@layer htmlang`, so unlayered user CSS
969 // (`@style`, `@raw`) overrides them regardless of specificity.
786 // (`@style`, `@raw`) overrides them regardless of specificity.
970− let styles_css = styles.to_css_formatted(dev, &doc.scoped_css);
787+ let styles_css = styles.to_css_formatted(dev);
971
788
972 // Emit the :root block first so the cascade picks up the custom
789 // Emit the :root block first so the cascade picks up the custom
973 // properties before the class rules consume them.
790 // properties before the class rules consume them.
@@ -998,115 +815,46 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
998 element_css.push_str(block);
815 element_css.push_str(block);
999 element_css.push('\n');
816 element_css.push('\n');
1000 } else {
817 } else {
818+ let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1001− element_css.push_str(&compact_css(block));
819+ element_css.push_str(&minified);
1002 }
820 }
1003 }
821 }
1004
822
1005 element_css
823 element_css
1006}
824}
1007
825
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
826/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1037−/// every layer, so an unlayered rule here would override the generated
827+/// every layer, so an unlayered `a{color:inherit}` would override
1038−/// ones. A page resets `box-sizing` everywhere and makes `<body>` a column
828+/// `@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 {
829+fn reset_css(dev: bool, focus_visible_css: &str) -> String {
1045− let own = OWN_ELEMENTS;
830+ let base = if dev {
1046− let mut rules = Vec::new();
831+ "*, *::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 {
832 } else {
1070− format!("{own}:where({hidden}){{display:none!important}}")
833+ "*,*::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− });
834+ };
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);
835+ let rules = format!("{}{}", base, focus_visible_css);
1077 if dev {
836 if dev {
1078− format!(
1079− "@layer {};\n@layer hl-reset {{\n{}}}\n",
837+ format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
1080− LAYERS.join(", "),
1081− rules
1082− )
1083 } else {
838 } else {
1084− format!("@layer {};@layer hl-reset{{{}}}", LAYERS.join(","), rules)
839+ format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
1085 }
840 }
1086}
841}
1087
842
1088/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
843/// 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 {
844+fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1090 let mut styles = StyleCollector::new();
845 let mut styles = StyleCollector::new();
1091 let mut ctx = GenContext {
846 let mut ctx = GenContext {
1092 dev,
847 dev,
1093 depth: 0,
848 depth: 0,
1094 has_interactive: false,
849 has_interactive: false,
1095− in_text: false,
1096− flow: Flow::default(),
1097− in_picture: false,
1098 };
850 };
1099 let mut body = String::new();
851 let mut body = String::new();
1100
852
1101− generate_children(&doc.nodes, None, &mut body, &mut styles, &mut ctx);
853+ for node in &doc.nodes {
1102−
854+ 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 }
855 }
1109− classes.extend(styles.classes());
856+
857+ let element_css = build_element_css(doc, &styles, dev);
1110
858
1111 if element_css.is_empty() {
859 if element_css.is_empty() {
1112 body
860 body
@@ -1121,20 +869,22 @@ fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -
1121// Node generation
869// Node generation
1122// ---------------------------------------------------------------------------
870// ---------------------------------------------------------------------------
1123
871
1124−/// Write `node`, which is inside an element with the layout `parent`
1125−/// (`None` at the top of the page).
1126fn generate_node(
872fn generate_node(
1127 node: &Node,
873 node: &Node,
1128− parent: Option<Layout>,
874+ parent_kind: Option<&ElementKind>,
1129 out: &mut String,
875 out: &mut String,
1130 styles: &mut StyleCollector,
876 styles: &mut StyleCollector,
1131 ctx: &mut GenContext,
877 ctx: &mut GenContext,
1132) {
878) {
1133 match node {
879 match node {
1134− Node::Element(elem) => generate_element(elem, parent, out, styles, ctx),
880+ Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
1135 Node::Text(segments) => {
881 Node::Text(segments) => {
1136− // In a row, column or grid each line of text is a child
882+ let needs_wrap = matches!(
1137− let needs_wrap = parent.is_some_and(Layout::is_container);
883+ parent_kind,
884+ Some(ElementKind::Row | ElementKind::El)
885+ ) || parent_kind
886+ .and_then(ElementKind::spec)
887+ .is_some_and(|spec| spec.wraps_text);
1138 if needs_wrap {
888 if needs_wrap {
1139 out.push_str(&ctx.indent());
889 out.push_str(&ctx.indent());
1140 out.push_str("<span>");
890 out.push_str("<span>");
@@ -1153,156 +903,73 @@ fn generate_node(
1153 }
903 }
1154}
904}
1155
905
1156−/// What goes between two things written one after the other in an element
1157−/// with this layout (`None`: the top of the page): text flows, so its lines
1158−/// and children are joined with a space; in HTML's own layout, and at the
1159−/// top of the page, two lines of text are separated by a line break (a
1160−/// space, except where whitespace is kept, as in `@pre`).
1161−fn separator(layout: Option<Layout>, previous_is_text: bool, next: &Node) -> Option<char> {
1162− match layout {
1163− Some(Layout::Text) => Some(' '),
1164− Some(Layout::Native) | None if previous_is_text && matches!(next, Node::Text(_)) => {
1165− Some('\n')
1166− }
1167− _ => None,
1168− }
1169−}
1170
906
1171−/// Write the children of an element (or of a `@fragment`, which has no
907+/// Emit the argument of an element whose argument is an HTML attribute
1172−/// element of its own) laid out as `layout` (`None`: the top of the page).
908+/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`).
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,
909+fn emit_argument_attr(out: &mut String, elem: &Element) {
1178− styles: &mut StyleCollector,
910+ if let Some(TagSpec {
1179− ctx: &mut GenContext,
911+ arg: TagArg::Attr(attr),
1180−) {
912+ ..
1181− generate_run(children, layout, &mut None, out, styles, ctx);
913+ }) = elem.kind.spec()
914+ && let Some(value) = &elem.argument
915+ {
916+ out.push(' ');
917+ out.push_str(attr);
918+ out.push_str("=\"");
919+ out.push_str(&html_escape(value));
920+ out.push('"');
921+ }
1182}
922}
1183
923
1184−/// [`generate_children`], with `previous` saying whether what was written
924+/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1185−/// last was a line of text (`None`: nothing yet). A `@fragment`'s children
925+/// are emitted with the generated class) and bare booleans like `required`.
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,
926+fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1193− styles: &mut StyleCollector,
927+ for attr in attrs {
1194− ctx: &mut GenContext,
928+ let key = attr.key.as_str();
1195−) {
929+ if attr.html && key != "id" && key != "class" {
1196− for child in children {
930+ out.push(' ');
1197− if let Node::Element(elem) = child
931+ out.push_str(key);
1198− && elem.kind == ElementKind::Fragment
932+ out.push_str("=\"");
1199− {
933+ out.push_str(&html_escape(strip_string_quotes(
1200− generate_run(&elem.children, layout, previous, out, styles, ctx);
934+ attr.value.as_deref().unwrap_or(""),
1201− continue;
935+ )));
1202− }
936+ out.push('"');
1203− let start = out.len();
937+ } else if !attr.html
1204− if let Some(previous_is_text) = *previous
938+ && attr.value.is_none()
1205− && let Some(sep) = separator(layout, previous_is_text, child)
939+ && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&key)
1206 {
940 {
1207− out.push(sep);
941+ out.push(' ');
1208− }
942+ out.push_str(key);
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 }
943 }
1217− *previous = Some(matches!(child, Node::Text(_)));
1218 }
944 }
1219}
945}
1220
946
1221−/// Emit the element's leading argument as its attribute (`@iframe URL` →
947+/// If the value is wrapped in a single pair of matching quotes (e.g.
1222−/// `src="URL"`, `@form /submit` → `action="/submit"`, `@link /a` →
948+/// `"Avatar"`), return the inner content. Quotes act as source-level
1223−/// `href="/a"`), first, before the class. `@image [inline] photo.png` puts
949+/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute
1224−/// the file itself into the page.
950+/// values. Multi-quoted values like `"h h" "s m"` are left unchanged —
1225−fn emit_argument_attr(out: &mut String, elem: &Element, in_picture: bool) {
951+/// those are real string tokens (used e.g. by CSS `grid-template-areas`).
1226− let (Some(attr), Some(value)) = (elem.kind.leading_attribute(in_picture), &elem.argument)
952+fn strip_string_quotes(val: &str) -> &str {
1227− else {
953+ let bytes = val.as_bytes();
1228− return;
954+ if bytes.len() < 2 {
1229− };
955+ return val;
1230− // `@image [inline]`'s file is already a `data:` URI (see the parser)
956+ }
1231− let value = html_escape(value);
957+ let first = bytes[0];
1232− out.push(' ');
958+ let last = bytes[bytes.len() - 1];
1233− out.push_str(attr);
959+ if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1234− out.push_str("=\"");
960+ let inner = &val[1..val.len() - 1];
1235− out.push_str(&value);
961+ if !inner.as_bytes().contains(&first) {
1236− out.push('"');
962+ return inner;
1237−}
1238−
1239−/// A file's bytes as a `data:` URI, its type from its extension.
1240−pub(crate) fn data_uri(path: &str, data: &[u8]) -> String {
1241− let mime = match path
1242− .rsplit('.')
1243− .next()
1244− .map(str::to_ascii_lowercase)
1245− .as_deref()
1246− {
1247− Some("png") => "image/png",
1248− Some("jpg" | "jpeg") => "image/jpeg",
1249− Some("gif") => "image/gif",
1250− Some("webp") => "image/webp",
1251− Some("avif") => "image/avif",
1252− Some("svg") => "image/svg+xml",
1253− Some("ico") => "image/x-icon",
1254− _ => "application/octet-stream",
1255− };
1256− format!("data:{};base64,{}", mime, base64_encode(data))
1257−}
1258−
1259−/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1260−/// are emitted with the generated class) and flags like `required`.
1261−fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1262− for attr in html_attrs(attrs) {
1263− let key = attr.key.as_str();
1264− if key != "id" && key != "class" {
1265− push_html_attr(out, attr);
1266 }
963 }
1267 }
964 }
1268−}
965+ val
1269−
1270−/// The HTML attributes of `attrs`, `key=value` ones and flags, each name
1271−/// once: the later one wins, as a style does. (Of two attributes with one
1272−/// name, the browser keeps the first, so a bundle's attribute or a
1273−/// function's own would win over one written after it.)
1274−fn html_attrs(attrs: &[Attribute]) -> impl Iterator<Item = &Attribute> {
1275− let is_html =
1276− |a: &Attribute| a.html || (a.value.is_none() && crate::vocab::is_html_flag(&a.key));
1277− attrs.iter().enumerate().filter_map(move |(i, attr)| {
1278− let later = attrs[i + 1..]
1279− .iter()
1280− .any(|other| is_html(other) && other.key == attr.key);
1281− (is_html(attr) && !later).then_some(attr)
1282− })
1283−}
1284−
1285−/// ` key="value"`, or ` key` for a flag.
1286−fn push_html_attr(out: &mut String, attr: &Attribute) {
1287− out.push(' ');
1288− out.push_str(&attr.key);
1289− if attr.html {
1290− out.push_str("=\"");
1291− // Quoted text has already lost its quotes (see parser.rs)
1292− out.push_str(&html_escape(attr.value.as_deref().unwrap_or("")));
1293− out.push('"');
1294− }
1295}
966}
1296
967
1297// True if any direct child is `@in-front` or `@behind`. Such children render
968// True if any direct child is `@in-front` or `@behind`. Such children render
1298// as absolutely positioned overlays, so the parent automatically becomes a
969// as absolutely positioned overlays, so the parent automatically becomes a
1299// positioning context (position:relative + isolation:isolate).
970// positioning context (position:relative + isolation:isolate).
1300fn has_overlay_children(elem: &Element) -> bool {
971fn 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| {
972+ elem.children.iter().any(|child| {
1306 matches!(
973 matches!(
1307 child,
974 child,
1308 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
975 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
@@ -1312,32 +979,48 @@ fn holds_overlays(children: &[Node]) -> bool {
1312
979
1313fn generate_element(
980fn generate_element(
1314 elem: &Element,
981 elem: &Element,
1315− parent: Option<Layout>,
982+ parent_kind: Option<&ElementKind>,
1316 out: &mut String,
983 out: &mut String,
1317 styles: &mut StyleCollector,
984 styles: &mut StyleCollector,
1318 ctx: &mut GenContext,
985 ctx: &mut GenContext,
1319) {
986) {
1320 // Self-closing elements
987 // Self-closing elements
1321− if elem.kind.layout() == Layout::Void {
988+ if elem.kind == ElementKind::Image || elem.kind.spec().is_some_and(|spec| spec.void) {
1322− generate_self_closing(elem, parent, out, styles, ctx);
989+ generate_self_closing(elem, parent_kind, out, styles, ctx);
1323 return;
990 return;
1324 }
991 }
1325− // A verbatim element (@script) writes its body as it is, with no HTML
992+ // @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() {
993+ if elem.kind == ElementKind::Script {
1328− let tag = elem.kind.spec().map_or("script", |spec| spec.html);
1329 out.push_str(&ctx.indent());
994 out.push_str(&ctx.indent());
1330− out.push('<');
995+ out.push_str("<script");
1331− out.push_str(tag);
996+ // Pass through src, type, defer, async, etc.
1332− emit_argument_attr(out, elem, false);
997+ for attr in &elem.attrs {
1333− let (id, class) = extract_id_class(&elem.attrs);
998+ let key = attr.key.as_str();
1334− for (key, value) in [("id", id), ("class", class)] {
999+ if matches!(
1335− if let Some(value) = value {
1000+ key,
1336− out.push_str(&format!(" {}=\"{}\"", key, html_escape(&value)));
1001+ "src"
1002+ | "type"
1003+ | "defer"
1004+ | "async"
1005+ | "crossorigin"
1006+ | "integrity"
1007+ | "nomodule"
1008+ | "id"
1009+ ) {
1010+ if let Some(val) = &attr.value {
1011+ out.push(' ');
1012+ out.push_str(key);
1013+ out.push_str("=\"");
1014+ out.push_str(&html_escape(val));
1015+ out.push('"');
1016+ } else {
1017+ out.push(' ');
1018+ out.push_str(key);
1019+ }
1337 }
1020 }
1338 }
1021 }
1339− emit_html_attrs(out, &elem.attrs);
1340 out.push('>');
1022 out.push('>');
1023+ // Children are raw JS code, not HTML
1341 for child in &elem.children {
1024 for child in &elem.children {
1342 match child {
1025 match child {
1343 Node::Text(segments) => {
1026 Node::Text(segments) => {
@@ -1351,9 +1034,7 @@ fn generate_element(
1351 _ => {}
1034 _ => {}
1352 }
1035 }
1353 }
1036 }
1354− out.push_str("</");
1037+ out.push_str("</script>");
1355− out.push_str(tag);
1356− out.push('>');
1357 out.push_str(ctx.nl());
1038 out.push_str(ctx.nl());
1358 return;
1039 return;
1359 }
1040 }
@@ -1364,13 +1045,13 @@ fn generate_element(
1364 return;
1045 return;
1365 }
1046 }
1366 if elem.kind == ElementKind::Fragment {
1047 if elem.kind == ElementKind::Fragment {
1367− // Render children without a wrapper element, as if they were
1048+ // Render children without a wrapper element
1368− // written where the fragment is
1049+ for child in &elem.children {
1369− generate_children(&elem.children, parent, out, styles, ctx);
1050+ generate_node(child, parent_kind, out, styles, ctx);
1051+ }
1370 return;
1052 return;
1371 }
1053 }
1372
1054
1373− let mut own = Flow::of(elem);
1374 let tag = match &elem.kind {
1055 let tag = match &elem.kind {
1375 ElementKind::Row | ElementKind::El => "div",
1056 ElementKind::Row | ElementKind::El => "div",
1376 ElementKind::Text => "span",
1057 ElementKind::Text => "span",
@@ -1379,11 +1060,6 @@ fn generate_element(
1379 ElementKind::Tag(spec) => spec.html,
1060 ElementKind::Tag(spec) => spec.html,
1380 _ => "",
1061 _ => "",
1381 };
1062 };
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();
1063 let kind_label = elem.kind.name();
1388
1064
1389 // Track interactive elements for focus-visible CSS
1065 // Track interactive elements for focus-visible CSS
@@ -1394,8 +1070,14 @@ fn generate_element(
1394 }
1070 }
1395
1071
1396 // Compute CSS for each state and get a class name
1072 // Compute CSS for each state and get a class name
1397− let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1073+ let overlay_children = has_overlay_children(elem);
1398− own.class = gen_class.clone();
1074+ let gen_class = compute_class(
1075+ &elem.attrs,
1076+ &elem.kind,
1077+ parent_kind,
1078+ styles,
1079+ overlay_children,
1080+ );
1399 let (id, user_class) = extract_id_class(&elem.attrs);
1081 let (id, user_class) = extract_id_class(&elem.attrs);
1400
1082
1401 if ctx.dev && elem.line_num > 0 {
1083 if ctx.dev && elem.line_num > 0 {
@@ -1409,7 +1091,16 @@ fn generate_element(
1409 out.push('<');
1091 out.push('<');
1410 out.push_str(tag);
1092 out.push_str(tag);
1411
1093
1094+
1095+ if elem.kind == ElementKind::Link
1096+ && let Some(url) = &elem.argument
1097+ {
1098+ out.push_str(" href=\"");
1099+ out.push_str(&html_escape(url));
1100+ out.push('"');
1101+ }
1102+
1412− emit_argument_attr(out, elem, ctx.in_picture);
1103+ emit_argument_attr(out, elem);
1413
1104
1414 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1105 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1415
1106
@@ -1430,27 +1121,28 @@ fn generate_element(
1430 }
1121 }
1431
1122
1432 out.push('>');
1123 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());
1124 out.push_str(ctx.nl());
1439
1125
1440− let layout = elem.kind.layout();
1126+ // Inline text argument
1127+ if renders_argument_as_text(&elem.kind)
1128+ && let Some(text) = &elem.argument
1129+ {
1130+ out.push_str(&html_escape(text));
1131+ }
1132+
1133+ // Children
1441 ctx.depth += 1;
1134 ctx.depth += 1;
1442− let outer_in_text = ctx.in_text;
1135+ let is_paragraph = elem.kind == ElementKind::Paragraph
1443− ctx.in_text = in_text || layout == Layout::Text;
1136+ || elem.kind.spec().is_some_and(|spec| spec.inline);
1444− let outer_flow = std::mem::replace(&mut ctx.flow, own);
1137+ for (i, child) in elem.children.iter().enumerate() {
1445− let outer_picture = std::mem::replace(&mut ctx.in_picture, elem.kind.is_tag("picture"));
1138+ generate_node(child, Some(&elem.kind), out, styles, ctx);
1446− generate_children(&elem.children, Some(layout), out, styles, ctx);
1139+ if is_paragraph && i < elem.children.len() - 1 {
1447− ctx.in_picture = outer_picture;
1140+ out.push(' ');
1448− ctx.flow = outer_flow;
1141+ }
1449− ctx.in_text = outer_in_text;
1142+ }
1450 ctx.depth -= 1;
1143 ctx.depth -= 1;
1451
1144
1452 out.push_str(&ctx.indent());
1145 out.push_str(&ctx.indent());
1453− ctx.dev = outer_dev;
1454 out.push_str("</");
1146 out.push_str("</");
1455 out.push_str(tag);
1147 out.push_str(tag);
1456 out.push('>');
1148 out.push('>');
@@ -1459,12 +1151,12 @@ fn generate_element(
1459
1151
1460fn generate_self_closing(
1152fn generate_self_closing(
1461 elem: &Element,
1153 elem: &Element,
1462− parent: Option<Layout>,
1154+ parent_kind: Option<&ElementKind>,
1463 out: &mut String,
1155 out: &mut String,
1464 styles: &mut StyleCollector,
1156 styles: &mut StyleCollector,
1465 ctx: &mut GenContext,
1157 ctx: &mut GenContext,
1466) {
1158) {
1467− let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1159+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1468 let (id, user_class) = extract_id_class(&elem.attrs);
1160 let (id, user_class) = extract_id_class(&elem.attrs);
1469
1161
1470 let (tag, kind_label) = match &elem.kind {
1162 let (tag, kind_label) = match &elem.kind {
@@ -1483,7 +1175,44 @@ fn generate_self_closing(
1483 out.push_str(&ctx.indent());
1175 out.push_str(&ctx.indent());
1484 out.push('<');
1176 out.push('<');
1485 out.push_str(tag);
1177 out.push_str(tag);
1486− emit_argument_attr(out, elem, ctx.in_picture);
1178+ emit_argument_attr(out, elem);
1179+
1180+ // Image src (with optional non-SVG base64 inlining)
1181+ if elem.kind == ElementKind::Image {
1182+ let src = elem.argument.as_deref().unwrap_or("");
1183+ let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
1184+ if is_inline && !src.is_empty() && !src.ends_with(".svg") {
1185+ let mime = if src.ends_with(".png") {
1186+ "image/png"
1187+ } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
1188+ "image/jpeg"
1189+ } else if src.ends_with(".gif") {
1190+ "image/gif"
1191+ } else if src.ends_with(".webp") {
1192+ "image/webp"
1193+ } else if src.ends_with(".avif") {
1194+ "image/avif"
1195+ } else {
1196+ "application/octet-stream"
1197+ };
1198+ if let Ok(data) = std::fs::read(src) {
1199+ let b64 = base64_encode(&data);
1200+ out.push_str(" src=\"data:");
1201+ out.push_str(mime);
1202+ out.push_str(";base64,");
1203+ out.push_str(&b64);
1204+ out.push('"');
1205+ } else {
1206+ out.push_str(" src=\"");
1207+ out.push_str(&html_escape(src));
1208+ out.push('"');
1209+ }
1210+ } else {
1211+ out.push_str(" src=\"");
1212+ out.push_str(&html_escape(src));
1213+ out.push('"');
1214+ }
1215+ }
1487
1216
1488 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1217 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1489
1218
@@ -1518,10 +1247,7 @@ fn generate_text_segments(
1518 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1247 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1519 TextSegment::Inline(elem) => {
1248 TextSegment::Inline(elem) => {
1520 let mut buf = String::new();
1249 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);
1250+ generate_element(elem, None, &mut buf, styles, ctx);
1524− ctx.flow = outer_flow;
1525 out.push_str(buf.trim_end());
1251 out.push_str(buf.trim_end());
1526 }
1252 }
1527 }
1253 }
@@ -1534,97 +1260,135 @@ fn generate_text_segments(
1534
1260
1535fn compute_class(
1261fn compute_class(
1536 attrs: &[Attribute],
1262 attrs: &[Attribute],
1537− site: &Site,
1263+ kind: &ElementKind,
1264+ parent_kind: Option<&ElementKind>,
1538 styles: &mut StyleCollector,
1265 styles: &mut StyleCollector,
1539− distinct: String,
1266+ has_overlay_children: bool,
1540) -> Option<String> {
1267) -> Option<String> {
1541− // Every condition an attribute is under, and none
1268+ let base = attrs_to_css(attrs, "", kind, parent_kind, has_overlay_children);
1542− let mut conditions = vec![Condition::default()];
1269+
1543− for attr in attrs.iter().filter(|a| !a.html) {
1270+ // Collect pseudo-state overrides
1544− let (condition, _) = Condition::of(&attr.key);
1271+ let mut pseudo = Vec::new();
1545− if !conditions.contains(&condition) {
1272+ for &(prefix, selector) in crate::vocab::PSEUDO_PREFIXES {
1546− conditions.push(condition);
1273+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1274+ if !css.is_empty() {
1275+ pseudo.push((selector.to_string(), css));
1276+ }
1277+ }
1278+
1279+ // Collect nth:EXPR: dynamic pseudo selectors
1280+ let mut nth_prefixes: Vec<String> = Vec::new();
1281+ for attr in attrs {
1282+ if attr.key.starts_with("nth:") {
1283+ let rest = &attr.key[4..];
1284+ if let Some(colon_pos) = rest.find(':') {
1285+ let prefix = format!("nth:{}:", &rest[..colon_pos]);
1286+ if !nth_prefixes.contains(&prefix) {
1287+ nth_prefixes.push(prefix);
1288+ }
1289+ }
1290+ }
1291+ }
1292+ for prefix in &nth_prefixes {
1293+ let expr = &prefix[4..prefix.len() - 1];
1294+ let selector = format!(":nth-child({})", expr);
1295+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1296+ if !css.is_empty() {
1297+ pseudo.push((selector, css));
1547 }
1298 }
1548 }
1299 }
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
1300
1573−/// The defaults of an element's kind where it is: an overlay's positioning
1301+ // Collect has(...): dynamic pseudo selectors
1574−/// context, its layout, and its kind's own CSS (a heading's `margin:0`).
1302+ let mut has_prefixes: Vec<String> = Vec::new();
1575−fn default_css(site: &Site) -> String {
1303+ for attr in attrs {
1576− let mut css = String::new();
1304+ if attr.key.starts_with("has(")
1577− // Elements with @in-front / @behind children become positioning
1305+ && let Some(close) = attr.key.find("):")
1578− // contexts
1306+ {
1579− if site.has_overlay_children {
1307+ let prefix = format!("{}:", &attr.key[..close + 1]);
1580− css.push_str("position:relative;isolation:isolate;");
1308+ if !has_prefixes.contains(&prefix) {
1309+ has_prefixes.push(prefix);
1310+ }
1311+ }
1581 }
1312 }
1582− if !site.root {
1313+ for prefix in &has_prefixes {
1583− css.push_str(layout_css(site.kind.layout(), site.inline));
1314+ let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
1315+ let selector = format!(":has({})", inner);
1316+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1317+ if !css.is_empty() {
1318+ pseudo.push((selector, css));
1319+ }
1584 }
1320 }
1585− css.push_str(site.kind.css());
1586− css
1587−}
1588
1321
1589−/// The declarations of a rule body, as (property, `property:value;`), for
1322+ // Collect responsive overrides
1590−/// bodies htmlang wrote: every declaration ends in `;`, and a `;` inside
1323+ let mut responsive = Vec::new();
1591−/// quotes or parentheses doesn't end one.
1324+ for &(bp_name, _) in BREAKPOINTS {
1592−fn declarations(css: &str) -> impl Iterator<Item = (&str, &str)> {
1325+ let prefix = format!("{}:", bp_name);
1593− let mut rest = css;
1326+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1594− std::iter::from_fn(move || {
1595− if rest.is_empty() {
1327+ if !css.is_empty() {
1596− return None;
1328+ responsive.push((bp_name.to_string(), css));
1597 }
1329 }
1598− let (mut depth, mut quote, mut escaped) = (0i32, None, false);
1330+ }
1599− let mut end = rest.len();
1331+
1600− for (i, c) in rest.char_indices() {
1332+ // Collect container query overrides
1601− match c {
1333+ let mut container = Vec::new();
1602− _ if escaped => escaped = false,
1334+ for &(bp_name, _) in BREAKPOINTS {
1603− '\\' => escaped = true,
1335+ let prefix = format!("cq-{}:", bp_name);
1604− c if Some(c) == quote => quote = None,
1336+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1605− _ if quote.is_some() => {}
1337+ if !css.is_empty() {
1606− '"' | '\'' => quote = Some(c),
1338+ 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 }
1339 }
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− })
1340+ }
1341+
1342+ let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
1343+ let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
1344+ let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind, has_overlay_children);
1345+ let motion_reduce =
1346+ attrs_to_css(attrs, "motion-reduce:", kind, parent_kind, has_overlay_children);
1347+ let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
1348+ let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
1349+
1350+ // Dedupe: if a property is declared twice within a single rule, keep only
1351+ // the last occurrence (element-kind defaults are written before
1352+ // user attributes, so a user [list-style disc] correctly overrides the
1353+ // default list-style:none, and we don't need to ship both).
1354+ let base = dedupe_declarations(&base);
1355+ let pseudo: Vec<(String, String)> = pseudo
1356+ .into_iter()
1357+ .map(|(sel, css)| (sel, dedupe_declarations(&css)))
1358+ .collect();
1359+ let responsive: Vec<(String, String)> = responsive
1360+ .into_iter()
1361+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1362+ .collect();
1363+ let dark = dedupe_declarations(&dark);
1364+ let print = dedupe_declarations(&print);
1365+ let motion_safe = dedupe_declarations(&motion_safe);
1366+ let motion_reduce = dedupe_declarations(&motion_reduce);
1367+ let landscape = dedupe_declarations(&landscape);
1368+ let portrait = dedupe_declarations(&portrait);
1369+ let container: Vec<(String, String)> = container
1370+ .into_iter()
1371+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1372+ .collect();
1373+
1374+ styles.get_class(
1375+ base,
1376+ pseudo,
1377+ responsive,
1378+ dark,
1379+ print,
1380+ motion_safe,
1381+ motion_reduce,
1382+ landscape,
1383+ portrait,
1384+ container,
1385+ )
1621}
1386}
1622
1387
1623/// Dedupe CSS declarations within a single rule body: for any property
1388/// Dedupe CSS declarations within a single rule body: for any property
1624/// declared more than once, keep only the last occurrence. Unparseable
1389/// declared more than once, keep only the last occurrence. Unparseable
1625−/// segments (no `:`) are preserved as-is. Semicolons inside parentheses or
1390+/// segments (no `:`) are preserved as-is. Semicolons inside parentheses are
1626−/// quoted strings are treated as part of a value, not as declaration
1391+/// treated as part of a value, not as declaration separators.
1627−/// separators.
1628fn dedupe_declarations(css: &str) -> String {
1392fn dedupe_declarations(css: &str) -> String {
1629 if css.is_empty() {
1393 if css.is_empty() {
1630 return String::new();
1394 return String::new();
@@ -1639,26 +1403,8 @@ fn dedupe_declarations(css: &str) -> String {
1639 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1403 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1640 let mut current = String::new();
1404 let mut current = String::new();
1641 let mut depth: i32 = 0;
1405 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() {
1406 for ch in css.chars() {
1647− if escaped {
1407+ 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;
1408 depth += 1;
1663 current.push(ch);
1409 current.push(ch);
1664 } else if ch == ')' {
1410 } else if ch == ')' {
@@ -1740,553 +1486,465 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
1740// Attribute → CSS mapping
1486// Attribute → CSS mapping
1741// ---------------------------------------------------------------------------
1487// ---------------------------------------------------------------------------
1742
1488
1743−/// Where an element is written, which its CSS depends on.
1489+/// (htmlang prefix, CSS selector suffix)
1744−struct Site<'a> {
1490+/// `display:flex;flex-direction:column;` — base layout for `@el` and every
1745− kind: &'a ElementKind,
1491+/// semantic wrapper that behaves like a column.
1746− /// How its parent lays out its children: its `width fill`, `height
1492+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
1493
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
1494
1771−// ---------------------------------------------------------------------------
1772−// Direction: what a child's `fill` and `shrink` compile against
1773−// ---------------------------------------------------------------------------
1774
1495
1775−/// The way a flex row or column lays out its children.
1496+fn attrs_to_css(
1776−#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1497+ attrs: &[Attribute],
1777−enum Axis {
1498+ state_prefix: &str,
1778− Row,
1499+ kind: &ElementKind,
1779− Column,
1500+ parent_kind: Option<&ElementKind>,
1780−}
1501+ has_overlay_children: bool,
1502+) -> String {
1503+ let mut css = String::new();
1781
1504
1782−impl Axis {
1505+ // Base element styles only for the default (non-state) pass
1783− /// The direction a `flex-direction` or `flex-flow` value sets, if it
1506+ if state_prefix.is_empty() {
1784− /// names one (not `var(--dir)`, which only the browser knows).
1507+ // Elements with @in-front / @behind children become positioning
1785− fn of_value(value: &str) -> Option<Axis> {
1508+ // contexts. Pushed before user attrs so an explicit `position` wins
1786− value.split_whitespace().find_map(|word| match word {
1509+ // via dedupe (only the last declaration of a property is kept).
1787− "row" | "row-reverse" => Some(Axis::Row),
1510+ if has_overlay_children {
1788− "column" | "column-reverse" => Some(Axis::Column),
1511+ css.push_str("position:relative;isolation:isolate;");
1789− _ => None,
1512+ }
1513+ match kind {
1514+ ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
1515+ ElementKind::El => css.push_str(FLEX_COLUMN),
1516+ ElementKind::Paragraph => css.push_str("margin:0;"),
1517+ ElementKind::Tag(spec) => css.push_str(spec.css),
1518+ _ => {}
1790− })
1519+ }
1791 }
1520 }
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
1521
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) {
1522+ for attr in attrs {
1823− let (condition, name) = Condition::of(&attr.key);
1523+ if attr.html {
1824− if !matches!(name, "flex-direction" | "flex-flow") || !condition.selector.is_empty() {
1524+ continue;
1525+ }
1526+ // Determine the effective key for this pass
1527+ let effective_key = if state_prefix.is_empty() {
1528+ if crate::vocab::is_prefixed(&attr.key) {
1825 continue;
1529 continue;
1826 }
1530 }
1827− let Some(axis) = attr.value.as_deref().and_then(Axis::of_value) else {
1531+ attr.key.as_str()
1532+ } else {
1533+ match attr.key.strip_prefix(state_prefix) {
1534+ Some(k) => k,
1828− continue;
1535+ None => 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 }
1536 }
1837− }
1537+ };
1838− changes.sort_by_key(|(at, _)| at_order(at));
1839− Flow {
1840− base: Some(base),
1841− changes,
1842− class: None,
1843− }
1844− }
1845
1538
1846− /// The direction under the at-rules `at`: the latest one set among
1539+ let val = attr.value.as_deref();
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
1540
1862−/// `width` or `height`.
1541+ match effective_key {
1863−#[derive(Clone, Copy, PartialEq, Eq)]
1542+ // Layout
1864−enum Dim {
1543+ "spacing" | "gap" => {
1865− Width,
1544+ if let Some(v) = val {
1866− Height,
1545+ push_css(&mut css, "gap", &css_px(v));
1546+ }
1547+ }
1548+ "padding" => {
1549+ if let Some(v) = val {
1550+ push_css(&mut css, "padding", &css_px_multi(v));
1551+ }
1552+ }
1553+ "padding-top" => {
1554+ if let Some(v) = val {
1555+ push_css(&mut css, "padding-top", &css_px(v));
1556+ }
1557+ }
1558+ "padding-bottom" => {
1559+ if let Some(v) = val {
1560+ push_css(&mut css, "padding-bottom", &css_px(v));
1561+ }
1562+ }
1563+ "padding-left" => {
1564+ if let Some(v) = val {
1565+ push_css(&mut css, "padding-left", &css_px(v));
1566+ }
1567+ }
1568+ "padding-right" => {
1569+ if let Some(v) = val {
1570+ push_css(&mut css, "padding-right", &css_px(v));
1571+ }
1867−}
1572+ }
1868
1573
1574+ // Sizing
1575+ "width" => {
1576+ if let Some(v) = val {
1577+ match v {
1578+ "fill" => match parent_kind {
1579+ Some(ElementKind::Row) => {
1580+ push_css(&mut css, "flex", "1");
1581+ push_css(&mut css, "min-width", "0");
1582+ }
1583+ _ => push_css(&mut css, "width", "100%"),
1584+ },
1585+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
1586+ _ => push_css(&mut css, "width", &css_px(v)),
1587+ }
1588+ }
1589+ }
1869−/// What a `width` or `height` says.
1590+ "height" => {
1870−#[derive(Clone, Copy, PartialEq, Eq)]
1591+ if let Some(v) = val {
1871−enum Size {
1592+ match v {
1872− Fill,
1593+ "fill" => match parent_kind {
1873− Shrink,
1594+ Some(parent) if parent.is_column() => {
1874− /// A size of its own (`width 300`)
1595+ push_css(&mut css, "flex", "1");
1875− Set,
1596+ push_css(&mut css, "min-height", "0");
1597+ }
1598+ _ => push_css(&mut css, "height", "100%"),
1599+ },
1600+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
1601+ _ => push_css(&mut css, "height", &css_px(v)),
1602+ }
1603+ }
1604+ }
1605+ "min-width" => {
1606+ if let Some(v) = val {
1607+ push_css(&mut css, "min-width", &css_px(v));
1608+ }
1609+ }
1610+ "max-width" => {
1611+ if let Some(v) = val {
1612+ push_css(&mut css, "max-width", &css_px(v));
1613+ }
1614+ }
1615+ "min-height" => {
1616+ if let Some(v) = val {
1617+ push_css(&mut css, "min-height", &css_px(v));
1618+ }
1619+ }
1620+ "max-height" => {
1621+ if let Some(v) = val {
1622+ push_css(&mut css, "max-height", &css_px(v));
1623+ }
1876−}
1624+ }
1877
1625
1878−impl Size {
1626+ // Alignment
1879− fn of(value: &str) -> Size {
1627+ "center-x" => match parent_kind {
1880− match value.trim() {
1628+ Some(parent) if parent.is_column() => {
1881− "fill" => Size::Fill,
1629+ push_css(&mut css, "align-self", "center");
1882− "shrink" => Size::Shrink,
1630+ }
1883− _ => Size::Set,
1631+ _ => {
1884− }
1632+ push_css(&mut css, "margin-left", "auto");
1885− }
1633+ push_css(&mut css, "margin-right", "auto");
1886−}
1634+ }
1635+ },
1636+ "center-y" => match parent_kind {
1637+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
1638+ _ => {
1639+ push_css(&mut css, "margin-top", "auto");
1640+ push_css(&mut css, "margin-bottom", "auto");
1641+ }
1642+ },
1643+ "align-left" => match parent_kind {
1644+ Some(parent) if parent.is_column() => {
1645+ push_css(&mut css, "align-self", "flex-start");
1646+ }
1647+ _ => push_css(&mut css, "margin-right", "auto"),
1648+ },
1649+ "align-right" => match parent_kind {
1650+ Some(parent) if parent.is_column() => {
1651+ push_css(&mut css, "align-self", "flex-end");
1652+ }
1653+ _ => push_css(&mut css, "margin-left", "auto"),
1654+ },
1655+ "align-top" => match parent_kind {
1656+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
1657+ _ => push_css(&mut css, "margin-bottom", "auto"),
1658+ },
1659+ "align-bottom" => match parent_kind {
1660+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
1661+ _ => push_css(&mut css, "margin-top", "auto"),
1662+ },
1663+
1664+ // Typography
1665+ "letter-spacing" => {
1666+ if let Some(v) = val {
1667+ push_css(&mut css, "letter-spacing", &css_px(v));
1668+ }
1669+ }
1887
1670
1888−/// The declarations `fill` and `shrink` make in a parent laid out along
1671+ // Overflow & positioning
1889−/// `axis` (`None`: not a flex row or column). Along the parent's direction,
1672+ "top" => {
1890−/// `fill` takes the remaining space and `shrink` keeps the content's size;
1673+ if let Some(v) = val {
1891−/// across it (or outside flex), `fill` is the full size and `shrink` fits
1674+ push_css(&mut css, "top", &css_px(v));
1892−/// the content.
1675+ }
1893−fn sizing(dim: Dim, size: Size, axis: Option<Axis>) -> &'static [(&'static str, &'static str)] {
1676+ }
1894− match (dim, size, axis) {
1677+ "right" => {
1895− (_, Size::Set, _) => &[],
1678+ if let Some(v) = val {
1896− (Dim::Width, Size::Fill, Some(Axis::Row)) => &[("flex", "1"), ("min-width", "0")],
1679+ push_css(&mut css, "right", &css_px(v));
1897− (Dim::Width, Size::Fill, _) => &[("width", "100%")],
1680+ }
1898− (Dim::Width, Size::Shrink, Some(Axis::Row)) => &[("flex-shrink", "0")],
1681+ }
1899− (Dim::Width, Size::Shrink, _) => &[("width", "fit-content")],
1682+ "bottom" => {
1900− (Dim::Height, Size::Fill, Some(Axis::Column)) => &[("flex", "1"), ("min-height", "0")],
1683+ if let Some(v) = val {
1901− (Dim::Height, Size::Fill, _) => &[("height", "100%")],
1684+ push_css(&mut css, "bottom", &css_px(v));
1902− (Dim::Height, Size::Shrink, Some(Axis::Column)) => &[("flex-shrink", "0")],
1685+ }
1903− (Dim::Height, Size::Shrink, _) => &[("height", "fit-content")],
1686+ }
1904− }
1687+ "left" => {
1905−}
1688+ if let Some(v) = val {
1689+ push_css(&mut css, "left", &css_px(v));
1690+ }
1691+ }
1906
1692
1907−/// Every property `fill` and `shrink` of `dim` can set, with its initial
1693+ // Effects
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
1694
1924−/// What an element's attributes under some prefixes say about its size.
1695+ // Flow
1925−#[derive(Default)]
1696+ "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
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
1697
1944−impl Sizes {
1698+ // Grid
1945− /// Read the attributes under the conditions that hold wherever `at`
1699+ "grid-cols" => {
1946− /// does, a later condition over an earlier one.
1700+ if let Some(v) = val {
1947− fn of(attrs: &[Attribute], at: &Condition) -> Sizes {
1701+ if let Ok(n) = v.parse::<u32>() {
1948− let mut sizes = Sizes::default();
1702+ push_css(
1949− let mut read: Vec<(Condition, &str, &str)> = Vec::new();
1703+ &mut css,
1950− for attr in attrs.iter().filter(|a| !a.html) {
1704+ "grid-template-columns",
1951− let (condition, name) = Condition::of(&attr.key);
1705+ &format!("repeat({},1fr)", n),
1952− let Some(value) = attr.value.as_deref().filter(|v| !v.trim().is_empty()) else {
1706+ );
1953− continue;
1707+ } else {
1954− };
1708+ push_css(&mut css, "grid-template-columns", v);
1955− if condition.holds_at(at) {
1709+ }
1956− read.push((condition, name, value));
1710+ }
1957 }
1711 }
1958− }
1712+ "grid-rows" => {
1959− read.sort_by_cached_key(|(condition, _, _)| condition.order());
1713+ if let Some(v) = val {
1960− for (_, name, value) in read {
1714+ if let Ok(n) = v.parse::<u32>() {
1961− match name {
1715+ push_css(
1962− "width" => {
1716+ &mut css,
1963− let size = Size::of(value);
1717+ "grid-template-rows",
1964− sizes.width = Some(size);
1718+ &format!("repeat({},1fr)", n),
1965− sizes.width_word |= size != Size::Set;
1719+ );
1966− }
1720+ } else {
1967− "height" => {
1721+ push_css(&mut css, "grid-template-rows", v);
1968− let size = Size::of(value);
1722+ }
1969− sizes.height = Some(size);
1723+ }
1970− sizes.height_word |= size != Size::Set;
1724+ }
1971− }
1725+ "col-span" => {
1972− "flex" => sizes.flex = true,
1726+ if let Some(v) = val {
1973− "flex-grow" => sizes.flex_grow = true,
1727+ push_css(&mut css, "grid-column", &format!("span {}", v));
1974− "flex-shrink" => sizes.flex_shrink = true,
1728+ }
1975− "flex-basis" => sizes.flex_basis = true,
1729+ }
1976− "min-width" => sizes.min_width = true,
1730+ "row-span" => {
1977− "min-height" => sizes.min_height = true,
1731+ if let Some(v) = val {
1978− _ => {}
1732+ push_css(&mut css, "grid-row", &format!("span {}", v));
1733+ }
1979 }
1734 }
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
1735
1999− /// Write a layout word's `property: value`, unless the element writes
1736+ // CSS containment for rendering performance
2000− /// the property itself. When it writes some of `flex`'s longhands, a
1737+ "contain" => {
2001− /// `flex` is written as the others (`flex-shrink 0, width fill` still
1738+ if let Some(v) = val {
2002− /// grows).
1739+ push_css(&mut css, "contain", v);
2003− fn push(&self, css: &mut String, property: &str, value: &str) {
1740+ } else {
2004− if property == "flex" && !self.flex {
1741+ push_css(&mut css, "contain", "layout style paint");
2005− let longhands = match value {
1742+ }
2006− "1" => [
1743+ }
2007− ("flex-grow", "1"),
1744+ "content-visibility" => {
2008− ("flex-shrink", "1"),
1745+ if let Some(v) = val {
2009− ("flex-basis", "0%"),
1746+ push_css(&mut css, "content-visibility", v);
2010− ],
1747+ } else {
2011− // The reset, `0 1 auto`
1748+ push_css(&mut css, "content-visibility", "auto");
2012− _ => [
1749+ }
2013− ("flex-grow", "0"),
1750+ }
2014− ("flex-shrink", "1"),
1751+
2015− ("flex-basis", "auto"),
1752+ // Outline (like border but doesn't affect layout)
2016− ],
1753+ "outline" => {
2017− };
1754+ if let Some(v) = val {
2018− if longhands.iter().any(|&(p, _)| self.writes(p)) {
1755+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
2019− for (longhand, value) in longhands {
1756+ if parts.len() == 2 {
2020− if !self.writes(longhand) {
1757+ push_css(
2021− push_css(css, longhand, value);
1758+ &mut css,
1759+ "outline",
1760+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
1761+ );
1762+ } else {
1763+ push_css(
1764+ &mut css,
1765+ "outline",
1766+ &format!("{} solid currentColor", css_px(parts[0])),
1767+ );
2022 }
1768 }
2023 }
1769 }
2024− return;
2025 }
1770 }
2026− }
2027− if !self.writes(property) {
2028− push_css(css, property, value);
2029− }
2030− }
2031−}
2032
1771
2033−/// The rule that gives an element's `fill` and `shrink`, as its attributes
1772+ // Logical properties (i18n-aware)
2034−/// under the at-rules `at` set them, their meaning in a parent laid out
1773+ "padding-inline" => {
2035−/// along `axis`. Every other property a layout word could have set is put
1774+ if let Some(v) = val {
2036−/// back to its initial value, so the rule is right whichever earlier rule
1775+ push_css(&mut css, "padding-inline", &css_px_multi(v));
2037−/// it overrides. Properties the element writes itself are left alone.
1776+ }
2038−fn sizing_rule(attrs: &[Attribute], at: &[usize], axis: Axis) -> String {
1777+ }
2039− let sizes = Sizes::of(attrs, &Condition::at_only(at));
1778+ "padding-block" => {
2040− let mut sets: Vec<(&str, &str)> = Vec::new();
1779+ if let Some(v) = val {
2041− let mut resets: Vec<(&str, &str)> = Vec::new();
1780+ push_css(&mut css, "padding-block", &css_px_multi(v));
2042− for (dim, size, word) in [
1781+ }
2043− (Dim::Width, sizes.width, sizes.width_word),
1782+ }
2044− (Dim::Height, sizes.height, sizes.height_word),
1783+ "margin-inline" => {
2045− ] {
1784+ if let Some(v) = val {
2046− if !word {
1785+ push_css(&mut css, "margin-inline", &css_px_multi(v));
2047− continue;
1786+ }
2048− }
1787+ }
2049− if let Some(size) = size {
1788+ "margin-block" => {
2050− sets.extend_from_slice(sizing(dim, size, Some(axis)));
1789+ if let Some(v) = val {
2051− }
1790+ push_css(&mut css, "margin-block", &css_px_multi(v));
2052− resets.extend_from_slice(&sizing_properties(dim));
1791+ }
2053− }
1792+ }
2054− let mut css = String::new();
2055− let mut written: Vec<&str> = Vec::new();
2056− for &(property, value) in &resets {
2057− if sets.iter().any(|&(p, _)| p == property) || written.contains(&property) {
2058− continue;
2059− }
2060− written.push(property);
2061− sizes.push(&mut css, property, value);
2062− }
2063− for &(property, value) in &sets {
2064− sizes.push(&mut css, property, value);
2065− }
2066− css
2067−}
2068
1793
2069−/// The rules an element's `fill` and `shrink` need under each chain of
1794+ // Logical property start/end variants
2070−/// at-rules where the direction of `flow` (its parent's) changes: `(at,
1795+ "padding-inline-start"
2071−/// body)`.
1796+ | "padding-inline-end"
2072−fn sizing_changes(attrs: &[Attribute], flow: &Flow) -> Vec<(Vec<usize>, String)> {
1797+ | "padding-block-start"
2073− flow.changes
1798+ | "padding-block-end"
2074− .iter()
1799+ | "margin-inline-start"
2075− .map(|(at, axis)| (at.clone(), sizing_rule(attrs, at, *axis)))
1800+ | "margin-inline-end"
2076− .filter(|(_, body)| !body.is_empty())
1801+ | "margin-block-start"
2077− .collect()
1802+ | "margin-block-end" => {
2078−}
1803+ if let Some(v) = val {
1804+ push_css(&mut css, effective_key, &css_px(v));
1805+ }
1806+ }
2079
1807
2080−/// An element's generated class, with the rules keyed on its parent's
1808+ // Logical inset
2081−/// class that its `fill` and `shrink` need where the parent's direction
1809+ "inset-inline" | "inset-block" => {
2082−/// changes under a prefix (see [`Flow`]): `:where(.a)>.b`, one class of
1810+ if let Some(v) = val {
2083−/// specificity like any class rule.
1811+ push_css(&mut css, effective_key, &css_px_multi(v));
2084−fn element_class(elem: &Element, site: &Site, styles: &mut StyleCollector) -> Option<String> {
1812+ }
2085− let as_child = match site.parent.class {
1813+ }
2086− Some(_) => sizing_changes(&elem.attrs, site.parent),
1814+ "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
2087− None => Vec::new(),
1815+ if let Some(v) = val {
2088− };
1816+ push_css(&mut css, effective_key, &css_px(v));
2089− // Two elements with the same CSS but different rules keyed on them
1817+ }
2090− // need two classes
1818+ }
2091− let mut distinct = String::new();
2092− for (at, body) in &as_child {
2093− distinct.push_str(&format!("<{:?}{}", at, body));
2094− }
2095− let class = compute_class(&elem.attrs, site, styles, distinct)?;
2096− if let Some(parent) = &site.parent.class {
2097− for (at, body) in as_child {
2098− styles.add_keyed(at, format!(":where(.{})>.{}", parent, class), body);
2099− }
2100− }
2101− Some(class)
2102−}
2103
1819
2104−/// The `display` of a layout (nothing for text, native and void elements,
1820+ // Logical border
2105−/// which keep HTML's own).
1821+ "border-inline"
2106−fn layout_css(layout: Layout, inline: bool) -> &'static str {
1822+ | "border-block"
2107− match (layout, inline) {
1823+ | "border-inline-start"
2108− (Layout::Column, false) => "display:flex;flex-direction:column;",
1824+ | "border-inline-end"
2109− (Layout::Column, true) => "display:inline-flex;flex-direction:column;",
1825+ | "border-block-start"
2110− (Layout::Row, false) => "display:flex;flex-direction:row;",
1826+ | "border-block-end" => {
2111− (Layout::Row, true) => "display:inline-flex;flex-direction:row;",
1827+ if let Some(v) = val {
2112− (Layout::Grid, false) => "display:grid;",
1828+ push_css(&mut css, effective_key, v);
2113− (Layout::Grid, true) => "display:inline-grid;",
1829+ }
2114− _ => "",
1830+ }
2115− }
2116−}
2117
1831
2118−/// The declarations of the attributes under `condition` (the defaults of
1832+ // Logical border-radius
2119−/// the element's kind are [`default_css`]'s).
1833+ "border-start-start-radius"
2120−fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> String {
1834+ | "border-start-end-radius"
2121− let mut css = String::new();
1835+ | "border-end-start-radius"
2122− // The auto margins of `center-x`, `align-left`, ..., written at the end
1836+ | "border-end-end-radius" => {
2123− let mut aligned = String::new();
1837+ if let Some(v) = val {
2124− let kind = site.kind;
1838+ push_css(&mut css, effective_key, &css_px(v));
2125− // Under `children:` the styles go on the children, whose parent is this
1839+ }
2126− // element: htmlang's words that place an element in its parent are an
1840+ }
2127− // error there (the parser reports them), and left out
2128− let on_children = condition.on_children();
2129− let inside = condition.inside().is_some();
2130− // The direction `fill` and `shrink` compile against
2131− let axis = site.parent.at(&condition.at);
2132−
2133− // htmlang's words for laying out children (`spacing`, `wrap`,
2134− // `grid-cols`) mean nothing on an element that doesn't lay out its
2135− // children, which the parser reports; they are left out. Under
2136− // `children:` they go on the children, whose layout isn't known here.
2137− let lays_out_children = kind.layout().is_container() || on_children;
2138− fn under<'k>(key: &'k str, condition: &Condition) -> Option<&'k str> {
2139− let (c, name) = Condition::of(key);
2140− (c == *condition).then_some(name)
2141− }
2142−
2143− for (index, attr) in attrs.iter().enumerate() {
2144− if attr.html {
2145− continue;
2146− }
2147− let Some(effective_key) = under(&attr.key, condition) else {
2148− continue;
2149− };
2150− if on_children && crate::vocab::places_in_parent(effective_key, attr.value.as_deref()) {
2151− continue;
2152− }
2153− // Under an element prefix (`@td:`) the styles go on elements whose
2154− // layout and parent aren't known here: htmlang's own words are an
2155− // error there (the parser reports them), and left out
2156− if inside && crate::vocab::is_htmlang_word(effective_key, attr.value.as_deref()) {
2157− continue;
2158− }
2159
1841
2160− let val = attr.value.as_deref();
1842+ // Logical scroll margins & padding
2161− // A style whose value came out empty (a field a record doesn't
1843+ "scroll-margin-inline"
2162− // have, `${if()}` without its other branch) is left out
1844+ | "scroll-margin-block"
2163− if val.is_some_and(|v| v.trim().is_empty()) {
1845+ | "scroll-padding-inline"
2164− continue;
1846+ | "scroll-padding-block" => {
2165− }
1847+ if let Some(v) = val {
1848+ push_css(&mut css, effective_key, &css_px_multi(v));
1849+ }
1850+ }
2166
1851
2167− match effective_key {
1852+ // Logical sizing
2168− // htmlang's own words: they lay out children or place the
1853+ "inline-size" | "block-size" | "min-inline-size" | "max-inline-size"
2169− // element in its parent, and mean more than one CSS property
1854+ | "min-block-size" | "max-block-size" => {
2170− "spacing" if !lays_out_children => {}
2171− "spacing" => {
2172 if let Some(v) = val {
1855 if let Some(v) = val {
2173− push_css(&mut css, "gap", &with_px("gap", v));
1856+ push_css(&mut css, effective_key, &css_px(v));
2174 }
1857 }
2175 }
1858 }
2176
1859
2177− // Sizing: `fill` and `shrink` compile against the parent's
1860+ // Margin
2178− // direction. Only the last `width` (`height`) counts, as for
1861+ "margin" => {
2179− // any style written twice.
1862+ if let Some(v) = val {
2180− "width" | "height" if val.is_some_and(|v| Size::of(v) != Size::Set) => {
1863+ push_css(&mut css, "margin", &css_px_multi(v));
2181− let later = attrs[index + 1..].iter().any(|a| {
2182− !a.html
2183− && under(&a.key, condition) == Some(effective_key)
2184− && a.value.as_deref().is_some_and(|v| !v.trim().is_empty())
2185− });
2186− if later {
2187− continue;
2188 }
1864 }
2189− let dim = if effective_key == "width" {
1865+ }
2190− Dim::Width
1866+ // Container queries
2191− } else {
1867+ "container" => {
2192− Dim::Height
1868+ push_css(&mut css, "container-type", "inline-size");
2193− };
1869+ }
2194− let size = Size::of(val.unwrap_or_default());
1870+
2195− // What the element writes itself, which a layout word
1871+ // Inset (shorthand for top/right/bottom/left)
2196− // leaves alone
1872+ "inset" => {
2197− let sizes = Sizes::of(attrs, condition);
1873+ if let Some(v) = val {
2198− for &(property, value) in sizing(dim, size, axis) {
1874+ push_css(&mut css, "inset", &css_px(v));
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 }
1875 }
1876+ }
1877+
1878+ // Table
1879+ "border-spacing" => {
2238 if let Some(v) = val {
1880 if let Some(v) = val {
2239− push_css(&mut css, effective_key, &with_px(effective_key, v));
1881+ push_css(&mut css, "border-spacing", &css_px(v));
2240 }
1882 }
2241 }
1883 }
2242
1884
2243− // Alignment: auto margins, which work along either direction.
1885+ // Text decoration
2244− // They are written last, so a `margin` on the same element
1886+ "text-decoration-thickness" => {
2245− // keeps its other sides (`center-x, margin 20`)
1887+ if let Some(v) = val {
2246− "center-x" => {
1888+ push_css(&mut css, "text-decoration-thickness", &css_px(v));
2247− push_css(&mut aligned, "margin-left", "auto");
1889+ }
2248− push_css(&mut aligned, "margin-right", "auto");
2249 }
1890 }
2250− "center-y" => {
1891+ "text-underline-offset" => {
2251− push_css(&mut aligned, "margin-top", "auto");
1892+ if let Some(v) = val {
2252− push_css(&mut aligned, "margin-bottom", "auto");
1893+ push_css(&mut css, "text-underline-offset", &css_px(v));
1894+ }
2253 }
1895 }
2254− "align-left" => push_css(&mut aligned, "margin-right", "auto"),
2255− "align-right" => push_css(&mut aligned, "margin-left", "auto"),
2256− "align-top" => push_css(&mut aligned, "margin-bottom", "auto"),
2257− "align-bottom" => push_css(&mut aligned, "margin-top", "auto"),
2258
1896
2259− "wrap" if lays_out_children => push_css(&mut css, "flex-wrap", "wrap"),
1897+ // Multi-column
2260− "wrap" => {}
1898+ "column-width" => {
1899+ if let Some(v) = val {
1900+ push_css(&mut css, "column-width", &css_px(v));
1901+ }
1902+ }
2261
1903
2262− // Grid
1904+ // New CSS properties
2263− "grid-cols" | "grid-rows" if !lays_out_children => {}
1905+ "column-gap" => {
2264− "grid-cols" | "grid-rows" => {
2265 if let Some(v) = val {
1906 if let Some(v) = val {
2266− let property = if effective_key == "grid-cols" {
1907+ push_css(&mut css, "column-gap", &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 }
1908 }
2276 }
1909 }
2277− "col-span" | "row-span" => {
1910+ "text-indent" => {
2278 if let Some(v) = val {
1911 if let Some(v) = val {
2279− let property = if effective_key == "col-span" {
1912+ push_css(&mut css, "text-indent", &css_px(v));
2280− "grid-column"
1913+ }
2281− } else {
1914+ }
2282− "grid-row"
1915+ "flex-basis" => {
2283− };
1916+ if let Some(v) = val {
2284− push_css(&mut css, property, &format!("span {}", v));
1917+ push_css(&mut css, "flex-basis", &css_px(v));
1918+ }
1919+ }
1920+ "scroll-margin" => {
1921+ if let Some(v) = val {
1922+ push_css(&mut css, "scroll-margin", &css_px(v));
1923+ }
1924+ }
1925+ "scroll-margin-top"
1926+ | "scroll-margin-bottom"
1927+ | "scroll-margin-left"
1928+ | "scroll-margin-right" => {
1929+ if let Some(v) = val {
1930+ push_css(&mut css, effective_key, &css_px(v));
1931+ }
1932+ }
1933+ "scroll-padding" => {
1934+ if let Some(v) = val {
1935+ push_css(&mut css, "scroll-padding", &css_px(v));
1936+ }
1937+ }
1938+ "scroll-padding-top"
1939+ | "scroll-padding-bottom"
1940+ | "scroll-padding-left"
1941+ | "scroll-padding-right" => {
1942+ if let Some(v) = val {
1943+ push_css(&mut css, effective_key, &css_px(v));
2285 }
1944 }
2286 }
1945 }
2287
1946
2288− // CSS's `line-clamp` has no support yet without its
1947+ // --- CSS Shorthands ---
2289− // `-webkit-` fallback, which needs a box to clamp
2290 "line-clamp" => {
1948 "line-clamp" => {
2291 if let Some(v) = val {
1949 if let Some(v) = val {
2292 push_css(&mut css, "display", "-webkit-box");
1950 push_css(&mut css, "display", "-webkit-box");
@@ -2296,14 +1954,27 @@ fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> Stri
2296 }
1954 }
2297 }
1955 }
2298
1956
2299− // Every other name is a CSS property (standard, custom,
1957+ // Any other standard CSS property is copied through, with `px`
2300− // vendor-prefixed, or one htmlang doesn't know, which the
1958+ // added to bare numbers where the property takes a length.
2301− // parser warned about), written under its own name with the
1959+ // `font-family "Inter, sans-serif"`: the quotes only keep the
2302− // value as written, except for `px` after a bare number where
1960+ // stack's commas from splitting the attribute list.
2303− // the property takes a length
1961+ "font-family" => {
2304− key if crate::vocab::is_property_name(key) => {
2305 if let Some(v) = val {
1962 if let Some(v) = val {
2306− push_css(&mut css, key, &with_px(key, v));
1963+ let v = match v.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
1964+ Some(stack) if stack.contains(',') => stack,
1965+ _ => v,
1966+ };
1967+ push_css(&mut css, "font-family", v);
1968+ }
1969+ }
1970+
1971+ key if crate::vocab::is_css_property(key) => {
1972+ if let Some(v) = val {
1973+ if crate::vocab::is_length_property(key) {
1974+ push_css(&mut css, key, &css_px_multi(v));
1975+ } else {
1976+ push_css(&mut css, key, v);
1977+ }
2307 }
1978 }
2308 }
1979 }
2309
1980
@@ -2311,7 +1982,6 @@ fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> Stri
2311 }
1982 }
2312 }
1983 }
2313
1984
2314− css.push_str(&aligned);
2315 css
1985 css
2316}
1986}
2317
1987
@@ -2322,6 +1992,34 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
2322 css.push(';');
1992 css.push(';');
2323}
1993}
2324
1994
1995+/// Known CSS units — if a value ends with one, skip appending `px`.
1996+/// Format a length value: a bare number gets `px` appended; anything else
1997+/// (values with units, keywords like `auto`, functions like `calc(...)`) is
1998+/// passed through unchanged.
1999+fn css_px(value: &str) -> String {
2000+ let v = value.trim();
2001+ if v == "0" {
2002+ return "0".to_string();
2003+ }
2004+ let is_bare_number = !v.is_empty()
2005+ && v.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
2006+ && v.parse::<f64>().is_ok();
2007+ if is_bare_number {
2008+ format!("{}px", v)
2009+ } else {
2010+ v.to_string()
2011+ }
2012+}
2013+
2014+/// Format multiple space-separated values, each getting px if needed.
2015+fn css_px_multi(value: &str) -> String {
2016+ value
2017+ .split_whitespace()
2018+ .map(css_px)
2019+ .collect::<Vec<_>>()
2020+ .join(" ")
2021+}
2022+
2325fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2023fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2326 let mut id = None;
2024 let mut id = None;
2327 let mut class = None;
2025 let mut class = None;
@@ -2450,30 +2148,4 @@ fn vlq_encode(value: i64, out: &mut String) {
2450 }
2148 }
2451}
2149}
2452
2150
2453−#[cfg(test)]
2151+
2454−mod tests {
2455− use super::*;
2456−
2457− #[test]
2458− fn a_name_is_a_stable_hash_of_the_style() {
2459− // The same in every build and on every platform
2460− assert_eq!(stable_hash(""), 0xf52a_15e9_a9b5_e89b);
2461− assert_eq!(hash_digits(35, 3), "z00");
2462− assert_eq!(hash_digits(u64::MAX, 13), "fsgs46211e5w3");
2463− }
2464−
2465− #[test]
2466− fn a_name_taken_in_the_file_takes_more_digits() {
2467− // These two styles' hashes end in the same 7 digits
2468− let mut styles = StyleCollector::new();
2469− let first = styles.get_class("width:129555px;".into(), Vec::new(), String::new());
2470− let second = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
2471− let (first, second) = (first.unwrap(), second.unwrap());
2472− assert_eq!(first.len(), 3 + CLASS_DIGITS, "{first}");
2473− assert_eq!(second.len(), 3 + CLASS_DIGITS + 1, "{second}");
2474− assert!(second.starts_with(&first), "{first} {second}");
2475− // The same style gets the name it got before
2476− let again = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
2477− assert_eq!(again.as_deref(), Some(second.as_str()));
2478− }
2479−}
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::{
3+ use crate::codegen::{generate, generate_dev, generate_partial, short_class_name};
4− CodegenOptions, generate, generate_dev, generate_partial, generate_with_classes,
5− };
6 use crate::parser::parse;
4 use crate::parser::parse;
7
5
8 fn compile(src: &str) -> String {
6 fn compile(src: &str) -> String {
@@ -49,42 +47,19 @@ mod tests {
49
47
50 #[test]
48 #[test]
51 fn dev_mode_is_deterministic_across_runs() {
49 fn dev_mode_is_deterministic_across_runs() {
52− let src =
50+ let src = "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
53− "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
54 let r1 = parse(src);
51 let r1 = parse(src);
55 let r2 = parse(src);
52 let r2 = parse(src);
56 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
53 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
57 }
54 }
58
55
59− /// The classes of `src`, compiled as a fragment
60− fn classes(src: &str) -> Vec<crate::codegen::Class> {
61− let options = CodegenOptions {
62− partial: true,
63− ..Default::default()
64− };
65− generate_with_classes(&parse(src).document, &options).1
66− }
67−
68 #[test]
56 #[test]
69− fn a_style_has_one_name_in_every_file() {
57+ fn short_class_name_is_stable() {
70− // A fragment's classes mean in a page what they mean in the
58+ assert_eq!(short_class_name(0), "a");
71− // fragment, whatever else either holds
59+ assert_eq!(short_class_name(25), "z");
72− let page = classes("@el [padding 10, background red] a\n@el [gap 4] b\n");
60+ // Higher indexes must produce unique, stable names.
73− let fragment = classes("@row [margin 2] x\n@el [padding 10, background red] y\n");
61+ let names: std::collections::HashSet<_> = (0..50_000).map(short_class_name).collect();
74− assert_eq!(page[0], fragment[1]);
62+ assert_eq!(names.len(), 50_000, "short_class_name collisions");
75− assert_ne!(page[1].style, fragment[0].style);
76− assert_ne!(page[1].name, fragment[0].name);
77− for class in page.iter().chain(&fragment) {
78− let digits = class.name.strip_prefix("hl-").expect("hl-");
79− assert_eq!(digits.len(), crate::codegen::CLASS_DIGITS, "{}", class.name);
80− assert!(
81− digits
82− .bytes()
83− .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase()),
84− "{}",
85− class.name
86− );
87− }
88 }
63 }
89
64
90 #[test]
65 #[test]
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+284−475
@@ -1,23 +1,16 @@
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 := sum (("==" | "!=" | "<" | ">" | "<=" | ">=") sum)?
20−//! range := sum (".." sum ("step" sum)?)?
21//! sum := product (("+" | "-") product)*
14//! sum := product (("+" | "-") product)*
22//! product := unary (("*" | "/" | "%") unary)*
15//! product := unary (("*" | "/" | "%") unary)*
23//! unary := "-" unary | primary
16//! unary := "-" unary | primary
@@ -25,89 +18,75 @@
25//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
18//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
26//! ```
19//! ```
27//!
20//!
28−//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is text. Values are
21+//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string. A variable
29−//! typed (see `value.rs`, which also defines truthiness and comparison):
22+//! loaded from a JSON array is a list: `length` counts its items and
30−//! `$name` keeps whatever it holds, a list or a record included, and
23+//! `contains` tests membership. Functions:
31−//! `A..B` (with `step N`) is a list of whole numbers. Functions:
24+//! `if(cond, a, b)`, tests (`contains(s, x)`, `starts-with(s, x)`,
32−//! `if(cond, a, b)` (`b` may be left out: empty), tests (`contains(s, x)`,
25+//! `ends-with(s, x)`), text (`uppercase`, `lowercase`, `capitalize`, `trim`,
33−//! `starts-with(s, x)`, `ends-with(s, x)`), text (`uppercase`, `lowercase`,
26+//! `length`, `reverse`, `truncate(s, n)`, `replace(s, old, new)`,
34−//! `capitalize`, `trim`, `length`, `reverse`, `truncate(s, n)`,
27+//! `default(s, fallback)`) and colors (`lighten(c, pct)`, `darken(c, pct)`,
35−//! `replace(s, old, new)`, `default(s, fallback)`). A colour variant is
28+//! `alpha(c, a)`, `mix(c1, c2, pct)`). In text, `${expr}` interpolates an
36−//! CSS, not a function here: `color-mix(in srgb, var(--brand), black 8%)`
29+//! expression.
37−//! is an attribute's value, written as it is.
38−//! `length`, `contains` and `reverse` work on the items of a list; the other
39−//! text functions take text, and a list is an error (a text with commas in
40−//! it is written in quotes or with `\,`, so it isn't a list).
41−//!
42−//! A `$name` ends as it does in text (see `interp.rs`), so `$post.title`
43−//! reads a field and `$a..$b` is two names. An undefined name is an error.
44
30
45use std::fmt;
31use std::fmt;
46
32
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)]
33#[derive(Debug, Clone, PartialEq)]
53−pub enum Error {
34+pub enum Value {
54− /// `$name` has no definition; `offset` is where the `$` is.
35+ Num(f64),
55− Undefined { name: String, offset: usize },
36+ Str(String),
56− /// Anything else: a syntax error, a type error, division by zero.
37+ Bool(bool),
57− Invalid(String),
38+ /// A list's items as text (from a JSON array).
39+ List(Vec<String>),
58}
40}
59
41
60−impl Error {
42+impl Value {
61− /// This error as a problem of a text in which the expression starts
43+ /// Empty strings, `false`, `0` and `"false"` / `"0"` are false.
62− /// at `base`.
44+ pub fn truthy(&self) -> bool {
63− pub(crate) fn at(self, base: usize) -> Problem {
64 match self {
45 match self {
65− Error::Undefined { name, offset } => Problem::Undefined {
46+ Value::Bool(b) => *b,
66− name,
47+ Value::Num(n) => *n != 0.0,
67− offset: base + offset,
48+ Value::Str(s) => !s.is_empty() && s != "false" && s != "0",
68− },
49+ Value::List(items) => !items.is_empty(),
69− Error::Invalid(message) => Problem::Invalid {
70− message,
71− offset: base,
72− },
73 }
50 }
74 }
51 }
75−}
76
52
77−impl From<String> for Error {
53+ fn as_num(&self) -> Option<f64> {
78− fn from(message: String) -> Self {
54+ match self {
79− Error::Invalid(message)
55+ Value::Num(n) => Some(*n),
56+ Value::Str(s) => s.trim().parse().ok(),
57+ Value::Bool(_) | Value::List(_) => None,
58+ }
80 }
59 }
81}
60}
82
61
83−impl fmt::Display for Error {
62+impl fmt::Display for Value {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
64 match self {
86− Error::Undefined { name, .. } => write!(f, "undefined variable '${}'", name),
65+ Value::Num(n) if n.fract() == 0.0 && n.abs() < 1e15 => write!(f, "{}", *n as i64),
87− Error::Invalid(message) => f.write_str(message),
66+ Value::Num(n) => write!(f, "{}", n),
67+ Value::Str(s) => f.write_str(s),
68+ Value::Bool(b) => write!(f, "{}", b),
69+ Value::List(items) => f.write_str(&items.join(", ")),
88 }
70 }
89 }
71 }
90}
72}
91
73
92−/// Evaluate `src` with the variables of `scope`. An undefined variable is
74+/// Resolves a variable name (the text after `$`). Returns `None` for
93−/// an error; a field that a record doesn't have is empty.
75+/// undefined variables.
94−pub fn eval(src: &str, scope: &dyn Scope) -> Result<Value, Error> {
76+pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<Value>;
95− let mut tokens = Vec::new();
77+
96− tokenize(src, scope, 0, &mut tokens)?;
78+/// Evaluate `src`. Undefined variables are empty strings.
79+pub fn eval(src: &str, resolve: Resolver) -> Result<Value, String> {
80+ let tokens = tokenize(src)?;
97 let mut parser = Parser {
81 let mut parser = Parser {
98 tokens: &tokens,
82 tokens: &tokens,
99 pos: 0,
83 pos: 0,
100− scope,
84+ resolve,
101− skip: false,
102 };
85 };
103 let value = parser.expr()?;
86 let value = parser.expr()?;
104 match parser.tokens.get(parser.pos) {
87 match parser.tokens.get(parser.pos) {
105 None => Ok(value),
88 None => Ok(value),
106− Some((tok, _)) => Err(Error::Invalid(format!(
89+ Some(tok) => Err(format!("unexpected {} in `{}`", tok, src.trim())),
107− "unexpected {} in `{}`",
108− tok,
109− src.trim()
110− ))),
111 }
90 }
112}
91}
113
92
@@ -138,116 +117,84 @@ impl fmt::Display for Token {
138 }
117 }
139}
118}
140
119
141−const OPERATORS: &[&str] = &[
120+const OPERATORS: &[&str] = &["==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%"];
142− "..", "==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%",
121+
143−];
122+fn tokenize(src: &str) -> Result<Vec<Token>, String> {
144−
123+ let chars: Vec<char> = src.chars().collect();
145−/// Read `src` into tokens, each with its byte offset (plus `base`).
124+ let mut tokens = Vec::new();
146−/// Variable names end as in text (see `interp.rs`), and `${...}` is a
147−/// variable (`${name}`) or a parenthesized expression.
148−fn tokenize(
149− src: &str,
150− scope: &dyn Scope,
151− base: usize,
152− tokens: &mut Vec<(Token, usize)>,
153−) -> Result<(), Error> {
154− let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
155 let mut i = 0;
125 let mut i = 0;
156− while let Some(c) = src[i..].chars().next() {
126+ let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
157− let at = base + i;
127+ while i < chars.len() {
128+ let c = chars[i];
158 if c.is_whitespace() {
129 if c.is_whitespace() {
159− i += c.len_utf8();
130+ i += 1;
160 } else if c == '(' {
131 } else if c == '(' {
161− tokens.push((Token::LParen, at));
132+ tokens.push(Token::LParen);
162 i += 1;
133 i += 1;
163 } else if c == ')' {
134 } else if c == ')' {
164− tokens.push((Token::RParen, at));
135+ tokens.push(Token::RParen);
165 i += 1;
136 i += 1;
166 } else if c == ',' {
137 } else if c == ',' {
167− tokens.push((Token::Comma, at));
138+ tokens.push(Token::Comma);
168 i += 1;
139 i += 1;
169 } else if c == '"' {
140 } else if c == '"' {
170 let start = i + 1;
141 let start = i + 1;
171− let end = src[start..]
142+ let end = chars[start..]
172− .find('"')
143+ .iter()
144+ .position(|&c| c == '"')
173 .map(|p| start + p)
145 .map(|p| start + p)
174 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
146 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
175− tokens.push((Token::Str(src[start..end].to_string()), at));
147+ tokens.push(Token::Str(chars[start..end].iter().collect()));
176 i = end + 1;
148 i = end + 1;
177 } else if c == '$' {
149 } else if c == '$' {
178− match interp::reference(&src[i + 1..], scope) {
150+ let start = i + 1;
179− Some((Reference::Var(path), len)) => {
151+ let mut end = start;
180− tokens.push((Token::Var(path), at));
152+ while end < chars.len() && is_var_char(chars[end]) {
181− i += 1 + len;
153+ end += 1;
182− }
154+ }
183− Some((Reference::Expr(inner), len)) => {
155+ while end > start && chars[end - 1] == '.' {
184− tokens.push((Token::LParen, at));
156+ end -= 1;
185− tokenize(inner, scope, at + 2, tokens)?;
186− tokens.push((Token::RParen, at + len));
187− i += 1 + len;
188− }
189− None => {
190− let n = interp::custom_property_len(&src[i + 1..]);
191− return Err(Error::Invalid(match n {
192− 0 => format!("`$` without a variable name in `{}`", src.trim()),
193− n => interp::custom_property_message(&src[i + 1..i + 1 + n]),
194− }));
195− }
196 }
157 }
158+ if end == start {
159+ return Err(format!("`$` without a variable name in `{}`", src.trim()));
160+ }
161+ tokens.push(Token::Var(chars[start..end].iter().collect()));
162+ i = end;
197 } else if let Some(op) = OPERATORS
163 } else if let Some(op) = OPERATORS
198 .iter()
164 .iter()
199− .find(|op| src[i..].starts_with(**op))
165+ .find(|op| chars[i..].iter().take(op.len()).copied().eq(op.chars()))
200 // A `-` followed by a letter starts a word (`-webkit-box`);
166 // A `-` followed by a letter starts a word (`-webkit-box`);
201 // inside words (`ease-in`) it is consumed by the word itself.
167 // inside words (`ease-in`) it is consumed by the word itself.
202− .filter(|op| **op != "-" || !src[i + 1..].starts_with(char::is_alphabetic))
168+ .filter(|op| **op != "-" || !chars.get(i + 1).is_some_and(|c| c.is_alphabetic()))
203 {
169 {
204− tokens.push((Token::Op(op), at));
170+ tokens.push(Token::Op(op));
205 i += op.len();
171 i += op.len();
206 } else if is_word_char(c) {
172 } else if is_word_char(c) {
207 let start = i;
173 let start = i;
208− // A word ends at `..`, so `1..5` is a range
174+ while i < chars.len() && is_word_char(chars[i]) {
209− while let Some(c) = src[i..]
175+ i += 1;
210− .chars()
211− .next()
212− .filter(|&c| is_word_char(c) && !src[i..].starts_with(".."))
213− {
214− i += c.len_utf8();
215 }
176 }
216− let word = &src[start..i];
177+ let word: String = chars[start..i].iter().collect();
217− tokens.push((
178+ tokens.push(match word.parse::<f64>() {
218− match word.parse::<f64>() {
179+ Ok(n) if !word.ends_with('.') => Token::Num(n),
219− Ok(n) if !word.ends_with('.') => Token::Num(n),
180+ _ => Token::Word(word),
220− _ => Token::Word(word.to_string()),
181+ });
221− },
222− at,
223− ));
224 } else {
182 } else {
225− return Err(Error::Invalid(format!(
183+ return Err(format!("unexpected `{}` in `{}`", c, src.trim()));
226− "unexpected `{}` in `{}`",
227− c,
228− src.trim()
229− )));
230 }
184 }
231 }
185 }
232− Ok(())
186+ Ok(tokens)
233}
187}
234
188
235struct Parser<'a> {
189struct Parser<'a> {
236− tokens: &'a [(Token, usize)],
190+ tokens: &'a [Token],
237 pos: usize,
191 pos: usize,
238− scope: &'a dyn Scope,
192+ 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}
193}
244
194
245−/// The value a skipped part stands for; nothing reads it.
246−const SKIPPED: Value = Value::Bool(false);
247−
248impl Parser<'_> {
195impl Parser<'_> {
249 fn peek(&self) -> Option<&Token> {
196 fn peek(&self) -> Option<&Token> {
250− self.tokens.get(self.pos).map(|(token, _)| token)
197+ self.tokens.get(self.pos)
251 }
198 }
252
199
253 fn eat_word(&mut self, word: &str) -> bool {
200 fn eat_word(&mut self, word: &str) -> bool {
@@ -270,167 +217,108 @@ impl Parser<'_> {
270 }
217 }
271 }
218 }
272
219
273− fn expect(&mut self, token: Token) -> Result<(), Error> {
220+ fn expect(&mut self, token: Token) -> Result<(), String> {
274 match self.peek() {
221 match self.peek() {
275 Some(t) if *t == token => {
222 Some(t) if *t == token => {
276 self.pos += 1;
223 self.pos += 1;
277 Ok(())
224 Ok(())
278 }
225 }
279− Some(t) => Err(format!("expected {} but found {}", token, t).into()),
226+ Some(t) => Err(format!("expected {} but found {}", token, t)),
280− None => Err(format!("expected {} at the end", token).into()),
227+ None => Err(format!("expected {} at the end", token)),
281 }
228 }
282 }
229 }
283
230
284− /// Parse with `f`, skipping evaluation when `skip` (or when already
231+ fn expr(&mut self) -> Result<Value, String> {
285− /// skipping).
286− fn skipping(
287− &mut self,
288− skip: bool,
289− f: impl FnOnce(&mut Self) -> Result<Value, Error>,
290− ) -> Result<Value, Error> {
291− let outer = self.skip;
292− self.skip |= skip;
293− let value = f(self);
294− self.skip = outer;
295− value
296− }
297−
298− fn expr(&mut self) -> Result<Value, Error> {
299 let mut left = self.and()?;
232 let mut left = self.and()?;
300 while self.eat_word("or") {
233 while self.eat_word("or") {
301− let decided = left.truthy();
234+ let right = self.and()?;
302− let right = self.skipping(decided, Self::and)?;
235+ left = Value::Bool(left.truthy() || right.truthy());
303− left = Value::Bool(decided || right.truthy());
304 }
236 }
305 Ok(left)
237 Ok(left)
306 }
238 }
307
239
308− fn and(&mut self) -> Result<Value, Error> {
240+ fn and(&mut self) -> Result<Value, String> {
309 let mut left = self.not()?;
241 let mut left = self.not()?;
310 while self.eat_word("and") {
242 while self.eat_word("and") {
311− let decided = !left.truthy();
243+ let right = self.not()?;
312− let right = self.skipping(decided, Self::not)?;
244+ left = Value::Bool(left.truthy() && right.truthy());
313− left = Value::Bool(!decided && right.truthy());
314 }
245 }
315 Ok(left)
246 Ok(left)
316 }
247 }
317
248
318− fn not(&mut self) -> Result<Value, Error> {
249+ fn not(&mut self) -> Result<Value, String> {
319 if self.eat_word("not") {
250 if self.eat_word("not") {
320 return Ok(Value::Bool(!self.not()?.truthy()));
251 return Ok(Value::Bool(!self.not()?.truthy()));
321 }
252 }
322 self.compare()
253 self.compare()
323 }
254 }
324
255
325− fn compare(&mut self) -> Result<Value, Error> {
256+ fn compare(&mut self) -> Result<Value, String> {
326− let left = self.range()?;
257+ let left = self.sum()?;
327 let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
258 let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
328 return Ok(left);
259 return Ok(left);
329 };
260 };
330− let right = self.range()?;
261+ let right = self.sum()?;
331− if self.skip {
262+ let numbers = left.as_num().zip(right.as_num());
332− return Ok(SKIPPED);
263+ let (l, r) = (left.to_string(), right.to_string());
333− }
264+ Ok(Value::Bool(match (op, numbers) {
334− Ok(Value::Bool(left.compare(op, &right)?))
265+ ("==", Some((a, b))) => a == b,
335− }
266+ ("!=", Some((a, b))) => a != b,
336−
267+ ("<", Some((a, b))) => a < b,
337− /// `A..B` or `A..B step N`: the whole numbers from A to B.
268+ (">", Some((a, b))) => a > b,
338− fn range(&mut self) -> Result<Value, Error> {
269+ ("<=", Some((a, b))) => a <= b,
339− let start = self.sum()?;
270+ (">=", Some((a, b))) => a >= b,
340− if self.eat_op(&[".."]).is_none() {
271+ ("==", None) => l == r,
341− return Ok(start);
272+ ("!=", None) => l != r,
342− }
273+ ("<", None) => l < r,
343− let end = self.sum()?;
274+ (">", None) => l > r,
344− let step = match self.eat_word("step") {
275+ ("<=", None) => l <= r,
345− true => Some(self.sum()?),
276+ (">=", None) => l >= r,
346− false => None,
277+ _ => unreachable!(),
347− };
278+ }))
348− if self.skip {
279+ }
349− return Ok(SKIPPED);
280+
350− }
281+ fn sum(&mut self) -> Result<Value, String> {
351− let number = |value: &Value| {
352− value.as_num().ok_or_else(|| {
353− Error::Invalid(format!(
354− "a range goes from one number to another, not from {} (`{}`)",
355− value.describe(),
356− value
357− ))
358− })
359− };
360− let step = match &step {
361− Some(step) => number(step)?,
362− None => 1.0,
363− };
364− Ok(crate::value::range(number(&start)?, number(&end)?, step)?)
365− }
366−
367− fn sum(&mut self) -> Result<Value, Error> {
368 let mut left = self.product()?;
282 let mut left = self.product()?;
369 while let Some(op) = self.eat_op(&["+", "-"]) {
283 while let Some(op) = self.eat_op(&["+", "-"]) {
370 let right = self.product()?;
284 let right = self.product()?;
371− left = self.arithmetic(op, &left, &right)?;
285+ left = arithmetic(op, &left, &right)?;
372 }
286 }
373 Ok(left)
287 Ok(left)
374 }
288 }
375
289
376− fn product(&mut self) -> Result<Value, Error> {
290+ fn product(&mut self) -> Result<Value, String> {
377 let mut left = self.unary()?;
291 let mut left = self.unary()?;
378 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
292 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
379 let right = self.unary()?;
293 let right = self.unary()?;
380− left = self.arithmetic(op, &left, &right)?;
294+ left = arithmetic(op, &left, &right)?;
381 }
295 }
382 Ok(left)
296 Ok(left)
383 }
297 }
384
298
385− fn unary(&mut self) -> Result<Value, Error> {
299+ fn unary(&mut self) -> Result<Value, String> {
386 if self.eat_op(&["-"]).is_some() {
300 if self.eat_op(&["-"]).is_some() {
387 let value = self.unary()?;
301 let value = self.unary()?;
388− if self.skip {
389− return Ok(SKIPPED);
390− }
391 return value
302 return value
392 .as_num()
303 .as_num()
393 .map(|n| Value::Num(-n))
304 .map(|n| Value::Num(-n))
394− .ok_or_else(|| Error::Invalid(format!("cannot negate `{}`", value)));
305+ .ok_or_else(|| format!("cannot negate `{}`", value));
395 }
306 }
396 self.primary()
307 self.primary()
397 }
308 }
398
309
399− fn primary(&mut self) -> Result<Value, Error> {
310+ fn primary(&mut self) -> Result<Value, String> {
400− let (token, at) = self
311+ let token = self
401− .tokens
312+ .peek()
402− .get(self.pos)
403 .cloned()
313 .cloned()
404 .ok_or_else(|| "expected a value at the end".to_string())?;
314 .ok_or_else(|| "expected a value at the end".to_string())?;
405 self.pos += 1;
315 self.pos += 1;
406 match token {
316 match token {
407 Token::Num(n) => Ok(Value::Num(n)),
317 Token::Num(n) => Ok(Value::Num(n)),
408− Token::Str(_) | Token::Var(_) if self.skip => Ok(SKIPPED),
318+ Token::Str(s) => Ok(Value::Str(interpolate(&s, self.resolve))),
409− Token::Str(s) => {
319+ Token::Var(name) => Ok((self.resolve)(&name).unwrap_or(Value::Str(String::new()))),
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)),
320 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
429 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
321 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) => {
322 Token::Word(name) if self.peek() == Some(&Token::LParen) => {
435 self.pos += 1;
323 self.pos += 1;
436 let mut args = Vec::new();
324 let mut args = Vec::new();
@@ -442,11 +330,7 @@ impl Parser<'_> {
442 }
330 }
443 }
331 }
444 self.expect(Token::RParen)?;
332 self.expect(Token::RParen)?;
445− check_call(&name, args.len())?;
333+ call(&name, args)
446− if self.skip {
447− return Ok(SKIPPED);
448− }
449− Ok(call(&name, args)?)
450 }
334 }
451 Token::Word(w) => Ok(Value::Str(w)),
335 Token::Word(w) => Ok(Value::Str(w)),
452 Token::LParen => {
336 Token::LParen => {
@@ -454,46 +338,11 @@ impl Parser<'_> {
454 self.expect(Token::RParen)?;
338 self.expect(Token::RParen)?;
455 Ok(value)
339 Ok(value)
456 }
340 }
457− other => Err(format!("unexpected {}", other).into()),
341+ other => Err(format!("unexpected {}", other)),
458 }
342 }
459 }
343 }
460}
344}
461
345
462−impl Parser<'_> {
463− /// `if(cond, a, b)` after its `(`: the branch that `cond` picks is
464− /// evaluated, the other only read. Without `b`, a false `cond` gives
465− /// an empty value.
466− fn choice(&mut self) -> Result<Value, Error> {
467− let arguments =
468− || Error::Invalid("if() takes 2 or 3 arguments: if(CONDITION, A, B)".into());
469− let condition = self.expr()?;
470− match self.peek() {
471− Some(Token::RParen) | None => return Err(arguments()),
472− _ => self.expect(Token::Comma)?,
473− }
474− let taken = condition.truthy();
475− let then = self.skipping(!taken, Self::expr)?;
476− let otherwise = if self.peek() == Some(&Token::Comma) {
477− self.pos += 1;
478− self.skipping(taken, Self::expr)?
479− } else {
480− Value::Str(String::new())
481− };
482− if self.peek() == Some(&Token::Comma) {
483− return Err(arguments());
484− }
485− self.expect(Token::RParen)?;
486− Ok(if taken { then } else { otherwise })
487− }
488−
489− fn arithmetic(&self, op: &str, left: &Value, right: &Value) -> Result<Value, Error> {
490− if self.skip {
491− return Ok(SKIPPED);
492− }
493− Ok(arithmetic(op, left, right)?)
494− }
495−}
496−
497fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
346fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
498 let (Some(a), Some(b)) = (left.as_num(), right.as_num()) else {
347 let (Some(a), Some(b)) = (left.as_num(), right.as_num()) else {
499 return Err(format!("`{} {} {}` needs numbers", left, op, right));
348 return Err(format!("`{} {} {}` needs numbers", left, op, right));
@@ -510,137 +359,186 @@ fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
510 }))
359 }))
511}
360}
512
361
513−/// The built-in functions (other than `if`) and how many arguments each
362+fn is_var_char(c: char) -> bool {
514−/// takes.
363+ 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}
364}
546
365
547−/// Call a built-in function with the right number of arguments (see
366+/// 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> {
367fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
553− let text = |i: usize| -> Result<String, String> {
368+ 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| {
369 let num = |i: usize| {
571 args.get(i)
370 args.get(i)
572 .and_then(Value::as_num)
371 .and_then(Value::as_num)
573 .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
372 .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
574 };
373 };
374+ let arity = |n: usize| {
375+ if args.len() == n {
376+ Ok(())
377+ } else {
378+ Err(format!("{}() takes {} argument{}", name, n, if n == 1 { "" } else { "s" }))
379+ }
380+ };
381+ let color = |rgb: (u8, u8, u8)| Value::Str(format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2));
575 Ok(match name {
382 Ok(match name {
576− "contains" => Value::Bool(match &args[0] {
383+ "if" => {
577− Value::List(list) => list.items.iter().any(|item| item.equals(&args[1])),
384+ arity(3)?;
578− _ => text(0)?.contains(&text(1)?),
385+ let mut args = args;
579− }),
386+ let otherwise = args.pop().unwrap();
580− "starts-with" => Value::Bool(text(0)?.starts_with(&text(1)?)),
387+ let then = args.pop().unwrap();
581− "ends-with" => Value::Bool(text(0)?.ends_with(&text(1)?)),
388+ if args[0].truthy() { then } else { otherwise }
582− "uppercase" => Value::Str(text(0)?.to_uppercase()),
389+ }
583− "lowercase" => Value::Str(text(0)?.to_lowercase()),
390+ "contains" => {
391+ arity(2)?;
392+ Value::Bool(match &args[0] {
393+ Value::List(items) => items.contains(&text(1)),
394+ other => other.to_string().contains(&text(1)),
395+ })
396+ }
397+ "starts-with" => { arity(2)?; Value::Bool(text(0).starts_with(&text(1))) }
398+ "ends-with" => { arity(2)?; Value::Bool(text(0).ends_with(&text(1))) }
399+ "uppercase" => { arity(1)?; Value::Str(text(0).to_uppercase()) }
400+ "lowercase" => { arity(1)?; Value::Str(text(0).to_lowercase()) }
584 "capitalize" => {
401 "capitalize" => {
585− let s = text(0)?;
402+ arity(1)?;
403+ let s = text(0);
586 let mut chars = s.chars();
404 let mut chars = s.chars();
587 Value::Str(match chars.next() {
405 Value::Str(match chars.next() {
588 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
406 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
589 None => String::new(),
407 None => String::new(),
590 })
408 })
591 }
409 }
592− "trim" => Value::Str(text(0)?.trim().to_string()),
410+ "trim" => { arity(1)?; Value::Str(text(0).trim().to_string()) }
593− "length" => Value::Num(match &args[0] {
411+ "length" => {
594− Value::List(list) => list.items.len(),
412+ arity(1)?;
595− Value::Record(fields) => fields.len(),
413+ Value::Num(match &args[0] {
596− _ => text(0)?.chars().count(),
414+ Value::List(items) => items.len(),
597− } as f64),
415+ other => other.to_string().chars().count(),
598− "reverse" => match &args[0] {
416+ } as f64)
599− Value::List(list) => Value::list(list.items.iter().rev().cloned().collect()),
417+ }
600− _ => Value::Str(text(0)?.chars().rev().collect()),
418+ "reverse" => { arity(1)?; 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]).map(|v| v.to_string()).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| {
530+ let text = |s: &str| Some(Value::Str(s.to_string()));
531+ match name {
532+ "tags" => Some(Value::List(vec!["rust".into(), "web dev".into()])),
533+ "count" => text("3"),
534+ "name" => text("World"),
535+ "theme" => text("dark"),
536+ "tricky" => text("a == b"),
537+ "empty" => text(""),
538+ _ => None,
539+ }
540+ };
541+ eval(src, &resolve).unwrap()
644 }
542 }
645
543
646 #[test]
544 #[test]
@@ -675,62 +573,7 @@ mod tests {
675 fn strings_and_if() {
573 fn strings_and_if() {
676 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
574 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
677 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
575 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
678− assert_eq!(ev("${name}").to_string(), "World");
576+ 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 }
577 }
735
578
736 #[test]
579 #[test]
@@ -740,6 +583,9 @@ mod tests {
740 assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
583 assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
741 assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
584 assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
742 assert_eq!(ev("default($empty, none)").to_string(), "none");
585 assert_eq!(ev("default($empty, none)").to_string(), "none");
586+ assert_eq!(ev("darken(#ffffff, 50)").to_string(), "#808080");
587+ assert_eq!(ev("mix(#000000, #ffffff, 50)").to_string(), "#808080");
588+ assert_eq!(ev("alpha(#3b82f6, 0.5)").to_string(), "#3b82f67f");
743 }
589 }
744
590
745 #[test]
591 #[test]
@@ -748,54 +594,17 @@ mod tests {
748 assert!(ev("contains($tags, \"web dev\")").truthy());
594 assert!(ev("contains($tags, \"web dev\")").truthy());
749 assert!(!ev("contains($tags, web)").truthy());
595 assert!(!ev("contains($tags, web)").truthy());
750 assert_eq!(ev("$tags").to_string(), "rust, web dev");
596 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 }
597 }
780
598
781 #[test]
599 #[test]
782 fn errors_are_reported() {
600 fn errors_are_reported() {
783− let none = Map::new(&[]);
601+ let none = |_: &str| None;
784 assert!(eval("dark * 2", &none).is_err());
602 assert!(eval("dark * 2", &none).is_err());
785 assert!(eval("1 +", &none).is_err());
603 assert!(eval("1 +", &none).is_err());
786 assert!(eval("(1", &none).is_err());
604 assert!(eval("(1", &none).is_err());
787 assert!(eval("1 / 0", &none).is_err());
605 assert!(eval("1 / 0", &none).is_err());
788 assert!(eval("nope(1)", &none).is_err());
606 assert!(eval("nope(1)", &none).is_err());
789 assert!(eval("uppercase(a, b)", &none).is_err());
607 assert!(eval("uppercase(a, b)", &none).is_err());
790− // Colour maths is CSS's (`color-mix()`), and a custom property is
608+ 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 }
609 }
801}
610}
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+1−4
@@ -1,11 +1,8 @@
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;
5+mod syntax;
8−pub mod value;
9pub mod vocab;
6pub mod vocab;
10
7
11#[cfg(test)]
8#[cfg(test)]
crates/htmlang-core/src/parser.rs+2070−6049
@@ -1,193 +1,76 @@
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;
6+use crate::syntax::{self, Syntax};
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
7
12// ---------------------------------------------------------------------------
8// ---------------------------------------------------------------------------
13// Public types
9// Public types
14// ---------------------------------------------------------------------------
10// ---------------------------------------------------------------------------
15
11
12+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13+pub enum Severity {
14+ Error,
15+ Warning,
16+ Info,
17+ Help,
18+}
19+
20+#[derive(Debug, Clone)]
21+pub struct Diagnostic {
22+ pub line: usize,
23+ pub column: Option<usize>,
24+ pub message: String,
25+ pub severity: Severity,
26+ pub source_line: Option<String>,
27+}
28+
16pub struct ParseResult {
29pub struct ParseResult {
17 pub document: Document,
30 pub document: Document,
18 pub diagnostics: Vec<Diagnostic>,
31 pub diagnostics: Vec<Diagnostic>,
19 pub included_files: Vec<PathBuf>,
32 pub included_files: Vec<PathBuf>,
20}
33}
21
34
22−/// An error that stops one line from being evaluated.
35+#[derive(Debug)]
23−type ParseError = Diagnostic;
36+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,
37 line: usize,
37− source: Option<String>,
38+ 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}
39}
60
40
61−impl FnDef {
41+impl fmt::Display for ParseError {
62− fn is_param(&self, name: &str) -> bool {
42+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
63− self.params.iter().any(|p| p.name == name)
43+ write!(f, "line {}: {}", self.line, self.message)
64 }
44 }
65}
45}
66
46
67−/// What a name means: every `@let`, parameter, loop variable and `@data`
47+// ---------------------------------------------------------------------------
68−/// binds one, and they share one namespace.
48+// Internal types
69−#[derive(Clone)]
49+// ---------------------------------------------------------------------------
70−enum Binding {
71− Value(Value),
72− Bundle(Rc<Vec<Attribute>>),
73− Function(Rc<FnDef>),
74−}
75−
76−/// The names visible at a line: one frame per block it is in (the
77−/// standard library, the file, an element's children, an `@if` branch, an
78−/// iteration of `@each`, a function's body), the innermost last. A
79−/// definition goes into the innermost frame, so it is visible from its
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−}
86−
87−impl Env {
88− fn push(&mut self) {
89− self.frames.push(Rc::default());
90− }
91−
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−}
162
50
163−impl interp::Scope for Env {
51+#[derive(Clone)]
164− fn get(&self, name: &str) -> Option<Value> {
52+struct FnDef {
165− self.value(name).cloned()
53+ params: Vec<String>,
166− }
54+ defaults: HashMap<String, String>,
55+ body: Vec<Syntax>,
167}
56}
168
57
169struct ParseContext {
58struct ParseContext {
170 /// Source line currently being parsed, for diagnostics raised deep
59 /// Source line currently being parsed, for diagnostics raised deep
171 /// inside helpers that don't take a line number.
60 /// inside helpers that don't take a line number.
172 current_line: usize,
61 current_line: usize,
173− /// What the `@page` said, once one has run
62+ /// Functions whose body has a scoped @style block.
174− page: Option<Page>,
63+ scoped_functions: std::collections::HashSet<String>,
175− /// Where that `@page` is, for the error a second one gets
64+ page_title: Option<String>,
176− page_at: Option<String>,
65+ lang: Option<String>,
66+ favicon: Option<String>,
177 meta_tags: Vec<(String, String)>,
67 meta_tags: Vec<(String, String)>,
178 head_blocks: Vec<String>,
68 head_blocks: Vec<String>,
179− /// The first `@meta` or `@head` that ran, for the warning a file
69+ variables: HashMap<String, String>,
180− /// without `@page` gets: a fragment has no `<head>` for them.
70+ defines: HashMap<String, Vec<Attribute>>,
181− head_at: Option<usize>,
71+ functions: HashMap<String, FnDef>,
182− /// What each name means at the line being evaluated.
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)>,
72 css_vars: Vec<(String, String)>,
188 custom_css: Vec<String>,
73 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)>,
74 og_tags: Vec<(String, String)>,
192 diagnostics: Vec<Diagnostic>,
75 diagnostics: Vec<Diagnostic>,
193 base_path: Option<PathBuf>,
76 base_path: Option<PathBuf>,
@@ -207,550 +90,35 @@ struct ParseContext {
207 fn_lines: HashMap<String, usize>,
90 fn_lines: HashMap<String, usize>,
208 /// Line numbers of attribute bundle definitions (name -> line)
91 /// Line numbers of attribute bundle definitions (name -> line)
209 define_lines: HashMap<String, usize>,
92 define_lines: HashMap<String, usize>,
210− /// Syntax nodes evaluated at least once, by id.
211− visited: HashSet<usize>,
212− /// The id the next parsed tree starts at, so ids are unique across
213− /// the file, its includes and the standard library.
214− next_id: usize,
215− /// The file's tree and those of the files it includes, with the
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−}
296−
297−/// Whether a file is a library (see [`Tree::is_library`]), with the files
298−/// its `@include` lines name read relative to `base`.
299−fn is_library_file(
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}
93}
321
94
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.
95/// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes.
327struct Evaluator;
96struct Evaluator;
328
97
329impl ParseContext {
98impl ParseContext {
330− /// Bind a value to `name` in the innermost block.
99+ /// Evaluate an expression (see `expr.rs`), reporting errors at `line`.
331− fn bind(&mut self, name: &str, value: Value) {
100+ 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);
101 track_var_refs(src, &mut self.used_variables);
344− match crate::expr::eval(src, &self.env) {
102+ let vars = &self.variables;
103+ let result = crate::expr::eval(src, &|name: &str| lookup(vars, name));
104+ match result {
345 Ok(value) => Some(value),
105 Ok(value) => Some(value),
346− Err(error) => {
106+ Err(message) => {
347− // Without the line to point into, show the expression
107+ self.diagnostics.push(Diagnostic {
348− let shown = matches!(&self.current_source, (current, Some(_)) if *current == line);
108+ line,
349− let before = self.diagnostics.len();
109+ column: None,
350− self.report(error.at(0), line, column);
110+ message: format!("invalid expression: {}", message),
351− if !shown && let Some(diagnostic) = self.diagnostics.get_mut(before) {
111+ severity: Severity::Error,
352− diagnostic.source_line = Some(src.into());
112+ source_line: Some(src.to_string()),
353− }
113+ });
354 None
114 None
355 }
115 }
356 }
116 }
357 }
117 }
358
118
359 /// Evaluate a condition; an invalid one is reported and counts as false.
119 /// 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 {
120+ fn condition(&mut self, src: &str, line: usize) -> bool {
361− self.eval(src, line, column)
121+ 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 }
122 }
755}
123}
756
124
@@ -759,19 +127,11 @@ impl ParseContext {
759const PRELUDE: &str = include_str!("std.hl");
127const PRELUDE: &str = include_str!("std.hl");
760
128
761fn load_prelude(ctx: &mut ParseContext) {
129fn load_prelude(ctx: &mut ParseContext) {
762− // The library's definitions are in a block of their own, around the
130+ let _ = Evaluator.eval_block(&syntax::parse(PRELUDE), ctx);
763− // file's, so the file's own `@let` of the same name hides them
764− ctx.env.push();
765− let tree = ctx.parse_tree(PRELUDE);
766− collect_namespace(&tree.nodes, None, &mut HashSet::new(), 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.
131 // Library definitions aren't the file's own: never report them unused.
771 ctx.fn_lines.clear();
132 ctx.fn_lines.clear();
772 ctx.define_lines.clear();
133 ctx.define_lines.clear();
773 ctx.let_lines.clear();
134 ctx.let_lines.clear();
774− ctx.env.push();
775}
135}
776
136
777pub fn parse(input: &str) -> ParseResult {
137pub fn parse(input: &str) -> ParseResult {
@@ -779,18 +139,20 @@ pub fn parse(input: &str) -> ParseResult {
779}
139}
780
140
781pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
141pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
142+ let tree = syntax::parse(input);
782 let mut ctx = ParseContext {
143 let mut ctx = ParseContext {
783 current_line: 0,
144 current_line: 0,
784− page: None,
145+ scoped_functions: std::collections::HashSet::new(),
785− page_at: None,
146+ page_title: None,
147+ lang: None,
148+ favicon: None,
786 meta_tags: Vec::new(),
149 meta_tags: Vec::new(),
787 head_blocks: Vec::new(),
150 head_blocks: Vec::new(),
788− head_at: None,
151+ variables: HashMap::new(),
789− env: Env::default(),
152+ defines: HashMap::new(),
790− defined_functions: HashMap::new(),
153+ functions: HashMap::new(),
791 css_vars: Vec::new(),
154 css_vars: Vec::new(),
792 custom_css: Vec::new(),
155 custom_css: Vec::new(),
793− scoped_css: Vec::new(),
794 og_tags: Vec::new(),
156 og_tags: Vec::new(),
795 diagnostics: Vec::new(),
157 diagnostics: Vec::new(),
796 base_path: base_path.map(|p| p.to_path_buf()),
158 base_path: base_path.map(|p| p.to_path_buf()),
@@ -804,84 +166,22 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
804 let_lines: HashMap::new(),
166 let_lines: HashMap::new(),
805 fn_lines: HashMap::new(),
167 fn_lines: HashMap::new(),
806 define_lines: HashMap::new(),
168 define_lines: HashMap::new(),
807− visited: HashSet::new(),
808− next_id: 0,
809− trees: 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 };
169 };
820 load_prelude(&mut ctx);
170 load_prelude(&mut ctx);
821− let tree = ctx.parse_tree(input);
171+ let nodes = Evaluator.eval_block(&tree, &mut ctx);
822− ctx.diagnostics.extend(tree.diagnostics.iter().cloned());
172+ validate_tree(&nodes, None, &mut ctx.diagnostics);
823− ctx.trees.push((tree.clone(), None));
173+ 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 {
174 ParseResult {
876 document: Document {
175 document: Document {
877− page: ctx.page,
176+ page_title: ctx.page_title,
177+ lang: ctx.lang,
178+ favicon: ctx.favicon,
878 meta_tags: ctx.meta_tags,
179 meta_tags: ctx.meta_tags,
879 head_blocks: ctx.head_blocks,
180 head_blocks: ctx.head_blocks,
880− variables,
181+ variables: ctx.variables,
881− defines,
182+ defines: ctx.defines,
882 css_vars: ctx.css_vars,
183 css_vars: ctx.css_vars,
883 custom_css: ctx.custom_css,
184 custom_css: ctx.custom_css,
884− scoped_css: ctx.scoped_css,
885 og_tags: ctx.og_tags,
185 og_tags: ctx.og_tags,
886 nodes,
186 nodes,
887 },
187 },
@@ -891,873 +191,444 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
891}
191}
892
192
893// ---------------------------------------------------------------------------
193// ---------------------------------------------------------------------------
894−// Code that never runs: an untaken branch, an uncalled function, a loop over
194+// Preprocessing: strip comments/blanks, collapse @raw blocks
895−// an empty list
896// ---------------------------------------------------------------------------
195// ---------------------------------------------------------------------------
897
196
898−/// Collect the functions defined anywhere in `nodes` and in the files they
197+// ---------------------------------------------------------------------------
899−/// include (by a literal path), into `ctx.namespace`.
198+// Evaluation
900−fn collect_namespace(
199+// ---------------------------------------------------------------------------
901− nodes: &[syntax::Node],
200+
902− base: Option<PathBuf>,
201+impl Evaluator {
903− seen: &mut HashSet<PathBuf>,
202+ fn eval_block(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec<Node> {
904− ctx: &mut ParseContext,
203+ let mut nodes = Vec::new();
905−) {
204+ for node in block {
906− let mut includes = Vec::new();
205+ match self.eval(node, ctx) {
907− for node in nodes {
206+ Ok(Some(new_nodes)) => nodes.extend(new_nodes),
908− node.walk(&mut |node| match node.directive().map(|d| &d.args) {
207+ Ok(None) => {}
909− Some(DirectiveArgs::Let(def)) if matches!(def.form, LetForm::Function(_)) => {
208+ // Record the error and go on, to report several in one pass
910− ctx.namespace.insert(def.name.clone());
209+ Err(e) => ctx.diagnostics.push(Diagnostic {
911− }
210+ line: e.line,
912− Some(DirectiveArgs::Text(Some(path))) if node.is_directive("include") => {
211+ column: None,
913− includes.push(path.raw.clone());
212+ message: e.message,
213+ severity: Severity::Error,
214+ source_line: Some(node.source()),
215+ }),
914 }
216 }
915− _ => {}
916− });
917− }
918− for path in includes {
919− let path = path.trim_matches('"');
920− if path.contains('$') {
921− continue;
922− }
923− let resolved = match &base {
924− Some(base) => base.join(path),
925− None => PathBuf::from(path),
926− };
927− if !seen.insert(resolved.clone()) {
928− continue;
929 }
217 }
930− let Ok(text) = ctx.read_file(&resolved) else {
218+ nodes
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 }
219 }
937−}
938
220
939−/// Check the lines that were never evaluated: element and function names,
221+ /// Evaluate a block in its own scope: `@let` inside doesn't leak out.
940−/// and the attributes of built-in elements (those that don't depend on a
222+ fn eval_scoped(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec<Node> {
941−/// variable). Names that come from data are only checked when evaluated.
223+ let saved_vars = ctx.variables.clone();
942−fn check_unevaluated(ctx: &mut ParseContext) {
224+ let nodes = self.eval_block(block, ctx);
943− let trees = std::mem::take(&mut ctx.trees);
225+ ctx.variables = saved_vars;
944− let mut seen = HashSet::new();
226+ nodes
945− for (tree, chain) in &trees {
946− let before = ctx.diagnostics.len();
947− for node in &tree.nodes {
948− check_unevaluated_node(node, ctx);
949− }
950− let mut found = ctx.diagnostics.split_off(before);
951− if let Some(chain) = chain {
952− for d in &mut found {
953− d.message = format!("{}\n in {}", d.message, chain);
954− }
955− }
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 }
227 }
960− ctx.trees = trees;
961−}
962
228
963−fn check_unevaluated_node(node: &syntax::Node, ctx: &mut ParseContext) {
229+ fn eval(&mut self, node: &Syntax, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
964− if !ctx.visited.contains(&node.id) {
230+ let line_num = node.line();
965− let line = node.span.line;
231+ ctx.current_line = line_num;
966− // A reference from code that doesn't run still counts as a use
232+ let (content, current_indent, children) = match node {
967− track_var_refs(&node.source, &mut ctx.used_variables);
233+ Syntax::Raw { text, .. } => return Ok(Some(vec![Node::Raw(text.clone())])),
968− let before = ctx.diagnostics.len();
234+ Syntax::Function { name, params, defaults, body, .. } => {
969− match &node.kind {
235+ self.define_function(name, params, defaults, body, line_num, ctx);
970− NodeKind::Element(element) => {
236+ return Ok(None);
971− for head in &element.chain {
237+ }
972− check_head(head, line, ctx);
238+ Syntax::If { branches } => {
973− }
239+ // Every condition is checked (and its errors reported);
974− check_unevaluated_content(node, element, ctx);
240+ // the first that holds picks the branch.
975− if let Some(text) = &element.text {
241+ let mut chosen = None;
976− check_inline_heads(text, line, ctx);
242+ for branch in branches {
243+ let holds = match &branch.condition {
244+ Some(condition) => ctx.condition(condition, branch.line),
245+ None => true,
246+ };
247+ if holds && chosen.is_none() {
248+ chosen = Some(&branch.body);
249+ }
977 }
250 }
251+ return Ok(chosen.map(|body| self.eval_scoped(body, ctx)));
978 }
252 }
979− NodeKind::Text(text) => check_inline_heads(text, line, ctx),
253+ Syntax::Each { header, body, empty, .. } => {
980− // `@page`'s attributes are checked like an element's (a layout's
254+ return self.eval_each(header, body, empty, line_num, ctx).map(Some);
981− // `@page` is in a function that may not run in this file)
982− NodeKind::Directive(syntax::Directive {
983− args:
984− DirectiveArgs::Page {
985− attrs: Some(list), ..
986− },
987− ..
988− }) => {
989− let words: Vec<String> = crate::vocab::PAGE_WORDS
990− .iter()
991− .map(|w| w.to_string())
992− .collect();
993− ctx.in_page = true;
994− check_attrs(&list.attrs, line, ctx, &words);
995− ctx.in_page = false;
996− check_page_tokens(&list.attrs, line, ctx);
997 }
255 }
998− _ => {}
256+ Syntax::Line { text, indent, children, .. } => (text.clone(), *indent, children),
999− }
1000− let text = written_line(node);
1001− for d in &mut ctx.diagnostics[before..] {
1002− d.source_line.get_or_insert_with(|| text.as_str().into());
1003− }
1004− }
1005− for child in &node.children {
1006− check_unevaluated_node(child, ctx);
1007− }
1008−}
1009−
1010−/// Check the content passed to the calls of a line that was never
1011−/// evaluated: its `@slot NAME` blocks, and, for a function without
1012−/// `@children`, any other content.
1013−fn check_unevaluated_content(
1014− node: &syntax::Node,
1015− element: &syntax::ElementLine,
1016− ctx: &mut ParseContext,
1017−) {
1018− let calls: Vec<Option<(String, Rc<FnDef>)>> = element
1019− .chain
1020− .iter()
1021− .map(|head| {
1022− let function = ctx.defined_functions.get(&head.name)?;
1023− Some((head.name.clone(), function.clone()))
1024− })
1025− .collect();
1026− check_fillers(node, element, &calls, ctx);
1027− let last = calls.len() - 1;
1028− for (i, call) in calls.iter().enumerate() {
1029− let Some((name, function)) = call else {
1030− continue;
1031− };
1032− if function.has_children || function.wraps_itself {
1033− continue;
1034− }
1035− let content = if i == last {
1036− element.text.is_some() || holds_content(&node.children, ctx)
1037− } else {
1038− element.chain[i + 1].name != "slot" || !slot_is_built_in(ctx)
1039 };
257 };
1040− if content {
1041− let head = &element.chain[i];
1042− ctx.push_once(no_children(
1043− name,
1044− node.span.line,
1045− name_column(head, node.span.line),
1046− ));
1047− }
1048− }
1049−}
1050
258
1051−/// Check a head that was never evaluated: its name, and its attributes
259+ // --- Directives ---
1052−/// (for a call, see [`check_call`]).
260+
1053−fn check_head(head: &syntax::Head, line: usize, ctx: &mut ParseContext) {
261+ // @page [lang en, favicon /f.png] Title
1054− let is_function =
262+ if let Some(rest) = content.strip_prefix("@page ") {
1055− ctx.namespace.contains(&head.name) || ctx.defined_functions.contains_key(&head.name);
263+ let rest = rest.trim_start();
1056− if is_function {
264+ let title = if rest.starts_with('[') {
1057− ctx.used_functions.insert(head.name.clone());
265+ let (attrs, title) = parse_attr_brackets_no_validate(rest, line_num, ctx)?;
1058− if let Some(function) = ctx.defined_functions.get(&head.name).cloned()
266+ for attr in attrs {
1059− && !function.wraps_itself
267+ let value = attr.value.unwrap_or_default();
1060− {
268+ match attr.key.as_str() {
1061− check_call(head, &function, line, ctx);
269+ "lang" => ctx.lang = Some(value),
270+ "favicon" => ctx.favicon = Some(value),
271+ other => ctx.diagnostics.push(Diagnostic {
272+ line: line_num,
273+ column: None,
274+ message: format!(
275+ "unknown @page attribute '{}' (expected lang or favicon)",
276+ other
277+ ),
278+ severity: Severity::Warning,
279+ source_line: Some(content.clone()),
280+ }),
281+ }
282+ }
283+ title
284+ } else {
285+ rest.to_string()
286+ };
287+ ctx.page_title = Some(substitute_vars(title.trim(), &ctx.variables));
288+ return Ok(None);
1062 }
289 }
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− }
1089− }
1090− }
1091− }
1092−}
1093−
1094−/// The attributes of a list that is never evaluated that can be checked
1095−/// together: not bundles or `if()`s. Names are checked; a value with a
1096−/// variable in it depends on what the variable holds, so only a literal
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−
1129−fn literal_attrs(tokens: &[syntax::Attr]) -> Vec<syntax::Attr> {
1130− // A prefixed group is checked with its prefixes, as far as its own
1131− // attributes can be; a prefixed bundle or `if()` depends on what it holds
1132− let group = |a: &syntax::Attr| {
1133− let prefixed = a.prefixed.as_ref()?;
1134− let syntax::Branch::Group { list, trailing } = &prefixed.target else {
1135− return None;
1136− };
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− }
1163− a
1164− })
1165− .chain(groups)
1166− .collect()
1167−}
1168
290
1169−/// Check a call that was never evaluated: how it passes its parameters
1170−/// (none written `name=value`, and every one without a default passed,
1171−/// when no bundle or `if()` could pass it), and its other attributes, like
1172−/// those of an element.
1173−fn check_call(head: &syntax::Head, function: &FnDef, line: usize, ctx: &mut ParseContext) {
1174− let name = head.name.as_str();
1175− let attrs = head.attrs.as_ref().map_or(&[][..], |list| &list.attrs[..]);
1176− written_parameter_forms(name, function, attrs, line, ctx);
1177− if let Some(list) = &head.attrs {
1178− let params: Vec<String> = function.params.iter().map(|p| p.name.clone()).collect();
1179− check_attrs(&list.attrs, line, ctx, &params);
1180− }
1181− let unknown = attrs
1182− .iter()
1183− .any(|a| a.choice.is_some() || (a.key.starts_with('$') && a.value.is_none()));
1184− if unknown {
1185− return;
1186− }
1187− for param in function.params.iter().filter(|p| p.default.is_none()) {
1188− if !attrs.iter().any(|a| a.key == param.name) {
1189− let column = name_column(head, line);
1190− ctx.push_once(missing_parameter(name, &param.name, line, column));
1191− }
1192− }
1193−}
1194
291
1195−/// Report each parameter a call's list writes `name=value`, with the value
292+ if let Some(rest) = content.strip_prefix("@let ") {
1196−/// as written and its column; returns their names, so the evaluated call
293+ let rest = rest.trim();
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− }
1223− reported
1224−}
1225
294
1226−fn check_inline_heads(text: &syntax::Text, line: usize, ctx: &mut ParseContext) {
295+ if !children.is_empty() {
1227− for segment in &text.segments {
296+ return Err(ParseError {
1228− if let Segment::Inline(inline) = segment {
297+ line: line_num,
1229− check_head(&inline.head, line, ctx);
298+ message: "@let with body requires a name".to_string(),
1230− if let Some(text) = &inline.text {
299+ });
1231− check_inline_heads(text, line, ctx);
1232 }
300 }
1233− }
1234− }
1235−}
1236−
1237−// ---------------------------------------------------------------------------
1238−// Evaluation
1239−// ---------------------------------------------------------------------------
1240−
1241−/// The `@else` lines that continue the `@if` or `@each` before `from`, and
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− }
1253− let condition = match node.directive().map(|d| &d.args) {
1254− Some(DirectiveArgs::Else { condition }) => condition.is_some(),
1255− _ => break,
1256− };
1257− if condition && !is_if {
1258− break;
1259− }
1260− branches.push(node);
1261− next = i;
1262− if !condition {
1263− break;
1264− }
1265− }
1266− (branches, next)
1267−}
1268
301
1269−impl Evaluator {
302+ if let Some((name, value)) = rest.split_once(' ') {
1270− fn eval_block(&mut self, block: &[syntax::Node], ctx: &mut ParseContext) -> Vec<Node> {
303+ let value = value.trim();
1271− let mut nodes = Vec::new();
304+ // `@let name = EXPR` computes its value (see expr.rs); any
1272− let mut i = 0;
305+ // other value is literal text with `$var` interpolation.
1273− while i < block.len() {
306+ if let Some(expression) = value.strip_prefix('=') {
1274− let node = &block[i];
307+ let value = ctx
1275− i += 1;
308+ .eval(expression.trim(), line_num)
1276− if node.is_trivia() {
309+ .map(|v| v.to_string())
1277− continue;
310+ .unwrap_or_default();
1278− }
311+ if name.starts_with("--") {
1279− let result = match node.directive().map(|d| d.name()) {
312+ ctx.css_vars.push((name.to_string(), value.clone()));
1280− Some(name @ ("if" | "each")) => {
1281− let (branches, next) = else_branches(block, i, name == "if");
1282− i = next;
1283− if name == "if" {
1284− Ok(Some(self.eval_if(node, &branches, ctx)))
1285− } else {
1286− self.eval_each(node, branches.first().copied(), ctx)
1287 }
313 }
314+ ctx.variables.insert(name.to_string(), value);
315+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
316+ return Ok(None);
1288 }
317 }
1289− // Reported as a syntax error
1290− Some("else") => {
1291− ctx.visited.insert(node.id);
1292− Ok(None)
1293− }
1294− _ => self.eval(node, ctx),
1295− };
1296− match result {
1297− Ok(Some(new_nodes)) => nodes.extend(new_nodes),
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− }
1307− nodes
1308− }
1309−
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
318
1319− /// `@if` with its `@else if` / `@else` branches. Every condition is
319+ // Attribute bundle: @let name [attr1, attr2, ...]
1320− /// checked (and its errors reported); the first that holds picks the
320+ if value.starts_with('[') {
1321− /// branch.
321+ let (attrs, _) = parse_attr_brackets(value, line_num, ctx)?;
1322− fn eval_if(
322+ ctx.defines.insert(name.to_string(), attrs);
1323− &mut self,
323+ ctx.define_lines.entry(name.to_string()).or_insert(line_num);
1324− node: &syntax::Node,
324+ return Ok(None);
1325− branches: &[&syntax::Node],
1326− ctx: &mut ParseContext,
1327− ) -> Vec<Node> {
1328− let mut chosen = None;
1329− for branch in std::iter::once(node).chain(branches.iter().copied()) {
1330− ctx.visited.insert(branch.id);
1331− ctx.enter(branch);
1332− let holds = match branch.directive().map(|d| &d.args) {
1333− Some(DirectiveArgs::Condition(condition))
1334− | Some(DirectiveArgs::Else {
1335− condition: Some(condition),
1336− }) => {
1337− !condition.raw.is_empty()
1338− && ctx.condition(
1339− &condition.raw,
1340− branch.span.line,
1341− Some(condition.span.column),
1342− )
1343 }
325 }
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
326
1355− fn eval(
327+ let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
1356− &mut self,
328+ // Support quoted string interpolation: @let greeting "Hello $name"
1357− node: &syntax::Node,
329+ &value[1..value.len() - 1]
1358− ctx: &mut ParseContext,
330+ } else {
1359− ) -> Result<Option<Vec<Node>>, ParseError> {
331+ value
1360− ctx.visited.insert(node.id);
332+ };
1361− ctx.enter(node);
333+ track_var_refs(value, &mut ctx.used_variables);
1362− match &node.kind {
334+ let value = substitute_vars(value, &ctx.variables);
1363− NodeKind::Blank | NodeKind::Comment => Ok(None),
335+ if name.starts_with("--") {
1364− // `@code` and `@textarea` show their body as text; `@script`'s
336+ // CSS custom property
1365− // goes into the page as it is
337+ ctx.css_vars.push((name.to_string(), value.clone()));
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 }
338 }
1382− Ok(nodes)
339+ ctx.variables.insert(name.to_string(), value);
1383− }
340+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
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 }
341 }
342+ return Ok(None);
1392 }
343 }
1393− }
1394
344
1395− fn eval_directive(
345+ // @meta NAME VALUE; `og:` names become Open Graph property tags
1396− &mut self,
346+ if let Some(rest) = content.strip_prefix("@meta ") {
1397− node: &syntax::Node,
347+ let rest = rest.trim();
1398− directive: &syntax::Directive,
348+ if let Some((name, value)) = rest.split_once(' ') {
1399− ctx: &mut ParseContext,
349+ let value = substitute_vars(value.trim(), &ctx.variables);
1400− ) -> Result<Option<Vec<Node>>, ParseError> {
350+ match name.trim().strip_prefix("og:") {
1401− let line_num = node.span.line;
351+ Some(property) => ctx.og_tags.push((property.to_string(), value)),
1402− let content = &node.source;
352+ None => ctx.meta_tags.push((name.trim().to_string(), value)),
1403− match (directive.name(), &directive.args) {
1404− // @page [lang=en, favicon /f.png, background #f8fafc] Title
1405− ("page", DirectiveArgs::Page { attrs, title }) => {
1406− let page = read_page(attrs.as_ref(), title.as_ref(), line_num, ctx);
1407− // A page has one root: a second `@page` (a layout called
1408− // twice, or one called after the page's own) is an error
1409− let here = match &ctx.current_file {
1410− Some(file) => format!("line {} of {}", line_num, file),
1411− None => format!("line {}", line_num),
1412− };
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 }
353 }
1436− Ok(None)
1437 }
354 }
355+ return Ok(None);
356+ }
1438
357
1439− ("let", DirectiveArgs::Let(def)) => {
1440− let name = def.name.as_str();
1441− // Values, bundles and functions share one namespace: a
1442− // definition replaces whatever the name meant in its block
1443− match &def.form {
1444− LetForm::Function(function) => {
1445− self.define_function(name, &function.params, node, ctx);
1446− }
1447− // `@let name = EXPR` computes its value (see expr.rs)
1448− LetForm::Computed(expression) => {
1449− let value = ctx
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− }
1478− }
1479− Ok(None)
1480− }
1481
358
1482− // @meta NAME VALUE; `og:` names become Open Graph property tags
359+ if content == "@head" || content.starts_with("@head ") {
1483− ("meta", DirectiveArgs::Text(Some(arg))) => {
360+ let trimmed = block_text(children).trim().to_string();
1484− let Some((name, value)) = arg.raw.split_once(char::is_whitespace) else {
361+ if !trimmed.is_empty() {
1485− ctx.diagnostics.push(
362+ ctx.head_blocks.push(trimmed);
1486− Diagnostic::error(
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);
1494− };
1495− let value_column = arg.span.column + arg.raw.len() - value.trim_start().len();
1496− // An HTML attribute value: quoted text loses its quotes
1497− let (value, _, _) =
1498− ctx.fill_value(value.trim(), line_num, Some(value_column), Sink::Text);
1499− // The same tag twice says nothing more (a layout called
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− }
1508− ctx.head_at.get_or_insert(line_num);
1509− Ok(None)
1510 }
363 }
364+ return Ok(None);
365+ }
1511
366
1512− ("head", _) => {
367+ // --- @style block (raw CSS) ---
1513− let text = verbatim_content(node);
368+ if content.trim() == "@style" {
1514− if !text.trim().is_empty() {
369+ let trimmed = block_text(children).trim().to_string();
1515− ctx.head_blocks.push(text.trim().to_string());
370+ if !trimmed.is_empty() {
1516− ctx.head_at.get_or_insert(line_num);
371+ ctx.custom_css.push(trimmed);
1517− }
1518− Ok(None)
1519 }
372 }
373+ return Ok(None);
374+ }
1520
375
1521− // @style: raw CSS
1522− ("style", _) => {
1523− let text = verbatim_content(node);
1524− // Only the @style at the top of a function's body is
1525− // scoped; anywhere else `&` is `:scope`, the whole page
1526− if has_top_level_ampersand(&text) {
1527− ctx.push_once(Diagnostic::warning(
1528− code::INVALID_VALUE,
1529− node.span.line,
1530− "`&` in this @style is the whole page: only an @style at the top of a \
1531− function's body is scoped to the function, with `&` its root element"
1532− .to_string(),
1533− ));
1534− }
1535− if !text.trim().is_empty() {
1536− ctx.custom_css.push(text.trim().to_string());
1537− }
1538− Ok(None)
1539− }
1540
376
1541− // @markdown: an indented block, or a file
377+ // --- @markdown block or file (convert markdown to HTML) ---
1542− ("markdown", DirectiveArgs::Text(file)) => {
378+ if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
1543− let Some(file) = file else {
379+ let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
1544− let md_lines: Vec<String> = verbatim_text(&node.children)
380+ if arg.is_empty() {
1545− .lines()
381+ // Inline markdown block: indented children
1546− .map(String::from)
382+ let md_lines: Vec<String> = block_text(children).lines().map(String::from).collect();
1547− .collect();
383+ let html = markdown_to_html(&md_lines);
1548− return Ok(Some(vec![Node::Raw(markdown_to_html(&md_lines))]));
384+ return Ok(Some(vec![Node::Raw(html)]));
385+ } else {
386+ // External markdown file: @markdown file.md
387+ let filename = substitute_vars(arg, &ctx.variables);
388+ let resolved = match &ctx.base_path {
389+ Some(base) => base.join(&filename),
390+ None => PathBuf::from(&filename),
1549 };
391 };
1550− let filename = ctx.interpolate_text(&file.raw, line_num, Some(file.span.column));
392+ let md_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1551− let resolved = ctx.resolve(&filename);
393+ cached.clone()
1552− let md_text = match ctx.read_file(&resolved) {
394+ } else {
1553− Ok(text) => text,
395+ match std::fs::read_to_string(&resolved) {
1554− Err(e) => {
396+ Ok(text) => {
1555− ctx.diagnostics.push(
397+ ctx.file_cache.insert(resolved.clone(), text.clone());
1556− Diagnostic::error(
398+ text
1557− code::UNREADABLE_FILE,
399+ }
1558− line_num,
400+ Err(e) => {
1559− format!("cannot read markdown '{}': {}", filename, e),
401+ ctx.diagnostics.push(Diagnostic {
1560− )
402+ line: line_num,
1561− .source(content.clone()),
403+ column: None,
1562− );
404+ message: format!("cannot read markdown '{}': {}", filename, e),
1563− return Ok(None);
405+ severity: Severity::Error,
406+ source_line: Some(content.clone()),
407+ });
408+ return Ok(None);
409+ }
1564 }
410 }
1565 };
411 };
1566 ctx.included_files.push(resolved);
412 ctx.included_files.push(resolved);
1567 let md_lines: Vec<String> = md_text.lines().map(|l| l.to_string()).collect();
413 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))]))
414+ let html = markdown_to_html(&md_lines);
415+ return Ok(Some(vec![Node::Raw(html)]));
1569 }
416 }
417+ }
1570
418
1571− ("include", DirectiveArgs::Text(Some(path))) => {
1572− self.eval_include(node.id, path, line_num, content, ctx)
1573− }
1574
419
1575− ("data", DirectiveArgs::Data { name, source, .. }) => {
1576− self.eval_data(name, source, line_num, content, ctx);
1577− Ok(None)
1578− }
1579
420
1580− // @raw: the rest of its line, or its indented body, verbatim
421+ if let Some(rest) = content.strip_prefix("@include ") {
1581− ("raw", _) => {
422+ let rest = rest.trim();
1582− let text = verbatim_content(node);
423+ let filename = rest
1583− let text = text.trim_end_matches('\n');
424+ .strip_prefix('"')
1584− Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())]))
425+ .and_then(|f| f.strip_suffix('"'))
1585− }
426+ .unwrap_or(rest);
427+ let filename = substitute_vars(filename, &ctx.variables);
1586
428
1587− // A header with a syntax error (reported), or a directive
429+ let resolved = match &ctx.base_path {
1588− // without its argument
430+ Some(base) => base.join(&filename),
1589− _ => Ok(None),
431+ None => PathBuf::from(&filename),
1590− }
432+ };
1591− }
1592
433
1593− fn eval_include(
434+ if ctx.include_stack.contains(&resolved) {
1594− &mut self,
435+ let cycle_chain = format_include_chain(&ctx.include_stack);
1595− id: usize,
436+ ctx.diagnostics.push(Diagnostic {
1596− path: &syntax::Arg,
437+ line: line_num,
1597− line_num: usize,
438+ column: None,
1598− content: &str,
439+ 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: {} → {})",
440 "circular include '{}' (cycle: {} → {})",
1617 filename, cycle_chain, filename
441 filename, cycle_chain, filename
1618 ),
442 ),
1619− )
443+ severity: Severity::Error,
1620− .source(content),
444+ source_line: Some(content.clone()),
1621− );
445+ });
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);
446 return Ok(None);
1637 }
447 }
1638− };
1639
448
1640− ctx.included_files.push(resolved.clone());
449+ let imported_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1641− ctx.include_stack.push(resolved.clone());
450+ cached.clone()
1642− let saved_base = ctx.base_path.clone();
451+ } else {
1643− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
452+ match std::fs::read_to_string(&resolved) {
1644−
453+ Ok(text) => {
1645− let diag_count_before = ctx.diagnostics.len();
454+ ctx.file_cache.insert(resolved.clone(), text.clone());
1646− let import_chain = format_include_chain(&ctx.include_stack);
455+ text
1647− let tree = ctx.parse_tree(&imported_text);
456+ }
1648− ctx.diagnostics.extend(tree.diagnostics.iter().cloned());
457+ Err(e) => {
1649− ctx.trees.push((tree.clone(), Some(import_chain.clone())));
458+ ctx.diagnostics.push(Diagnostic {
1650− let files = Rc::make_mut(&mut ctx.included_by).entry(id).or_default();
459+ line: line_num,
1651− if !files.iter().any(|(_, chain)| *chain == import_chain) {
460+ column: None,
1652− files.push((tree.clone(), import_chain.clone()));
461+ message: format!("cannot include '{}': {}", filename, e),
1653− }
462+ severity: Severity::Error,
1654− let saved_file = ctx.current_file.replace(import_chain.clone());
463+ source_line: Some(content.clone()),
1655− let defined_before: HashSet<String> = ctx
464+ });
1656− .let_lines
465+ return Ok(None);
1657− .keys()
466+ }
1658− .chain(ctx.define_lines.keys())
467+ }
1659− .chain(ctx.fn_lines.keys())
468+ };
1660− .cloned()
469+
1661− .collect();
470+ ctx.included_files.push(resolved.clone());
1662− let included_nodes = self.eval_block(&tree.nodes, ctx);
471+ ctx.include_stack.push(resolved.clone());
1663− ctx.current_file = saved_file;
472+ let saved_base = ctx.base_path.clone();
1664− // An included file's definitions aren't reported unused here, where
473+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1665− // their lines aren't this file's: a library's are there to be
474+
1666− // picked from, and any other file reports its own when checked
475+ let diag_count_before = ctx.diagnostics.len();
1667− let keep = |name: &String, _: &mut usize| defined_before.contains(name);
476+
1668− ctx.let_lines.retain(keep);
477+ let included_nodes = self.eval_block(&syntax::parse(&imported_text), ctx);
1669− ctx.define_lines.retain(keep);
478+
1670− ctx.fn_lines.retain(keep);
479+ // Annotate new diagnostics with import chain
1671−
480+ let import_chain = format_include_chain(&ctx.include_stack);
1672− // Annotate new diagnostics with import chain
481+ for d in &mut ctx.diagnostics[diag_count_before..] {
1673− for d in &mut ctx.diagnostics[diag_count_before..] {
482+ d.message = format!("{}\n in {}", d.message, import_chain);
1674− d.message = format!("{}\n in {}", d.message, import_chain);
483+ }
484+
485+ ctx.base_path = saved_base;
486+ ctx.include_stack.pop();
487+ return Ok(Some(included_nodes));
1675 }
488 }
1676
489
1677− ctx.base_path = saved_base;
1678− ctx.include_stack.pop();
1679− Ok(Some(included_nodes))
1680− }
1681
490
1682− /// @data $name file.json values as $name.key
491+
1683− /// @data $name dir/*.json a list of the files' records
492+ // --- @data (load JSON file into variables) ---
1684− /// @data $name [...] / {...} inline JSON
493+
1685− /// @data $name env:NAME [DEFAULT] an environment variable
494+ // @data $name file.json values as $name.key
1686− fn eval_data(
495+ // @data $name dir/*.json a list of the files' records
1687− &mut self,
496+ // @data $name [...] / {...} inline JSON
1688− prefix: &str,
497+ // @data $name env:NAME [DEFAULT] an environment variable
1689− source: &syntax::Arg,
498+ if let Some(rest) = content.strip_prefix("@data ") {
1690− line_num: usize,
499+ let rest = rest.trim();
1691− content: &str,
500+ let Some((prefix, filename)) = rest
1692− ctx: &mut ParseContext,
501+ .strip_prefix('$')
1693− ) {
502+ .and_then(|named| named.split_once(char::is_whitespace))
1694− let prefix = prefix.to_string();
503+ .map(|(name, source)| (name.to_string(), source.trim().to_string()))
1695− let filename = source.raw.as_str();
504+ else {
1696− if let Some(env) = filename.strip_prefix("env:") {
505+ return Err(ParseError {
1697− let (var, default) = match env.split_once(char::is_whitespace) {
506+ line: line_num,
1698− Some((var, default)) => (var, Some(default.trim())),
507+ message: format!(
1699− None => (env, None),
508+ "@data needs a name: write `@data $name {}` and use `$name.key`",
509+ rest.trim_start_matches('$')
510+ ),
511+ });
1700 };
512 };
1701− let default_column = default.map(|d| source.span.column + filename.len() - d.len());
513+
1702− let value = std::env::var(var)
514+ if let Some(env) = filename.strip_prefix("env:") {
1703− .ok()
515+ let (var, default) = match env.split_once(char::is_whitespace) {
1704− .or_else(|| default.map(|d| ctx.interpolate_text(d, line_num, default_column)));
516+ Some((var, default)) => (var, Some(default.trim())),
1705− if value.is_none() {
517+ None => (env, None),
1706− ctx.diagnostics.push(
518+ };
1707− Diagnostic::warning(
519+ let value = std::env::var(var)
1708− code::UNSET_ENVIRONMENT,
520+ .ok()
1709− line_num,
521+ .or_else(|| default.map(|d| substitute_vars(d, &ctx.variables)));
1710− format!(
522+ if value.is_none() {
523+ ctx.diagnostics.push(Diagnostic {
524+ line: line_num,
525+ column: None,
526+ message: format!(
1711 "environment variable '{}' is not set and has no default",
527 "environment variable '{}' is not set and has no default",
1712 var
528 var
1713 ),
529 ),
1714− )
530+ severity: Severity::Warning,
1715− .source(content),
531+ source_line: Some(content.clone()),
1716− );
532+ });
533+ }
534+ ctx.variables.insert(prefix, value.unwrap_or_default());
535+ return Ok(None);
1717 }
536 }
1718− ctx.bind(&prefix, Value::Str(value.unwrap_or_default()));
1719− return;
1720− }
1721
537
1722− // Inline JSON: @data $links [{"label": "Home", "url": "/"}]
538+ // Inline JSON: @data $links [{"label": "Home", "url": "/"}]
1723− let (json_text, source) = if filename.starts_with(['[', '{']) {
539+ let (json_text, source) = if filename.starts_with(['[', '{']) {
1724− (filename.to_string(), "the inline data".to_string())
540+ (filename, "the inline data".to_string())
1725− } else {
541+ } else {
1726− let filename = ctx.interpolate_text(filename, line_num, Some(source.span.column));
542+ let filename = substitute_vars(&filename, &ctx.variables);
1727− if filename.contains('*') {
543+ if filename.contains('*') {
1728− self.load_data_glob(&prefix, &filename, line_num, content, ctx);
544+ self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
1729− return;
545+ return Ok(None);
1730− }
1731− let resolved = ctx.resolve(&filename);
1732− match std::fs::read_to_string(&resolved) {
1733− Ok(text) => {
1734− ctx.included_files.push(resolved);
1735− (text, format!("'{}'", filename))
1736 }
546 }
1737− Err(e) => {
547+ let resolved = match &ctx.base_path {
1738− ctx.diagnostics.push(
548+ Some(base) => base.join(&filename),
1739− Diagnostic::error(
549+ None => PathBuf::from(&filename),
1740− code::UNREADABLE_FILE,
550+ };
1741− line_num,
551+ match std::fs::read_to_string(&resolved) {
1742− format!("cannot load data '{}': {}", filename, e),
552+ Ok(text) => {
1743− )
553+ ctx.included_files.push(resolved);
1744− .source(content),
554+ (text, format!("'{}'", filename))
1745− );
555+ }
1746− return;
556+ Err(e) => {
557+ ctx.diagnostics.push(Diagnostic {
558+ line: line_num,
559+ column: None,
560+ message: format!("cannot load data '{}': {}", filename, e),
561+ severity: Severity::Error,
562+ source_line: Some(content.clone()),
563+ });
564+ return Ok(None);
565+ }
1747 }
566 }
567+ };
568+ match parse_json_with_error(&json_text) {
569+ Ok(json) => flatten_json(&prefix, &json, &mut ctx.variables),
570+ Err(detail) => ctx.diagnostics.push(Diagnostic {
571+ line: line_num,
572+ column: None,
573+ message: format!("invalid JSON in {}: {}", source, detail),
574+ severity: Severity::Error,
575+ source_line: Some(content.clone()),
576+ }),
1748 }
577 }
1749− };
578+ return Ok(None);
1750− match parse_json_with_error(&json_text) {
579+ }
1751− Ok(json) => ctx.bind(&prefix, json_value(json)),
580+
1752− Err(detail) => ctx.diagnostics.push(
581+ // --- @if / @else ---
1753− Diagnostic::error(
582+
1754− code::INVALID_JSON,
583+ if content.trim() == "@else" || content.trim().starts_with("@else if ") {
1755− line_num,
584+ return Err(ParseError {
1756− format!("invalid JSON in {}: {}", source, detail),
585+ line: line_num,
1757− )
586+ message: "@else without matching @if".to_string(),
1758− .source(content),
587+ });
1759− ),
588+ }
589+
590+ // --- @each loop ---
591+
592+ // @raw: its indented body, or the rest of the line, verbatim
593+ if content == "@raw" {
594+ let text = block_text(children);
595+ let text = text.trim_end_matches('\n');
596+ return Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())]));
597+ }
598+ if let Some(rest) = content.strip_prefix("@raw ") {
599+ return Ok(Some(vec![Node::Raw(rest.to_string())]));
600+ }
601+
602+ // --- Function call ---
603+
604+ if content.starts_with('@') {
605+ let name = extract_element_name(&content);
606+ if ctx.functions.contains_key(name) {
607+ ctx.used_functions.insert(name.to_string());
608+ let nodes = self.expand_fn_call(name, &content, children, line_num, ctx)?;
609+ return Ok(Some(nodes));
610+ }
611+ }
612+
613+ // --- Elements ---
614+
615+ if content.starts_with('@') {
616+ let node = self.parse_element_line(&content, children, line_num, ctx)?;
617+ return Ok(Some(vec![inline_svg(node, line_num, ctx)]));
1760 }
618 }
619+
620+ // --- Bare text ---
621+
622+ let var_warnings = check_undefined_vars(
623+ &protect_escapes(&content),
624+ &ctx.variables,
625+ line_num,
626+ current_indent,
627+ );
628+ ctx.diagnostics.extend(var_warnings);
629+ track_var_refs(&content, &mut ctx.used_variables);
630+ let segments = parse_text_segments(&content, ctx);
631+ Ok(Some(vec![Node::Text(segments)]))
1761 }
632 }
1762
633
1763 /// `@data $name dir/*.json`: a list with one record per matching file
634 /// `@data $name dir/*.json`: a list with one record per matching file
@@ -1791,15 +662,13 @@ impl Evaluator {
1791 })
662 })
1792 .collect(),
663 .collect(),
1793 Err(e) => {
664 Err(e) => {
1794− ctx.diagnostics.push(
665+ ctx.diagnostics.push(Diagnostic {
1795− Diagnostic::new(
666+ line: line_num,
1796− code::UNREADABLE_FILE,
667+ column: None,
1797− Severity::Error,
668+ message: format!("cannot read directory for '{}': {}", pattern, e),
1798− line_num,
669+ severity: Severity::Error,
1799− format!("cannot read directory for '{}': {}", pattern, e),
670+ source_line: Some(content.to_string()),
1800− )
671+ });
1801− .source(content.to_string()),
1802− );
1803 return;
672 return;
1804 }
673 }
1805 };
674 };
@@ -1814,15 +683,13 @@ impl Evaluator {
1814 let text = match std::fs::read_to_string(&file) {
683 let text = match std::fs::read_to_string(&file) {
1815 Ok(text) => text,
684 Ok(text) => text,
1816 Err(e) => {
685 Err(e) => {
1817− ctx.diagnostics.push(
686+ ctx.diagnostics.push(Diagnostic {
1818− Diagnostic::new(
687+ line: line_num,
1819− code::UNREADABLE_FILE,
688+ column: None,
1820− Severity::Error,
689+ message: format!("cannot load data '{}': {}", file.display(), e),
1821− line_num,
690+ severity: Severity::Error,
1822− format!("cannot load data '{}': {}", file.display(), e),
691+ source_line: Some(content.to_string()),
1823− )
692+ });
1824− .source(content.to_string()),
1825− );
1826 continue;
693 continue;
1827 }
694 }
1828 };
695 };
@@ -1833,431 +700,245 @@ impl Evaluator {
1833 }
700 }
1834 records.push(JsonValue::Object(pairs));
701 records.push(JsonValue::Object(pairs));
1835 }
702 }
1836− Ok(_) => ctx.diagnostics.push(
703+ Ok(_) => ctx.diagnostics.push(Diagnostic {
1837− Diagnostic::new(
704+ line: line_num,
1838− code::INVALID_JSON,
705+ column: None,
1839− Severity::Error,
706+ message: format!("'{}' should hold a JSON object", file.display()),
1840− line_num,
707+ severity: Severity::Error,
1841− format!("'{}' should hold a JSON object", file.display()),
708+ source_line: Some(content.to_string()),
1842− )
709+ }),
1843− .source(content.to_string()),
710+ Err(detail) => ctx.diagnostics.push(Diagnostic {
1844− ),
711+ line: line_num,
1845− Err(detail) => ctx.diagnostics.push(
712+ column: None,
1846− Diagnostic::new(
713+ message: format!("invalid JSON in '{}': {}", file.display(), detail),
1847− code::INVALID_JSON,
714+ severity: Severity::Error,
1848− Severity::Error,
715+ source_line: Some(content.to_string()),
1849− line_num,
716+ }),
1850− format!("invalid JSON in '{}': {}", file.display(), detail),
1851− )
1852− .source(content.to_string()),
1853− ),
1854 }
717 }
1855 ctx.included_files.push(file);
718 ctx.included_files.push(file);
1856 }
719 }
1857− ctx.bind(name, json_value(JsonValue::Array(records)));
720+ flatten_json(name, &JsonValue::Array(records), &mut ctx.variables);
1858 }
721 }
1859
722
1860− /// `@each $item in LIST` or `@each $item, $index in LIST`, with the
723+ /// `@each $item in LIST` or `@each $item, $index in LIST`. A LIST from
1861− /// `@else` that follows it. LIST is a value (see
724+ /// JSON binds each record or value to `$item`; any other list is text,
1862− /// [`ParseContext::slot_value`]): a list written with commas, a range,
725+ /// split on commas, or a range `A..B [step N]`.
1863− /// or one `$name` or `${...}` that holds a list. Each item is bound
1864− /// whole to `$item`, and each iteration is a block of its own.
1865 fn eval_each(
726 fn eval_each(
1866 &mut self,
727 &mut self,
1867− node: &syntax::Node,
728+ header: &str,
1868− empty_branch: Option<&syntax::Node>,
729+ body: &[Syntax],
730+ empty: &[Syntax],
731+ line_num: usize,
1869 ctx: &mut ParseContext,
732 ctx: &mut ParseContext,
1870− ) -> Result<Option<Vec<Node>>, ParseError> {
733+ ) -> Result<Vec<Node>, ParseError> {
1871− ctx.visited.insert(node.id);
734+ let Some((names, list_src)) = header.split_once(" in ") else {
1872− if let Some(branch) = empty_branch {
735+ return Err(ParseError {
1873− ctx.visited.insert(branch.id);
736+ line: line_num,
1874− }
737+ message: "@each requires: @each $item in LIST".to_string(),
1875− ctx.enter(node);
738+ });
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− }
1900 };
739 };
1901− if bracketed {
740+ let names: Vec<&str> = names.split(',').map(|v| v.trim().trim_start_matches('$')).collect();
1902− let inner = list.raw[1..list.raw.len() - 1].trim().to_string();
741+ if names.len() > 2 {
1903− let diagnostic = not_a_list(
742+ return Err(ParseError {
1904− format!(
743+ line: line_num,
1905− "@each takes a list written without brackets, `@each ${} in {}`; \
744+ message: "@each takes `$item` or `$item, $index`: to loop over records, \
1906− JSON data goes in `@data`",
745+ load them with @data and use `$item.key`"
1907− item, inner
746+ .to_string(),
1908− ),
747+ });
1909− ctx,
1910− )
1911− .suggest(Some(inner));
1912− ctx.push_once(diagnostic);
1913 }
748 }
1914− let items: Vec<Value> = match source {
749+ let (item, index) = (names[0], names.get(1).copied());
1915− Value::List(list) => list.items.to_vec(),
750+ let list_src = list_src.trim();
1916− // Reported (an undefined `$name`, brackets): there is nothing
751+ track_var_refs(list_src, &mut ctx.used_variables);
1917− // to repeat
752+
1918− _ if !filled => Vec::new(),
753+ // A list loaded from JSON, by name
1919− Value::Record(_) => {
754+ let data_list = list_src.strip_prefix('$').and_then(|name| {
1920− let diagnostic = not_a_list(
755+ let len = ctx.variables.get(&format!("{}#", name))?.parse::<usize>().ok()?;
1921− format!(
756+ Some((name.to_string(), len))
1922− "@each repeats for the items of a list, and `{}` is a record: \
757+ });
1923− loop over a list of records, or use its fields",
758+ let undefined = list_src.strip_prefix('$').filter(|name| {
1924− list.raw
759+ name.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
1925− ),
760+ && !ctx.variables.contains_key(*name)
1926− ctx,
761+ });
1927− );
762+ let text_items: Vec<String> = match (&data_list, undefined) {
1928− ctx.push_once(diagnostic);
763+ (Some(_), _) => Vec::new(),
764+ // A missing variable is an empty list: quietly for a record's
765+ // missing field (`$post.tags`), with a warning otherwise
766+ (None, Some(name)) => {
767+ let root = name.split('.').next().unwrap_or(name);
768+ if !ctx.variables.contains_key(root) {
769+ ctx.diagnostics.push(Diagnostic {
770+ line: line_num,
771+ column: None,
772+ message: format!("undefined variable '${}'", name),
773+ severity: Severity::Warning,
774+ source_line: Some(format!("@each {}", header)),
775+ });
776+ }
1929 Vec::new()
777 Vec::new()
1930 }
778 }
1931− // Empty text (a field a record doesn't have) is no items; any
779+ (None, None) => text_list_items(&substitute_vars(list_src, &ctx.variables)),
1932− // other value is one
1933− value if value.to_string().is_empty() => Vec::new(),
1934− value => vec![value],
1935 };
780 };
781+ let count = data_list.as_ref().map_or(text_items.len(), |(_, len)| *len);
1936
782
1937− if body.iter().all(syntax::Node::is_trivia) {
783+ if body.is_empty() {
1938− return Ok(Some(Vec::new()));
784+ return Ok(Vec::new());
1939 }
785 }
1940− if items.is_empty() {
786+ if count == 0 {
1941− return Ok(Some(self.eval_scoped(empty, ctx)));
787+ return Ok(self.eval_scoped(empty, ctx));
1942 }
788 }
789+ let saved_vars = ctx.variables.clone();
1943 let mut nodes = Vec::new();
790 let mut nodes = Vec::new();
1944− for (i, value) in items.into_iter().enumerate() {
791+ for i in 0..count {
1945− ctx.env.push();
792+ match &data_list {
1946− ctx.bind(item, value);
793+ Some((name, _)) => bind_item(&mut ctx.variables, &format!("{}.{}", name, i), item),
1947− if let Some(index) = index {
794+ None => {
1948− ctx.bind(index, Value::Num(i as f64));
795+ let text = text_items.get(i).cloned().unwrap_or_default();
796+ ctx.variables.insert(item.to_string(), text);
797+ }
798+ }
799+ if let Some(index) = index {
800+ ctx.variables.insert(index.to_string(), i.to_string());
1949 }
801 }
1950 nodes.extend(self.eval_block(body, ctx));
802 nodes.extend(self.eval_block(body, ctx));
1951− ctx.env.pop();
1952 }
803 }
1953− Ok(Some(nodes))
804+ ctx.variables = saved_vars;
805+ Ok(nodes)
1954 }
806 }
1955
807
1956 fn define_function(
808 fn define_function(
1957 &mut self,
809 &mut self,
1958 name: &str,
810 name: &str,
1959− params: &[syntax::Param],
811+ params: &[String],
1960− node: &syntax::Node,
812+ defaults: &HashMap<String, String>,
813+ body: &[Syntax],
814+ line_num: usize,
1961 ctx: &mut ParseContext,
815 ctx: &mut ParseContext,
1962 ) {
816 ) {
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
817 // 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
818+ // function: its rules apply inside a `.hl-NAME` wrapper.
1967− // generated class never has a second `-`, so the two can't meet).
819+ let (style, body): (Vec<&Syntax>, Vec<&Syntax>) = body
1968− // The hash is the stylesheet's, so two functions of one name with
820+ .iter()
1969− // two stylesheets, in two files, don't style each other's elements
821+ .partition(|node| matches!(node, Syntax::Line { text, .. } if text == "@style"));
1970− // in a page that has both. It is written into the page when the
822+ if !style.is_empty() {
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
823 let css: String = style
1976 .iter()
824 .iter()
1977− .map(|node| {
825+ .filter_map(|node| match node {
1978− ctx.visited.insert(node.id);
826+ Syntax::Line { children, .. } => Some(block_text(children)),
1979− verbatim_content(node)
827+ _ => None,
1980 })
828 })
1981 .collect();
829 .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
830 // Nested under the scope class, so any CSS works (multi-line
1986 // rules, at-rules)
831 // rules, at-rules)
1987− let css = if css.trim().is_empty() {
832+ if !css.trim().is_empty() {
1988− String::new()
833+ ctx.custom_css.push(format!(".hl-{} {{\n{}}}", name, css));
1989− } else {
834+ }
1990− format!(".{} {{\n{}}}", class, css)
835+ ctx.scoped_functions.insert(name.to_string());
1991− };
1992− (class, css)
1993− });
1994−
1995− // A default is filled in only when a call leaves its parameter
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 }
836 }
837+
2000 ctx.fn_lines.entry(name.to_string()).or_insert(line_num);
838 ctx.fn_lines.entry(name.to_string()).or_insert(line_num);
2001− let wraps_itself = ElementKind::from_name(name).is_some()
839+ ctx.functions.insert(
2002− && body.iter().any(|node| calls_unguarded(node, name));
840+ name.to_string(),
2003− if wraps_itself {
841+ FnDef {
2004− ctx.push_once(
842+ params: params.to_vec(),
2005− Diagnostic::error(
843+ defaults: defaults.clone(),
2006− code::RECURSIVE_CALL,
844+ body: body.into_iter().cloned().collect(),
2007− line_num,
845+ },
2008− format!(
846+ );
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 }
847 }
2044
848
2045− /// An element line: a chain of elements and function calls, the last of
849+ fn expand_fn_call(
2046− /// which gets the indented children.
2047− fn eval_element(
2048 &mut self,
850 &mut self,
2049− node: &syntax::Node,
851+ name: &str,
2050− element: &syntax::ElementLine,
852+ content: &str,
853+ children: &[Syntax],
854+ line_num: usize,
2051 ctx: &mut ParseContext,
855 ctx: &mut ParseContext,
2052 ) -> Result<Vec<Node>, ParseError> {
856 ) -> Result<Vec<Node>, ParseError> {
2053− // An unclosed attribute list is a syntax error, already reported
857+ // Recursive function cycle detection
2054− if element
858+ if ctx.fn_call_stack.contains(&name.to_string()) {
2055− .chain
859+ return Err(ParseError {
2056− .iter()
860+ line: line_num,
2057− .any(|h| h.attrs.as_ref().is_some_and(|a| !a.closed))
861+ message: format!(
2058− {
862+ "recursive function call to @{} (call stack: {})",
2059− return Ok(Vec::new());
863+ name,
2060− }
864+ ctx.fn_call_stack.join(" -> ")
2061− let line_num = node.span.line;
865+ ),
2062− let last = element.chain.len() - 1;
866+ });
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 {
2071− None
2072− };
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 }
867 }
2089− Ok(current)
868+ ctx.fn_call_stack.push(name.to_string());
2090− }
2091
869
2092− /// Give a resolved `@name` its children: an element holds them, and a
870+ // Parse [param value, ...] arguments
2093− /// function's body puts them where its `@children` is.
871+ let rest = &content[1 + name.len()..];
2094− fn complete(
872+ let rest = rest.trim_start();
2095− &mut self,
873+
2096− resolved: Resolved,
874+ let (args, trailing_text) = if rest.starts_with('[') {
2097− children: Vec<Node>,
875+ parse_attr_brackets_no_validate(rest, line_num, ctx)?
2098− ctx: &mut ParseContext,
876+ } else {
2099− ) -> Result<Vec<Node>, ParseError> {
877+ (Vec::new(), rest.to_string())
2100− match resolved {
878+ };
2101− Resolved::Element(mut element) => {
879+
2102− element.children.extend(children);
880+ // Clone function definition (releases borrow on ctx). If the function is
2103− let line = element.line_num;
881+ // missing (shouldn't normally happen — callers check ctx.functions.contains_key
2104− Ok(vec![inline_svg(Node::Element(element), line, ctx)])
882+ // first — but we avoid panicking on malformed state).
883+ let fn_def = match ctx.functions.get(name) {
884+ Some(def) => def.clone(),
885+ None => {
886+ ctx.fn_call_stack.pop();
887+ return Err(ParseError {
888+ line: line_num,
889+ message: format!("undefined function @{}", name),
890+ });
2105 }
891 }
2106− Resolved::Call(call) => self.expand_fn_call(*call, children, ctx),
892+ };
2107− }
2108− }
2109
893
2110− fn expand_fn_call(
894+ // Parse caller's children, separating named slots from default children
2111− &mut self,
895+ let all_caller_children = self.eval_block(children, ctx);
2112− call: Call,
2113− all_caller_children: Vec<Node>,
2114− ctx: &mut ParseContext,
2115− ) -> Result<Vec<Node>, ParseError> {
2116− let Call {
2117− name,
2118− function: fn_def,
2119− args,
2120− values,
2121− text: trailing_text,
2122− written_form,
2123− written_keys,
2124− line: line_num,
2125− name_at,
2126− source: content,
2127− } = call;
2128− let (name, content) = (name.as_str(), content.as_str());
2129−
2130− // Separate the caller's `@slot NAME` blocks from its other
2131− // children. A block for a slot the function doesn't declare was
2132− // reported where it is written (see `check_fillers`), like one
2133− // without a name.
2134 let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
896 let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
2135 let mut caller_children = Vec::new();
897 let mut caller_children = Vec::new();
2136 for child in all_caller_children {
898 for child in all_caller_children {
2137− if let Node::Element(elem) = child {
899+ if let Node::Element(ref elem) = child
2138− match elem.kind {
900+ && let ElementKind::Slot(ref slot_name) = elem.kind
2139− ElementKind::Slot(slot_name) => {
901+ && !slot_name.is_empty()
2140− slot_contents
902+ {
2141− .entry(slot_name)
903+ slot_contents
2142− .or_default()
904+ .entry(slot_name.clone())
2143− .extend(elem.children);
905+ .or_default()
2144− }
906+ .extend(elem.children.clone());
2145− kind => caller_children.push(Node::Element(Element { kind, ..elem })),
2146− }
2147 continue;
907 continue;
2148 }
908 }
2149 caller_children.push(child);
909 caller_children.push(child);
2150 }
910 }
2151 // Text after the call, as in `@card [color red] New`, is content:
911 // 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
912+ // it goes first among the caller's children.
2153− // evaluated where the call is written.
913+ let trailing_text = trailing_text.trim();
2154− if let Some(text) = &trailing_text {
914+ if !trailing_text.is_empty() {
2155− caller_children.insert(0, Node::Text(text_segments(text, ctx)));
915+ caller_children.insert(0, Node::Text(parse_text_segments(trailing_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 }
916 }
2166
917
2167− if fn_def.wraps_itself {
918+ // Save variable state, inject function parameters
2168− return Ok(Vec::new());
919+ let saved_vars = ctx.variables.clone();
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−
2187− // The body runs where the function is defined: it sees what was
2188− // visible there, the function itself (so it can call itself), and
2189− // its parameters, bound by name in the order they are declared. A
2190− // default is filled in at the call, seeing the same, with the
2191− // parameters before it.
2192− let caller_env = std::mem::replace(&mut ctx.env, fn_def.env.clone());
2193− ctx.env.push();
2194− ctx.env.define(name, Binding::Function(fn_def.clone()));
2195 let mut consumed = vec![false; args.len()];
920 let mut consumed = vec![false; args.len()];
2196− for param in &fn_def.params {
921+ for (i, param) in fn_def.params.iter().enumerate() {
2197− let mut passed = None;
922+ let named = args.iter().position(|a| a.key == *param);
2198− for (j, arg) in args.iter().enumerate() {
923+ // Positional fallback, unless that argument is named for a
2199− if arg.key != param.name {
924+ // different parameter.
2200− continue;
925+ let positional = args
2201− }
926+ .get(i)
927+ .filter(|a| !fn_def.params.contains(&a.key) && a.value.is_some())
928+ .map(|_| i);
929+ let value = named
930+ .filter(|&j| args[j].value.is_some())
931+ .or(positional)
932+ .and_then(|j| {
933+ consumed[j] = true;
934+ args[j].value.clone()
935+ })
936+ .or_else(|| fn_def.defaults.get(param).cloned())
937+ .unwrap_or_default();
938+ if let Some(j) = named {
2202 consumed[j] = true;
939 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 }
940 }
2229− let value = match passed {
941+ ctx.variables.insert(param.clone(), value);
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 => {
2249− let (column, text) = match &name_at {
2250− Some((column, text)) => (Some(*column), text.as_str()),
2251− None => (None, content),
2252− };
2253− ctx.push_once(
2254− missing_parameter(name, &param.name, line_num, column).source(text),
2255− );
2256− Value::empty()
2257− }
2258− },
2259− };
2260− ctx.bind(&param.name, value);
2261 }
942 }
2262 // Arguments that aren't parameters are attributes for the
943 // Arguments that aren't parameters are attributes for the
2263 // function's root element, so a function can be styled like an
944 // function's root element, so a function can be styled like an
@@ -2269,24 +950,12 @@ impl Evaluator {
2269 .map(|(a, _)| a.clone())
950 .map(|(a, _)| a.clone())
2270 .collect();
951 .collect();
2271
952
2272− // Evaluate the body with the parameters in scope, as lines of the
953+ // Evaluate the body with the parameters in scope
2273− // file that defines the function
954+ let body_nodes = self.eval_block(&fn_def.body, ctx);
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
955
2280− // Back to the caller's names and call stack
956+ // Restore variables and call stack
2281− ctx.env = caller_env;
957+ ctx.variables = saved_vars;
2282 ctx.fn_call_stack.pop();
958 ctx.fn_call_stack.pop();
2283− if ctx.fn_call_stack.is_empty() {
2284− ctx.too_deep = false;
2285− }
2286− (ctx.current_line, ctx.current_source, ctx.current_node) = caller;
2287−
2288− // What the body wrote is the call's
2289− belongs_to_call(&mut body_nodes, name, line_num);
2290
959
2291 // Replace @children with caller's children and @slot with slot content
960 // Replace @children with caller's children and @slot with slot content
2292 let mut result_nodes =
961 let mut result_nodes =
@@ -2294,7 +963,7 @@ impl Evaluator {
2294
963
2295 // Attributes that aren't parameters style the function's root
964 // Attributes that aren't parameters style the function's root
2296 // element, and a scoped @style's class goes on it too.
965 // element, and a scoped @style's class goes on it too.
2297− let scope_class = fn_def.scoped.as_ref().map(|(class, _)| class.clone());
966+ let scope_class = ctx.scoped_functions.contains(name).then(|| format!("hl-{}", name));
2298 if !forwarded.is_empty() || scope_class.is_some() {
967 if !forwarded.is_empty() || scope_class.is_some() {
2299 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
968 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
2300 Node::Element(e) => Some(e),
969 Node::Element(e) => Some(e),
@@ -2307,45 +976,17 @@ impl Evaluator {
2307 let mut problems = Vec::new();
976 let mut problems = Vec::new();
2308 match root {
977 match root {
2309 Some(root) => {
978 Some(root) => {
2310− // A call's `class=` adds to the root's own classes
979+ root.attrs.extend(forwarded);
2311− for attr in forwarded {
2312− let existing = (attr.html && attr.key == "class")
2313− .then(|| root.attrs.iter_mut().find(|a| a.html && a.key == "class"))
2314− .flatten();
2315− match existing {
2316− Some(class) => {
2317− let mut value = class.value.take().unwrap_or_default();
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− }
2324− class.value = Some(value.trim().to_string());
2325− }
2326− None => root.attrs.push(attr),
2327− }
2328− }
2329− // The scoped `@style` goes into the page once, when the
2330− // function is first called
2331− if let Some((_, css)) = &fn_def.scoped
2332− && !css.is_empty()
2333− && !ctx.scoped_css.contains(css)
2334− {
2335− ctx.scoped_css.push(css.clone());
2336− }
2337 if let Some(class) = scope_class {
980 if let Some(class) = scope_class {
2338 match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
981 match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
2339 Some(attr) => {
982 Some(attr) => {
2340 let existing = attr.value.take().unwrap_or_default();
983 let existing = attr.value.take().unwrap_or_default();
2341− attr.value =
984+ attr.value = Some(format!("{} {}", existing, class).trim().to_string());
2342− Some(format!("{} {}", existing, class).trim().to_string());
2343 }
985 }
2344 None => root.attrs.push(Attribute {
986 None => root.attrs.push(Attribute {
2345 key: "class".to_string(),
987 key: "class".to_string(),
2346 value: Some(class),
988 value: Some(class),
2347 html: true,
989 html: true,
2348− quoted: None,
2349 }),
990 }),
2350 }
991 }
2351 }
992 }
@@ -2373,583 +1014,50 @@ impl Evaluator {
2373 }
1014 }
2374 }
1015 }
2375 for message in problems {
1016 for message in problems {
2376− ctx.diagnostics.push(
1017+ ctx.diagnostics.push(Diagnostic {
2377− Diagnostic::error(code::NO_SINGLE_ROOT, line_num, message)
1018+ line: line_num,
2378− .source(content)
1019+ column: None,
2379− .subject(name),
1020+ message,
2380− );
1021+ severity: Severity::Warning,
1022+ source_line: Some(content.to_string()),
1023+ });
2381 }
1024 }
2382 }
1025 }
2383
1026
2384 Ok(result_nodes)
1027 Ok(result_nodes)
2385 }
1028 }
2386−}
2387−
2388−/// A `@name` read where it is written: a built-in element, or a call.
2389−enum Resolved {
2390− Element(Element),
2391− Call(Box<Call>),
2392−}
2393−
2394−/// A call of a function, with its arguments evaluated where it is written.
2395−struct Call {
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−
2416−/// What `@name` is where it is written: a call of the function `name`
2417−/// when one is defined, else the built-in element. A whole line, each link
2418−/// of a chain and an inline element in text all resolve here.
2419−fn resolve(
2420− head: &syntax::Head,
2421− text: Option<&syntax::Text>,
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−
2457−/// A line of another file (the standard library, an included file) means
2458−/// nothing in this one: the problems reported since `before` while
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−
2488−/// A call nested deeper than [`MAX_CALL_DEPTH`].
2489−fn too_deep(name: &str, stack: &[String], line: usize) -> Diagnostic {
2490− let message = if stack.iter().any(|n| n == name) {
2491− format!(
2492− "recursive function call to @{} goes more than {} calls deep: a function that \
2493− calls itself needs a condition that stops it (`@if`)",
2494− name, MAX_CALL_DEPTH
2495− )
2496− } else {
2497− format!(
2498− "function calls nest more than {} deep at @{} (starting with {})",
2499− MAX_CALL_DEPTH,
2500− name,
2501− stack
2502− .iter()
2503− .take(3)
2504− .map(|n| format!("@{}", n))
2505− .collect::<Vec<_>>()
2506− .join(" → ")
2507− )
2508− };
2509− Diagnostic::error(code::RECURSIVE_CALL, line, message).subject(name)
2510−}
2511−
2512−/// Mark what a function's body wrote as the call's: it gets the call's
2513−/// line, so what is reported about it points at the call, and the name of
2514−/// the function, unless a call inside the body already claimed it.
2515−fn belongs_to_call(nodes: &mut [Node], function: &str, line: usize) {
2516− fn claim(element: &mut Element, function: &str, line: usize) {
2517− element.line_num = line;
2518− element.function.get_or_insert_with(|| function.to_string());
2519− belongs_to_call(&mut element.children, function, line);
2520− }
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− }
2529− }
2530− }
2531− Node::Raw(_) => {}
2532− }
2533− }
2534−}
2535−
2536−/// What an inline `{@name ...}` stands for in its line of text: an
2537−/// element; the text of a function whose body is text; or, for a body with
2538−/// several roots, its nodes as a fragment.
2539−fn inline_segments(mut nodes: Vec<Node>, line: usize) -> Vec<TextSegment> {
2540− if let [Node::Element(_)] = nodes.as_slice()
2541− && let Some(Node::Element(element)) = nodes.pop()
2542− {
2543− return vec![TextSegment::Inline(element)];
2544− }
2545− if nodes.iter().all(|n| matches!(n, Node::Text(_))) {
2546− let mut segments = Vec::new();
2547− for node in nodes {
2548− if let Node::Text(run) = node {
2549− if !segments.is_empty() {
2550− segments.push(TextSegment::Plain(" ".to_string()));
2551− }
2552− segments.extend(run);
2553− }
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−
2567−/// Fill in the default of a parameter that a call leaves out, like the
2568−/// value of an attribute passed for it: variables are filled in, one
2569−/// `$name` keeps its type and one `"..."` is quoted text. It sees what the
2570−/// body sees: the names visible where the function is defined, and the
2571−/// parameters before it. Its problems are reported at the definition, once.
2572−fn fill_default(
2573− fn_def: &FnDef,
2574− param: &syntax::Param,
2575− default: &str,
2576− ctx: &mut ParseContext,
2577−) -> Value {
2578− // A default that uses a later parameter is an error in the
2579− // definition; the later one isn't bound yet, so don't report it again
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
1029
2605−/// Read `@page`'s attribute list and title. The page is the root element,
1030+ fn parse_element_line(
2606−/// and its attributes are checked like any element's: `key=value` (and a
1031+ &mut self,
2607−/// bare boolean HTML attribute) go on `<html>`, styles on `<body>`, and
1032+ content: &str,
2608−/// `favicon FILE` is its one word of htmlang's own.
1033+ children: &[Syntax],
2609−fn read_page(
1034+ line_num: usize,
2610− attrs: Option<&syntax::AttrList>,
1035+ ctx: &mut ParseContext,
2611− title: Option<&syntax::Arg>,
1036+ ) -> Result<Node, ParseError> {
2612− line: usize,
1037+ let segments = split_chain(content);
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− }
2646− if words.contains(&attr.key) {
2647− if page.favicon.is_some() {
2648− let diagnostic = Diagnostic::warning(
2649− code::DUPLICATE_ATTRIBUTE,
2650− line,
2651− "duplicate attribute 'favicon': the later one wins".to_string(),
2652− )
2653− .subject("favicon");
2654− ctx.diagnostics.push(at_attribute(diagnostic, column, ctx));
2655− }
2656− // The file itself goes into the page when it can be read
2657− // (relative to the file); otherwise it is linked
2658− page.favicon = value.map(|path| match std::fs::read(ctx.resolve(&path)) {
2659− Ok(data) => crate::codegen::data_uri(&path, &data),
2660− Err(_) => path,
2661− });
2662− } else if attr.html || boolean {
2663− page.html_attrs.push(attr);
2664− } else {
2665− page.styles.push(attr);
2666− }
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
1038
2676−/// `@page`'s own rules for one of its attributes, checked where the
1039+ // Parse each segment into an Element
2677−/// `@page` runs and where it doesn't (a layout that isn't called): its
1040+ let mut elements: Vec<Element> = Vec::new();
2678−/// word is written `favicon FILE`, and `inline` belongs to `@image`.
1041+ for seg in &segments {
2679−/// Returns whether the attribute can be used (a `favicon` with its file,
1042+ let elem = parse_single_element(seg.trim(), line_num, ctx)?;
2680−/// or any other attribute but `inline`).
1043+ elements.push(elem);
2681−fn check_page_attr(
2682− key: &str,
2683− html: bool,
2684− value: Option<&str>,
2685− line: usize,
2686− column: Option<usize>,
2687− ctx: &mut ParseContext,
2688−) -> bool {
2689− let report = |ctx: &mut ParseContext, diagnostic: Diagnostic| {
2690− let diagnostic = at_attribute(diagnostic, column, ctx);
2691− ctx.diagnostics.push(diagnostic);
2692− };
2693− if crate::vocab::PAGE_WORDS.contains(&key) {
2694− if html {
2695− report(
2696− ctx,
2697− Diagnostic::error(
2698− code::PARAMETER_FORM,
2699− line,
2700− format!(
2701− "'{0}' is @page's own word, not an HTML attribute: write `{0} {1}`",
2702− key,
2703− value.filter(|v| !v.is_empty()).unwrap_or("FILE")
2704− ),
2705− )
2706− .subject(format!("{}=", key))
2707− .suggest(Some(format!("{} ", key))),
2708− );
2709 }
1044 }
2710− if value.is_none_or(|v| v.trim().is_empty()) {
2711− report(
2712− ctx,
2713− Diagnostic::error(
2714− code::MISSING_VALUE,
2715− line,
2716− format!("'{0}' needs its file: `{0} favicon.png`", key),
2717− )
2718− .subject(key),
2719− );
2720− return false;
2721− }
2722− return true;
2723− }
2724− if !html && crate::vocab::base_attribute(key) == "inline" {
2725− report(
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−
2741−/// A parameter passed `name=value`, the form of an HTML attribute.
2742−fn parameter_form(function: &str, key: &str, value: &str, line: usize) -> Diagnostic {
2743− Diagnostic::error(
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−
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−
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
1045
2782−/// Define `$name`, or, for `--name`, the CSS custom property on `:root`
1046+ // Parse indented children (belong to the innermost element)
2783−/// (which gets quoted text with its quotes, and is read with
1047+ let children = self.eval_block(children, ctx);
2784−/// `var(--name)`, never as a variable). `@let name.field value` gives the
2785−/// record `$name` that field (or the list `$name` a new item at that
2786−/// index), as a new value in this block.
2787−fn set_variable(name: &str, value: Value, line_num: usize, ctx: &mut ParseContext) {
2788− if name.starts_with("--") {
2789− let css = value
2790− .quoted_css()
2791− .map_or_else(|| value.to_string(), str::to_string);
2792− let at_line = |diagnostic: Diagnostic, ctx: &ParseContext| match &ctx.current_source {
2793− (current, Some(text)) if *current == line_num => diagnostic.source(text.clone()),
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− }
2813− match css_breakout(&css) {
2814− Some(reason) => {
2815− let diagnostic = Diagnostic::error(
2816− code::INVALID_VALUE,
2817− line_num,
2818− format!(
2819− "the value of '{}' has {}: `{}` can't be written into the page's CSS",
2820− name, reason, css
2821− ),
2822− )
2823− .subject(css.as_str());
2824− ctx.push_once(at_line(diagnostic, ctx));
2825− }
2826− None => match ctx.css_vars.iter_mut().find(|(n, _)| n == name) {
2827− Some(declared) => {
2828− let diagnostic = Diagnostic::warning(
2829− code::DUPLICATE_ATTRIBUTE,
2830− line_num,
2831− format!(
2832− "{name} is declared again: :root has one value for the whole page, \
2833− so every element gets this one, also above this line (a theme \
2834− redefines a token on an element: `[dark:{name} {css}]`)"
2835− ),
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− }
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
1048
2873−/// `value` (the value of `$name`) with the field at `path` set to `new`:
1049+ // Build chain right-to-left: rightmost gets children, each wraps the next
2874−/// a record's field, or the item of a list at an index it has. A name
1050+ let mut current_children = children;
2875−/// without a value, or one whose value is empty (a field a record doesn't
1051+ for mut elem in elements.into_iter().rev() {
2876−/// have), becomes a record; any other value has no fields to set.
1052+ elem.children.extend(current_children);
2877−fn with_field(
1053+ current_children = vec![Node::Element(elem)];
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− }
2895− None => fields.push((field.to_string(), with_field(None, &inner, rest, new)?)),
2896− }
2897− Ok(Value::Record(Rc::new(fields)))
2898− }
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 }
1054 }
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
1055
2932−/// What a verbatim directive holds (`@head`, `@style`, `@raw`): the rest
1056+ current_children.into_iter().next().ok_or(ParseError {
2933−/// of its line (`@style .a { color: red }`), or else its indented block.
1057+ line: line_num,
2934−/// The two together are a syntax error, reported by the tree; the line
1058+ message: "element chain produced no nodes (this is a parser bug)".to_string(),
2935−/// wins.
1059+ })
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−
2943−/// The text of a verbatim body (`@head`, `@style`, `@markdown`, `@raw`).
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 }
1060 }
2952− text
2953}
1061}
2954
1062
2955// ---------------------------------------------------------------------------
1063// ---------------------------------------------------------------------------
@@ -2996,708 +1104,88 @@ fn replace_children_and_slots(
2996}
1104}
2997
1105
2998// ---------------------------------------------------------------------------
1106// ---------------------------------------------------------------------------
2999−// Where @slot and @children are written
1107+// Element parsing
3000// ---------------------------------------------------------------------------
1108// ---------------------------------------------------------------------------
3001
1109
3002−/// The files each `@include` line brought in, by the line's node id, with
1110+fn extract_element_name(content: &str) -> &str {
3003−/// the include chain that leads to each.
1111+ let without_at = &content[1..];
3004−type IncludedBy = HashMap<usize, Vec<(Rc<Tree>, String)>>;
1112+ match without_at.find([' ', '[']) {
3005−
1113+ Some(i) => &without_at[..i],
3006−/// The lines of the files an `@include` line brings in, and the include
1114+ None => without_at,
3007−/// chain of each (for diagnostics).
1115+ }
3008−type Included<'a, 'f> = &'f dyn Fn(&syntax::Node) -> Vec<(&'a [syntax::Node], Option<&'a str>)>;
3009−
3010−/// Whether `name` is a function here: defined so far, or anywhere in the
3011−/// file and the files it includes.
3012−fn is_function(name: &str, ctx: &ParseContext) -> bool {
3013− ctx.env.function(name).is_some() || ctx.namespace.contains(name)
3014}
1116}
3015
1117
3016−/// Whether `name` is one slot name: letters, digits, `-` and `_`,
1118+fn parse_single_element(
3017−/// starting with a letter (like a function's name).
1119+ content: &str,
3018−fn is_slot_name(name: &str) -> bool {
1120+ line_num: usize,
3019− name.chars().next().is_some_and(char::is_alphabetic)
1121+ ctx: &mut ParseContext,
3020− && name
1122+) -> Result<Element, ParseError> {
3021− .chars()
1123+ let (kind, rest) = if let Some(without_at) = content.strip_prefix('@') {
3022− .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
1124+ match without_at.find([' ', '[']) {
3023−}
1125+ Some(i) => {
1126+ let kind_str = &without_at[..i];
1127+ let rest = if without_at.as_bytes()[i] == b'[' {
1128+ without_at[i..].to_string()
1129+ } else {
1130+ without_at[i + 1..].to_string()
1131+ };
1132+ match parse_element_kind(kind_str, line_num) {
1133+ Ok(kind) => (kind, rest),
1134+ Err(mut e) => {
1135+ // Also suggest user-defined functions
1136+ if let Some(fn_suggestion) = suggest_fn_name(kind_str, ctx) {
1137+ e.message =
1138+ format!("{}, or did you mean @{}?", e.message, fn_suggestion);
1139+ }
1140+ return Err(e);
1141+ }
1142+ }
1143+ }
1144+ None => match parse_element_kind(without_at, line_num) {
1145+ Ok(kind) => (kind, String::new()),
1146+ Err(mut e) => {
1147+ if let Some(fn_suggestion) = suggest_fn_name(without_at, ctx) {
1148+ e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion);
1149+ }
1150+ return Err(e);
1151+ }
1152+ },
1153+ }
1154+ } else {
1155+ return Err(ParseError {
1156+ line: line_num,
1157+ message: format!("expected @element or [attrs], got: {}", content),
1158+ });
1159+ };
3024
1160
3025−/// What a `@slot` or `@children` line is written in.
1161+ // Parse optional [attrs]
3026−#[derive(Clone, Copy)]
1162+ let (attrs, rest) = if rest.starts_with('[') {
3027−enum Place<'a> {
1163+ parse_attr_brackets(&rest, line_num, ctx)?
3028− /// The top of a file or of a function's body, or a built-in element
1164+ } else {
3029− /// (by its name).
1165+ (Vec::new(), rest)
3030− Element(Option<&'a str>),
1166+ };
3031− /// A call: its content.
3032− Call,
3033−}
3034
1167
3035−/// Where a line is, for [`slot_uses`].
1168+ let rest = rest.trim().to_string();
3036−#[derive(Clone, Copy)]
1169+ track_var_refs(&rest, &mut ctx.used_variables);
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
1170
3048−impl At<'_> {
1171+ // For @link, first token of rest is URL, remainder is inline text
3049− /// The top of a file, or of a function's body.
1172+ let mut children = Vec::new();
3050− fn top(body: bool) -> Self {
1173+ let argument = if rest.is_empty() {
3051− At {
1174+ None
3052− body,
1175+ } else if kind == ElementKind::Link {
3053− place: Place::Element(None),
1176+ let rest_sub = substitute_vars(&protect_escapes(&rest), &ctx.variables);
3054− call: None,
1177+ if let Some((url, text)) = rest_sub.split_once(' ') {
3055− file: None,
1178+ let text = text.trim();
3056− }
1179+ if !text.is_empty() {
3057− }
1180+ children.push(Node::Text(parse_text_segments(text, ctx)));
3058−}
1181+ }
3059−
1182+ Some(restore_escapes(url))
3060−/// What [`slot_uses`] knows about the names and files it walks.
1183+ } else {
3061−struct Scan<'a, 'f> {
1184+ Some(restore_escapes(&rest_sub))
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−
3080−impl SlotUse<'_> {
3081− fn is_slot(&self) -> bool {
3082− self.head.name == "slot"
3083− }
3084−
3085− /// The slot's name as written.
3086− fn name(&self) -> &str {
3087− self.text.map_or("", |t| t.raw.trim())
3088− }
3089−
3090− /// A `@slot NAME` that marks a place in a function's body (not one
3091− /// that fills a slot of a call).
3092− fn declares(&self) -> bool {
3093− self.is_slot() && !self.inline && !matches!(self.at.place, Place::Call)
3094− }
3095−}
3096−
3097−/// Where each `@slot` and `@children` in `block` is written. A `@slot`
3098−/// directly under a call (under its `@if`, `@else` and `@each` too) fills
3099−/// one of its slots; anywhere else in a function's body it declares one.
3100−/// An included file's lines are where its `@include` is.
3101−fn slot_uses<'a>(
3102− block: &'a [syntax::Node],
3103− at: At<'a>,
3104− scan: &Scan<'a, '_>,
3105− out: &mut Vec<SlotUse<'a>>,
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− ) {
3114− for segment in &text.segments {
3115− if let Segment::Inline(inline_element) = segment {
3116− let head = &inline_element.head;
3117− if matches!(head.name.as_str(), "slot" | "children")
3118− && !(scan.is_function)(&head.name)
3119− {
3120− out.push(SlotUse {
3121− node,
3122− head,
3123− text: inline_element.text.as_ref(),
3124− inline: true,
3125− at,
3126− });
3127− }
3128− if let Some(text) = &inline_element.text {
3129− inline(text, node, at, scan, out);
3130− }
3131− }
3132− }
3133− }
3134− for node in block {
3135− match &node.kind {
3136− NodeKind::Directive(directive) => match &directive.args {
3137− DirectiveArgs::Let(def) if matches!(def.form, LetForm::Function(_)) => {
3138− if scan.into_lets {
3139− let top = At {
3140− file: at.file,
3141− ..At::top(true)
3142− };
3143− slot_uses(&node.children, top, scan, out);
3144− }
3145− }
3146− // A value's body is an error of its own
3147− DirectiveArgs::Let(_) => {}
3148− _ if directive.spec.body == BodyKind::Verbatim => {}
3149− // `@if`, `@else` and `@each` put their lines in place, and
3150− // `@include` its file's; the lines under a directive that
3151− // takes no body are its siblings
3152− _ => {
3153− for (lines, file) in (scan.included)(node) {
3154− slot_uses(lines, At { file, ..at }, scan, out);
3155− }
3156− slot_uses(&node.children, at, scan, out);
3157− }
3158− },
3159− NodeKind::Element(line) => {
3160− let mut at = at;
3161− let last = line.chain.len() - 1;
3162− for (i, head) in line.chain.iter().enumerate() {
3163− let text = if i == last { line.text.as_ref() } else { None };
3164− let function = (scan.is_function)(&head.name);
3165− if !function && matches!(head.name.as_str(), "slot" | "children") {
3166− out.push(SlotUse {
3167− node,
3168− head,
3169− text,
3170− inline: false,
3171− at,
3172− });
3173− } else if let Some(text) = text {
3174− inline(text, node, at, scan, out);
3175− }
3176− if function {
3177− at.place = Place::Call;
3178− if !at.body {
3179− at.call = Some((&head.name, node.span.line));
3180− }
3181− } else {
3182− at.place = Place::Element(Some(&head.name));
3183− }
3184− }
3185− slot_uses(&node.children, at, scan, out);
3186− }
3187− NodeKind::Text(text) => {
3188− inline(text, node, at, scan, out);
3189− slot_uses(&node.children, at, scan, out);
3190− }
3191− NodeKind::Blank | NodeKind::Comment | NodeKind::Verbatim(_) => {}
3192− }
3193− }
3194−}
3195−
3196−/// The slots a function's body declares, and whether it has a
3197−/// `@children`, when it is defined. The files its `@include` lines bring
3198−/// in count too: they are read now, since they run only at a call.
3199−fn declared_slots(body: &[syntax::Node], ctx: &mut ParseContext) -> (Vec<String>, bool) {
3200− let mut files = HashMap::new();
3201− read_includes(
3202− body,
3203− ctx.base_path.clone(),
3204− &mut Vec::new(),
3205− ctx,
3206− &mut files,
3207− );
3208− let included = |node: &syntax::Node| match files.get(&node.id) {
3209− Some(tree) => vec![(&tree.nodes[..], None)],
3210− None => Vec::new(),
3211− };
3212− let is_function = |name: &str| is_function(name, ctx);
3213− slots_of(body, &is_function, &included)
3214−}
3215−
3216−/// Read and parse the files the `@include` lines in `nodes` bring in (by
3217−/// a literal path, relative to `base`), and the files those include, by
3218−/// the node id of each `@include` line.
3219−fn read_includes(
3220− nodes: &[syntax::Node],
3221− base: Option<PathBuf>,
3222− stack: &mut Vec<PathBuf>,
3223− ctx: &mut ParseContext,
3224− files: &mut HashMap<usize, Rc<Tree>>,
3225−) {
3226− let mut includes = Vec::new();
3227− for node in nodes {
3228− node.walk(&mut |node| {
3229− if let Some(DirectiveArgs::Text(Some(path))) = node.directive().map(|d| &d.args)
3230− && node.is_directive("include")
3231− {
3232− includes.push((node.id, path.raw.clone()));
3233− }
3234− });
3235− }
3236− for (id, path) in includes {
3237− let path = path.trim_matches('"');
3238− if path.contains('$') {
3239− continue;
3240− }
3241− let resolved = match &base {
3242− Some(base) => base.join(path),
3243− None => PathBuf::from(path),
3244− };
3245− if stack.contains(&resolved) {
3246− continue;
3247− }
3248− let Ok(text) = ctx.read_file(&resolved) else {
3249− continue;
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− }
3258−}
3259−
3260−/// The slots a function's body declares (`@slot NAME`, in order), and
3261−/// whether it has a `@children` for the content of a call. A `@slot`
3262−/// directly under a call in the body fills that call's slot instead, so
3263−/// `is_function` says which names are functions. The files the body
3264−/// includes aren't read.
3265−pub fn function_slots(
3266− body: &[syntax::Node],
3267− is_function: &dyn Fn(&str) -> bool,
3268−) -> (Vec<String>, bool) {
3269− slots_of(body, is_function, &|_| Vec::new())
3270−}
3271−
3272−fn slots_of<'a>(
3273− body: &'a [syntax::Node],
3274− is_function: &dyn Fn(&str) -> bool,
3275− included: Included<'a, '_>,
3276−) -> (Vec<String>, bool) {
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− }
3290− }
3291− let has_children = uses.iter().any(|u| !u.is_slot() && !u.inline);
3292− (slots, has_children)
3293−}
3294−
3295−/// The line as written, indented, when it is one physical line, so a
3296−/// column points into it.
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−
3304−/// The lines of the files an `@include` line brought in when it ran, from
3305−/// `included_by`.
3306−fn ran_includes<'a>(
3307− included_by: &'a IncludedBy,
3308− node: &syntax::Node,
3309−) -> Vec<(&'a [syntax::Node], Option<&'a str>)> {
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−
3319−/// Check where every `@slot` and `@children` of the file and the files it
3320−/// includes is written, whether or not it runs: a slot has one name, a
3321−/// function's body marks places with them, and a call fills its slots
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− })
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 }
1185 }
3364 } else {
1186 } else {
3365− "@children".to_string()
1187+ Some(restore_escapes(&substitute_vars(&protect_escapes(&rest), &ctx.variables)))
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− }
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−}
3464−
3465−/// Whether `@slot` is the built-in element here, and not a function
3466−/// defined so far that took its name.
3467−fn slot_is_built_in(ctx: &ParseContext) -> bool {
3468− ctx.env.function("slot").is_none()
3469−}
3470−
3471−/// The `@slot NAME` blocks directly under a call, in `block` (under its
3472−/// `@if`, `@else` and `@each` too, and in the files its `@include` lines
3473−/// brought in).
3474−fn fillers<'a>(
3475− block: &'a [syntax::Node],
3476− file: Option<&'a str>,
3477− included: &'a IncludedBy,
3478− out: &mut Vec<Filler<'a>>,
3479−) {
3480− for node in block {
3481− match &node.kind {
3482− NodeKind::Directive(directive)
3483− if matches!(directive.name(), "if" | "else" | "each") =>
3484− {
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− }
3491− }
3492− NodeKind::Element(line) if line.chain.len() == 1 && line.chain[0].name == "slot" => {
3493− if let Some(text) = &line.text {
3494− out.push(Filler { node, text, file });
3495− }
3496− }
3497− _ => {}
3498− }
3499− }
3500−}
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− }
3563− ctx.push_once(d);
3564− }
3565− }
3566−}
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 };
1188 };
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−}
3614−
3615−// ---------------------------------------------------------------------------
3616−// Element parsing
3617−// ---------------------------------------------------------------------------
3618−
3619−/// Build the element for `@name [attrs] text` (on its own line, in a chain,
3620−/// or inline in text).
3621−fn parse_single_element(
3622− head: &syntax::Head,
3623− text: Option<&syntax::Text>,
3624− line_num: usize,
3625− ctx: &mut ParseContext,
3626−) -> Result<Element, ParseError> {
3627− let kind = parse_element_kind(&head.name, line_num, ctx)?;
3628− let mut attrs = head.attrs.as_ref().map_or_else(Vec::new, |list| {
3629− parse_attr_list(&list.attrs, line_num, ctx, true, &[])
3630− });
3631−
3632− // The leading argument: for an element whose row names a leading
3633− // attribute (`@link`'s href, `@image`'s src, `@form`'s action, ...),
3634− // the first token of the text fills it, split before anything is
3635− // filled in, and the rest is content (an error on an element without
3636− // content). `@slot`'s whole text is its name. Any other element's text
3637− // is content, parsed like any line of text, as in `@el [padding 8]
3638− // Hello` or `@h2 Meet {@text htmlang}`; its layout decides how it
3639− // combines with the lines under it.
3640− let mut children = Vec::new();
3641− let mut argument = None;
3642− let leading = kind.arg().attributes();
3643− match text {
3644− Some(text) if !leading.is_empty() => {
3645− let (token, rest) = text.split_leading();
3646− let given = attrs
3647− .iter()
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− }
3683− }
3684− }
3685− }
3686− Some(text) if matches!(kind, ElementKind::Slot(_)) => {
3687− argument =
3688− Some(ctx.interpolate_text(&text.raw, text.span.line, Some(text.span.column)));
3689− }
3690− Some(text) => children.push(Node::Text(text_segments(text, ctx))),
3691− // `@link [href=/about]`: the attribute is the leading argument, so
3692− // both forms compile the same (`@image [src=a.svg, inline]`). Not
3693− // for `@source`, whose attribute depends on where it is.
3694− None if leading.len() == 1 => {
3695− if let Some(i) = attrs.iter().position(|a| a.html && a.key == leading[0]) {
3696− argument = Some(attrs.remove(i).value.unwrap_or_default());
3697− }
3698− }
3699− None => {}
3700− }
3701
1189
3702 // For @slot, the argument is the slot name
1190 // For @slot, the argument is the slot name
3703 let kind = if let ElementKind::Slot(_) = kind {
1191 let kind = if let ElementKind::Slot(_) = kind {
@@ -3706,352 +1194,72 @@ fn parse_single_element(
3706 kind
1194 kind
3707 };
1195 };
3708
1196
1197+ // Every other element treats its argument as leading text content, as
1198+ // in `@el [padding 8] Hello` or `@paragraph Read {@link /more more}`.
1199+ let argument = match argument {
1200+ Some(_) if !argument_is_special(&kind) && !renders_argument_as_text(&kind) => {
1201+ children.insert(0, Node::Text(parse_text_segments(&rest, ctx)));
1202+ None
1203+ }
1204+ other => other,
1205+ };
1206+
3709 Ok(Element {
1207 Ok(Element {
3710 kind,
1208 kind,
3711 attrs,
1209 attrs,
3712 argument,
1210 argument,
3713 children,
1211 children,
3714 line_num,
1212 line_num,
3715− function: None,
3716 })
1213 })
3717}
1214}
3718
1215
3719−/// Whether `node` or a line inside it calls `@name` outside any `@if`
1216+/// Elements whose argument is not text content: a URL, a source, an action,
3720−/// (in a chain, or inline in text).
1217+/// or a slot name.
3721−fn calls_unguarded(node: &syntax::Node, name: &str) -> bool {
1218+fn argument_is_special(kind: &ElementKind) -> bool {
3722− if node.is_directive("if") || node.is_directive("else") {
1219+ matches!(
3723− return false;
1220+ kind,
3724− }
1221+ ElementKind::Link | ElementKind::Image | ElementKind::Script | ElementKind::Slot(_)
3725− let text_calls = |text: &syntax::Text| {
1222+ ) || kind
3726− text.segments
1223+ .spec()
3727− .iter()
1224+ .is_some_and(|spec| matches!(spec.arg, TagArg::Attr(_)))
3728− .any(|s| matches!(s, Segment::Inline(inline) if inline.head.name == name))
1225+}
3729− };
1226+
3730− let here = match &node.kind {
1227+/// The directive names (without `@`) the parser recognizes, for tools such
3731− NodeKind::Element(element) => {
1228+/// as the language server.
3732− element.chain.iter().any(|head| head.name == name)
1229+pub fn known_directives() -> &'static [&'static str] {
3733− || element.text.as_ref().is_some_and(text_calls)
1230+ KNOWN_DIRECTIVES
3734− }
1231+}
3735− NodeKind::Text(text) => text_calls(text),
1232+
3736− _ => false,
1233+const KNOWN_DIRECTIVES: &[&str] = &[
3737− };
1234+ "page",
3738− here || node
1235+ "let",
3739− .children
1236+ "include",
3740− .iter()
1237+ "raw",
3741− .any(|child| calls_unguarded(child, name))
1238+ "if",
3742−}
1239+ "else",
3743−
1240+ "each",
3744−/// `@td [padding 8] @children`: `@children` and `@slot NAME` mark where a
1241+ "meta",
3745−/// call's content goes only on a line of their own; after an element's
1242+ "head",
3746−/// attributes, or in a verbatim body, they are text.
1243+ "style",
3747−fn marker_in_text(node: &syntax::Node) -> Option<Diagnostic> {
1244+ "markdown",
3748− let is_marker = |text: &str| {
1245+ "data",
3749− let text = text.trim();
1246+];
3750− text == "@children" || text == "@slot" || text.starts_with("@slot ")
3751− };
3752− let mut line = node.span.line;
3753− let mut source = written_line(node);
3754− let (marker, message) = match &node.kind {
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−
3825−/// A first word taken as a URL that reads as text: a word with a capital
3826−/// and nothing a URL or a file name has, as in `@link [hx-get=/panel] Open
3827−/// panel`, whose href would be `Open`. It still is (a relative URL can be
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− }
3839− let name = kind.name();
3840− let attr = leading_name(kind);
3841− let text = if takes_no_text(kind) {
3842− String::new()
3843− } else {
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− }
3868− let name: String = name_start
3869− .chars()
3870− .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
3871− .collect();
3872− if ElementKind::from_name(&name).is_none() && ctx.env.function(&name).is_none() {
3873− return None;
3874− }
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− };
3918− let d = Diagnostic::error(
3919− code::DUPLICATE_ATTRIBUTE,
3920− text.span.line,
3921− format!(
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−}
3946−
3947−/// Text after the leading argument of an element without content:
3948−/// `@image logo.png Our logo`.
3949−fn after_the_leading_argument(kind: &ElementKind, token: &str, rest: &syntax::Text) -> Diagnostic {
3950− let name = kind.name();
3951− let attr = leading_name(kind);
3952− let alt = if matches!(kind, ElementKind::Image) || kind.is_tag("area") {
3953− " For a text alternative, write `alt=...`."
3954− } else {
3955− ""
3956− };
3957− let holds = if kind.layout() == Layout::Void {
3958− "it has no content"
3959− } else {
3960− "it holds only the elements on the lines under it"
3961− };
3962− let bare = syntax::quoted_string(token).unwrap_or(token);
3963− Diagnostic::error(
3964− code::UNEXPECTED_ARGUMENT,
3965− rest.span.line,
3966− format!(
3967− "@{0} takes one word after its attributes, its {1} ('{2}'), and {5}, so '{3}' \
3968− would go nowhere. A value with a space in it is quoted: `\"{6} {3}\"`.{4}",
3969− name, attr, token, rest.raw, alt, holds, bare
3970− ),
3971− )
3972− .subject(rest.raw.clone())
3973−}
3974
1247
3975−/// The element named `name`, or an "unknown element" error that suggests
1248+fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
3976−/// the closest element, directive or function.
1249+ if let Some(kind) = ElementKind::from_name(s) {
3977−fn parse_element_kind(
3978− name: &str,
3979− line_num: usize,
3980− ctx: &mut ParseContext,
3981−) -> Result<ElementKind, ParseError> {
3982− if let Some(kind) = ElementKind::from_name(name) {
3983 return Ok(kind);
1250 return Ok(kind);
3984 }
1251 }
3985− // A known name in a place it can't go: say why instead of suggesting
3986− // the name itself.
3987− let misplaced = if crate::ast::directive(name).is_some() {
3988− Some(format!(
3989− "unknown element @{}: @{} is a directive, which goes at the start of its own line",
3990− name, name
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()
1252 let all_known: Vec<&str> = ElementKind::all_names()
4032− .chain(DIRECTIVES.iter().map(|d| d.name))
1253+ .chain(KNOWN_DIRECTIVES.iter().copied())
4033 .collect();
1254 .collect();
4034− // An HTML element htmlang writes under its own name (`@a` is `@link`).
1255+ let message = match suggest_closest(s, &all_known) {
4035− let written_otherwise = crate::ast::HTML_NAMES_WRITTEN_OTHERWISE
1256+ Some(closest) => format!("unknown element @{}, did you mean @{}?", s, closest),
4036− .iter()
1257+ None => format!("unknown element @{}", s),
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 };
1258 };
4048− let function = suggest_fn_name(name, ctx);
1259+ Err(ParseError {
4049− if let Some(function) = &function {
1260+ line: line_num,
4050− message = format!("{}, or did you mean @{}?", message, function);
1261+ message,
4051− }
1262+ })
4052− Err(Diagnostic::error(code::UNKNOWN_ELEMENT, line_num, message)
4053− .subject(name)
4054− .suggest(closest.map(str::to_string).or(function)))
4055}
1263}
4056
1264
4057/// Format include/import stack as a readable chain for error messages.
1265/// Format include/import stack as a readable chain for error messages.
@@ -4122,37 +1330,34 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
4122 best
1330 best
4123}
1331}
4124
1332
4125−/// Suggest the closest user-defined function name for typos, from the
1333+/// 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> {
1334fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
4128 let input_chars: Vec<char> = input.chars().collect();
1335 let input_chars: Vec<char> = input.chars().collect();
4129 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
1336 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
4130− let mut best: Option<(usize, &String)> = None;
1337+ let mut best: Option<String> = None;
4131− let visible: Vec<&String> = ctx.defined_functions.keys().collect();
1338+ let mut best_dist = usize::MAX;
4132− for name in visible.into_iter().chain(ctx.namespace.iter()) {
1339+ for name in ctx.functions.keys() {
4133 let nlen = name.chars().count();
1340 let nlen = name.chars().count();
4134 if nlen.abs_diff(input_chars.len()) > max_allowed {
1341 if nlen.abs_diff(input_chars.len()) > max_allowed {
4135 continue;
1342 continue;
4136 }
1343 }
4137 let name_chars: Vec<char> = name.chars().collect();
1344 let name_chars: Vec<char> = name.chars().collect();
4138 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
1345 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
4139− if dist <= max_allowed && best.is_none_or(|b| (dist, name) < b) {
1346+ if dist < best_dist && dist <= max_allowed {
4140− best = Some((dist, name));
1347+ best_dist = dist;
1348+ best = Some(name.clone());
4141 }
1349 }
4142 }
1350 }
4143− best.map(|(_, name)| name.clone())
1351+ best
4144}
1352}
4145
1353
4146/// Suggest the closest variable name for undefined `$var` references.
1354/// Suggest the closest variable name for undefined `$var` references.
4147−fn suggest_var_name(input: &str, vars: &[&str]) -> Option<String> {
1355+fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<String> {
4148 let input_chars: Vec<char> = input.chars().collect();
1356 let input_chars: Vec<char> = input.chars().collect();
4149 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
1357 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
4150 let mut best: Option<String> = None;
1358 let mut best: Option<String> = None;
4151 let mut best_dist = usize::MAX;
1359 let mut best_dist = usize::MAX;
4152− let mut vars = vars.to_vec();
1360+ for name in vars.keys().filter(|k| !k.ends_with('#')) {
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();
1361 let nlen = name.chars().count();
4157 if nlen.abs_diff(input_chars.len()) > max_allowed {
1362 if nlen.abs_diff(input_chars.len()) > max_allowed {
4158 continue;
1363 continue;
@@ -4161,1988 +1366,992 @@ fn suggest_var_name(input: &str, vars: &[&str]) -> Option<String> {
4161 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
1366 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
4162 if dist < best_dist && dist <= max_allowed {
1367 if dist < best_dist && dist <= max_allowed {
4163 best_dist = dist;
1368 best_dist = dist;
4164− best = Some(name.to_string());
1369+ best = Some(name.clone());
4165 }
1370 }
4166 }
1371 }
4167 best
1372 best
4168}
1373}
4169
1374
4170−// ---------------------------------------------------------------------------
1375+/// Check for undefined `$var` references and return "did you mean?" diagnostics.
4171−// Attribute parsing
1376+/// `indent` is the line's leading whitespace, which `input` has had trimmed;
4172−// ---------------------------------------------------------------------------
1377+/// reported columns and source lines include it.
4173−
1378+fn check_undefined_vars(
4174−/// The quoted strings of a CSS value, without their quotes.
1379+ input: &str,
4175−fn css_strings(value: &str) -> Vec<&str> {
1380+ vars: &HashMap<String, String>,
4176− let mut strings = Vec::new();
1381+ line_num: usize,
4177− let mut start = None;
1382+ indent: usize,
4178− let mut escaped = false;
1383+) -> Vec<Diagnostic> {
4179− for (i, c) in value.char_indices() {
1384+ let mut warnings = Vec::new();
4180− match (c, start) {
1385+ if !input.contains('$') {
4181− _ if escaped => escaped = false,
1386+ return warnings;
4182− ('\\', Some(_)) => escaped = true,
1387+ }
4183− ('"', None) => start = Some(i + 1),
1388+ let chars: Vec<char> = input.chars().collect();
4184− ('"', Some(from)) => {
1389+ let mut i = 0;
4185− strings.push(&value[from..i]);
1390+ while i < chars.len() {
4186− start = None;
1391+ if chars[i] == '$'
1392+ && i + 1 < chars.len()
1393+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
1394+ {
1395+ let col = i;
1396+ let start = i + 1;
1397+ let mut end = start;
1398+ while end < chars.len()
1399+ && (chars[end].is_alphanumeric()
1400+ || chars[end] == '-'
1401+ || chars[end] == '_'
1402+ || chars[end] == '.')
1403+ {
1404+ end += 1;
4187 }
1405 }
4188− _ => {}
1406+ while end > start && chars[end - 1] == '.' {
1407+ end -= 1;
1408+ }
1409+ let name: String = chars[start..end].iter().collect();
1410+ if !name.is_empty()
1411+ && !vars.contains_key(&name)
1412+ && let Some(closest) = suggest_var_name(&name, vars)
1413+ {
1414+ warnings.push(Diagnostic {
1415+ line: line_num,
1416+ column: Some(indent + col),
1417+ message: format!(
1418+ "undefined variable '${}', did you mean '${}'?",
1419+ name, closest
1420+ ),
1421+ severity: Severity::Warning,
1422+ source_line: Some(format!("{}{}", " ".repeat(indent), input)),
1423+ });
1424+ }
1425+ i = end;
1426+ } else {
1427+ i += 1;
4189 }
1428 }
4190 }
1429 }
4191− strings
1430+ warnings
4192}
1431}
4193
1432
4194−/// An `if(...)` in a CSS value that isn't CSS's if(): CSS writes each
1433+// ---------------------------------------------------------------------------
4195−/// branch `CONDITION: VALUE`, so an if() without a `:` is something else.
1434+// Attribute parsing
4196−/// Quoted CSS strings are text, not calls.
1435+// ---------------------------------------------------------------------------
4197−fn not_css_if(value: &str) -> Option<&str> {
1436+
4198− let mut quote = None;
1437+/// Attributes that expect purely numeric values (px-based) or values with CSS units.
4199− let mut prev = None;
1438+const NUMERIC_ATTRS: &[&str] = &[
4200− let mut chars = value.char_indices();
1439+ "spacing",
4201− while let Some((at, c)) = chars.next() {
1440+ "gap",
4202− match quote {
1441+ "padding",
4203− Some(_) if c == '\\' => {
1442+ "padding-top",
4204− chars.next();
1443+ "padding-bottom",
1444+ "padding-left",
1445+ "padding-right",
1446+ "min-width",
1447+ "max-width",
1448+ "min-height",
1449+ "max-height",
1450+ "border-radius",
1451+ "font-size",
1452+ "column-gap",
1453+ "row-gap",
1454+ "top",
1455+ "right",
1456+ "bottom",
1457+ "left",
1458+ "letter-spacing",
1459+];
1460+
1461+const CSS_UNIT_SUFFIXES: &[&str] = &[
1462+ "px", "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm",
1463+ "mm", "in", "pt", "pc", "fr",
1464+];
1465+
1466+fn has_css_unit(value: &str) -> bool {
1467+ CSS_UNIT_SUFFIXES.iter().any(|u| value.ends_with(u))
1468+ || value.starts_with("var(")
1469+ || value.starts_with("calc(")
1470+ || value.starts_with("clamp(")
1471+ || value.starts_with("min(")
1472+ || value.starts_with("max(")
1473+}
1474+
1475+/// Attributes that accept numeric OR keyword values.
1476+const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
1477+const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
1478+
1479+fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext) {
1480+ let base_key = crate::vocab::base_attribute(attr.key.as_str());
1481+
1482+ if let Some(val) = &attr.value {
1483+ if NUMERIC_ATTRS.contains(&base_key) {
1484+ // All space-separated parts must be numeric or have a CSS unit
1485+ for part in val.split_whitespace() {
1486+ if part.parse::<f64>().is_err() && !has_css_unit(part) {
1487+ ctx.diagnostics.push(Diagnostic {
1488+ line: line_num,
1489+ column: None,
1490+ message: format!(
1491+ "'{}' expects a numeric value (with optional unit), got '{}'",
1492+ attr.key, val
1493+ ),
1494+ severity: Severity::Warning,
1495+ source_line: None,
1496+ });
1497+ return;
1498+ }
4205 }
1499 }
4206− Some(q) if c == q => quote = None,
1500+ } else if NUMERIC_OR_KEYWORD_ATTRS.contains(&base_key) {
4207− Some(_) => {}
1501+ let is_keyword = SIZE_KEYWORDS.contains(&val.as_str());
4208− None if matches!(c, '"' | '\'') => quote = Some(c),
1502+ let is_numeric = val.parse::<f64>().is_ok();
4209− None => {
1503+ let has_unit = has_css_unit(val);
4210− let word_before =
1504+ if !is_keyword && !is_numeric && !has_unit {
4211− prev.is_some_and(|p: char| p.is_alphanumeric() || matches!(p, '-' | '_'));
1505+ ctx.diagnostics.push(Diagnostic {
4212− if !word_before && value[at..].starts_with("if(") {
1506+ line: line_num,
4213− let mut depth = 0;
1507+ column: None,
4214− let close = value[at + 2..].char_indices().find_map(|(i, c)| {
1508+ message: format!(
4215− match c {
1509+ "'{}' expects a number or one of [{}], got '{}'",
4216− '(' => depth += 1,
1510+ attr.key,
4217− ')' => depth -= 1,
1511+ SIZE_KEYWORDS.join(", "),
4218− _ => {}
1512+ val
4219− }
1513+ ),
4220− (depth == 0).then_some(at + 2 + i)
1514+ severity: Severity::Warning,
1515+ source_line: None,
1516+ });
1517+ }
1518+ } else if base_key == "opacity" {
1519+ if let Ok(v) = val.parse::<f64>() {
1520+ if !(0.0..=1.0).contains(&v) {
1521+ ctx.diagnostics.push(Diagnostic {
1522+ line: line_num,
1523+ column: None,
1524+ message: format!("'opacity' should be between 0 and 1, got '{}'", val),
1525+ severity: Severity::Warning,
1526+ source_line: None,
4221 });
1527 });
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 }
1528 }
1529+ } else {
1530+ ctx.diagnostics.push(Diagnostic {
1531+ line: line_num,
1532+ column: None,
1533+ message: format!("'opacity' expects a numeric value, got '{}'", val),
1534+ severity: Severity::Warning,
1535+ source_line: None,
1536+ });
4227 }
1537 }
4228− }
1538+ } else if base_key == "z-index" {
4229− prev = Some(c);
1539+ if val.parse::<i32>().is_err() {
4230− }
1540+ ctx.diagnostics.push(Diagnostic {
4231− None
1541+ line: line_num,
4232−}
1542+ column: None,
4233−
1543+ 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
1544+ severity: Severity::Warning,
4235−/// means: a `;` (outside parentheses, where CSS's own `if()` uses it), a
1545+ source_line: None,
4236−/// `{` or a `}` would end the declaration or the rule, and an unclosed
1546+ });
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 }
1547 }
4253− (Some(q), c) if c == q => quote = None,
1548+ } else if base_key == "display" {
4254− (Some(_), _) => {}
1549+ const DISPLAY_VALUES: &[&str] = &[
4255− (None, '"' | '\'') => quote = Some(c),
1550+ "none",
4256− (None, '(') => depth += 1,
1551+ "block",
4257− (None, ')') if depth == 0 => return Some("a `)` that closes nothing"),
1552+ "inline",
4258− (None, ')') => depth -= 1,
1553+ "inline-block",
4259− (None, ';') if depth == 0 => return Some("a `;`, which would end the declaration"),
1554+ "flex",
4260− (None, '{' | '}') => return Some("a `{` or `}`, which would end the CSS rule"),
1555+ "inline-flex",
4261− _ => {}
1556+ "grid",
4262− }
1557+ "inline-grid",
4263− }
1558+ "table",
4264− match (quote, depth) {
1559+ "table-row",
4265− (Some('"'), _) => Some("a `\"` that isn't closed"),
1560+ "table-cell",
4266− (Some(_), _) => Some("a `'` that isn't closed"),
1561+ "contents",
4267− (None, 0) => None,
1562+ "flow-root",
4268− (None, _) => Some("a `(` that isn't closed"),
1563+ "list-item",
4269− }
1564+ ];
4270−}
1565+ if !DISPLAY_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4271−
1566+ 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
1567+ let msg = match suggestion {
4273−/// digits. A `#` word in a CSS value is a color, except inside `url(...)`
1568+ Some(s) => format!("unknown display value '{}', did you mean '{}'?", val, s),
4274−/// and quoted strings.
1569+ None => format!("unknown display value '{}'", val),
4275−fn bad_hex_colors(value: &str) -> Vec<&str> {
1570+ };
4276− let mut bad = Vec::new();
1571+ ctx.diagnostics.push(Diagnostic {
4277− let mut quote = None;
1572+ line: line_num,
4278− let mut in_url = 0usize;
1573+ column: None,
4279− let mut depth = 0usize;
1574+ message: msg,
4280− let mut chars = value.char_indices().peekable();
1575+ severity: Severity::Warning,
4281− while let Some((at, c)) = chars.next() {
1576+ source_line: None,
4282− match (quote, c) {
1577+ });
4283− (_, '\\') => {
4284− chars.next();
4285 }
1578 }
4286− (Some(q), c) if c == q => quote = None,
1579+ } else if base_key == "position" {
4287− (Some(_), _) => {}
1580+ const POSITION_VALUES: &[&str] = &["static", "relative", "absolute", "fixed", "sticky"];
4288− (None, '"' | '\'') => quote = Some(c),
1581+ if !POSITION_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4289− (None, '(') => {
1582+ let suggestion = suggest_closest(val, POSITION_VALUES);
4290− depth += 1;
1583+ let msg = match suggestion {
4291− if value[..at].ends_with("url") && in_url == 0 {
1584+ Some(s) => format!("unknown position value '{}', did you mean '{}'?", val, s),
4292− in_url = depth;
1585+ None => format!("unknown position value '{}'", val),
4293− }
1586+ };
1587+ ctx.diagnostics.push(Diagnostic {
1588+ line: line_num,
1589+ column: None,
1590+ message: msg,
1591+ severity: Severity::Warning,
1592+ source_line: None,
1593+ });
4294 }
1594 }
4295− (None, ')') => {
1595+ } else if base_key == "overflow" || base_key == "overflow-x" || base_key == "overflow-y" {
4296− if in_url == depth {
1596+ const OVERFLOW_VALUES: &[&str] = &["visible", "hidden", "scroll", "auto", "clip"];
4297− in_url = 0;
1597+ if !OVERFLOW_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4298− }
1598+ let suggestion = suggest_closest(val, OVERFLOW_VALUES);
4299− depth = depth.saturating_sub(1);
1599+ let msg = match suggestion {
1600+ Some(s) => format!("unknown overflow value '{}', did you mean '{}'?", val, s),
1601+ None => format!("unknown overflow value '{}'", val),
1602+ };
1603+ ctx.diagnostics.push(Diagnostic {
1604+ line: line_num,
1605+ column: None,
1606+ message: msg,
1607+ severity: Severity::Warning,
1608+ source_line: None,
1609+ });
4300 }
1610 }
4301− (None, '#') if in_url == 0 => {
1611+ } else if base_key == "text-align" {
4302− let end = value[at + 1..]
1612+ const TEXT_ALIGN_VALUES: &[&str] =
4303− .find(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_'))
1613+ &["left", "right", "center", "justify", "start", "end"];
4304− .map_or(value.len(), |n| at + 1 + n);
1614+ if !TEXT_ALIGN_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4305− let hex = &value[at + 1..end];
1615+ let suggestion = suggest_closest(val, TEXT_ALIGN_VALUES);
4306− if !(matches!(hex.len(), 3 | 4 | 6 | 8)
1616+ let msg = match suggestion {
4307− && hex.chars().all(|c| c.is_ascii_hexdigit()))
1617+ Some(s) => format!("unknown text-align value '{}', did you mean '{}'?", val, s),
4308− {
1618+ None => format!("unknown text-align value '{}'", val),
4309− bad.push(&value[at..end]);
1619+ };
4310− }
1620+ ctx.diagnostics.push(Diagnostic {
1621+ line: line_num,
1622+ column: None,
1623+ message: msg,
1624+ severity: Severity::Warning,
1625+ source_line: None,
1626+ });
4311 }
1627 }
4312− _ => {}
1628+ } else if base_key == "cursor" {
4313− }
1629+ const CURSOR_VALUES: &[&str] = &[
4314− }
1630+ "auto",
4315− bad
1631+ "default",
4316−}
1632+ "none",
4317−
1633+ "pointer",
4318−/// A diagnostic about an attribute, with its column and source line when
1634+ "wait",
4319−/// it is on the line being evaluated.
1635+ "text",
4320−fn at_attribute(diagnostic: Diagnostic, column: Option<usize>, ctx: &ParseContext) -> Diagnostic {
1636+ "move",
4321− match (&ctx.current_source, column) {
1637+ "not-allowed",
4322− ((current, Some(text)), Some(column)) if *current == diagnostic.line => {
1638+ "crosshair",
4323− diagnostic.column(column).source(text.clone())
1639+ "grab",
4324− }
1640+ "grabbing",
4325− _ => diagnostic,
1641+ "help",
4326− }
1642+ "progress",
4327−}
1643+ "col-resize",
4328−
1644+ "row-resize",
4329−/// Check a style's value. Values go to the CSS as written, so only what
1645+ "n-resize",
4330−/// can't be wrong in any CSS is checked: a value that would break out of
1646+ "s-resize",
4331−/// its rule is an error (and it is left out); a hex color without 3, 4, 6
1647+ "e-resize",
4332−/// or 8 digits, an `if()` that isn't CSS's and a quoted font stack are
1648+ "w-resize",
4333−/// warnings. Returns whether the value can be written.
1649+ "zoom-in",
4334−fn check_css_value(
1650+ "zoom-out",
4335− attr: &Attribute,
1651+ "context-menu",
4336− base: &str,
1652+ "cell",
4337− line: usize,
1653+ "copy",
4338− column: Option<usize>,
1654+ "alias",
4339− ctx: &mut ParseContext,
1655+ "no-drop",
4340−) -> bool {
1656+ ];
4341− let Some(value) = &attr.value else {
1657+ if !CURSOR_VALUES.contains(&val.as_str())
4342− return true;
1658+ && !val.starts_with("url(")
4343− };
1659+ && !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 {
1660 {
4585− Some((_, htmlang)) if own.contains(htmlang) => (Some(*htmlang), false),
1661+ let suggestion = suggest_closest(val, CURSOR_VALUES);
4586− Some(_) => (None, true),
1662+ let msg = match suggestion {
4587− None => (None, false),
1663+ Some(s) => format!("unknown cursor value '{}', did you mean '{}'?", val, s),
4588− };
1664+ None => format!("unknown cursor value '{}'", val),
4589− // `@each:`, `@markdown:`: a directive, which is no element
1665+ };
4590− if crate::ast::directive(name).is_some() {
1666+ ctx.diagnostics.push(Diagnostic {
4591− return fail(
1667+ line: line_num,
4592− ctx,
1668+ column: None,
4593− code::UNKNOWN_PREFIX,
1669+ message: msg,
4594− format!(
1670+ severity: Severity::Warning,
4595− "unknown element @{} in '{}': @{} is a directive, and an element \
1671+ source_line: None,
4596− prefix names an element with an HTML tag of its own, such as `@td:`",
1672+ });
4597− name, prefix, name
4598− ),
4599− prefix,
4600− None,
4601− );
4602 }
1673 }
4603− // `@div:`, `@span:`: several elements write it
1674+ } else if base_key == "font-weight" {
4604− if shared {
1675+ const WEIGHT_VALUES: &[&str] = &[
4605− return fail(
1676+ "normal", "bold", "bolder", "lighter", "100", "200", "300", "400", "500", "600",
4606− ctx,
1677+ "700", "800", "900",
4607− code::UNKNOWN_PREFIX,
1678+ ];
4608− format!(
1679+ if !WEIGHT_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4609− "unknown element @{} in '{}': htmlang writes <{}> for several \
1680+ ctx.diagnostics.push(Diagnostic {
4610− elements, so no element prefix picks it out",
1681+ line: line_num,
4611− name, prefix, name
1682+ column: None,
1683+ message: format!(
1684+ "'font-weight' expects a weight keyword or number 100-900, got '{}'",
1685+ val
4612 ),
1686 ),
4613− prefix,
1687+ severity: Severity::Warning,
4614− None,
1688+ source_line: None,
4615− );
1689+ });
4616 }
1690 }
4617− // A name with the same first letter first (`@tdd:` is `@td:`)
1691+ } else if base_key == "color" || base_key == "background" {
4618− let same_letter: Vec<&str> = own
1692+ // Validate named CSS colors (only if not hex, rgb, hsl, var, etc.)
4619− .iter()
1693+ if !val.starts_with('#')
4620− .copied()
1694+ && !val.starts_with("rgb")
4621− .filter(|n| n.chars().next() == name.chars().next())
1695+ && !val.starts_with("hsl")
4622− .collect();
1696+ && !val.starts_with("var(")
4623− let closest = written_otherwise
1697+ && !val.starts_with("linear-gradient")
4624− .or_else(|| suggest_closest(name, &same_letter))
1698+ && !val.starts_with("radial-gradient")
4625− .or_else(|| suggest_closest(name, &own));
1699+ && !val.starts_with("conic-gradient")
4626− let message = match (closest, written_otherwise) {
1700+ && !val.starts_with("oklch")
4627− (Some(closest), Some(_)) => format!(
1701+ && !val.starts_with("oklab")
4628− "unknown element @{} in '{}', did you mean '@{}:'? (@{} writes <{}>)",
1702+ && !val.starts_with("color(")
4629− name, prefix, closest, closest, name
1703+ && !val.starts_with("light-dark(")
4630− ),
1704+ && !val.contains("url(")
4631− (Some(closest), None) => format!(
1705+ && !val.contains(' ')
4632− "unknown element @{} in '{}', did you mean '@{}:'?",
1706+ // skip shorthand multi-value
4633− name, prefix, closest
1707+ {
4634− ),
1708+ const NAMED_COLORS: &[&str] = &[
4635− (None, _) => format!("unknown element @{} in '{}'", name, prefix),
1709+ "transparent",
4636− };
1710+ "currentcolor",
4637− let suggestion = closest.map(|c| format!("@{}:", c));
1711+ "inherit",
4638− fail(ctx, code::UNKNOWN_PREFIX, message, prefix, suggestion)
1712+ "initial",
4639− }
1713+ "unset",
4640− Some(kind) if kind.own_tag().is_none() => {
1714+ "black",
4641− let reason = match kind.tag() {
1715+ "white",
4642− None => format!(
1716+ "red",
4643− "@{} writes no element of its own, so there is nothing for `{}` to style",
1717+ "green",
4644− name, prefix
1718+ "blue",
4645− ),
1719+ "yellow",
4646− Some(_) if kind.is_verbatim() => {
1720+ "orange",
4647− format!("@{} isn't shown, so there is nothing to style", name)
1721+ "purple",
4648− }
1722+ "pink",
4649− Some("span") => format!(
1723+ "brown",
4650− "@{} writes a <span>, as every line of text in a row or column does, so \
1724+ "gray",
4651− `{}` can't pick it out",
1725+ "grey",
4652− name, prefix
1726+ "cyan",
4653− ),
1727+ "magenta",
4654− Some(tag) => {
1728+ "lime",
4655− let others: Vec<String> = ElementKind::all_names()
1729+ "olive",
4656− .filter(|n| {
1730+ "navy",
4657− *n != name
1731+ "teal",
4658− && ElementKind::from_name(n).and_then(|k| k.tag()) == Some(tag)
1732+ "aqua",
4659− })
1733+ "fuchsia",
4660− .map(|n| format!("@{}", n))
1734+ "maroon",
4661− .collect();
1735+ "silver",
4662− let others = match others.split_last() {
1736+ "coral",
4663− Some((last, [])) => last.clone(),
1737+ "salmon",
4664− Some((last, rest)) => format!("{} and {}", rest.join(", "), last),
1738+ "tomato",
4665− None => String::new(),
1739+ "crimson",
1740+ "firebrick",
1741+ "darkred",
1742+ "indigo",
1743+ "violet",
1744+ "plum",
1745+ "orchid",
1746+ "thistle",
1747+ "lavender",
1748+ "gold",
1749+ "khaki",
1750+ "wheat",
1751+ "tan",
1752+ "sienna",
1753+ "chocolate",
1754+ "peru",
1755+ "beige",
1756+ "ivory",
1757+ "linen",
1758+ "snow",
1759+ "seashell",
1760+ "mintcream",
1761+ "skyblue",
1762+ "steelblue",
1763+ "royalblue",
1764+ "dodgerblue",
1765+ "cornflowerblue",
1766+ "slategray",
1767+ "slategrey",
1768+ "dimgray",
1769+ "dimgrey",
1770+ "lightgray",
1771+ "lightgrey",
1772+ "darkgray",
1773+ "darkgrey",
1774+ "gainsboro",
1775+ "whitesmoke",
1776+ ];
1777+ if !NAMED_COLORS.contains(&val.to_lowercase().as_str()) {
1778+ let lower = val.to_lowercase();
1779+ let suggestion = suggest_closest(&lower, NAMED_COLORS);
1780+ let msg = match suggestion {
1781+ Some(s) => format!("unknown color '{}', did you mean '{}'?", val, s),
1782+ None => format!("unknown color '{}'", val),
4666 };
1783 };
4667− format!(
1784+ ctx.diagnostics.push(Diagnostic {
4668− "@{} writes a <{}>, as {} do, so `{}` can't pick it out",
1785+ line: line_num,
4669− name, tag, others, prefix
1786+ column: None,
4670− )
1787+ message: msg,
1788+ severity: Severity::Warning,
1789+ source_line: None,
1790+ });
4671 }
1791 }
4672− };
1792+ }
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 }
1793 }
4742− Some(_) => true,
4743 }
1794 }
4744}
1795}
4745
1796
4746−/// Check an attribute's prefixes: each is known, their selector prefixes
1797+fn parse_attr_brackets(
4747−/// read left to right with a pseudo-element (`before:`) last, the argument
1798+ input: &str,
4748−/// of `has(...)` or `nth-child(...)` can be written into the CSS, and a word that
1799+ 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,
1800 ctx: &mut ParseContext,
4758−) -> bool {
1801+) -> Result<(Vec<Attribute>, String), ParseError> {
4759− use crate::vocab;
1802+ parse_attr_brackets_inner(input, line_num, ctx, true)
4760− let (prefixes, base) = vocab::split_prefixes(&attr.key);
1803+}
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
1804
4771− if let Some(problem) = unknown_prefix(base) {
1805+fn parse_attr_brackets_no_validate(
4772− let diagnostic = Diagnostic::error(code::UNKNOWN_PREFIX, line, problem.message)
1806+ input: &str,
4773− .subject(problem.subject)
1807+ line_num: usize,
4774− .suggest(problem.suggestion);
1808+ ctx: &mut ParseContext,
4775− let diagnostic = at_attribute(diagnostic, column, ctx);
1809+) -> Result<(Vec<Attribute>, String), ParseError> {
4776− ctx.diagnostics.push(diagnostic);
1810+ 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}
1811}
4911
1812
4912−/// Check an attribute written as a style or a flag (not `key=value`): its
1813+fn parse_attr_brackets_inner(
4913−/// prefix, its name, and, for a style, its value. A name htmlang doesn't
1814+ input: &str,
4914−/// know but CSS could have (`corner-shape`) is passed to the CSS as written,
1815+ 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,
1816 ctx: &mut ParseContext,
4924−) -> bool {
1817+ validate: bool,
4925− use crate::vocab;
1818+) -> Result<(Vec<Attribute>, String), ParseError> {
4926− let (prefixes, base) = vocab::split_prefixes(&attr.key);
1819+ let mut depth = 0;
4927− let fail = |ctx: &mut ParseContext, code: &'static str, message: String, subject: &str| {
1820+ 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
1821
4934− if !check_prefixes(attr, line, column, ctx) {
1822+ for (i, c) in input.char_indices() {
4935− return false;
1823+ match c {
4936− }
1824+ '[' => depth += 1,
4937− let prefixed = !prefixes.is_empty();
1825+ ']' => {
4938− // `md:id=x`: `=` makes an HTML attribute, which has no states
1826+ depth -= 1;
4939− if prefixed && let Some((name, _)) = base.split_once('=') {
1827+ if depth == 0 {
4940− return fail(
1828+ end_pos = i;
4941− ctx,
1829+ break;
4942− code::INVALID_PREFIX,
1830+ }
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 }
1831 }
4972− return fail(
1832+ _ => {}
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 }
1833 }
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 }
1834 }
5024− if vocab::is_html_attribute(base) && !prefixed {
1835+
5025− let diagnostic = Diagnostic::error(
1836+ if depth != 0 {
5026− code::HTML_ATTRIBUTE_FORM,
1837+ return Err(ParseError {
5027− line,
1838+ line: line_num,
5028− format!(
1839+ message: "unclosed '[' in attribute list".to_string(),
5029− "'{}' is an HTML attribute: write `{}={}`",
1840+ });
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 }
1841 }
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
1842
5043−/// Whether an attribute of a bundle could pass a parameter to a function
1843+ let attrs_inner = &input[1..end_pos];
5044−/// it is spliced into (`@let t [title Hello]`): a name without a prefix
1844+ let remaining = input[end_pos + 1..].trim().to_string();
5045−/// that isn't a style or a flag. Such a name is checked where the bundle is
1845+ let attrs = parse_attr_list(attrs_inner, line_num, ctx, validate);
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
1846
5061−/// At a call, a name close to one of the function's parameters is a
1847+ Ok((attrs, remaining))
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− }
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}
1848}
5097
1849
5098−/// An attribute name htmlang doesn't know. A style whose name CSS could
1850+fn is_valid_hex_color(s: &str) -> bool {
5099−/// have goes to the CSS as written, with a warning; anything else (a flag,
1851+ if !s.starts_with('#') {
5100−/// a name that isn't a word) is an error. Returns whether it is kept.
1852+ return true; // Not a hex color, skip
5101−fn unknown_attribute(
5102− attr: &Attribute,
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 }
1853 }
5182− let mut message = match (&suggestion, error) {
1854+ let hex = &s[1..];
5183− (Some(closest), true) => {
1855+ matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
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− }
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}
1856}
5229
1857
5230−/// Evaluate the attributes of a list: bundles are spliced in, `if()`
5231−/// chooses, and variables fill values. A variable fills only the value it
5232−/// is written in: attributes come from bundles, never from text. With
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.
5236fn parse_attr_list(
1858fn parse_attr_list(
5237− tokens: &[syntax::Attr],
1859+ input: &str,
5238 line_num: usize,
1860 line_num: usize,
5239 ctx: &mut ParseContext,
1861 ctx: &mut ParseContext,
5240 validate: bool,
1862 validate: bool,
5241− text_keys: &[String],
5242) -> Vec<Attribute> {
1863) -> Vec<Attribute> {
5243− parse_attrs(tokens, line_num, ctx, validate, text_keys)
1864+ let mut attrs = Vec::new();
5244− .into_iter()
1865+ let mut seen_keys: Vec<String> = Vec::new();
5245− .map(|(attr, _)| attr)
1866+
5246− .collect()
1867+ for part in split_commas(input) {
5247−}
1868+ let part = part.trim();
5248−
1869+ // A whole attribute `if(cond, a, b)` is the chosen branch's text.
5249−/// [`parse_attr_list`], with the value of each attribute whose key is one
1870+ let chosen;
5250−/// of `text_keys` (a call's parameters) as a value with its type (see
1871+ let part = match choose_if(part, ctx, line_num) {
5251−/// [`ParseContext::slot_value`]), so a parameter can get a list or a
1872+ Some(branch) => {
5252−/// record.
1873+ chosen = branch;
5253−fn parse_attrs(
1874+ chosen.as_str()
5254− tokens: &[syntax::Attr],
1875+ }
5255− line_num: usize,
1876+ None => part,
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−}
5264−
5265−/// The attributes of a list read so far, and the keys written (for the
5266−/// duplicate check).
5267−#[derive(Default)]
5268−struct ReadAttrs {
5269− attrs: Vec<(Attribute, Option<Value>)>,
5270− seen_keys: Vec<(String, bool)>,
5271−}
5272−
5273−/// [`parse_attrs`] for the attributes of `tokens`, each under the prefixes
5274−/// `prefix` (`md:` for those of `md:[...]`), which distribute over a
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,
5281− ctx: &mut ParseContext,
5282− validate: bool,
5283− text_keys: &[String],
5284− out: &mut ReadAttrs,
5285−) {
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 };
1877 };
5303− let (key, value, html) = (token.key.clone(), token.value.clone(), token.html);
1878+ if part.is_empty() {
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;
1879 continue;
5321 }
1880 }
5322
1881
5323− // `$name` (or `${name}`) alone: an attribute bundle, spliced in
1882+ // $define reference — expand attribute bundle
5324− let bundle = key.strip_prefix('$').filter(|_| value.is_none() && !html);
1883+ if let Some(name) = part.strip_prefix('$')
5325− let bundle = bundle.and_then(|after| match interp::reference(after, &ctx.env)? {
1884+ && let Some(define_attrs) = ctx.defines.get(name)
5326− (interp::Reference::Var(name), len) if len == after.len() => Some(name),
1885+ {
5327− _ => None,
1886+ ctx.used_defines.insert(name.to_string());
5328− });
1887+ attrs.extend(define_attrs.clone());
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;
1888 continue;
5391 }
1889 }
5392
1890
5393− // Only a style has states: `md:[id=x]`, `md:id=x`
1891+ track_var_refs(part, &mut ctx.used_variables);
5394− if html && (!prefix.is_empty() || !crate::vocab::split_prefixes(&key).0.is_empty()) {
1892+
5395− let written = format!("{}{}", prefix, key);
1893+ // A value `if(cond, a, b)` picks `a` or `b` (free text) by `cond`;
5396− let (name, _) = crate::vocab::split_prefixes(&written);
1894+ // an empty choice leaves the attribute out.
5397− let diagnostic = Diagnostic::error(
1895+ let Some(part) = choose_if_value(part, ctx, line_num) else {
5398− code::INVALID_PREFIX,
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;
1896 continue;
5411− }
5412− let key = if prefix.is_empty() {
5413− key
5414− } else {
5415− format!("{}{}", prefix, key)
5416 };
1897 };
1898+ let part = substitute_vars(&part, &ctx.variables);
5417
1899
5418− // A value's variables are filled in. A style's value is CSS, where
1900+ let attr = if let Some((key, value)) = split_html_attribute(&part) {
5419− // quoted text keeps its quotes; an HTML attribute's (and a
1901+ Attribute {
5420− // parameter's) is text, where it loses them.
1902+ key: key.to_string(),
5421− let sink = if html || text_keys.contains(&key) {
1903+ value: Some(value.to_string()),
5422− Sink::Text
1904+ html: true,
5423− } else {
5424− Sink::Css
5425− };
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− }
5439− let text = value.to_string();
5440− typed = Some(value);
5441− Some(text)
5442 }
1905 }
5443− Some(value) => {
1906+ } else if let Some((key, value)) = part.split_once(' ') {
5444− let at = column(token.raw.len() - value.len());
1907+ let value = value.trim().to_string();
5445− let (text, is_quoted, ok) = ctx.fill_value(&value, line, at, sink);
1908+ Attribute {
5446− filled = ok;
1909+ key: key.trim().to_string(),
5447− quoted = is_quoted;
1910+ value: Some(value),
5448− Some(text)
1911+ html: false,
1912+ }
1913+ } else {
1914+ Attribute {
1915+ key: part.to_string(),
1916+ value: None,
1917+ html: false,
5449 }
1918 }
5450 };
1919 };
5451− let attr = Attribute {
5452− key,
5453− value,
5454− html,
5455− quoted,
5456− };
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
1920
5461− // A duplicate is reported (compare the full key, so `border` and
1921+ // Warn on duplicate attributes (compare full key so pseudo-class
5462− // `hover:border` differ, and the form, so `width=800` and
1922+ // variants like `border` and `hover:border` are not conflated)
5463− // `width 200` do too), and the later one wins
5464 if validate {
1923 if validate {
5465− let seen = (normal_key(&attr.key), attr.html);
1924+ if seen_keys.contains(&attr.key) {
5466− if out.seen_keys.contains(&seen) {
1925+ ctx.diagnostics.push(Diagnostic {
5467− let written = if attr.html { "=" } else { "" };
1926+ line: line_num,
5468− let message = format!(
1927+ column: None,
5469− "duplicate attribute '{}{}': the later one wins",
1928+ message: format!("duplicate attribute '{}'", attr.key),
5470− attr.key, written
1929+ severity: Severity::Warning,
5471− );
1930+ source_line: None,
5472− let diagnostic = Diagnostic::warning(code::DUPLICATE_ATTRIBUTE, line, message)
1931+ });
5473− .subject(attr.key.as_str());
5474− let diagnostic = at_attribute(diagnostic, column(0), ctx);
5475− ctx.diagnostics.push(diagnostic);
5476 } else {
1932 } else {
5477− out.seen_keys.push(seen);
1933+ seen_keys.push(attr.key.clone());
5478 }
1934 }
5479− }
5480
1935
5481− if validate && misspelled_parameter(&attr, text_keys, line, column(0), ctx) {
1936+ // Color validation for hex colors
5482− continue;
1937+ if !attr.html
5483− }
1938+ && matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
5484− if prefixed_parameter(&attr, text_keys, line, column(0), validate, ctx) {
1939+ && let Some(ref val) = attr.value
5485− continue;
1940+ && val.starts_with('#')
5486− }
1941+ && !is_valid_hex_color(val)
5487− if validate && attr.html && attr.key == "class" {
1942+ {
5488− reserved_class(&attr, line, column(0), ctx);
1943+ ctx.diagnostics.push(Diagnostic {
5489− }
1944+ line: line_num,
5490− // `padding=4`: a style written as an HTML attribute, which the
1945+ column: None,
5491− // browser ignores (`width=` and `height=` are real on some
1946+ message: format!("invalid hex color '{}'", val),
5492− // elements, see `validate_tree`)
1947+ severity: Severity::Warning,
5493− if validate
1948+ source_line: None,
5494− && attr.html
1949+ });
5495− && crate::vocab::is_style_attribute(&attr.key)
1950+ }
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 }
1951 }
5512
1952
5513− // A style or a flag: its name is checked, and what can't be
1953+ // Warn on unknown attributes. `key=value` HTML attributes may use any
5514− // written into the page is an error and left out
1954+ // name; everything else must be a known style.
5515− let checked = validate && !attr.html && !(ctx.in_bundle && could_be_parameter(&attr));
1955+ if validate && !attr.html {
5516− let before = out.attrs.last().map(|(a, _)| a);
1956+ let base_key = crate::vocab::base_attribute(attr.key.as_str());
5517− if checked && !check_attribute(&attr, line, column(0), before, ctx) {
1957+ let is_boolean_html =
5518− continue;
1958+ attr.value.is_none() && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&base_key);
1959+ if is_boolean_html || crate::vocab::is_style_attribute(base_key) {
1960+ validate_attr_value(&attr, line_num, ctx);
1961+ } else if crate::vocab::HTML_ATTRIBUTES.contains(&base_key)
1962+ || base_key.starts_with("aria-")
1963+ || base_key.starts_with("data-")
1964+ {
1965+ ctx.diagnostics.push(Diagnostic {
1966+ line: line_num,
1967+ column: None,
1968+ message: format!(
1969+ "'{}' is an HTML attribute: write `{}={}`",
1970+ attr.key,
1971+ attr.key,
1972+ attr.value.as_deref().unwrap_or("")
1973+ ),
1974+ severity: Severity::Warning,
1975+ source_line: None,
1976+ });
1977+ } else {
1978+ let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
1979+ let msg = match suggestion {
1980+ Some(closest) => {
1981+ format!(
1982+ "unknown attribute '{}', did you mean '{}'?",
1983+ attr.key, closest
1984+ )
1985+ }
1986+ None => format!("unknown attribute '{}'", attr.key),
1987+ };
1988+ ctx.diagnostics.push(Diagnostic {
1989+ line: line_num,
1990+ column: None,
1991+ message: msg,
1992+ severity: Severity::Warning,
1993+ source_line: None,
1994+ });
1995+ }
5519 }
1996 }
5520
1997
5521− out.attrs.push((attr, typed));
1998+ attrs.push(attr);
5522 }
1999 }
2000+
2001+ attrs
5523}
2002}
5524
2003
5525−/// At a call, a parameter under prefixes (`md:title x`, `md:[title x]`):
2004+/// Split an HTML attribute written `key=value` (`alt=`, `type=email`,
5526−/// a parameter has one value, with no states, so it is reported (with
2005+/// `aria-label=Close menu`). Returns `None` for style attributes.
5527−/// `validate`) and left out; returns true then. A parameter named like a
2006+fn split_html_attribute(part: &str) -> Option<(&str, &str)> {
5528−/// style (`color`) is a style under a prefix, for the root element.
2007+ let first_token = part.split(char::is_whitespace).next()?;
5529−fn prefixed_parameter(
2008+ let eq = first_token.find('=')?;
5530− attr: &Attribute,
2009+ let key = &part[..eq];
5531− text_keys: &[String],
2010+ let valid_key = key.starts_with(|c: char| c.is_ascii_alphabetic())
5532− line: usize,
2011+ && key
5533− column: Option<usize>,
2012+ .chars()
5534− validate: bool,
2013+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
5535− ctx: &mut ParseContext,
2014+ 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}
2015}
5566
2016
5567−/// The prefixes of a prefixed group, bundle or `if()` inside `outer` (the
2017+fn split_commas(input: &str) -> Vec<&str> {
5568−/// prefixes of the groups it is in), when they are all known; else the
2018+ let mut parts = Vec::new();
5569−/// unknown one is reported. Text after a group's `]` is an error too.
2019+ let mut start = 0;
5570−fn group_prefix(
2020+ let mut depth = 0;
5571− token: &syntax::Attr,
2021+ let mut in_quotes = false;
5572− prefixed: &syntax::Prefixed,
2022+
5573− outer: &str,
2023+ for (i, c) in input.char_indices() {
5574− line: usize,
2024+ match c {
5575− ctx: &mut ParseContext,
2025+ '"' => in_quotes = !in_quotes,
5576−) -> Option<String> {
2026+ _ if in_quotes => {}
5577− let at = |ctx: &ParseContext, diagnostic: Diagnostic, span: syntax::Span| match &ctx
2027+ '[' | '(' => depth += 1,
5578− .current_source
2028+ ']' | ')' => depth -= 1,
5579− {
2029+ ',' if depth == 0 => {
5580− (current, Some(text)) if span.line != 0 && *current == span.line => {
2030+ parts.push(&input[start..i]);
5581− diagnostic.column(span.column).source(text.clone())
2031+ 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 }
2032 }
5612− });
2033+ _ => {}
5613− let diagnostic = Diagnostic::error(code::UNKNOWN_PREFIX, line, problem.message)
2034+ }
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 }
2035 }
5620− Some(format!("{}{}", outer, known.concat()))
2036+ parts.push(&input[start..]);
2037+ parts
5621}
2038}
5622
2039
5623−/// An attribute of the bundle `bundle`, spliced in under `prefix`
2040+// ---------------------------------------------------------------------------
5624−/// (`md:$card`): the same attribute with the prefixes in front, when it can
2041+// Chain splitting (the > operator)
5625−/// take them. An HTML attribute can't; a style's prefixes are checked, as
2042+// ---------------------------------------------------------------------------
5626−/// is a flag or a name that isn't a style (a bundle's parameter), which
2043+
5627−/// under a prefix are attributes like any other.
2044+fn split_chain(content: &str) -> Vec<String> {
5628−fn prefixed_member(
2045+ let mut segments = Vec::new();
5629− attr: &Attribute,
2046+ let mut current = String::new();
5630− prefix: &str,
2047+ let chars: Vec<char> = content.chars().collect();
5631− bundle: &str,
2048+ let mut i = 0;
5632− line: usize,
2049+ let mut bracket_depth = 0;
5633− column: Option<usize>,
2050+
5634− validate: bool,
2051+ while i < chars.len() {
5635− ctx: &mut ParseContext,
2052+ if chars[i] == '[' {
5636−) -> Option<Attribute> {
2053+ 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 }
2054 }
5653− return None;
2055+ if chars[i] == ']' {
5654− }
2056+ 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 }
2057 }
5668− }
5669− Some(prefixed)
5670−}
5671
2058
5672−/// `class=hl-x`: the `hl-` prefix is htmlang's, for its generated classes,
2059+ // Match " > @" outside brackets
5673−/// whose rules would then apply to the element too. It goes into the page
2060+ if bracket_depth == 0
5674−/// as written, so this is a warning.
2061+ && i + 3 < chars.len()
5675−fn reserved_class(attr: &Attribute, line: usize, column: Option<usize>, ctx: &mut ParseContext) {
2062+ && chars[i] == ' '
5676− let classes = attr.value.as_deref().unwrap_or_default();
2063+ && chars[i + 1] == '>'
5677− for class in classes.split_whitespace() {
2064+ && chars[i + 2] == ' '
5678− if class.starts_with(crate::codegen::CLASS_PREFIX) {
2065+ && chars[i + 3] == '@'
5679− let diagnostic = Diagnostic::warning(
2066+ {
5680− code::INVALID_VALUE,
2067+ segments.push(current.trim().to_string());
5681− line,
2068+ current = String::new();
5682− format!(
2069+ i += 3; // skip " > ", keep the "@"
5683− "class '{}' starts with `{}`, which htmlang keeps for the classes it \
2070+ 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 }
2071 }
2072+
2073+ current.push(chars[i]);
2074+ i += 1;
5694 }
2075 }
5695−}
5696
2076
5697−/// A style's key as the CSS reads it, for finding duplicates: its at-rule
2077+ if !current.trim().is_empty() {
5698−/// prefixes in one order (`dark:md:` is `md:dark:`), then its selector
2078+ 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 }
2079 }
5715− normal.push_str(name);
5716− normal
5717−}
5718
2080
5719−/// `[$name]` where `$name` isn't an attribute bundle.
2081+ 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}
2082}
5770
2083
5771// ---------------------------------------------------------------------------
2084// ---------------------------------------------------------------------------
5772// Text segment parsing (inline {...} elements)
2085// Text segment parsing (inline {...} elements)
5773// ---------------------------------------------------------------------------
2086// ---------------------------------------------------------------------------
5774
2087
5775−/// While a slot is filled in, each escape (see [`syntax::ESCAPES`]) is a
2088+/// Escapes in text: `\@`, `\$`, `\{`, `\--` and `\\` stand for the
5776−/// private-use placeholder, U+E000 plus its place in the table, so it
2089+/// character(s) themselves. While a text is processed, each is a private-use
5777−/// can't start a variable, a quote or an inline element. Afterwards it
2090+/// placeholder, so it can't start a variable, an inline element or a line.
5778−/// becomes what it stands for.
2091+const ESCAPES: &[(&str, char, &str)] = &[
2092+ ("\\\\", '\u{E000}', "\\"),
2093+ ("\\$", '\u{E001}', "$"),
2094+ ("\\{", '\u{E002}', "{"),
2095+ ("\\@", '\u{E003}', "@"),
2096+ ("\\--", '\u{E004}', "--"),
2097+];
2098+
5779fn protect_escapes(text: &str) -> String {
2099fn protect_escapes(text: &str) -> String {
5780 if !text.contains('\\') {
2100 if !text.contains('\\') {
5781 return text.to_string();
2101 return text.to_string();
5782 }
2102 }
5783− let mut out = String::with_capacity(text.len());
2103+ let mut out = text.to_string();
5784− let mut i = 0;
2104+ for (escape, placeholder, _) in ESCAPES {
5785− while let Some(c) = text[i..].chars().next() {
2105+ out = out.replace(escape, &placeholder.to_string());
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 }
2106 }
5797 out
2107 out
5798}
2108}
5799
2109
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−
5806fn restore_escapes(text: &str) -> String {
2110fn restore_escapes(text: &str) -> String {
5807− text.chars()
2111+ let mut out = text.to_string();
5808− .fold(String::with_capacity(text.len()), |mut out, c| {
2112+ for (_, placeholder, literal) in ESCAPES {
5809− match escape_of(c) {
2113+ out = out.replace(*placeholder, literal);
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−
5818−/// Inside the quoted strings of a CSS value, `\"` and `\\` mean in CSS what
5819−/// they mean in htmlang, so they stay as written there.
5820−fn keep_css_string_escapes(protected: &str) -> String {
5821− let mut out = String::with_capacity(protected.len());
5822− let mut inside = false;
5823− for c in protected.chars() {
5824− match escape_of(c) {
5825− Some(escape @ ("\\\"" | "\\\\")) if inside => out.push_str(escape),
5826− _ => {
5827− if c == '"' {
5828− inside = !inside;
5829− }
5830− out.push(c);
5831− }
5832− }
5833 }
2114 }
5834 out
2115 out
5835}
2116}
5836
2117
5837−/// Evaluate text: fill in the variables of its plain runs and build its
2118+fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment> {
5838−/// inline elements. Each run is one slot, so a value can't make markup.
5839−fn text_segments(text: &syntax::Text, ctx: &mut ParseContext) -> Vec<TextSegment> {
5840 let mut segments = Vec::new();
2119 let mut segments = Vec::new();
5841− // The plain text so far, and where it starts
2120+ let mut current_text = String::new();
5842− let mut plain = String::new();
2121+ let chars: Vec<char> = protect_escapes(input).chars().collect();
5843− let mut start: Option<syntax::Span> = None;
2122+ let mut i = 0;
5844− let flush = |plain: &mut String,
2123+
5845− start: &mut Option<syntax::Span>,
2124+ while i < chars.len() {
5846− segments: &mut Vec<TextSegment>,
2125+ if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '@' {
5847− ctx: &mut ParseContext| {
2126+ // Flush accumulated plain text
5848− if !plain.is_empty() {
2127+ if !current_text.is_empty() {
5849− let at = start.unwrap_or(text.span);
2128+ segments.push(TextSegment::Plain(restore_escapes(&substitute_vars(
5850− let filled = ctx.interpolate_text(plain, at.line, Some(at.column));
2129+ &current_text,
5851− segments.push(TextSegment::Plain(filled));
2130+ &ctx.variables,
5852− plain.clear();
2131+ ))));
5853− }
2132+ current_text.clear();
5854− *start = None;
2133+ }
5855− };
2134+
5856− for segment in &text.segments {
2135+ // Find matching }
5857− match segment {
2136+ let mut depth = 0;
5858− Segment::Plain { raw, span } => {
2137+ let start = i + 1; // after {
5859− start.get_or_insert(*span);
2138+ i += 1;
5860− plain.push_str(raw);
2139+ loop {
5861− }
2140+ if i >= chars.len() {
5862− Segment::Inline(inline) => {
2141+ break;
5863− let line = ctx.current_line;
2142+ }
5864− match resolve(&inline.head, inline.text.as_ref(), line, &text.raw, ctx) {
2143+ if chars[i] == '{' {
5865− Ok(resolved) => {
2144+ depth += 1;
5866− if !inline.closed {
2145+ } else if chars[i] == '}' {
5867− let d = Diagnostic::warning(
2146+ if depth == 0 {
5868− code::UNCLOSED_BRACKET,
2147+ break;
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 }
2148 }
5892− Err(mut e) => {
2149+ depth -= 1;
5893− // A misspelled element is an error inline too (a
2150+ }
5894− // brace that is text is written `\{`); the braces
2151+ i += 1;
5895− // stay as written
2152+ }
5896− e.source_line = Some(text.raw.as_str().into());
2153+
5897− ctx.diagnostics.push(e);
2154+ let inner: String = chars[start..i].iter().collect();
5898− start.get_or_insert(inline.span);
2155+ i += 1; // skip }
5899− plain.push('{');
2156+
5900− plain.push_str(&inline.raw);
2157+ match parse_single_element(&inner, ctx.current_line, ctx) {
5901− if inline.closed {
2158+ Ok(elem) => segments.push(TextSegment::Inline(elem)),
5902− plain.push('}');
2159+ Err(e) => {
5903− }
2160+ // Keep the braces as literal text, but flag what looks
2161+ // like a mistyped inline element.
2162+ if inner.trim_start().starts_with('@') {
2163+ ctx.diagnostics.push(Diagnostic {
2164+ line: ctx.current_line,
2165+ column: None,
2166+ message: e.message,
2167+ severity: Severity::Warning,
2168+ source_line: Some(input.to_string()),
2169+ });
5904 }
2170 }
2171+ current_text.push('{');
2172+ current_text.push_str(&inner);
2173+ current_text.push('}');
5905 }
2174 }
5906 }
2175 }
2176+ } else {
2177+ current_text.push(chars[i]);
2178+ i += 1;
5907 }
2179 }
5908 }
2180 }
5909− flush(&mut plain, &mut start, &mut segments, ctx);
5910− segments
5911−}
5912
2181
5913−/// The variable `raw` is, when it is exactly one `$name` or `${name}`.
2182+ if !current_text.is_empty() {
5914−fn whole_reference(raw: &str, env: &Env) -> Option<String> {
2183+ segments.push(TextSegment::Plain(restore_escapes(&substitute_vars(
5915− let after = raw.trim().strip_prefix('$')?;
2184+ &current_text,
5916− match interp::reference(after, env)? {
2185+ &ctx.variables,
5917− (interp::Reference::Var(path), len) if len == after.len() => Some(path),
2186+ ))));
5918− _ => None,
5919 }
2187 }
2188+
2189+ segments
5920}
2190}
5921
2191
5922−/// The attributes of `tokens` after each whole-attribute `if()` has
2192+/// The items of a text list: a range `A..B [step N]`, or comma-separated.
5923−/// chosen (see [`syntax::Choice`]), in order, into `out`. The branch not
2193+fn text_list_items(list: &str) -> Vec<String> {
5924−/// taken isn't evaluated; with `validate`, its attribute names are checked
2194+ if let Some((start, rest)) = list.split_once("..") {
5925−/// like code that doesn't run, under the prefixes `prefix` the `if()` is
2195+ let (end, step) = match rest.split_once(" step ") {
5926−/// under (`hover:if(...)`).
2196+ Some((end, step)) => (end.trim(), step.trim().parse::<i64>().unwrap_or(1).max(1)),
5927−fn choose_attrs(
2197+ None => (rest.trim(), 1),
5928− tokens: &[syntax::Attr],
5929− prefix: &str,
5930− line_num: usize,
5931− ctx: &mut ParseContext,
5932− validate: bool,
5933− text_keys: &[String],
5934− out: &mut Vec<syntax::Attr>,
5935−) {
5936− for token in tokens {
5937− let Some(choice) = &token.choice else {
5938− out.push(token.clone());
5939− continue;
5940− };
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 };
2198 };
5948− let (chosen, other) = match taken {
2199+ if let (Ok(start), Ok(end)) = (start.trim().parse::<i64>(), end.parse::<i64>()) {
5949− true => (choice.branches.first(), choice.branches.get(1)),
2200+ return numeric_range(start, end, step);
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− }
5957− }
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 }
2201 }
5969 }
2202 }
2203+ list.split(',')
2204+ .map(|s| s.trim().to_string())
2205+ .filter(|s| !s.is_empty())
2206+ .collect()
5970}
2207}
5971
2208
5972−/// Report what is wrong with the shape of `if()`: an empty condition, a
2209+/// Make `$target` (and `$target.key`, and nested lists) a copy of the list
5973−/// number of branches other than one or two, text after a group's `]`.
2210+/// item stored under `source`.
5974−/// Returns the condition when the `if()` can be evaluated.
2211+fn bind_item(vars: &mut HashMap<String, String>, source: &str, target: &str) {
5975−fn choice_shape<'a>(
2212+ let under = |key: &str, name: &str| {
5976− token: &syntax::Attr,
2213+ key.strip_prefix(name)
5977− choice: &'a syntax::Choice,
2214+ .is_some_and(|rest| rest.is_empty() || rest.starts_with(['.', '#']))
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 {
5984− token.span.line
5985− };
5986− // The line the column points into, when it is the one being evaluated
5987− let source = |ctx: &ParseContext, diagnostic: Diagnostic, at: usize| match &ctx.current_source {
5988− (current, Some(text)) if *current == at => diagnostic.source(text.clone()),
5989− _ => diagnostic,
5990 };
2215 };
5991− for branch in &choice.branches {
2216+ vars.retain(|key, _| !under(key, target));
5992− if let syntax::Branch::Group {
2217+ let copies: Vec<(String, String)> = vars
5993− trailing: Some(trailing),
2218+ .iter()
5994− ..
2219+ .filter(|(key, _)| under(key, source))
5995− } = branch
2220+ .map(|(key, value)| (format!("{}{}", target, &key[source.len()..]), value.clone()))
5996− {
2221+ .collect();
5997− let diagnostic = source(
2222+ vars.extend(copies);
5998− ctx,
2223+ // A record has no text of its own
5999− Diagnostic::error(
2224+ vars.entry(target.to_string()).or_default();
6000− code::UNEXPECTED_ARGUMENT,
2225+}
6001− trailing.span.line,
2226+
6002− format!(
2227+/// Inclusive integer range from `start` to `end` (counting down when
6003− "'{}' after the `]` of a group in if(): a branch is one attribute, \
2228+/// `start > end`), stepping by `step`. Stops instead of overflowing.
6004− one `[group]` of attributes or one `$bundle`",
2229+fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
6005− trailing.raw
2230+ let mut items = Vec::new();
6006− ),
2231+ let mut n = start;
6007− )
2232+ if start <= end {
6008− .column(trailing.span.column)
2233+ while n <= end {
6009− .subject(trailing.raw.as_str()),
2234+ items.push(n.to_string());
6010− trailing.span.line,
2235+ match n.checked_add(step) {
6011− );
2236+ Some(next) => n = next,
6012− ctx.push_once(diagnostic);
2237+ None => break,
2238+ }
6013 }
2239 }
6014− }
2240+ } else {
6015− match &choice.condition {
2241+ while n >= end {
6016− Some(condition) if (1..=2).contains(&choice.branches.len()) => Some(condition),
2242+ items.push(n.to_string());
6017− _ => {
2243+ match n.checked_sub(step) {
6018− let diagnostic = source(
2244+ Some(next) => n = next,
6019− ctx,
2245+ None => break,
6020− Diagnostic::error(
2246+ }
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 }
2247 }
6035 }
2248 }
2249+ items
6036}
2250}
6037
2251
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
2252
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
2253
6053−/// [`check_attrs`] for a branch of the `if()` `token`, under the prefixes
2254+/// Evaluate `if(cond, a)` or `if(cond, a, b)` when it is all of `text`,
6054−/// `prefix` it is under: `hover:if($on, color red, [id=x])` checks
2255+/// returning the chosen branch (empty when `cond` fails and there is no `b`).
6055−/// `hover:[id=x]`, an error whichever branch is taken.
2256+fn choose_if(text: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
6056−fn check_branch(
2257+ let inner = text.strip_prefix("if(")?.strip_suffix(')')?;
6057− token: &syntax::Attr,
2258+ let args = split_if_args(inner);
6058− branch: &syntax::Branch,
2259+ // `if(a) (b)` isn't one call
6059− prefix: &str,
2260+ if split_trailing_paren(inner) || !(2..=3).contains(&args.len()) {
6060− line: usize,
2261+ return None;
6061− ctx: &mut ParseContext,
6062− text_keys: &[String],
6063−) {
6064− if prefix.is_empty() {
6065− check_attrs(branch.attrs(), line, ctx, text_keys);
6066− return;
6067 }
2262 }
6068− let list = syntax::AttrList {
2263+ let branch = if ctx.condition(args[0].trim(), line) {
6069− attrs: branch.attrs().to_vec(),
2264+ args[1]
6070− span: token.span,
2265+ } else {
6071− closed: true,
2266+ args.get(2).copied().unwrap_or("")
6072− quoted_close: None,
6073 };
2267 };
6074− let grouped = syntax::Attr {
2268+ Some(branch.trim().to_string())
6075− key: prefix.to_string(),
2269+}
6076− value: None,
2270+
6077− html: false,
2271+/// Whether the parentheses in `inner` close before its end, as in the
6078− raw: token.raw.clone(),
2272+/// inside of `if(a)(b)`.
6079− span: token.span,
2273+fn split_trailing_paren(inner: &str) -> bool {
6080− choice: None,
2274+ let mut depth = 0i32;
6081− prefixed: Some(Box::new(syntax::Prefixed {
2275+ let mut quote = None;
6082− prefix: prefix.to_string(),
2276+ for c in inner.chars() {
6083− prefix_span: token.span,
2277+ match c {
6084− target: syntax::Branch::Group {
2278+ '"' | '\'' if quote == Some(c) => quote = None,
6085− list,
2279+ '"' | '\'' if quote.is_none() => quote = Some(c),
6086− trailing: None,
2280+ _ if quote.is_some() => {}
6087− },
2281+ '(' => depth += 1,
6088− })),
2282+ ')' => {
2283+ depth -= 1;
2284+ if depth < 0 {
2285+ return true;
2286+ }
2287+ }
2288+ _ => {}
2289+ }
2290+ }
2291+ false
2292+}
2293+
2294+/// Resolve a value written `if(cond, a, b)`: evaluate `cond` and keep the
2295+/// chosen branch's text, or `None` when the choice is empty. Other
2296+/// attributes are returned unchanged.
2297+fn choose_if_value(part: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
2298+ let Some(pos) = part.find(['=', ' ']) else {
2299+ return Some(part.to_string());
6089 };
2300 };
6090− check_attrs(std::slice::from_ref(&grouped), line, ctx, text_keys);
2301+ let (head, value) = part.split_at(pos + 1);
2302+ match choose_if(value.trim(), ctx, line) {
2303+ Some(branch) if branch.is_empty() => None,
2304+ Some(branch) => Some(format!("{}{}", head, branch)),
2305+ None => Some(part.to_string()),
2306+ }
6091}
2307}
6092
2308
6093−/// [`check_attrs`] for the branches of each `if()` of `tokens`, which are
2309+fn split_if_args(input: &str) -> Vec<&str> {
6094−/// under the prefixes `prefix`, also inside a prefixed group or as a
2310+ let mut parts = Vec::new();
6095−/// prefixed `if()`.
2311+ let mut start = 0;
6096−fn check_choices(
2312+ let mut depth = 0;
6097− tokens: &[syntax::Attr],
2313+ let mut quote = None;
6098− prefix: &str,
2314+ for (i, c) in input.char_indices() {
6099− line: usize,
2315+ match c {
6100− ctx: &mut ParseContext,
2316+ '"' | '\'' if quote == Some(c) => quote = None,
6101− text_keys: &[String],
2317+ '"' | '\'' if quote.is_none() => quote = Some(c),
6102−) {
2318+ _ if quote.is_some() => {}
6103− for token in tokens {
2319+ '(' => depth += 1,
6104− if let Some(choice) = &token.choice
2320+ ')' => depth -= 1,
6105− && choice_shape(token, choice, line, ctx).is_some()
2321+ ',' if depth == 0 => {
6106− {
2322+ parts.push(&input[start..i]);
6107− for branch in &choice.branches {
2323+ start = i + 1;
6108− check_branch(token, branch, prefix, line, ctx, text_keys);
6109 }
2324 }
6110− }
2325+ _ => {}
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 }
2326 }
6115 }
2327 }
2328+ parts.push(&input[start..]);
2329+ parts
6116}
2330}
6117
2331
6118// ---------------------------------------------------------------------------
2332// ---------------------------------------------------------------------------
6119// Post-parse validation (context-dependent warnings)
2333// Post-parse validation (context-dependent warnings)
6120// ---------------------------------------------------------------------------
2334// ---------------------------------------------------------------------------
6121
2335
2336+/// Attributes that only make sense on container elements (@row, @el, @el).
2337+const CONTAINER_ONLY_ATTRS: &[&str] = &[
2338+ "spacing",
2339+ "gap",
2340+ "wrap",
2341+ "grid-cols",
2342+ "grid-rows",
2343+ "container",
2344+ "container-name",
2345+ "container-type",
2346+];
2347+
6122fn element_kind_name(kind: &ElementKind) -> String {
2348fn element_kind_name(kind: &ElementKind) -> String {
6123 format!("@{}", kind.name())
2349 format!("@{}", kind.name())
6124}
2350}
6125
2351
6126−/// HTML elements whose start tag ends an open `<p>`: written inside a
2352+fn is_container(kind: &ElementKind) -> bool {
6127−/// `@paragraph`, the browser moves them out of it. (`div` isn't here:
2353+ matches!(kind, ElementKind::Row | ElementKind::El)
6128−/// inside text, htmlang writes its `<div>`s as `<span>`s.)
2354+ || kind.spec().is_some_and(|spec| spec.container)
6129−#[rustfmt::skip]
6130−const ENDS_A_PARAGRAPH: &[&str] = &[
6131− "address", "article", "aside", "blockquote", "dd", "details", "dialog", "dl", "dt",
6132− "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6",
6133− "header", "hgroup", "hr", "li", "main", "menu", "nav", "ol", "p", "pre", "search",
6134− "section", "summary", "table", "ul",
6135−];
6136−
6137−/// The HTML element `kind` is written as, if the browser would take it out
6138−/// of a `<p>`.
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}
2355}
6147
2356
6148/// Stricter checks for `htmlang lint`, on top of the diagnostics every
2357/// Stricter checks for `htmlang lint`, on top of the diagnostics every
@@ -6152,171 +2361,133 @@ pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
6152 fn walk(nodes: &[Node], depth: usize, out: &mut Vec<Diagnostic>) {
2361 fn walk(nodes: &[Node], depth: usize, out: &mut Vec<Diagnostic>) {
6153 for node in nodes {
2362 for node in nodes {
6154 let Node::Element(elem) = node else { continue };
2363 let Node::Element(elem) = node else { continue };
6155− let start = out.len();
2364+ let mut warn = |message: String| {
6156− let mut warn = |code: &'static str, message: String| {
2365+ out.push(Diagnostic {
6157− out.push(Diagnostic::warning(code, elem.line_num, message))
2366+ line: elem.line_num,
2367+ column: None,
2368+ message,
2369+ severity: Severity::Warning,
2370+ source_line: None,
2371+ })
6158 };
2372 };
6159 if depth > 10 {
2373 if depth > 10 {
6160− warn(
2374+ warn(format!(
6161− code::DEEP_NESTING,
2375+ "deeply nested element ({} levels): consider simplifying",
6162− format!(
2376+ depth
6163− "deeply nested element ({} levels): consider simplifying",
2377+ ));
6164− depth
6165− ),
6166− );
6167 }
2378 }
6168− // A function's body may draw with an empty element (a spacer,
2379+ if matches!(elem.kind, ElementKind::Row | ElementKind::El)
6169− // a dot): only one written in the page is flagged
6170− if elem.kind.layout().is_container()
6171 && elem.children.is_empty()
2380 && elem.children.is_empty()
6172− && elem.function.is_none()
6173 {
2381 {
6174− warn(
2382+ warn(format!("empty container (@{}) has no children", elem.kind.name()));
6175− code::EMPTY_CONTAINER,
6176− format!("empty container (@{}) has no children", elem.kind.name()),
6177− );
6178 }
2383 }
6179 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
2384 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
6180− warn(
2385+ warn("@button missing 'type' attribute (defaults to submit)".to_string());
6181− code::MISSING_BUTTON_TYPE,
6182− "@button missing 'type' attribute (defaults to submit)".to_string(),
6183− );
6184 }
2386 }
6185− in_function_body(elem, &mut out[start..]);
6186 walk(&elem.children, depth + 1, out);
2387 walk(&elem.children, depth + 1, out);
6187 }
2388 }
6188 }
2389 }
6189 let mut out = Vec::new();
2390 let mut out = Vec::new();
6190 walk(nodes, 0, &mut out);
2391 walk(nodes, 0, &mut out);
6191− dedupe(&mut out);
6192 out
2392 out
6193}
2393}
6194
2394
6195−/// `in_paragraph`: the nodes are inside a `@paragraph` (and not inside a
2395+
6196−/// `@button` in it, which the browser keeps whole).
6197fn validate_tree(
2396fn validate_tree(
6198 nodes: &[Node],
2397 nodes: &[Node],
6199 parent_kind: Option<&ElementKind>,
2398 parent_kind: Option<&ElementKind>,
6200− in_paragraph: bool,
6201 diagnostics: &mut Vec<Diagnostic>,
2399 diagnostics: &mut Vec<Diagnostic>,
6202) {
2400) {
6203 for node in nodes {
2401 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 {
2402 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 {
2403 for attr in &elem.attrs {
6237 let base = crate::vocab::base_attribute(&attr.key);
2404 let base = crate::vocab::base_attribute(&attr.key);
6238−
2405+ if base == "width"
6239− // htmlang's words for laying out children, on an element
2406+ && attr.value.as_deref() == Some("fill")
6240− // that doesn't lay out its children: left out (codegen)
2407+ && !matches!(parent_kind, Some(ElementKind::Row))
6241− if !attr.html
2408+ {
6242− && crate::vocab::CONTAINER_ATTRIBUTES.contains(&base)
2409+ diagnostics.push(Diagnostic {
6243− && !elem.kind.layout().is_container()
2410+ line: elem.line_num,
6244− && !has_no_element(&elem.kind)
2411+ column: None,
6245− && !crate::vocab::split_prefixes(&attr.key)
2412+ message: "'width fill' works best inside @row; using 100% as fallback"
6246− .0
2413+ .to_string(),
6247− .contains(&"children:")
2414+ severity: Severity::Warning,
2415+ source_line: None,
2416+ });
2417+ }
2418+ if base == "height"
2419+ && attr.value.as_deref() == Some("fill")
2420+ && !parent_kind.is_some_and(ElementKind::is_column)
6248 {
2421 {
6249− diagnostics.push(not_a_container(&elem.kind, elem.line_num, base));
2422+ diagnostics.push(Diagnostic {
2423+ line: elem.line_num,
2424+ column: None,
2425+ message: "'height fill' works best inside @el; using 100% as fallback"
2426+ .to_string(),
2427+ severity: Severity::Warning,
2428+ source_line: None,
2429+ });
2430+ }
2431+
2432+ // Container-only attributes on non-container elements
2433+ if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
2434+ diagnostics.push(Diagnostic {
2435+ line: elem.line_num,
2436+ column: None,
2437+ message: format!(
2438+ "'{}' has no effect on {} (only works on @row, @el, @el)",
2439+ base,
2440+ element_kind_name(&elem.kind)
2441+ ),
2442+ severity: Severity::Warning,
2443+ source_line: None,
2444+ });
6250 }
2445 }
6251
2446
6252 // Form-specific: placeholder only on @input/@textarea
2447 // Form-specific: placeholder only on @input/@textarea
6253 if base == "placeholder"
2448 if base == "placeholder"
6254 && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
2449 && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
6255 {
2450 {
6256− diagnostics.push(Diagnostic::new(
2451+ diagnostics.push(Diagnostic {
6257− code::NO_EFFECT,
2452+ line: elem.line_num,
6258− Severity::Warning,
2453+ column: None,
6259− elem.line_num,
2454+ message: format!(
6260− format!(
6261 "'placeholder' has no effect on {} (only works on @input, @textarea)",
2455 "'placeholder' has no effect on {} (only works on @input, @textarea)",
6262 element_kind_name(&elem.kind)
2456 element_kind_name(&elem.kind)
6263 ),
2457 ),
6264− ));
2458+ severity: Severity::Warning,
2459+ source_line: None,
2460+ });
6265 }
2461 }
6266
2462
6267− // 'for' only on @label and @output
2463+ // 'for' only on @label
6268− if base == "for" && !(elem.kind.is_tag("label") || elem.kind.is_tag("output")) {
2464+ if base == "for" && !elem.kind.is_tag("label") {
6269− diagnostics.push(Diagnostic::new(
2465+ diagnostics.push(Diagnostic {
6270− code::NO_EFFECT,
2466+ line: elem.line_num,
6271− Severity::Warning,
2467+ column: None,
6272− elem.line_num,
2468+ message: format!(
6273− format!(
2469+ "'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)
2470 element_kind_name(&elem.kind)
6276 ),
2471 ),
6277− ));
2472+ severity: Severity::Warning,
6278− }
2473+ source_line: None,
6279−
2474+ });
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 }
2475 }
6307
2476
6308 // 'rows'/'cols' only on @textarea
2477 // 'rows'/'cols' only on @textarea
6309− if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea") {
2478+ if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea")
6310− diagnostics.push(Diagnostic::new(
2479+ {
6311− code::NO_EFFECT,
2480+ diagnostics.push(Diagnostic {
6312− Severity::Warning,
2481+ line: elem.line_num,
6313− elem.line_num,
2482+ column: None,
6314− format!(
2483+ message: format!(
6315 "'{}' has no effect on {} (only works on @textarea)",
2484 "'{}' has no effect on {} (only works on @textarea)",
6316 base,
2485 base,
6317 element_kind_name(&elem.kind)
2486 element_kind_name(&elem.kind)
6318 ),
2487 ),
6319− ));
2488+ severity: Severity::Warning,
2489+ source_line: None,
2490+ });
6320 }
2491 }
6321
2492
6322 // Media-specific attributes only on @video/@audio
2493 // Media-specific attributes only on @video/@audio
@@ -6331,35 +2502,40 @@ fn validate_tree(
6331 | "preload"
2502 | "preload"
6332 ) && !matches!(elem.kind.name(), "video" | "audio")
2503 ) && !matches!(elem.kind.name(), "video" | "audio")
6333 {
2504 {
6334− diagnostics.push(Diagnostic::new(
2505+ diagnostics.push(Diagnostic {
6335− code::NO_EFFECT,
2506+ line: elem.line_num,
6336− Severity::Warning,
2507+ column: None,
6337− elem.line_num,
2508+ message: format!(
6338− format!(
6339 "'{}' has no effect on {} (only works on @video, @audio)",
2509 "'{}' has no effect on {} (only works on @video, @audio)",
6340 base,
2510 base,
6341 element_kind_name(&elem.kind)
2511 element_kind_name(&elem.kind)
6342 ),
2512 ),
6343− ));
2513+ severity: Severity::Warning,
2514+ source_line: None,
2515+ });
6344 }
2516 }
6345 }
2517 }
6346 // Missing alt text on @image
2518 // Missing alt text on @image
6347 if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt")
2519 if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt")
6348 {
2520 {
6349− diagnostics.push(Diagnostic::new(
2521+ diagnostics.push(Diagnostic {
6350− code::MISSING_ALT,
2522+ line: elem.line_num,
6351− Severity::Warning,
2523+ column: None,
6352− elem.line_num,
2524+ message: "@image missing 'alt' attribute (accessibility)".to_string(),
6353− "@image missing 'alt' attribute (accessibility)".to_string(),
2525+ severity: Severity::Warning,
6354− ));
2526+ source_line: None,
2527+ });
6355 }
2528 }
6356− if elem.kind.is_tag("input") && !elem.attrs.iter().any(|a| a.key == "type") {
2529+ if elem.kind.is_tag("input")
6357− diagnostics.push(Diagnostic::new(
2530+ && !elem.attrs.iter().any(|a| a.key == "type")
6358− code::MISSING_INPUT_TYPE,
2531+ {
6359− Severity::Warning,
2532+ diagnostics.push(Diagnostic {
6360− elem.line_num,
2533+ line: elem.line_num,
6361− "@input missing 'type' attribute (defaults to 'text')".to_string(),
2534+ column: None,
6362− ));
2535+ message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
2536+ severity: Severity::Warning,
2537+ source_line: None,
2538+ });
6363 }
2539 }
6364 if matches!(elem.kind, ElementKind::Link) {
2540 if matches!(elem.kind, ElementKind::Link) {
6365 // For @link, argument is the URL, not text content
2541 // For @link, argument is the URL, not text content
@@ -6370,50 +2546,44 @@ fn validate_tree(
6370 .iter()
2546 .iter()
6371 .any(|a| a.key == "aria-label" || a.key == "title")
2547 .any(|a| a.key == "aria-label" || a.key == "title")
6372 {
2548 {
6373− diagnostics.push(Diagnostic::new(
2549+ diagnostics.push(Diagnostic {
6374− code::MISSING_LINK_TEXT,
2550+ line: elem.line_num,
6375− Severity::Warning,
2551+ column: None,
6376− elem.line_num,
2552+ message: "@link has no visible text or aria-label (accessibility)"
6377− "@link has no visible text or aria-label (accessibility)".to_string(),
2553+ .to_string(),
6378− ));
2554+ severity: Severity::Warning,
2555+ source_line: None,
2556+ });
6379 }
2557 }
6380 }
2558 }
6381
2559
6382− // Contrast ratio check for hex color pairs, each pair on the
2560+ // Contrast ratio check for hex color pairs
6383− // same elements: the element's own, or those an element prefix
2561+ {
6384− // (`@td:`) picks out inside it
2562+ let bg_color = elem
6385− let inside = |key: &str| {
2563+ .attrs
6386− crate::vocab::split_prefixes(key)
2564+ .iter()
6387− .0
2565+ .find(|a| crate::vocab::base_attribute(&a.key) == "background")
6388− .into_iter()
2566+ .and_then(|a| a.value.as_deref());
6389− .find_map(crate::vocab::element_prefix)
2567+ let fg_color = elem
6390− .map(str::to_string)
2568+ .attrs
6391− };
2569+ .iter()
6392− let mut targets: Vec<Option<String>> = Vec::new();
2570+ .find(|a| crate::vocab::base_attribute(&a.key) == "color")
6393− for attr in elem.attrs.iter().filter(|a| !a.html) {
2571+ .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)
2572 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))
2573+ && let (Some(bg_rgb), Some(fg_rgb)) = (crate::expr::parse_hex_rgb(bg), crate::expr::parse_hex_rgb(fg))
6410 {
2574 {
6411 let ratio = contrast_ratio(bg_rgb, fg_rgb);
2575 let ratio = contrast_ratio(bg_rgb, fg_rgb);
6412 if ratio < 4.5 {
2576 if ratio < 4.5 {
6413− diagnostics.push(Diagnostic::new(code::LOW_CONTRAST, Severity::Warning, elem.line_num, format!(
2577+ diagnostics.push(Diagnostic {
2578+ line: elem.line_num,
2579+ column: None,
2580+ message: format!(
6414 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
2581 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
6415 ratio, fg, bg
2582 ratio, fg, bg
6416− )));
2583+ ),
2584+ severity: Severity::Warning,
2585+ source_line: None,
2586+ });
6417 }
2587 }
6418 }
2588 }
6419 }
2589 }
@@ -6428,34 +2598,45 @@ fn validate_tree(
6428 let has_title = elem.attrs.iter().any(|a| a.key == "title");
2598 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"));
2599 let in_label = parent_kind.is_some_and(|k| k.is_tag("label"));
6430 if !has_id && !has_aria_label && !has_title && !in_label {
2600 if !has_id && !has_aria_label && !has_title && !in_label {
6431− diagnostics.push(Diagnostic::new(code::MISSING_LABEL, Severity::Warning, elem.line_num, format!(
2601+ diagnostics.push(Diagnostic {
2602+ line: elem.line_num,
2603+ column: None,
2604+ message: format!(
6432 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
2605 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
6433 element_kind_name(&elem.kind)
2606 element_kind_name(&elem.kind)
6434− )));
2607+ ),
2608+ severity: Severity::Warning,
2609+ source_line: None,
2610+ });
6435 }
2611 }
6436 }
2612 }
6437
2613
6438 // @iframe should have title attribute
2614 // @iframe should have title attribute
6439− if elem.kind.is_tag("iframe") && !elem.attrs.iter().any(|a| a.key == "title") {
2615+ if elem.kind.is_tag("iframe")
6440− diagnostics.push(Diagnostic::new(
2616+ && !elem.attrs.iter().any(|a| a.key == "title")
6441− code::MISSING_TITLE,
2617+ {
6442− Severity::Warning,
2618+ diagnostics.push(Diagnostic {
6443− elem.line_num,
2619+ line: elem.line_num,
6444− "@iframe missing 'title' attribute (accessibility)".to_string(),
2620+ column: None,
6445− ));
2621+ message: "@iframe missing 'title' attribute (accessibility)".to_string(),
2622+ severity: Severity::Warning,
2623+ source_line: None,
2624+ });
6446 }
2625 }
6447
2626
6448 // @button should have accessible text
2627 // @button should have accessible text
6449 if elem.kind.is_tag("button") {
2628 if elem.kind.is_tag("button") {
6450− let has_text = !elem.children.is_empty();
2629+ let has_text = elem.argument.is_some() || !elem.children.is_empty();
6451 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
2630 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
6452 if !has_text && !has_aria {
2631 if !has_text && !has_aria {
6453− diagnostics.push(Diagnostic::new(
2632+ diagnostics.push(Diagnostic {
6454− code::MISSING_BUTTON_TEXT,
2633+ line: elem.line_num,
6455− Severity::Warning,
2634+ column: None,
6456− elem.line_num,
2635+ message: "@button has no text content or aria-label (accessibility)"
6457− "@button has no text content or aria-label (accessibility)".to_string(),
2636+ .to_string(),
6458− ));
2637+ severity: Severity::Warning,
2638+ source_line: None,
2639+ });
6459 }
2640 }
6460 }
2641 }
6461
2642
@@ -6468,15 +2649,16 @@ fn validate_tree(
6468 let has_track = elem
2649 let has_track = elem
6469 .children
2650 .children
6470 .iter()
2651 .iter()
6471− .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("track")));
2652+ .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("source")));
6472 if !has_aria && !has_track {
2653 if !has_aria && !has_track {
6473− diagnostics.push(Diagnostic::new(
2654+ diagnostics.push(Diagnostic {
6474− code::MISSING_CAPTIONS,
2655+ line: elem.line_num,
6475− Severity::Warning,
2656+ column: None,
6476− elem.line_num,
2657+ 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(),
2658 .to_string(),
6479− ));
2659+ severity: Severity::Warning,
2660+ source_line: None,
2661+ });
6480 }
2662 }
6481 }
2663 }
6482
2664
@@ -6486,204 +2668,20 @@ fn validate_tree(
6486 && let Ok(n) = val.parse::<i32>()
2668 && let Ok(n) = val.parse::<i32>()
6487 && n > 0
2669 && n > 0
6488 {
2670 {
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)));
2671+ diagnostics.push(Diagnostic {
2672+ line: elem.line_num,
2673+ column: None,
2674+ message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n),
2675+ severity: Severity::Warning,
2676+ source_line: None,
2677+ });
6490 }
2678 }
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
2679
6662−/// Diagnostics about an element a function's body wrote are reported at
2680+ 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 }
2681 }
6669 }
2682 }
6670}
2683}
6671
2684
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 {
2685fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
6688 fn linearize(c: u8) -> f64 {
2686 fn linearize(c: u8) -> f64 {
6689 let s = c as f64 / 255.0;
2687 let s = c as f64 / 255.0;
@@ -6696,24 +2694,6 @@ fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
6696 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
2694 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
6697}
2695}
6698
2696
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 {
2697fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
6718 let l1 = relative_luminance(c1.0, c1.1, c1.2);
2698 let l1 = relative_luminance(c1.0, c1.1, c1.2);
6719 let l2 = relative_luminance(c2.0, c2.1, c2.2);
2699 let l2 = relative_luminance(c2.0, c2.1, c2.2);
@@ -6721,48 +2701,65 @@ fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
6721 (lighter + 0.05) / (darker + 0.05)
2701 (lighter + 0.05) / (darker + 0.05)
6722}
2702}
6723
2703
2704+// ---------------------------------------------------------------------------
2705+// Variable substitution
2706+// ---------------------------------------------------------------------------
2707+
2708+/// Interpolate `$name` variables and `${expr}` expressions into text.
2709+/// Undefined variables, and expressions that don't evaluate, are left as
2710+/// written so they show up in the output.
2711+fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
2712+ if !input.contains('$') {
2713+ return input.to_string();
2714+ }
2715+ let is_name_char = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
2716+ let mut result = String::with_capacity(input.len());
2717+ let mut rest = input;
2718+ while let Some(pos) = rest.find('$') {
2719+ result.push_str(&rest[..pos]);
2720+ let after = &rest[pos + 1..];
2721+ if after.starts_with('{')
2722+ && let Some(close) = matching_brace(after)
2723+ {
2724+ let source = &after[1..close];
2725+ match crate::expr::eval(source, &|name: &str| lookup(vars, name)) {
2726+ Ok(value) => result.push_str(&value.to_string()),
2727+ Err(_) => result.push_str(&rest[pos..pos + 1 + close + 1]),
2728+ }
2729+ rest = &after[close + 1..];
2730+ continue;
2731+ }
2732+ let mut end = after.find(|c: char| !is_name_char(c)).unwrap_or(after.len());
2733+ while end > 0 && after[..end].ends_with('.') {
2734+ end -= 1;
2735+ }
2736+ let name = &after[..end];
2737+ match vars.get(name) {
2738+ Some(value) if !name.is_empty() => result.push_str(value),
2739+ _ => {
2740+ result.push('$');
2741+ result.push_str(name);
2742+ }
2743+ }
2744+ rest = &after[end..];
2745+ }
2746+ result.push_str(rest);
2747+ result
2748+}
2749+
6724/// `@image [inline] icon.svg` becomes the SVG's markup, with `width`,
2750/// `@image [inline] icon.svg` becomes the SVG's markup, with `width`,
6725−/// `height`, `color` / `fill` and the HTML attributes (`class=`, `id=`,
2751+/// `height`, `color` / `fill`, `class=` and `id=` applied to the `<svg>`
6726−/// `aria-label=`, ...) applied to the `<svg>` tag; any other attribute is
2752+/// tag. Other nodes are returned unchanged.
6727−/// an error. Other nodes are returned unchanged.
6728fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
2753fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
6729 let Node::Element(elem) = &node else {
2754 let Node::Element(elem) = &node else {
6730 return node;
2755 return node;
6731 };
2756 };
6732− let inline = elem.kind == ElementKind::Image
2757+ let is_inline_svg = elem.kind == ElementKind::Image
6733 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
2758 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
6734− && elem.argument.as_deref().is_some_and(|src| !src.is_empty());
2759+ && elem.argument.as_deref().is_some_and(|src| src.ends_with(".svg"));
6735− if !inline {
2760+ if !is_inline_svg {
6736 return node;
2761 return node;
6737 }
2762 }
6738− // A raster image: its file, read relative to this one, as a `data:` URI
6739− if !elem
6740− .argument
6741− .as_deref()
6742− .is_some_and(|src| src.ends_with(".svg"))
6743− {
6744− let Node::Element(mut elem) = node else {
6745− return node;
6746− };
6747− let filename = elem.argument.clone().unwrap_or_default();
6748− let resolved = ctx.resolve(&filename);
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− }
6754− Err(e) => ctx.diagnostics.push(Diagnostic::new(
6755− code::UNREADABLE_FILE,
6756− Severity::Error,
6757− line_num,
6758− format!(
6759− "cannot load image '{}' to put it into the page: {}",
6760− filename, e
6761− ),
6762− )),
6763− }
6764− return Node::Element(elem);
6765− }
6766 let filename = elem.argument.clone().unwrap_or_default();
2763 let filename = elem.argument.clone().unwrap_or_default();
6767 let resolved = match &ctx.base_path {
2764 let resolved = match &ctx.base_path {
6768 Some(base) => base.join(&filename),
2765 Some(base) => base.join(&filename),
@@ -6771,104 +2768,81 @@ fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
6771 let mut svg = match std::fs::read_to_string(&resolved) {
2768 let mut svg = match std::fs::read_to_string(&resolved) {
6772 Ok(text) => text.trim().to_string(),
2769 Ok(text) => text.trim().to_string(),
6773 Err(e) => {
2770 Err(e) => {
6774− ctx.diagnostics.push(Diagnostic::new(
2771+ ctx.diagnostics.push(Diagnostic {
6775− code::UNREADABLE_FILE,
2772+ line: line_num,
6776− Severity::Error,
2773+ column: None,
6777− line_num,
2774+ message: format!("cannot load SVG '{}': {}", filename, e),
6778− format!("cannot load SVG '{}': {}", filename, e),
2775+ severity: Severity::Error,
6779− ));
2776+ source_line: None,
2777+ });
6780 return node;
2778 return node;
6781 }
2779 }
6782 };
2780 };
6783 ctx.included_files.push(resolved);
2781 ctx.included_files.push(resolved);
6784− // The file's markup has no class of its own, so a style reaches it only
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 {
2782 for attr in &elem.attrs {
6792− let target = match (attr.key.as_str(), attr.html, &attr.value) {
2783+ let Some(value) = &attr.value else { continue };
6793− ("inline", false, None) | ("src", true, _) => continue,
2784+ let target = match (attr.key.as_str(), attr.html) {
6794− // `fill` and `shrink` are layout words, which need a class
2785+ ("width", false) | ("height", false) => attr.key.as_str(),
6795− ("width" | "height", false, Some(value))
2786+ ("color", false) | ("fill", false) => "fill",
6796− if matches!(value.as_str(), "fill" | "shrink") =>
2787+ ("class", true) | ("id", true) => attr.key.as_str(),
6797− {
2788+ _ => continue,
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− _ => {
6821− left_out.push(attr.key.clone());
6822− continue;
6823− }
6824 };
2789 };
6825− let value = attr.value.as_deref().unwrap_or("");
2790+ svg = set_svg_attr(&svg, target, value);
6826− svg = set_svg_attr(&svg, target, &html_escape_md(value));
6827− }
6828− if !style.is_empty() {
6829− // After the file's own `style=`, which stays: these come later, so
6830− // they win where both set a property
6831− let mut value = html_escape_md(&style.join(";"));
6832− if let Some(own) = svg_attr_value(&svg, "style")
6833− .map(|range| svg[range].trim().trim_end_matches(';').to_string())
6834− .filter(|own| !own.is_empty())
6835− {
6836− value = format!("{};{}", own, value);
6837− }
6838− svg = set_svg_attr(&svg, "style", &value);
6839 }
2791 }
6840− if !left_out.is_empty() {
2792+ Node::Raw(svg)
6841− let alt = left_out.iter().any(|name| name == "alt");
2793+}
6842− let names = left_out
2794+
6843− .iter()
2795+/// Index of the `}` matching the `{` that `s` starts with (skipping
6844− .map(|name| format!("'{}'", name))
2796+/// braces inside string literals).
6845− .collect::<Vec<_>>()
2797+fn matching_brace(s: &str) -> Option<usize> {
6846− .join(", ");
2798+ let mut depth = 0;
6847− let mut message = format!(
2799+ 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=`, ...)",
2800+ for (i, c) in s.char_indices() {
6849− names
2801+ match c {
6850− );
2802+ '"' => in_string = !in_string,
6851− if alt {
2803+ _ if in_string => {}
6852− message.push_str("; an SVG has no alt text: name it with `aria-label=` and `role=img`");
2804+ '{' => depth += 1,
2805+ '}' => {
2806+ depth -= 1;
2807+ if depth == 0 {
2808+ return Some(i);
2809+ }
2810+ }
2811+ _ => {}
6853 }
2812 }
6854− ctx.diagnostics.push(Diagnostic::new(
6855− code::UNEXPECTED_ARGUMENT,
6856− Severity::Error,
6857− line_num,
6858− message,
6859− ));
6860 }
2813 }
6861− Node::Raw(svg)
2814+ None
6862}
2815}
6863
2816
6864// ---------------------------------------------------------------------------
2817// ---------------------------------------------------------------------------
6865// Variable usage tracking
2818// Variable usage tracking
6866// ---------------------------------------------------------------------------
2819// ---------------------------------------------------------------------------
6867
2820
6868−/// Record the names a string refers to (by syntax) as used.
2821+/// Scan a string for $name references and record them in the used set.
6869fn track_var_refs(input: &str, used: &mut HashSet<String>) {
2822fn track_var_refs(input: &str, used: &mut HashSet<String>) {
6870− if input.contains('$') {
2823+ if !input.contains('$') {
6871− used.extend(interp::names(input).into_iter().map(String::from));
2824+ return;
2825+ }
2826+ let chars: Vec<char> = input.chars().collect();
2827+ let mut i = 0;
2828+ while i < chars.len() {
2829+ if chars[i] == '$'
2830+ && i + 1 < chars.len()
2831+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
2832+ {
2833+ let start = i + 1;
2834+ let mut end = start;
2835+ while end < chars.len()
2836+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
2837+ {
2838+ end += 1;
2839+ }
2840+ let name: String = chars[start..end].iter().collect();
2841+ used.insert(name);
2842+ i = end;
2843+ } else {
2844+ i += 1;
2845+ }
6872 }
2846 }
6873}
2847}
6874
2848
@@ -6876,63 +2850,49 @@ fn track_var_refs(input: &str, used: &mut HashSet<String>) {
6876// Unused definition warnings
2850// Unused definition warnings
6877// ---------------------------------------------------------------------------
2851// ---------------------------------------------------------------------------
6878
2852
6879−/// Warn about definitions nothing uses, except `exported` ones (the
2853+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
2854 // Check unused @let variables
6883 for (name, &line) in &ctx.let_lines {
2855 for (name, &line) in &ctx.let_lines {
6884− if exported.contains(name) {
2856+ if name.starts_with("--") {
6885− // A library's names are for other files (a custom property
2857+ continue; // CSS vars are always used
6886− // isn't a variable, so it is never listed here)
6887− continue;
6888 }
2858 }
6889 if !ctx.used_variables.contains(name) {
2859 if !ctx.used_variables.contains(name) {
6890− ctx.diagnostics.push(
2860+ ctx.diagnostics.push(Diagnostic {
6891− Diagnostic::new(
2861+ line,
6892− code::UNUSED_VARIABLE,
2862+ column: None,
6893− Severity::Warning,
2863+ message: format!("unused variable '${}' (defined but never referenced)", name),
6894− line,
2864+ severity: Severity::Warning,
6895− format!("unused variable '${}' (defined but never referenced)", name),
2865+ source_line: None,
6896− )
2866+ });
6897− .subject(name.as_str()),
6898− );
6899 }
2867 }
6900 }
2868 }
6901
2869
6902− // Check unused attribute bundles (@let name [...]). A `$name` in code
2870+ // 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 {
2871 for (name, &line) in &ctx.define_lines {
6905− if !ctx.used_defines.contains(name)
2872+ if !ctx.used_defines.contains(name) {
6906− && !ctx.used_variables.contains(name)
2873+ ctx.diagnostics.push(Diagnostic {
6907− && !exported.contains(name)
2874+ line,
6908− {
2875+ column: None,
6909− ctx.diagnostics.push(
2876+ message: format!(
6910− Diagnostic::new(
2877+ "unused attribute bundle '${}' (defined but never referenced)",
6911− code::UNUSED_BUNDLE,
2878+ name
6912− Severity::Warning,
2879+ ),
6913− line,
2880+ severity: Severity::Warning,
6914− format!(
2881+ source_line: None,
6915− "unused attribute bundle '${}' (defined but never referenced)",
2882+ });
6916− name
6917− ),
6918− )
6919− .subject(name.as_str()),
6920− );
6921 }
2883 }
6922 }
2884 }
6923
2885
6924 // Check unused functions (@let name ... with body)
2886 // Check unused functions (@let name ... with body)
6925 for (name, &line) in &ctx.fn_lines {
2887 for (name, &line) in &ctx.fn_lines {
6926− if !ctx.used_functions.contains(name) && !exported.contains(name) {
2888+ if !ctx.used_functions.contains(name) {
6927− ctx.diagnostics.push(
2889+ ctx.diagnostics.push(Diagnostic {
6928− Diagnostic::new(
2890+ line,
6929− code::UNUSED_FUNCTION,
2891+ column: None,
6930− Severity::Warning,
2892+ message: format!("unused function '@{}' (defined but never called)", name),
6931− line,
2893+ severity: Severity::Warning,
6932− format!("unused function '@{}' (defined but never called)", name),
2894+ source_line: None,
6933− )
2895+ });
6934− .subject(name.as_str()),
6935− );
6936 }
2896 }
6937 }
2897 }
6938}
2898}
@@ -7018,6 +2978,34 @@ fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
7018 }
2978 }
7019}
2979}
7020
2980
2981+/// Visit the lines of a block in source order, children after their line.
2982+fn for_each_line<'a>(block: &'a [Syntax], f: &mut impl FnMut(&'a Syntax)) {
2983+ for node in block {
2984+ f(node);
2985+ if let Syntax::Line { children, .. } = node {
2986+ for_each_line(children, f);
2987+ }
2988+ }
2989+}
2990+
2991+/// The text of a block whose lines are content, not htmlang (`@head`,
2992+/// `@style`, `@markdown`): each line trimmed, verbatim text as is.
2993+fn block_text(block: &[Syntax]) -> String {
2994+ let mut text = String::new();
2995+ for_each_line(block, &mut |node| match node {
2996+ Syntax::Line { text: line, .. } => {
2997+ text.push_str(line.trim());
2998+ text.push('\n');
2999+ }
3000+ Syntax::Raw { text: raw, .. } => {
3001+ text.push_str(raw);
3002+ text.push('\n');
3003+ }
3004+ _ => {}
3005+ });
3006+ text
3007+}
3008+
7021fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
3009fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
7022 pos = skip_ws(chars, pos);
3010 pos = skip_ws(chars, pos);
7023 if pos >= chars.len() {
3011 if pos >= chars.len() {
@@ -7198,21 +3186,68 @@ fn skip_ws(chars: &[char], mut pos: usize) -> usize {
7198 pos
3186 pos
7199}
3187}
7200
3188
7201−/// A JSON value as an htmlang value: an object is a record, an array a
3189+/// Flatten a JSON value into variable assignments.
7202−/// list, a string text (never quoted text), `null` empty. A number keeps
3190+/// - Top-level object: each key becomes `prefix.key`
7203−/// the text it is written with (`1.50`), and reads as a number.
3191+/// - Top-level array: `prefix` becomes comma-separated, `prefix._count` set
7204−fn json_value(json: JsonValue) -> Value {
3192+/// - Array of objects: each item becomes space-separated values for @each destructuring
7205− match json {
3193+fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, String>) {
7206− JsonValue::Null => Value::empty(),
3194+ match value {
7207− JsonValue::Bool(b) => Value::Bool(b),
3195+ JsonValue::Str(s) => {
7208− JsonValue::Number(n) | JsonValue::Str(n) => Value::Str(n),
3196+ vars.insert(prefix.to_string(), s.clone());
7209− JsonValue::Array(items) => Value::list(items.into_iter().map(json_value).collect()),
3197+ }
7210− JsonValue::Object(pairs) => Value::Record(Rc::new(
3198+ JsonValue::Number(n) => {
7211− pairs
3199+ vars.insert(prefix.to_string(), n.clone());
7212− .into_iter()
3200+ }
7213− .map(|(key, value)| (key, json_value(value)))
3201+ JsonValue::Bool(b) => {
3202+ vars.insert(prefix.to_string(), b.to_string());
3203+ }
3204+ JsonValue::Null => {
3205+ vars.insert(prefix.to_string(), String::new());
3206+ }
3207+ JsonValue::Object(pairs) => {
3208+ for (key, val) in pairs {
3209+ flatten_json(&format!("{}.{}", prefix, key), val, vars);
3210+ }
3211+ }
3212+ JsonValue::Array(items) => {
3213+ // A list: its length under `NAME#` (see `lookup`), its text
3214+ // items joined as its value, and each item as `NAME.INDEX`
3215+ vars.insert(format!("{}#", prefix), items.len().to_string());
3216+ let text: Vec<String> = items
3217+ .iter()
3218+ .map(json_value_to_string)
3219+ .filter(|s| !s.is_empty())
3220+ .collect();
3221+ vars.insert(prefix.to_string(), text.join(", "));
3222+ // Also set indexed access: prefix.0, prefix.1, etc.
3223+ for (i, item) in items.iter().enumerate() {
3224+ flatten_json(&format!("{}.{}", prefix, i), item, vars);
3225+ }
3226+ }
3227+ }
3228+}
3229+
3230+/// Resolve `$name` for an expression: a list if `name` was loaded from a
3231+/// JSON array, else its text.
3232+fn lookup(vars: &HashMap<String, String>, name: &str) -> Option<crate::expr::Value> {
3233+ use crate::expr::Value;
3234+ match vars.get(&format!("{}#", name)).and_then(|n| n.parse::<usize>().ok()) {
3235+ Some(len) => Some(Value::List(
3236+ (0..len)
3237+ .map(|i| vars.get(&format!("{}.{}", name, i)).cloned().unwrap_or_default())
7214 .collect(),
3238 .collect(),
7215 )),
3239 )),
3240+ None => vars.get(name).cloned().map(Value::Str),
3241+ }
3242+}
3243+
3244+fn json_value_to_string(v: &JsonValue) -> String {
3245+ match v {
3246+ JsonValue::Str(s) => s.clone(),
3247+ JsonValue::Number(n) => n.clone(),
3248+ JsonValue::Bool(b) => b.to_string(),
3249+ JsonValue::Null => String::new(),
3250+ JsonValue::Array(_) | JsonValue::Object(_) => String::new(),
7216 }
3251 }
7217}
3252}
7218
3253
@@ -7220,29 +3255,6 @@ fn json_value(json: JsonValue) -> Value {
7220// SVG attribute injection helper
3255// SVG attribute injection helper
7221// ---------------------------------------------------------------------------
3256// ---------------------------------------------------------------------------
7222
3257
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 {
3258fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
7247 // Only the opening <svg ...> tag is touched, so child attributes such as
3259 // Only the opening <svg ...> tag is touched, so child attributes such as
7248 // `stroke-width` are left alone.
3260 // `stroke-width` are left alone.
@@ -7253,14 +3265,26 @@ fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
7253 .find('>')
3265 .find('>')
7254 .map(|p| tag_start + p)
3266 .map(|p| tag_start + p)
7255 .unwrap_or(svg.len());
3267 .unwrap_or(svg.len());
3268+ let tag = &svg[tag_start..tag_end];
7256
3269
7257− // Replace an existing attribute
3270+ // Replace an existing attribute (must be preceded by whitespace so that
7258− if let Some(existing) = svg_attr_value(svg, attr_name) {
3271+ // `width` doesn't match `stroke-width`).
7259− let mut result = String::with_capacity(svg.len());
3272+ let pattern = format!("{}=\"", attr_name);
7260− result.push_str(&svg[..existing.start]);
3273+ let existing = tag.match_indices(&pattern).find(|(pos, _)| {
7261− result.push_str(value);
3274+ tag[..*pos]
7262− result.push_str(&svg[existing.end..]);
3275+ .chars()
7263− return result;
3276+ .next_back()
3277+ .is_some_and(char::is_whitespace)
3278+ });
3279+ if let Some((pos, _)) = existing {
3280+ let value_start = tag_start + pos + pattern.len();
3281+ if let Some(end) = svg[value_start..tag_end].find('"') {
3282+ let mut result = String::with_capacity(svg.len());
3283+ result.push_str(&svg[..value_start]);
3284+ result.push_str(value);
3285+ result.push_str(&svg[value_start + end..]);
3286+ return result;
3287+ }
7264 }
3288 }
7265
3289
7266 // Otherwise inject it into the opening tag (before a self-closing `/`).
3290 // Otherwise inject it into the opening tag (before a self-closing `/`).
@@ -7583,10 +3607,7 @@ mod tests {
7583 #[test]
3607 #[test]
7584 fn parses_page_directive() {
3608 fn parses_page_directive() {
7585 let r = parse_ok("@page My Title\n");
3609 let r = parse_ok("@page My Title\n");
7586− assert_eq!(
3610+ 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 }
3611 }
7591
3612
7592 #[test]
3613 #[test]
crates/htmlang-core/src/std.hl+1−1
@@ -2,7 +2,7 @@
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.
3
3
4-- Takes up the remaining space in a row or column.
4-- Takes up the remaining space in a row or column.
5−@let @spacer
5+@let spacer
6 @el [flex 1]
6 @el [flex 1]
7
7
8-- Cut text off at one line with an ellipsis: [$truncate]
8-- Cut text off at one line with an ellipsis: [$truncate]
crates/htmlang-core/src/syntax.rs+317−2846

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+158−775

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+18−69

This diff is too big to show here.

editors/vscode/snippets/htmlang.json+24−93

This diff is too big to show here.

editors/vscode/syntaxes/htmlang.tmLanguage.json+11−256

This diff is too big to show here.

examples/blog.hl+11−9

This diff is too big to show here.

examples/demo.hl+60−91

This diff is too big to show here.

examples/demo.html+509−594

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+49−55

This diff is too big to show here.

examples/landing.hl+78−87

This diff is too big to show here.

src/bin/htmlang_lsp/analysis.rs+888−1294

This diff is too big to show here.

src/bin/htmlang_lsp/completion.rs+157−1358

This diff is too big to show here.

src/bin/htmlang_lsp/docs.rs+86−352

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+108−55

This diff is too big to show here.

src/bin/htmlang_lsp/navigation.rs+260−504

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

This diff is too big to show here.

src/fmt.rs+381−395

This diff is too big to show here.

src/lib.rs−3

This diff is too big to show here.

src/main.rs+53−215

This diff is too big to show here.

src/serve.rs+50−128

This diff is too big to show here.

tests/common/mod.rsdeleted−140

This diff is too big to show here.

tests/docs.rs+11−83

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+35−2033

This diff is too big to show here.

tests/snapshots.rs+217−4333

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

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

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

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

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

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

This diff is too big to show here.

tests/snapshots/fn_defaults.hl+1−1

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

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

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

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

This diff is too big to show here.

tests/snapshots/mixin_spread.hl+1−1

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

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

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

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

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/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.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.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+2−2

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

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

This diff is too big to show here.

tests/snapshots/stress_deeply_nested.hl+20−20

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

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

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.