on master help sign in
Make htmlang more elegant: the 24 review proposals (#1)
693bde9iko
CONTRIBUTING.md+90−7
@@ -19,14 +19,82 @@ 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-` + a short name; an element's defaults (its layout,
44+ `ElementKind::css`) go with its class, as `:where(.hl-a)`, never a
45+ rule on a global element selector, so `@markdown`/`@raw` HTML and a
46+ page embedding a fragment are untouched. The rules are in cascade
47+ layers (`LAYERS`): `hl-reset`, `hl-kind` (the defaults), `hl-inside`
48+ (element prefixes, `@td:`, each an `@scope` on the class, and
49+ functions' scoped `@style`, written when a function is called) and
50+ `htmlang` (the elements' own styles and `children:`), so which one
51+ wins is the layer's order, not specificity.
52+ A style's prefixes are a `Condition`: its at-rule prefixes (sorted, so
53+ `dark:md:` is `md:dark:`, and written as nested `@media`/`@container`
54+ blocks) and its selector chain (read left to right; an element prefix
55+ splits it into the scope's root and the elements it styles). Each
56+ class keeps one list of (condition, declarations), and every block and
57+ chain is written in one fixed order, never the order of the source.
22− - `ast.rs` — the element kinds; every plain HTML element is one row in `TAGS`.
58+ - `ast.rs` — the element kinds; every plain HTML element is one row in
59+ `TAGS` with its one `Layout` (column, row, grid, text, native or void,
60+ which decides its text lines, `spacing` and its children's layout
61+ words), whether its text is shown as written (`literal`: `@code`,
62+ `@textarea`, whose indented body is verbatim text, HTML-escaped), the
63+ one HTML attribute its first word fills (`arg`: `src`, `action`, ...;
64+ `ElementKind::arg` gives `@link`'s and `@image`'s too) and whether its
65+ body is foreign text written into the page as it is (`verbatim`:
66+ `@script`), and every directive one row in `DIRECTIVES` (its argument
67+ and its kind of body; a verbatim directive's line is its one-line body
68+ or file, and it takes no attributes). The list of
69+ elements is fixed; `HTML_NAMES_WRITTEN_OTHERWISE` maps HTML's `a`, `img`,
70+ `span`, `p` and `div` to htmlang's own names for the unknown-element
71+ suggestion only.
23 - `vocab.rs` — the attribute vocabulary (htmlang attributes, CSS properties,
72 - `vocab.rs` — the attribute vocabulary (htmlang attributes, CSS properties,
73+ HTML attributes, `@page`'s own `favicon`), the pixel rule
74+ (`LENGTH_PROPERTIES`, the one table of properties whose bare numbers
75+ are px, and `with_px`, the one function that applies it) and the
76+ prefixes: `PSEUDOS`, CSS's pseudo-classes and pseudo-elements under
77+ their CSS names, each marked `:`, `::` or taking an argument, in the
78+ order their rules are written; `children:`; element prefixes (`@td:`,
79+ any name; the parser checks it is an element with a tag of its own,
24− HTML attributes) and the state/media prefixes.
80+ `ElementKind::own_tag`); and the width, media and container prefixes,
81+ with the rank that orders the at-rule blocks. A new pseudo-class is one
82+ row in `PSEUDOS` and one word in DESIGN.md's list.
25− - `expr.rs` — the expression language for conditions and computed values.
83+ - `expr.rs` — the expression language for conditions, computed values and
84+ `${...}`. It evaluates only what decides the result (the branch `if()`
85+ takes, the side of `and`/`or` that decides) and only reads the rest.
86+ - `value.rs` — the value types (text, number, true/false, list,
87+ record) and the rules defined once on them: truthiness, comparison,
88+ how numbers and lists print, ranges.
89+ - `interp.rs` — `$name` and `${...}`: where a name ends and how one slot
90+ of text (a text run, an attribute's value, an argument, a path) is
91+ filled in, including what quoted text inserts in CSS and in text. The
92+ one place variables are interpolated.
26 - `std.hl` — the standard library, written in htmlang and loaded before
93 - `std.hl` — the standard library, written in htmlang and loaded before
27 every file.
94 every file.
28- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
95- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
29−- `src/` — CLI, dev server and formatter.
96+- `src/` — CLI, dev server and formatter (`fmt.rs`, which prints the syntax
97+ tree back with its comments and blank lines).
30- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`). Its
98- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`). Its
31 completions come from the compiler's tables; hover and completion text live
99 completions come from the compiler's tables; hover and completion text live
32 in `docs.rs`.
100 in `docs.rs`.
@@ -34,7 +102,9 @@ Linux, macOS, and Windows. Your change should pass all three.
34- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
102- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
35- `tests/regressions.rs` — one test per fixed bug.
103- `tests/regressions.rs` — one test per fixed bug.
36- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`,
104- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`,
37− and every page in `examples/`.
105+ and every page in `examples/`: any diagnostic fails it.
106+- `tests/fmt.rs` — formats every example and snapshot input: the result is
107+ stable, compiles to the same HTML and keeps every comment.
38- `examples/` — complete pages: a landing page, a blog, a docs page, and a
108- `examples/` — complete pages: a landing page, a blog, a docs page, and a
39 tour of the whole language (`demo.hl`).
109 tour of the whole language (`demo.hl`).
40
110
@@ -45,7 +115,9 @@ Linux, macOS, and Windows. Your change should pass all three.
45 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
115 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
46 tests alongside the code.
116 tests alongside the code.
472. Prefer the smallest mechanism: a component belongs in `std.hl`, an HTML
1172. Prefer the smallest mechanism: a component belongs in `std.hl`, an HTML
118+ element is a row in `TAGS` (and a line in DESIGN.md's table of layouts
119+ and a name in the VS Code grammar's element list, which tests check), and
48− element is a row in `TAGS`, and a CSS property needs no code at all. Only
120+ a CSS property needs no code at all. Only
49 thread a feature through the parser and codegen when it needs to be, and
121 thread a feature through the parser and codegen when it needs to be, and
50 describe it in the LSP's `docs.rs`.
122 describe it in the LSP's `docs.rs`.
513. Document it in `DESIGN.md` (the examples there are compiled by the tests).
1233. Document it in `DESIGN.md` (the examples there are compiled by the tests).
@@ -57,9 +129,20 @@ Linux, macOS, and Windows. Your change should pass all three.
57
129
58- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
130- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
59- Keep `@` prefixes and bracket-attribute syntax consistent with existing
131- Keep `@` prefixes and bracket-attribute syntax consistent with existing
60− directives. Add `KNOWN_DIRECTIVES` entries when you add a new `@foo`.
132+ directives. A new `@foo` is a row in `ast::DIRECTIVES`; the parser, the
133+ formatter and the language server read that table, and `tests/editor.rs`
134+ fails when the VS Code grammar's word lists drift from it.
135+- Every diagnostic has a code from `diagnostic::code`; add one there when no
136+ existing code fits, and key tool behavior (quick fixes, tests) on the code
137+ rather than on the message. Put the name the diagnostic is about in
138+ `subject` and a replacement in `suggestion`.
61- Diagnostics should include `line` and, when practical, `column` and a
139- Diagnostics should include `line` and, when practical, `column` and a
62 `source_line` excerpt. Use `Severity::Help` for suggestions, not `Warning`.
140 `source_line` excerpt. Use `Severity::Help` for suggestions, not `Warning`.
141+- Check names, not values, and never drop input silently. When something
142+ the author wrote can't go into the page, report an error; a warning means
143+ it went into the page as written. Check a CSS value only for what is
144+ wrong in any CSS (see `css_breakout` in `parser.rs`), not against a list
145+ of values the browser may know better.
63- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
146- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
64 diagnostic so the compiler keeps going.
147 diagnostic so the compiler keeps going.
65
148
DESIGN.md+1157−152
@@ -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 [max-width 640, center-x, padding 40, spacing 16]
8+@el [width fill, 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 {@text [font-weight bold] htmlang}.
11+ This page was written in {@b 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,23 +19,28 @@ 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),
22− element is a row or a column, `spacing` sets the gap between its children,
23+ `spacing` sets the gap between its children,
23 and each child says how it sits in its parent (`width fill`, `center-x`,
24 and each child says how it sits in its parent (`width fill`, `center-x`,
24 `align-right`). The layout attributes are the only styling words htmlang
25 `align-right`). The layout attributes are the only styling words htmlang
25 adds.
26 adds.
26- **Everything else is CSS or HTML, under its own name.** A style is any CSS
27- **Everything else is CSS or HTML, under its own name.** A style is any CSS
27− property with a CSS value. An element that isn't about layout has its HTML
28+ property with a CSS value. An element that isn't about layout or text has
28− name, and an HTML attribute is written `key=value`. Nothing is renamed or
29+ its HTML name, and an HTML attribute is written `key=value`. Nothing is
29− abbreviated.
30+ renamed or abbreviated.
30- **Each thing is done one way.** `@let` defines every reusable piece, `@data`
31- **Each thing is done one way.** `@let` defines every reusable piece, `@data`
31 loads data, `@include` brings in a file, and `if()` makes an attribute
32 loads data, `@include` brings in a file, and `if()` makes an attribute
32 conditional. A layout is an ordinary function.
33 conditional. A layout is an ordinary function.
33- **The output is what you wrote.** Each page compiles to one self-contained
34- **The output is what you wrote.** Each page compiles to one self-contained
34 HTML file. Its CSS is inside, and it has no JavaScript unless you write
35 HTML file. Its CSS is inside, and it has no JavaScript unless you write
35 some. The compiler doesn't add attributes, tags or rewritten values that
36 some. The compiler doesn't add attributes, tags or rewritten values that
36− the source doesn't ask for.
37+ the source doesn't ask for, and it doesn't leave out anything you wrote:
38+ what can't go into the page is an error. The one exception is a style
39+ whose value comes out empty, such as an optional field a record doesn't
40+ have: it is simply not written.
37- **Everything runs at compile time.** Variables, expressions, loops and
41- **Everything runs at compile time.** Variables, expressions, loops and
38− conditions are resolved when the page is built.
42+ conditions are resolved when the page is built. What CSS computes in the
43+ browser (`var()`, `calc()`, `color-mix()`) stays CSS, written as it is.
39
44
40## Syntax
45## Syntax
41
46
@@ -46,7 +51,9 @@ Every example block in this file is compiled by the test suite
46 children
51 children
47```
52```
48
53
49−Children are indented under their parent. Attributes go inside `[...]`,
54+Children are indented under their parent, with spaces or with tabs (a
55+tab counts as one space, so a file that mixes them is a warning).
56+Attributes go inside `[...]`,
50separated by commas, and the list may span several lines:
57separated by commas, and the list may span several lines:
51
58
52```
59```
@@ -54,7 +61,7 @@ separated by commas, and the list may span several lines:
54 padding 20,
61 padding 20,
55 background white,
62 background white,
56 border-radius 8,
63 border-radius 8,
57− box-shadow 0 2px 4px rgba(0,0,0,0.1)
64+ box-shadow 0 2 4 rgba(0,0,0,0.1)
58]
65]
59 Content
66 Content
60```
67```
@@ -66,53 +73,137 @@ An attribute can take one of three forms:
66- `key=value` is an **HTML attribute** (`id=main`, `type=email`,
73- `key=value` is an **HTML attribute** (`id=main`, `type=email`,
67 `aria-label=Close`).
74 `aria-label=Close`).
68- A bare word is a **flag**: a layout attribute such as `center-x`, or a
75- A bare word is a **flag**: a layout attribute such as `center-x`, or a
69− boolean HTML attribute such as `required`, `disabled` or `open`.
76+ boolean HTML attribute such as `required`, `disabled`, `hidden` or `open`.
70
77
71−A comma inside `(...)` or `"..."` doesn't split attributes, so a font stack
78+A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else,
79+`\,` keeps a comma in the value:
80+
81+```
72−is written `font-family "Inter, sans-serif"`.
82+@el [transition opacity 0.3s\, transform 0.3s, font-family "Open Sans"\, sans-serif]
83+```
84+
85+A variable fills an attribute's value (`padding $gap`, `alt=$title`), never
86+its name or a whole attribute: attributes come from a
87+[bundle](#definitions), written `[$card]`, and a condition chooses whole
88+attributes with [`if()`](#conditional-attributes).
89+
90+The attribute list belongs to the element name right before it. Anywhere
91+else, `[` is an ordinary character, so a line of text can contain one:
92+
93+```
94+@text Use [ to open a list
95+@paragraph Arrays look like [1, 2, 3].
96+```
73
97
74### Text
98### Text
75
99
76A line that doesn't start with `@` is text. Text after an element's
100A line that doesn't start with `@` is text. Text after an element's
77−attributes is the element's content. Inside a line of text, `{...}` holds an
101+attributes is the element's content: its first line of text, read like any
78−inline element:
102+other. Inside a line of text, `{...}` holds an inline element, and
103+[escapes](#escapes) and [`$names`](#variables) work:
79
104
80```
105```
81@paragraph
106@paragraph
82− This is {@text [font-weight bold] important}, and this is {@link https://example.com a link}.
107+ This is {@strong important}, and this is {@link https://example.com a link}.
83@text [font-weight bold, font-size 24, color #333] Hello world
108@text [font-weight bold, font-size 24, color #333] Hello world
109+@h2 Welcome to {@text [color #3b82f6] htmlang}
110+@ul
111+ @li Read {@link /docs the docs}
84@section [padding 8] Text after the attributes is content too.
112@section [padding 8] Text after the attributes is content too.
85```
113```
86
114
87−A backslash makes the next character literal:
115+How an element's lines combine is its [layout](#rows-columns-and-text)'s
116+business: in a row or column each line is a child; in a text element lines
117+flow. Text that `@if`, `@each`, `@fragment` or a function writes counts as
118+lines written in its place, so in a text element it flows with the rest,
119+joined with a space, and in a native element it is on lines of its own.
120+
121+The text of `@code` and `@textarea` is shown as written. On its line and
122+inline, a `{@...}` in it is text, while escapes and `$names` still work.
123+The lines indented under it are a [verbatim](#verbatim-bodies) sample:
124+nothing in them is htmlang (`@`, `$`, `{`, `\` and `--` are text), and
125+they are HTML-escaped with their line breaks and indentation kept. This
126+is how a page shows code, htmlang included. `@pre` is an ordinary element,
127+so `@pre > @code` is HTML's `<pre><code>`, and a function can wrap `@pre`
128+around a sample. A sample is text on the line or lines in the block, not
129+both.
130+
131+```
132+@paragraph
133+ Write {@code {@link /docs docs}} for a link.
134+@pre [padding 16] > @code
135+ @el [padding 40]
136+ <b>shown as text</b>
137+@textarea [aria-label=Notes]
138+ First line
139+ Second line, indented
140+```
141+
142+A sample in the block has no `$names`, so a version number in it can't
143+come from a variable; a one-line sample on `@code`'s own line can
144+(`@code cargo install htmlang@$version`).
145+
146+### Escapes
88
147
148+A backslash before one of these characters stands for the character itself.
149+The table is the same in every htmlang string: text, arguments, attribute
150+values, `@let`, `@page` and `@meta`.
151+
152+| Escape | Writes | For |
153+|---|---|---|
89−- `\@` and `\--` let a line of text start with `@` or `--`.
154+| `\@`, `\--` | `@`, `--` | a line of text that starts with `@` or `--` |
90−- `\$` writes a `$` that isn't a variable.
155+| `\$` | `$` | a `$` before a name, which would start a variable |
91−- `\{` writes a brace that doesn't start an inline element.
156+| `\{`, `\}` | `{`, `}` | braces that don't start or end an inline element |
92−- `\\` writes a backslash.
157+| `\[`, `\]` | `[`, `]` | brackets that don't open or close an attribute list |
158+| `\,` | `,` | a comma in an attribute's value |
159+| `\"` | `"` | a quote that doesn't start or end quoted text |
160+| `\\` | `\` | a backslash before one of these characters |
161+
162+A backslash before any other character is kept as written, so CSS's own
163+escapes and patterns pass through: `before:content "\201C"`,
164+`pattern=\d{3}`. A `$` that isn't followed by a letter or `_` is text
165+anyway: `$5`, `$$`.
93
166
94```
167```
95\@htmlang on social media
168\@htmlang on social media
96@let price 5
169@let price 5
97−@text \$price is $price dollars
170+@paragraph \$price is $price dollars: {@mark \{braces\}}, \[brackets\] and a comma\, too
171+@input [type=text, pattern=\d{3}-\d{4}, title=Costs \$5]
98```
172```
99
173
100### Comments
174### Comments
101
175
176+A line whose first word is `--` is a comment: `--` followed by a space, or
102−A line starting with `--` is a comment. A comment must be on its own line,
177+alone on its line. A comment must be on its own line, because `--` later in
103−because `--` later in a line is ordinary text.
178+a line is ordinary text. A comment may sit between the lines of an attribute
179+list, and `--name` with no space is not a comment but a
180+[custom property](#custom-properties), so it can start a line of a list
181+(anywhere else, a line starting `--name` or `---` is text).
104
182
105```
183```
106-- this is a comment
184-- this is a comment
107@row [spacing 10]
185@row [spacing 10]
108 -- todo: more nav items
186 -- todo: more nav items
109 @link / Home
187 @link / Home
188+@el [
189+ -- the gap, wider on larger screens
190+ --gap 12px,
191+ md:--gap 24px,
192+ spacing var(--gap)
193+]
194+ @text One
195+ @text Two
110```
196```
111
197
112### Chains
198### Chains
113
199
114`>` puts elements that have one child each on one line. The last element in
200`>` puts elements that have one child each on one line. The last element in
115−the chain gets the indented children.
201+the chain gets the indented children. A `>` is a chain only between two
202+elements (`@name [attributes]`); in text it is just a character. A
203+function call can be a link of a chain, like any element. After an
204+element's leading argument (`@link /about`) the rest of the line is text,
205+so a link around an image is `@link [href=/] > @image logo.png` (the
206+compiler warns when `> @name` follows a leading argument).
116
207
117```
208```
118@el [padding 16, background blue, border-radius 8] > @link https://example.com
209@el [padding 16, background blue, border-radius 8] > @link https://example.com
@@ -121,10 +212,20 @@ the chain gets the indented children.
121
212
122### Verbatim bodies
213### Verbatim bodies
123
214
124−`@raw` writes HTML into the output exactly as given, either the rest of its
125−line or its indented block. The bodies of `@style`, `@head`, `@script` and
215+The bodies of `@raw`, `@style`, `@head`, `@markdown`, `@script`, `@code`
126−`@markdown` are also kept exactly as written: nothing in them is parsed as
216+and `@textarea` are foreign text, kept exactly as written: nothing in them
127−htmlang, and a `--` line in them is not a comment.
217+is parsed as htmlang, and a `--` line in them is not a comment. `@raw`
218+writes its HTML into the page, `@style` its CSS and `@head` its HTML into
219+the `<head>`; `@code` and `@textarea` show theirs as text. Which lines are
220+verbatim is decided by the element or directive on the line, also at the
221+end of a chain (`@pre > @code`, `@el > @script`).
222+
223+The header's line follows one rule. For `@raw`, `@style` and `@head`, text
224+on the line is a one-line body; for `@markdown` it is the file and for
225+`@script` the `src`. Text on the line and an indented body together are an
226+error. Only `@script` takes attributes: an attribute list on `@raw`,
227+`@style`, `@head` or `@markdown` is an error, so content that starts with
228+`[` (a CSS attribute selector) goes in the indented block.
128
229
129```
230```
130@raw <hr class="fancy">
231@raw <hr class="fancy">
@@ -132,16 +233,99 @@ htmlang, and a `--` line in them is not a comment.
132 <div class="custom-widget">
233 <div class="custom-widget">
133 <span>Hand-written HTML</span>
234 <span>Hand-written HTML</span>
134 </div>
235 </div>
236+@style .note { color: gray; }
135```
237```
136
238
239+### Directives
240+
241+A directive is a built-in word that isn't an element. Each one takes a
242+fixed kind of argument and a fixed kind of body. `@page` is the one
243+directive that is also an element: it stands for the page's root, `<body>`,
244+whose content is the rest of the file (see [Page and head](#page-and-head)).
245+
246+| Directive | Argument | Body |
247+|---|---|---|
248+| `@page` | attributes (styles for `<body>`, `key=value` for `<html>`) and a title | none |
249+| `@let` | a name and a value or bundle, or `@name` and parameters | a function's body |
250+| `@include` | a file | none |
251+| `@data` | a variable and a source | none |
252+| `@meta` | a name and a value | none |
253+| `@if`, `@else`, `@each` | a condition or a loop | htmlang |
254+| `@raw`, `@style`, `@head` | the rest of the line: a one-line body | verbatim, instead of the line |
255+| `@markdown` | a file | verbatim Markdown, instead of the file |
256+
257+An indented line under a directive that takes no body is an error, because
258+it would otherwise silently become a sibling.
259+
260+### Diagnostics
261+
262+The compiler checks the whole file, including code that doesn't run: the
263+branch of an `@if` or an `if()` that isn't taken, the body of a function that is never
264+called and the body of a loop over an empty list. Unknown elements,
265+functions, attributes and prefixes are reported there too. In code that
266+doesn't run, a call is checked against every function in the file and the
267+files it includes; where the code runs, a name is looked up where it is
268+written, so a function's body sees only what is defined above the function
269+(a call to a function defined further down is reported when the body
270+runs, and two functions can't call each other). A `$name` is checked only
271+where the code runs, since any of them may come from data: an undefined
272+variable in a branch that isn't taken, or in a function that is never
273+called, isn't reported.
274+
275+**Names are checked, and values are CSS's.** A misspelled element, prefix,
276+parameter or slot is an error, and so is an undefined variable. A CSS
277+property htmlang doesn't know, but whose name CSS could have, is written to
278+the CSS as it is, with a warning that suggests the closest known name
279+(`text-grow`, or `colr`, which asks "did you mean `color`?"). Values go to
280+the CSS as written, so the browser decides what `z-index auto` or `color
281+rebeccapurple` means; only what is wrong in any CSS is reported (see
282+[CSS properties](#css-properties)).
283+
284+**Nothing you write is left out silently.** An error means something
285+couldn't go into the page: a flag htmlang doesn't know, an attribute
286+`@fragment` has no element for, content for a function without
287+`@children`. A warning means it went into the page as written, but may not
288+be what you meant. A warning is reported once, even from a line that runs
289+many times in a loop or a function.
290+
291+Every diagnostic has a stable code, such as `unknown-element` or
292+`unused-variable`. The command line prints it as `error[unknown-element]`,
293+`--format json` has it in a `code` field, and the editor's quick fixes are
294+keyed on it.
295+
137## Layout
296## Layout
138
297
139−### Rows and columns
298+### Rows, columns and text
299+
300+Every element has one layout, which decides what the lines written inside
301+it are. **In a row or column each line is a child; in a text element lines
302+flow.**
140
303
141−`@el` is the container, and it lays out its children in a column. `@row`
304+| Layout | Elements | What is inside it |
142−lays them out side by side. Every other container (`@section`, `@nav`,
305+|---|---|---|
143−`@article`, `@form`, and so on) is a column too, so `@row` is the only
306+| 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`) |
144−element that changes direction. `@grid` is a CSS grid.
307+| row | `@row` | The same, side by side |
308+| grid | `@grid` | Each line of text is a cell |
309+| 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 |
310+| native | `@table`, `@caption`, `@select`, `@pre`, `@textarea`, `@video`, ... | HTML's own layout, which htmlang leaves alone |
311+| void | `@input`, `@hr`, `@br`, `@image`, `@source` | Nothing: it takes no content |
312+
313+The table under [HTML elements](#html-elements) gives every element's layout. The
314+page itself is a column too: `@page` is the [root element](#page-and-head),
315+`<body>`, so what is written at the top of a page stacks like the children
316+of an `@el`, `height fill` there takes the rest of the window and
317+`center-y` centres in it.
318+
319+```
320+@el [spacing 4]
321+ First line
322+ Second line
323+@button [type=button]
324+ Save
325+ changes
326+```
327+
328+The column shows two lines 4px apart; the button says "Save changes".
145
329
146These attributes are htmlang's own. They describe how an element lays out
330These attributes are htmlang's own. They describe how an element lays out
147its children, and how it sits in its parent:
331its children, and how it sits in its parent:
@@ -149,14 +333,31 @@ its children, and how it sits in its parent:
149| Attribute | Effect |
333| Attribute | Effect |
150|---|---|
334|---|---|
151| `spacing N` | Gap between children |
335| `spacing N` | Gap between children |
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 |
336+| `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 |
153−| `height fill` / `height shrink` / `height N` | Take the remaining space in a column, fit the content, or an exact size |
337+| `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 |
154−| `center-x`, `center-y` | Center the element in its parent |
338+| `center-x`, `center-y` | Center the element in its parent (auto margins) |
155−| `align-left`, `align-right`, `align-top`, `align-bottom` | Align the element in its parent |
339+| `align-left`, `align-right`, `align-top`, `align-bottom` | Align the element in its parent (an auto margin on the other side) |
156| `wrap` | Let a row wrap onto more lines |
340| `wrap` | Let a row wrap onto more lines |
157−| `grid-cols N`, `grid-rows N` | Equal grid columns or rows (on `@grid`) |
341+| `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`) |
158| `col-span N`, `row-span N` | Cells a grid child spans |
342| `col-span N`, `row-span N` | Cells a grid child spans |
159
343
344+`spacing`, `wrap`, `grid-cols` and `grid-rows` lay out an element's
345+children, so they go on a row, column or grid. On a text, native or void
346+element they are an error: text has no gap between its lines. For a flex
347+layout of your own on such an element, write the CSS (`@label [display
348+flex, gap 8]`).
349+
350+A child's `fill` and `shrink` follow the direction its parent's CSS
351+actually sets: a row for `@row`, a column for `@el` and the other columns,
352+or whatever the parent's own `flex-direction` (or `flex-flow`) says. Along
353+that direction, `fill` takes the remaining space and `shrink` keeps the
354+content's size; across it, `fill` is the full size and `shrink` fits the
355+content. `center-x`, `center-y` and `align-*` are auto margins, which work
356+in either direction; a `margin` on the same element keeps its other sides
357+(`[center-x, margin 20]`). A layout word never overrides a CSS property
358+the element writes itself: `[width fill, min-width 200]` keeps its 200px,
359+and `[flex-shrink 0, width fill]` still grows but never shrinks.
360+
160```
361```
161@row [spacing 8]
362@row [spacing 8]
162 @el [width fill, padding 12, background #f3f4f6] Fills the row
363 @el [width fill, padding 12, background #f3f4f6] Fills the row
@@ -165,15 +366,77 @@ its children, and how it sits in its parent:
165 @text [center-x] Centered
366 @text [center-x] Centered
166 @el [height fill, background #dbeafe] Fills the rest of the column
367 @el [height fill, background #dbeafe] Fills the rest of the column
167 @text [align-right] Right-aligned
368 @text [align-right] Right-aligned
369+@nav [flex-direction row, spacing 20]
370+ @link /features Features
371+ @el [width fill]
372+ @link /signup Sign up
168@grid [grid-cols 3, spacing 8, md:grid-cols 4]
373@grid [grid-cols 3, spacing 8, md:grid-cols 4]
169 @el [col-span 2, background #fef3c7] Two columns wide
374 @el [col-span 2, background #fef3c7] Two columns wide
170 @el [background #fde68a] One
375 @el [background #fde68a] One
171 @el [background #fcd34d] One
376 @el [background #fcd34d] One
172```
377```
173
378
379+A direction set under media or container prefixes (`md:flex-direction
380+row`, `print:`, `cq-md:`, or a stack of them such as `md:dark:`) switches
381+the children's `fill` and `shrink` in that same condition. As in elm-ui, whose child rules are keyed on the
382+parent's class (`.r > .wf`), each child gets a rule keyed on its parent's
383+class in the parent's `@media` or `@container` block. A width prefix holds
384+from its width up, so `sm:flex-direction column` still applies at `lg:`
385+unless `lg:` sets a direction of its own. A direction under a state prefix
386+(`hover:`, `first-child:`, `children:`, or a stack with one in it) leaves the
387+children's layout words as they are.
388+
389+```
390+@header [spacing 16, md:flex-direction row, md:align-items center]
391+ @text [font-weight 800] Launchpad
392+ @el [width fill]
393+ @nav [flex-direction row, spacing 20]
394+ @link /features Features
395+ @link /pricing Pricing
396+@section [padding 24]
397+ @el [width fill, max-width 800, center-x, spacing 16]
398+ The full width up to 800px, in the middle
399+```
400+
401+On a phone the header is a column and its `@el [width fill]` is the full
402+width; from md up it is a row and the `@el` takes the free space, pushing
403+the navigation to the right. The section's `@el` is the usual centred
404+column: the full width, at most 800px, with auto margins on both sides.
405+
174Everything else about layout is plain CSS: `justify-content`, `align-items`,
406Everything else about layout is plain CSS: `justify-content`, `align-items`,
175`flex 1 1 240px`, `grid-template-areas`, `position`.
407`flex 1 1 240px`, `grid-template-areas`, `position`.
176
408
409+### Layout inside text
410+
411+A row, column or grid written inside text, inline in a line (`{@el ...}`) or
412+as a child of a text element, is laid out inline (`display: inline-flex`, or
413+`inline-grid`), and keeps its attributes. Anywhere inside text, `@el`, `@row`
414+and `@grid` (and `@in-front` and `@behind`) are written as a `<span>` rather
415+than a `<div>`, since text can't hold a `<div>`.
416+
417+```
418+@paragraph
419+ Price: {@el [padding 2 6, background #fef3c7, border-radius 4] $9} a month.
420+@h2
421+ Plans
422+ @row [spacing 4, font-size 14] > @text New
423+```
424+
425+Any other element keeps its HTML element. One whose HTML ends a paragraph,
426+such as `@section`, `@ul` or `@h2`, is a warning inside `@paragraph`
427+(`block-in-paragraph`): the browser would move it, and what follows it, out
428+of the `<p>`.
429+
430+`@link` is text too, so a card written inside a link is an inline `<span>`
431+that shrinks to its content. A clickable card is the link itself, laid out
432+as a column with CSS:
433+
434+```
435+@link [href=/post, display flex, flex-direction column, gap 8, padding 16, border 1 solid #e5e7eb]
436+ @strong A post
437+ @text Its summary.
438+```
439+
177### Overlays: `@in-front` and `@behind`
440### Overlays: `@in-front` and `@behind`
178
441
179These layers work like elm-ui's `inFront` and `behind`. Their children fill
442These layers work like elm-ui's `inFront` and `behind`. Their children fill
@@ -214,15 +477,114 @@ same name and value as in CSS: `padding 20`, `font-weight bold`,
214`border 1 solid #e5e7eb`, `border-radius 8`, `background red`,
477`border 1 solid #e5e7eb`, `border-radius 8`, `background red`,
215`grid-template-areas "a b"`, `display none`.
478`grid-template-areas "a b"`, `display none`.
216
479
480+A quoted string in a value is CSS's own, so it keeps its quotes and its
481+commas: `font-family "Inter, sans-serif"` names one family called
482+"Inter, sans-serif", and the compiler warns about it. A font stack is
483+several values with commas between them, written `\,`:
484+`font-family Inter\, sans-serif`.
485+
217−Values mean what they mean in CSS, with one addition: **a bare number in a
486+Values mean what they mean in CSS, with one addition: **in a length
487+property, every bare number outside parentheses and quotes is pixels**. So
218−length is pixels**. So `padding 20` is `20px`, `border 1 solid red` is
488+`padding 20` is `20px`, `border 1 solid red` is `1px solid red`,
219−`1px solid red`, and `margin 0 auto` is `0 auto`. Values that have a unit,
489+`box-shadow 0 2 4 rgba(0,0,0,0.1)` is `0 2px 4px rgba(0,0,0,0.1)`, and
490+`margin 0 auto` is `0 auto` (a zero stays `0`). Numbers inside a function
491+belong to it (`rgb(255 128 0)`, `calc(100% - 20px)`), and values that have
220−keywords and CSS functions pass through as written (`width 50%`,
492+a unit or are keywords pass through as written (`width 50%`, `max-width
221−`max-width min(100%, 800px)`), and so do numbers that aren't lengths
493+min(100%, 800px)`). Every other property takes numbers as CSS does, so
222−(`opacity 0.5`, `z-index 2`, `line-height 1.5`, `flex 1`).
494+`opacity 0.5`, `z-index 2`, `line-height 1.5`, `flex 1 1 240px`,
495+`grid-column 1 / 3`, `initial-letter 3` and `border-image-width 2` are
496+written as they are, and so are custom properties (`--cols 3`), which have
497+no type to go by.
498+
499+The length properties are these, and only these:
500+
501+| Group | Properties |
502+|---|---|
503+| Size | `width`, `height`, `min-*` and `max-*` of both, `block-size`, `inline-size` and their `min-`/`max-`, `flex-basis`, `column-width`, `contain-intrinsic-*` |
504+| Space | `margin`, `padding`, `scroll-margin`, `scroll-padding` and every side of them, `gap`, `row-gap`, `column-gap`, `border-spacing` |
505+| Position | `top`, `right`, `bottom`, `left`, `inset` and `inset-*`, `translate`, `transform-origin`, `perspective`, `perspective-origin`, `offset-position`, `offset-anchor`, `offset-distance` |
506+| Border | `border` and every `border-*` shorthand, width and radius (not `border-image-*`), `outline`, `outline-width`, `outline-offset`, `column-rule`, `column-rule-width` |
507+| Shadow | `box-shadow`, `text-shadow` |
508+| Grid | `grid-template-columns`, `grid-template-rows`, `grid-auto-columns`, `grid-auto-rows` |
509+| Background | `background-position` (and `-x`, `-y`), `background-size`, `object-position`, `mask-position`, `mask-size` |
510+| Text | `font-size`, `letter-spacing`, `word-spacing`, `text-indent`, `text-decoration-thickness`, `text-underline-offset`, `vertical-align`, `baseline-shift` |
511+| Other | `shape-margin`, `overflow-clip-margin`, `view-timeline-inset` |
512+
513+Shorthands that mix lengths with numbers of other kinds (`font`,
514+`background`, `mask`, `grid`, `grid-template`, `offset`, `flex`) are not in
515+the table, so the lengths in them are written with their unit:
516+`font 700 16px/1.5 Inter`.
517+
518+Nothing else is added to a value, and a shorthand means what it means in
519+CSS: `outline 2 solid red` is `2px solid red`, while `outline 2 red` names
520+no line style, so CSS draws no outline. `container card / inline-size`
521+and `contain content` are CSS's own shorthands too, and a bare `contain` is
522+an error, like any style without a value. `line-clamp N` alone also adds
523+the `-webkit-box` declarations that browsers still need to cut text off
524+after N lines.
525+
526+A property htmlang doesn't know is written to the CSS as it is, so new CSS
527+(`text-grow per-line`) works before htmlang lists it; the
528+compiler warns and suggests the closest known name, in case it is a typo.
529+Vendor-prefixed properties (`-webkit-tap-highlight-color`) pass without a
530+warning. The value of a property htmlang doesn't know is written exactly as
531+it is, without pixels added. A style needs a value: `[padding]` is an error.
532+
533+```
534+@el [-webkit-tap-highlight-color transparent, -webkit-text-stroke 1px black]
535+ Passed through as written
536+```
223
537
224−`line-clamp N` also adds the `-webkit-box` declarations that browsers still
538+A value is checked only for what is wrong in any CSS. A `;`, `{` or `}`
225−need to cut text off after N lines.
539+(outside a quoted string, and for `;` outside parentheses, as in CSS's own
540+`if()`) or an unclosed quote or parenthesis would break out of the rule, and
541+a `</style` (even quoted) would end the page's style element, so each is an
542+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
543+style whose value comes out empty, such as a field that a record doesn't
544+have, is left out. Since a value is CSS's, a missing comma between two
545+styles makes one value: `padding 8 color red` is `padding: 8px color red`,
546+which the browser ignores. A style written as an HTML attribute
547+(`padding=8`) is a warning that shows its style form.
548+
549+#### Custom properties
550+
551+A custom property (`--name`) is a CSS property like any other: it goes on
552+any element, under any prefix, and is written into the element's class.
553+Its name is yours, so it is never checked against a list.
554+CSS passes it down, so it holds for the element and everything inside it,
555+where `var(--name)` reads it. Its value is written exactly as it is, since
556+a custom property has no type for pixels to go by: `--hue 200` can feed
557+`hsl(var(--hue) 80% 50%)` and `--cols 3` can feed `repeat(var(--cols),
558+1fr)`. A length therefore takes its unit: `--gap 16px`, not `--gap 16`.
559+
560+```
561+@el [
562+ --gap 16px,
563+ md:--gap 24px,
564+ --hue 200,
565+ spacing var(--gap),
566+ background hsl(var(--hue) 80% 95%)
567+]
568+ @text One
569+ @text Two
570+```
571+
572+`@let --name value` declares a custom property for the whole page, on
573+`:root` (see [CSS custom properties](#css-custom-properties));
574+`[--name value]` sets it on one element and everything inside it. So a
575+design token is declared once with `@let`, and a theme redefines it where
576+it changes, once, rather than repeating a colour on every element that
577+uses it:
578+
579+```
580+@let --ink #0f172a
581+@let --surface white
582+@let --subtle #f8fafc
583+@page [color var(--ink), background var(--surface), dark:[--ink #e2e8f0, --surface #0b1220, --subtle #111a2e]] Tokens
584+@section [padding 96 24, background var(--subtle)]
585+ @article [padding 24, background var(--surface)]
586+ Both follow dark mode, from the one place that sets it.
587+```
226
588
227### Prefixes
589### Prefixes
228
590
@@ -230,44 +592,201 @@ A prefix applies a style only in some condition:
230
592
231| Prefix | Applies |
593| Prefix | Applies |
232|---|---|
594|---|---|
233−| `hover:`, `active:`, `focus:`, `focus-visible:`, `focus-within:`, `disabled:`, `checked:`, `visited:`, `target:`, `valid:`, `invalid:`, `empty:`, `placeholder:`, `selection:` | In that state |
595+| `hover:`, `focus:`, `first-child:`, `checked:`, `user-invalid:`, `open:`, ... | When that CSS pseudo-class matches (`:hover`, `:first-child`, ...) |
234−| `first:`, `last:`, `odd:`, `even:`, `nth:EXPR:` | By the element's position among its siblings |
596+| `nth-child(odd):`, `not(.featured):`, `has(> img):`, `is(...):`, ... | When that pseudo-class, with its argument, matches |
597+| `before:`, `after:`, `marker:`, `placeholder:`, `backdrop:`, ... | To that pseudo-element (`::before`, `::marker`, ...) |
235| `children:` | To each direct child |
598| `children:` | To each direct child |
236−| `before:`, `after:` | To the `::before` / `::after` pseudo-element (together with `content`) |
599+| `@td:`, `@h2:`, `@link:`, ... | To every such element inside it, at any depth (see [Styling the elements inside](#styling-the-elements-inside)) |
237−| `has(SELECTOR):` | When the element contains a match |
238| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640, 768, 1024, 1280, 1536px) |
600| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640, 768, 1024, 1280, 1536px) |
239| `cq-sm:` … `cq-2xl:` | From that container width up (the same widths, for an ancestor with `container-type inline-size`) |
601| `cq-sm:` … `cq-2xl:` | From that container width up (the same widths, for an ancestor with `container-type inline-size`) |
240| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Under that media condition |
602| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Under that media condition |
241
603
604+The selector prefixes are CSS's own pseudo-classes and pseudo-elements,
605+under their CSS names, without the colons: `link`, `visited`,
606+`first-child`, `last-child`, `only-child`, `first-of-type`,
607+`last-of-type`, `only-of-type`, `empty`, `target`, `open`,
608+`popover-open`, `default`, `checked`, `indeterminate`,
609+`placeholder-shown`, `autofill`, `required`, `optional`, `valid`,
610+`invalid`, `user-valid`, `user-invalid`, `read-only`, `read-write`,
611+`focus-within`, `hover`, `focus`, `focus-visible`, `active`, `enabled`
612+and `disabled`; with an argument, `nth-child()`, `nth-last-child()`,
613+`nth-of-type()`, `nth-last-of-type()`, `not()`, `is()`, `where()` and
614+`has()`; and the pseudo-elements `before`, `after`, `marker`,
615+`placeholder`, `selection`, `backdrop`, `first-line`, `first-letter` and
616+`file-selector-button`, which htmlang writes with CSS's `::`. The argument
617+is CSS, written as it is between balanced parentheses, and it may hold
618+spaces and colons: the key ends at the first space outside parentheses, so
619+`has(> img):padding 0` and `is(:hover, :focus-visible):color red` are one
620+key each. `children:` is htmlang's one combinator word, and an element's
621+name with `@` (`@td:`) picks out the elements of that kind inside. A name
622+that isn't one of these (`hvoer:`, or `first:` for `first-child:`) is an
623+unknown prefix, an error, not a selector that silently never matches.
624+
625+A prefix goes on a style or a layout flag, and prefixes stack: the key
626+is a chain of prefixes, then the property, and the style applies where
627+all of them hold. The selector prefixes (every row above the widths)
628+read left to right: `hover:children:` styles the children of a hovered
629+element, `children:hover:` a hovered child. A pseudo-element is not an
630+element with states of its own, so it comes last among them:
631+`hover:before:color` is the `::before` of a hovered element, and
632+`before:hover:color` is an error that says so. The width, media and
633+container prefixes go anywhere in the chain, in any order, and each is
634+an `@media` or `@container` rule around the ones inside it:
635+`md:dark:padding 32` and `dark:md:padding 32` are the same style, from
636+768px up in dark mode.
637+
638+A prefix written before a `[group]` applies to each style in it, and one
639+written before a `$bundle` to each style the bundle holds, so styles that
640+share a condition are written under it once. A prefix before an `if()`
641+applies to the branch it picks. The group or bundle holds styles only:
642+an HTML attribute (`md:[id=x]`) or a flag that is not a style has no
643+states, and it is an error there, as it is with a prefix of its own
644+(`md:id=x`).
645+
646+`children:` styles each direct child, as the parent sees it. They take
647+effect only where the child sets nothing itself, since what an element
648+says about itself wins over what its parent says about it. They do win
649+over the defaults of the child's kind, so `children:display list-item`
650+brings back a list's markers and `children:margin-bottom 8` spaces
651+headings. The words
652+that place an element in its parent (`width fill`, `width shrink`,
653+`center-x`, `align-*`) are errors under `children:`, because there the
654+parent they measure against is this element: they go on the children
655+themselves, or the CSS they stand for does (`children:flex 1`).
656+
657+#### Styling the elements inside
658+
659+An element prefix, `@NAME:`, styles every element of that kind inside
660+this one, at any depth. `@table [@td:padding 8]` pads every cell of the
661+table, and `@article [@h2:font-size 20]` sizes every heading in the
662+article. It is how content that has no attributes of its own is styled:
663+the HTML `@markdown` writes, and the rows a loop writes.
664+
665+```
666+@let team Ada, Grace, Linus
667+@table [border-collapse collapse, width 100%, @th:[padding 8, text-align left], @td:padding 8, @tr:border-bottom 1 solid #e5e7eb]
668+ @tr
669+ @th Name
670+ @th Team
671+ @each $name in $team
672+ @tr
673+ @td $name
674+ @td [padding 2] Platform
675+@article [spacing 12, @h2:font-size 20, @code:[padding 1 4, background #f1f5f9], @link:hover:color #2563eb]
676+ @markdown
677+ ## Notes
678+
679+ Run `htmlang check` before you [publish](/publish).
242```
680```
681+
682+- **The element has a tag of its own.** `@td:` is every `<td>`,
683+ `@paragraph:` every `<p>`, `@link:` every `<a>` and `@image:` every
684+ `<img>`, the ones `@markdown` and `@raw` write included. An element that
685+ shares its tag can't be picked out, so its prefix is an error: `@el`,
686+ `@row`, `@grid`, `@in-front` and `@behind` all write `<div>`, and `@text`
687+ writes the `<span>` that every line of text in a row or column is too.
688+ So is a function's name (`@card:`), since a function writes whatever its
689+ body says and has no tag of its own, and a name that isn't an element
690+ (`@a:` suggests `@link:`).
691+- **It takes CSS properties.** htmlang's layout words (`spacing`, `wrap`,
692+ `grid-cols`, `col-span`, `width fill`, `center-x`, `align-*`) compile
693+ against each element's own layout and its parent's, which the prefix
694+ doesn't know, so they are errors under it: write them on the elements,
695+ or the CSS they stand for (`@li:margin-inline auto`). HTML attributes
696+ have no states: `@td:colspan=2` is an error, like `md:colspan=2`.
697+- **It composes with the other prefixes, read left to right.** Selector
698+ prefixes after it choose among the elements:
699+ `@tr:nth-child(even):background #f8fafc` shades every other row, and
700+ `@link:hover:color red` colours a hovered link. Those before it are
701+ about the element itself: `hover:@link:color red` recolours its links
702+ while it is hovered. Width, media and container prefixes go anywhere
703+ (`md:@td:padding 12`). A `[group]` or a `$bundle` takes it like any
704+ prefix: `@th:[padding 8, text-align left]`. A style has one element
705+ prefix, and not `children:` as well: each says which elements the style
706+ is for.
707+- **What an element says about itself wins.** A cell's own `[padding 2]`
708+ wins over the table's `@td:padding 8`, and a parent's `children:` wins
709+ over it too. The prefix wins over the defaults of the element's kind, so
710+ `@main [@h2:margin-block-start 16]` spaces headings, which start with
711+ `margin: 0`. Where two elements around one both style it, the nearest
712+ wins, whatever the order they are written in: in an `@article
713+ [@td:padding 16]` around a `@table [@td:padding 8]`, the table's cells
714+ get 8.
715+- **It styles what is inside, not the element itself.** `@ul
716+ [@ul:padding-inline-start 16]` indents the lists inside the list, not
717+ the list itself.
718+
719+`children:` and an element prefix answer different questions:
720+`children:` is for the direct children, whatever they are, as the parent
721+sees them; `@td:` is for every cell inside, however deep. An element's
722+own attributes are for it alone.
723+
724+Each style under an element prefix compiles to a rule in CSS's `@scope`,
725+on the element's class, in the layer `hl-inside` (see [CSS](#css)):
726+`@scope (.hl-a) { :scope td { padding: 8px } }`. `@scope`'s proximity
727+rule is what makes the nearest element win. `@scope` is Baseline 2025,
728+supported by Chrome and Edge 118, Safari 17.4 and Firefox 146; an older
729+browser ignores these rules, so the elements inside keep the styles they
730+have without them.
731+
732+```
733+@let card [padding 16, border 1 solid #e5e7eb, border-radius 8]
734+@let active true
243−@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
735+@el [padding 16, background #3b82f6, hover:background #2563eb, md:[padding 32, font-size 20], dark:[background #1e3a8a, hover:background #1e40af]]
244 @text [color white] Click me
736 @text [color white] Click me
245−@row [spacing 4, children:flex 1]
737+@row [spacing 4, children:flex 1, hover:children:opacity 0.8]
246− @el [odd:background #f3f4f6] A
738+ @el [nth-child(odd):background #f3f4f6] A
247− @el [odd:background #f3f4f6] B
739+ @el [nth-child(odd):background #f3f4f6] B
248− @el [odd:background #f3f4f6] C
740+ @el [nth-child(odd):background #f3f4f6] C
249−@el [before:content "→ ", before:color red]
741+@el [before:content "→ ", before:color red, hover:before:color blue]
250 Item with an arrow
742 Item with an arrow
743+@section [spacing 16, md:$card, hover:if($active, [background #eef2ff, color #3730a3])]
744+ A card from md up
251```
745```
252
746
747+The styles of one element follow a fixed order, whatever the order they
748+are written in: those without a prefix, then those with selector
749+prefixes only, then each width, media and container condition in the
750+order of the table (a stack like `md:dark:` after `dark:` on its own),
751+with its selector chains inside it. So where two styles both hold, the
752+one later in the table wins (`dark:` over `md:`, `lg:` over `md:`), and a
753+stack wins over each of its prefixes on its own (`md:dark:` over `dark:`
754+and `md:`). Selector chains follow CSS's usual order: `link:` and
755+`visited:`, the position and form states, then `hover:`, `focus:`,
756+`active:`, and `enabled:` and `disabled:` last. So a hovered row wins over
757+`nth-child(odd):`, and a pressed button's `active:` over its `focus:`.
758+
253### Conditional attributes
759### Conditional attributes
254
760
255−`if(CONDITION, A, B)` chooses `A` or `B` depending on the condition. It works
761+`if(CONDITION, A, B)`, written as a whole attribute, is `A` when the
256−as a value or as a whole attribute. If the chosen side is empty, the attribute
762+condition holds and `B` otherwise. Each branch is an attribute, a `[group]`
257−is left out, and `B` itself can be omitted.
763+of attributes or a `$bundle`. An empty branch leaves the attribute out, and
764+`B` itself can be left out. Several attributes that depend on one condition
765+are one `if()` with a group:
258
766
259```
767```
260@let active true
768@let active true
261−@el [background if($active, blue, gray), if($active, font-weight bold), padding if($active, 12)]
769+@el [if($active, background blue, background gray), if($active, font-weight bold)]
262 Conditionally styled
770 Conditionally styled
771+@link [if($active, [background #eef2ff, font-weight 600, aria-current=page])] /docs Docs
263```
772```
264
773
774+A value that depends on a condition is an [expression](#expressions),
775+`${if(CONDITION, A, B)}`. Its branches are expressions, so a number or a
776+single word is written as it is, and other CSS text is quoted:
777+`padding ${if($active, 24, 0)}`, `color ${if($active, #10b981, "var(--muted)")}`.
778+An `if()` written in a value without `${...}` is CSS's own `if()`, which the
779+browser decides, and it is passed through like any CSS function:
780+`width if(media(width > 40em): 50%; else: 100%)`.
781+
265## HTML
782## HTML
266
783
267−### Elements
784+### HTML elements
268
785
269The layout and text elements have elm-ui's names. Every other element has
786The layout and text elements have elm-ui's names. Every other element has
270−its HTML name.
787+its HTML name: `@strong`, `@em`, `@small`, `@br`, `@sub`, `@caption`,
788+`@tfoot`, `@optgroup`, `@hgroup` and the others in the table of layouts
789+below.
271
790
272| Element | Output | Purpose |
791| Element | Output | Purpose |
273|---|---|---|
792|---|---|---|
@@ -276,38 +795,82 @@ its HTML name.
276| `@grid` | div, grid | Grid container (with `grid-cols` or `grid-template-*`) |
795| `@grid` | div, grid | Grid container (with `grid-cols` or `grid-template-*`) |
277| `@in-front` / `@behind` | div, absolute | Overlay layers that fill the parent |
796| `@in-front` / `@behind` | div, absolute | Overlay layers that fill the parent |
278| `@text` | span | Styled inline text |
797| `@text` | span | Styled inline text |
279−| `@paragraph` | p | Flowing text with inline elements |
798+| `@paragraph` | p | A paragraph of flowing text |
280−| `@link URL` | a | Link, whose content is the text after the URL |
799+| `@link URL` | a | Link: the URL is its `href`, and the text after it is its content |
281−| `@image SRC` | img | Image |
800+| `@image SRC` | img | Image: the one word after its attributes is its `src` |
282−| `@fragment` | (none) | Its children, without a wrapper element |
801+| `@fragment` | (none) | Its children, without a wrapper element (so it takes no attributes) |
283−
802+
284−The HTML elements, by kind:
803+Every element has one [layout](#rows-columns-and-text):
285−
804+
286−- **Semantic containers**, all laid out as columns: `@nav`, `@header`,
805+| Layout | Elements |
806+|---|---|
287− `@footer`, `@main`, `@section`, `@article`, `@aside`, `@address`,
807+| 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` |
288− `@search`, `@form`, `@details` / `@summary`, `@dialog`, `@figure` /
808+| row | `@row` |
289− `@figcaption`, `@blockquote` / `@cite`, `@fieldset` / `@legend`,
809+| grid | `@grid` |
290− `@noscript`.
810+| 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` |
291−- **Text**: `@h1` … `@h6`, `@code`, `@pre`, `@mark`, `@kbd`, `@abbr`,
811+| 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` |
292− `@time`.
812+| void | `@image`, `@input`, `@hr`, `@br`, `@wbr`, `@col`, `@source`, `@track`, `@embed`, `@area` |
293−- **Lists and tables**: `@ul` / `@ol` / `@li`, `@dl` / `@dt` / `@dd`,
813+
294− `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`.
814+`@fragment`, `@children` and `@slot` have no element of their own: what
295−- **Forms**: `@input`, `@button`, `@select` / `@option`, `@textarea`,
815+they hold takes the layout of the element they are in. The top of a page is
296− `@label`, `@datalist`, `@progress`, `@meter`, `@output`. For `@form URL`,
816+a column, `<body>` (see [Page and head](#page-and-head)). In a native
297− the URL is the form's `action`.
817+element, and at the top of a fragment, lines of text are separated by a line break,
298−- **Media and embeds**: `@picture` / `@source`, `@video SRC`, `@audio SRC`,
818+which HTML shows as a space except where whitespace is kept, as in `@pre`
299− `@iframe SRC`, `@canvas`, `@script`, `@hr`.
819+and `@textarea`.
820+
821+The text-level elements (`@strong`, `@em`, `@b`, `@i`, `@small`, `@sub`,
822+`@q`, `@del`, ...) are text with the browser's own style and no CSS of
823+htmlang's, so they work inside a line like any inline element. Table parts,
824+`@optgroup` and `@ruby` keep HTML's own layout:
825+
826+```
827+@paragraph
828+ {@strong Note:} water is H{@sub 2}O,{@br}
829+ and {@em this} is {@small fine print}.
830+@table
831+ @caption Team
832+ @thead
833+ @tr
834+ @th Name
835+ @tbody
836+ @tr
837+ @td Ada
838+ @tfoot
839+ @tr
840+ @td One person
841+@select [aria-label=Fruit]
842+ @optgroup Citrus
843+ @option Lemon
844+```
845+
846+The list of elements is fixed: a name that isn't in it is an unknown
847+element, not a new HTML tag, since a function is called with the same `@`
848+and a misspelled one would otherwise become a tag. HTML's `<div>`, `<span>`,
849+`<p>`, `<a>` and `<img>` are htmlang's own `@el`, `@text`, `@paragraph`,
850+`@link` and `@image`, so `@a` is an unknown element whose error suggests
851+`@link`. The head's elements (`<meta>`, `<style>`, `<title>`, `<link>`) are
852+written with [directives](#page-and-head). `<data>` and `<slot>` share their
853+names with `@data` and `@slot`, and `<template>` holds content that only a
854+script uses; these, and `<svg>` and `<math>`, are written with `@raw`.
855+
856+An element without a closing tag (`@input`, `@hr`, `@br`, `@image`,
857+`@source`, ...) takes no content.
300
858
301Browser default margins on headings, paragraphs, lists and figures are reset
859Browser default margins on headings, paragraphs, lists and figures are reset
860+to 0 (in the element's own class, so Markdown and raw HTML keep theirs; see
302−to 0, so `spacing` controls the gaps. List items are columns like other
861+[CSS](#css)), so `spacing` controls the gaps. Lists (`@ul`, `@ol`, `@menu`) and list
862+items are columns like other containers, so `spacing` on a list is the gap between its items, and
303−containers, so a list shows no markers. To bring them back, write
863+a list shows no markers. To bring them back, write
304`[list-style disc, padding-inline-start 20, children:display list-item]` on
864`[list-style disc, padding-inline-start 20, children:display list-item]` on
305−the list.
865+the list, and `marker:` styles the markers themselves (`::marker`).
306
866
307```
867```
308@ol [spacing 4]
868@ol [spacing 4]
309 @li First
869 @li First
310 @li Second
870 @li Second
871+@ul [list-style disc, padding-inline-start 20, children:display list-item, children:marker:color #2563eb]
872+ @li [first-child:font-weight 700] Markers
873+ @li Back
311@form [method=post, spacing 8] /subscribe
874@form [method=post, spacing 8] /subscribe
312 @label [for=email] Email
875 @label [for=email] Email
313 @input [type=email, name=email, id=email, required]
876 @input [type=email, name=email, id=email, required]
@@ -317,12 +880,75 @@ the list.
317 @text The answer.
880 @text The answer.
318```
881```
319
882
883+### The leading argument
884+
885+An element that points at a URL or a file takes it as the first word after
886+its attributes, its leading argument, which fills one HTML attribute:
887+
888+| Element | Leading argument |
889+|---|---|
890+| `@link`, `@area` | `href` |
891+| `@image`, `@script`, `@iframe`, `@video`, `@audio`, `@track`, `@embed` | `src` |
892+| `@source` | `srcset` directly inside `@picture`, `src` inside `@video` and `@audio` |
893+| `@form` | `action` |
894+| `@object` | `data` |
895+| `@optgroup` | `label` |
896+
897+The first word is taken before any `$name` in it is filled in, and it ends
898+at the first space that isn't inside `"..."` or `${...}`: `@link $url More`
899+and `@link /page/${$n + 1} Next` take the whole `$url` and
900+`/page/${$n + 1}`, and `@link "/my page" Open` takes `/my page`. After it,
901+the rest of the text is the element's content, read like any text; an
902+element without content (`@image`, `@source`, `@track`, `@embed`, `@area`)
903+takes nothing more, and a word after its argument is an error (write a
904+text alternative as `alt=...`). So is a word after an `@optgroup`'s label,
905+since it holds only the `@option` lines under it. A value with a space in
906+it is quoted, as such a label: `@optgroup "Citrus fruits"`. The first word is
907+always the argument, so on an element whose attribute is often left out
908+(`@form`, `@video`, `@audio`), text goes on the lines under it: `@form Sign
909+in` would make `Sign` the form's action.
910+
911+The attribute can be written as an attribute instead, and it means the same:
912+`@link [href=/about]` with its text on the lines under it, or
913+`@image [src=logo.svg, inline]`. Both at once, as in
914+`@link [href=/a] About`, is an error that says which word was taken as the
915+`href`.
916+
917+```
918+@nav [flex-direction row, spacing 16]
919+ @link /docs The docs
920+ @link [aria-current=page] /pricing Pricing
921+@image [alt=The team, width 320] "team photo.jpg"
922+@picture
923+ @source [type=image/avif] hero.avif
924+ @image [alt=The office] hero.jpg
925+@video [controls] intro.mp4
926+ @track [kind=captions, srclang=en] intro.vtt
927+ Your browser doesn't play this video.
928+```
929+
930+`@script` is an element like the others: its leading argument is its `src`,
931+any HTML attribute passes through (`@script [type=module, defer] app.js`),
932+and without a `src` its indented body is its JavaScript, kept exactly as
933+written. A `src` and a body together are an error, since the browser runs
934+only the file. It isn't shown, so a style on it is an error.
935+
936+```
937+@script [defer] analytics.js
938+@script
939+ document.body.dataset.ready = "yes"
940+```
941+
320### HTML attributes
942### HTML attributes
321
943
322HTML attributes are written `key=value`: `id=main`, `class=note`,
944HTML attributes are written `key=value`: `id=main`, `class=note`,
323`href=/about`, `type=email`, `alt=Logo`, `target=_blank`,
945`href=/about`, `type=email`, `alt=Logo`, `target=_blank`,
324−`aria-label=Close menu`, `data-id=42`. Any name works. Boolean attributes are
946+`aria-label=Close menu`, `data-id=42`. Any name works, one with a colon
947+in it included (`xml:lang=en`, `x-on:click=open`): before an `=` a colon is
948+part of the name, not a prefix. Boolean attributes are
325−written bare: `required`, `disabled`, `checked`, `open`, `popover`. A style
949+written bare: `required`, `disabled`, `checked`, `hidden`, `open`, `popover`.
950+An HTML attribute written like a style (`type email`) is an error that
951+shows the `key=value` form. A style
326and an HTML attribute can share a name, because the `=` tells them apart:
952and an HTML attribute can share a name, because the `=` tells them apart:
327
953
328```
954```
@@ -342,12 +968,12 @@ and an HTML attribute can share a name, because the `=` tells them apart:
342@let primary #3b82f6
968@let primary #3b82f6
343-- A computed value: `=` makes the rest an expression
969-- A computed value: `=` makes the rest an expression
344@let gap = 8 * 2
970@let gap = 8 * 2
345−-- A string, with $variables interpolated
971+-- Quoted text, with $variables filled in (see Variables for its quotes)
346@let greeting "Hello from $primary"
972@let greeting "Hello from $primary"
347-- An attribute bundle, used as [$card]
973-- An attribute bundle, used as [$card]
348@let card [padding 20, background white, border-radius 8]
974@let card [padding 20, background white, border-radius 8]
349−-- A function: a @let with an indented body
975+-- A function: `@` before its name, its parameters in brackets, and a body
350−@let panel $title
976+@let @panel [title]
351 @el [$card, spacing $gap]
977 @el [$card, spacing $gap]
352 @text [font-weight bold, color $primary] $title
978 @text [font-weight bold, color $primary] $title
353 @children
979 @children
@@ -358,23 +984,186 @@ and an HTML attribute can share a name, because the `=` tells them apart:
358 Attributes after a bundle override it.
984 Attributes after a bundle override it.
359```
985```
360
986
361−A definition applies from its own line onward.
987+The line alone says what a `@let` defines: an `@` before the name makes a
988+function, a `[` after the name an attribute bundle, and anything else a
989+value. A value or a bundle is named without `$` and used with it; a function
990+is named with `@` and called with it. Only a function has a body, so an
991+indented block under a value or a bundle is an error, and so are `@let $x`
992+(write `@let x`) and a `@let` without a value.
362
993
363−### Functions
994+Values, bundles and functions share one namespace, and one rule says where
995+a name is visible: **from the line that defines it to the end of its
996+block**. A block is the file, the lines indented under an element, a branch
997+of `@if`, one repetition of `@each` or a function's body. A `@let` gives its
998+name a new meaning in its block, whatever the name meant before, and hides
999+what the name means around the block until the block ends:
364
1000
365−A function is called like an element:
1001+```
1002+@let gap 8
1003+@el [spacing $gap]
1004+ @let gap 16
1005+ @el [padding $gap] Sixteen
1006+@el [padding $gap] Eight
1007+```
1008+
1009+Parameters, `@each` variables and `@data` names follow the same rule, and
1010+each repetition of `@each` starts afresh, so a `@let` in one doesn't carry
1011+over to the next. A function's body sees its parameters and what is
1012+visible where the function is defined: not the names at a call, and not a
1013+definition further down, so a function means the same wherever it is
1014+called. `@let t.greeting Hello` gives the record `$t` the field `greeting`
1015+(making `$t` a record when it isn't defined), and `@let items.0 first`
1016+replaces the first item of the list `$items`; a field of any other value is
1017+an error.
1018+
1019+### Variables
1020+
1021+`$name` inserts a value wherever htmlang holds text: a line of text, an
1022+attribute's value, an element's argument, a `@let` value, a file path, the
1023+title of `@page` and the value of `@meta`. Each line is read first, and then
1024+`$name` fills the one place it is written in. What it inserts is never read
1025+again as htmlang, so a value can't become an attribute, an attribute's name,
1026+a comma between attributes, an inline element or another `$name`. Attributes
1027+come from a bundle:
1028+
1029+```
1030+@let link-style [color #2563eb, text-decoration underline]
1031+@let label Docs
1032+@link [$link-style, aria-label=$label] /docs Read the $label
1033+```
1034+
1035+A name starts with a letter or `_` and goes on with letters, digits, `_`
1036+and `-`. It ends at the first other character, so after `@let lang fr`,
1037+`$lang.json` is `fr.json`. A `.field` continues the name only when the value
1038+is a record or a list (`$post.title`, `$tags.0`). `${name}` is the same name
1039+with explicit ends, for text that follows it directly, and `${EXPR}` inserts
1040+the value of an [expression](#expressions). A `$` followed by anything else
1041+is text.
1042+
1043+```
1044+@let lang fr
1045+@let size 4
1046+@data $post {"title": "Hello"}
1047+@text locales/$lang.json, ${size}px, $post.title, $5
1048+```
1049+
1050+A name that isn't defined is an error. A field that a record doesn't have
1051+is empty, and so is any field of it, so optional fields of `@data` records
1052+work: `@if $post.draft` is false, `$post.author.name` is empty when there is
1053+no `author`, and `${default($post.tag, none)}` gives `none`.
1054+
1055+### Values
1056+
1057+A value is text, a number, `true` or `false`, a list or a record, and a
1058+name holds a whole value: a `@let`, a parameter, `@each` and `@data` bind
1059+one, and binding the name again replaces it. What a `@let` holds is what it
1060+says:
1061+
1062+```
1063+-- Text, as written
1064+@let size 16px
1065+-- Commas make a list, which prints as it was written
1066+@let fruits apple, banana, cherry
1067+-- Quoted, or with `\,`, text with commas in it is one text
1068+@let tagline "Fast, simple"
1069+@let motto small\, fast
1070+-- A range of whole numbers
1071+@let steps 1..9 step 4
1072+-- One `$name` or `${...}` keeps the type of what it holds
1073+@data $posts [{"title": "Hello"}, {"title": "Again"}]
1074+@let first $posts.0
1075+@let backwards ${reverse($fruits)}
1076+@text [font-size $size] $fruits: ${length($fruits)}. $tagline: ${length($tagline)}. $motto. $steps
1077+@text $first.title, $backwards
1078+```
366
1079
367−- Its parameters are passed as attributes. A parameter with a default
1080+The last lines give `apple, banana, cherry: 3. Fast, simple: 12. small,
368− (`$tone=info`) can be left out.
1081+fast. 1..9 step 4` and `Hello, cherry, banana, apple`. A value that is
369−- Any other attributes style the function's root element, so
1082+exactly one `$name` or `${...}` keeps its type wherever it goes: a `@let`, a
370− `@panel [title Hi, padding 40]` works the same as styling a built-in
1083+function's parameter or default, the list of `@each`. So a record can be
371− element.
1084+passed to a function whole (`@post-card [post $p]`), and a list stays a
372−- Text after the attributes and the indented children replace `@children`.
1085+list.
373− A caller's `@slot NAME` block replaces the function's `@slot NAME`, and
1086+
374− the slot's own children are the default content.
1087+- Text written without quotes reads as a number when it is one (`3`,
1088+ `0.5`), and as false when it is `false` or `0`: that is how htmlang writes
1089+ numbers and flags (`@let gap 8`, `@card [dot false]`). Quoted text is
1090+ always text.
1091+- **False** is `false`, empty text, `0` and an empty list (or record).
1092+ Everything else is true, including `no` and quoted `"0"`.
1093+- `==` and `!=` compare numbers as numbers (`1.0 == 1`), lists and records
1094+ item by item, and anything else as text, exactly (`#FFF` isn't `#fff`).
1095+ `<`, `>`, `<=` and `>=` compare numbers as numbers and text by character
1096+ order.
1097+- A computed number prints without float noise: whole numbers without
1098+ decimals, others with at most four (`${100 / 3}` is `33.3333`).
1099+- A list prints as written when the source wrote it, and otherwise as its
1100+ items joined with `, `. A record has no text of its own, so writing one
1101+ (or a list of records) where text goes is an error: write one of its
1102+ fields, or loop over the list with `@each`.
1103+- A `$list` in a comma list is one item, a list inside the list:
1104+ `@let more $items, d` has two items. htmlang has no list concatenation.
1105+
1106+**Quoted text remembers that it was quoted.** A value written `"..."` (in a
1107+`@let`, an attribute or a function's argument) keeps its quotes in a CSS
1108+value, where CSS needs them, and loses them in text, in HTML attribute
1109+values and in expressions (`${length($x)}` counts no quotes). It stays
1110+quoted when it passes through another `@let` or a parameter. Inside a quoted string of a CSS value, it inserts
1111+what it says, without its own quotes:
375
1112
376```
1113```
377−@let card $title $tone=#f9fafb
1114+@let arrow "→ "
1115+@let label "Next, please"
1116+@el [before:content $arrow, after:content " ($label)", aria-label=$label] $label
1117+```
1118+
1119+This gives `content:"→ "`, `content:" (Next, please)"`,
1120+`aria-label="Next, please"` and the text `Next, please`. A value written
1121+without quotes is inserted as written everywhere.
1122+
1123+### Functions
1124+
1125+A function's parameters are listed in brackets after its name and written
1126+the way a call passes them: a name alone (`title`) is a required parameter,
1127+and a name and a value (`tone #f9fafb`) is a parameter with that default.
1128+The body uses them as `$title` and `$tone`. `@let @name` without brackets
1129+takes no parameters.
1130+
1131+A default is filled in at each call that leaves its parameter out, like a
1132+value passed for it: it may hold spaces, quotes, escapes, `${...}` and
1133+`$variables`. It sees what the body sees: the names visible where the
1134+function is defined and the parameters declared before it
1135+(`[title, heading "About $title"]`). A default that uses a parameter
1136+declared after it is an error.
1137+
1138+A function is called like an element, wherever an element can be: on a
1139+line of its own, in a [chain](#chains) (`@el > @card [title Hi]`) or
1140+inline in text (`{@key Ctrl+K}`):
1141+
1142+- Its parameters are passed by name, as attributes: `name value`, or the
1143+ name alone for `true`. A parameter with a default can be left out;
1144+ leaving out one without a default is an error that names it. `=` writes
1145+ an HTML attribute, so `title=Hi` for a parameter is an error too.
1146+- Every other attribute goes to the function's root element: a style, a
1147+ flag, or a `key=value` HTML attribute such as `id=intro`. It is checked
1148+ like an attribute written on that element, so `@panel [title Hi,
1149+ padding 40]` works the same as styling a built-in element, and
1150+ `[paddin 40]` gets the same warning. A name close to one of the
1151+ parameters (`titel Hi`) is a misspelled parameter, which is an error.
1152+ This needs the body to have a single root element: attributes on a call
1153+ whose body has several roots, or is text, are an error. A `class=` adds
1154+ to the root's own classes.
1155+- A parameter passed twice (in the call's list, or after a bundle that
1156+ passes it) takes the later value, as any attribute does.
1157+- Text after the attributes and the indented lines are the call's
1158+ content, and replace `@children` in the body. A `@slot NAME` block
1159+ directly under the call (or under an `@if`, `@else` or `@each` there)
1160+ fills the body's `@slot NAME` instead.
1161+- The lines indented under `@children` or `@slot NAME` in the body are its
1162+ fallback: they are shown when a call passes no content, or no block for
1163+ that slot.
1164+
1165+```
1166+@let @card [title, tone #f9fafb]
378 @article [padding 16, spacing 8, background $tone, border-radius 8]
1167 @article [padding 16, spacing 8, background $tone, border-radius 8]
379 @row [align-items center]
1168 @row [align-items center]
380 @h3 $title
1169 @h3 $title
@@ -394,12 +1183,64 @@ A function is called like an element:
394 @text Updated today
1183 @text Updated today
395```
1184```
396
1185
1186+`@children` takes a fallback the same way:
1187+
1188+```
1189+@let @notice
1190+ @el [padding 12, background #fef3c7]
1191+ @children
1192+ @text Nothing to report.
1193+
1194+@notice
1195+@notice Deploys are paused today.
1196+```
1197+
1198+Content that would go nowhere is an error, so a mistake can't drop it:
1199+
1200+- a `@slot NAME` block for a slot the function doesn't have (the message
1201+ lists the slots it has);
1202+- text or lines passed to a function whose body has no `@children`;
1203+- a `@slot NAME` block inside an element at the call, instead of directly
1204+ under the call;
1205+- `@slot` or `@children` outside a function's body, or inline in text;
1206+- `@children` or `@slot` after an element's attributes (`@td [padding 8]
1207+ @children`), where it would be text: it goes on a line of its own.
1208+
1209+A slot's name is one word of letters, digits, `-` and `_`, starting with a
1210+letter, like a function's. Inside a body, a call can pass on what the
1211+function got: `@children` under the call passes the content, and a
1212+`@slot footer` block holding `@slot footer` passes that slot.
1213+
1214+A parameter that is off unless the call names it is a flag: give it the
1215+default `false`, and name it alone to turn it on.
1216+
1217+```
1218+@let @post-card [post, featured false, label "About $post"]
1219+ @article [spacing 8, padding ${if($featured, 24, 0)}]
1220+ @if $featured
1221+ @text [font-weight bold] Featured
1222+ @h3 $label
1223+
1224+@post-card [post htmlang, featured]
1225+@post-card [post CSS]
1226+```
1227+
397An `@style` block at the top of a function body is **scoped** to the
1228An `@style` block at the top of a function body is **scoped** to the
398function. Its rules apply inside the function's root element, and `&` is the
1229function. Its rules apply inside the function's root element, and `&` is the
399−root itself. This requires the body to have a single root element.
1230+root itself. This requires the body to have a single root element. The
1231+stylesheet goes into the page once, when the function is first called, so
1232+a function that is never called adds nothing. It is in the layer
1233+`hl-inside`, with the styles of element prefixes (see [CSS](#css)): what an
1234+element says about itself wins over it, so a call's `[padding 20]` wins
1235+over the function's `& { padding: 12px; }`, as it would over the root's own
1236+defaults. Where CSS properties are all it needs, an element prefix on the
1237+root says the same without a stylesheet (`@aside [@h2:font-size 18]`).
1238+Only an `@style` at the top of the body is scoped: one under an `@if` or an
1239+element is the page's, where `&` is the whole page, so the compiler warns
1240+about a `&` there.
400
1241
401```
1242```
402−@let note $kind=Note
1243+@let @note [kind Note]
403 @style
1244 @style
404 & { border-left: 4px solid #3b82f6; }
1245 & { border-left: 4px solid #3b82f6; }
405 .title { font-weight: bold; }
1246 .title { font-weight: bold; }
@@ -410,26 +1251,94 @@ root itself. This requires the body to have a single root element.
410@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
1251@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
411```
1252```
412
1253
413−A function whose body is text holds multi-line content:
1254+A call in text or in a chain whose body has several roots, or is text,
1255+puts them in its place, like `@fragment`. A function whose body is text
1256+holds multi-line content, and a small function works inside a sentence:
414
1257
415```
1258```
416−@let intro
1259+@let @intro
417 htmlang is a layout language.
1260 htmlang is a layout language.
418− It compiles to {@text [font-weight bold] static HTML}.
1261+ It compiles to {@strong static HTML}.
1262+@let @key
1263+ @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
1264+ @children
419
1265
420@paragraph
1266@paragraph
421 @intro
1267 @intro
1268+@paragraph
1269+ In short: {@intro} Press {@key Ctrl+K} to search.
422```
1270```
423
1271
1272+A function may call itself, under a condition that stops it, so it can
1273+render nested data such as a menu. A parameter passed one `$name` that
1274+holds a record or a list gets the whole value, fields and items
1275+included. Calls nest at most 64 deep: a function that calls itself with
1276+nothing to stop it is an error. Since a body sees only what is defined
1277+above its function, two functions can't call each other.
1278+
1279+```
1280+@data $menu [
1281+ {"label": "Guide", "children": [{"label": "Install"}, {"label": "Usage"}]},
1282+ {"label": "Reference"}
1283+]
1284+@let @tree [items]
1285+ @ul [padding-left 16]
1286+ @each $item in $items
1287+ @li
1288+ @text $item.label
1289+ @if $item.children
1290+ @tree [items $item.children]
1291+
1292+@tree [items $menu]
1293+```
1294+
1295+The elements a function's body writes are the call's: a problem with
1296+one of them, such as low contrast or `spacing` on an element that isn't a
1297+row, column or grid, is reported at the call, once, and names the function. A
1298+problem in the text of a body is reported on its own line, or at the call
1299+when the function comes from `std.hl` or another file.
1300+
1301+Naming a function after a built-in element or a directive is a warning:
1302+`@let @button` replaces `@button` in every call after it, and a function
1303+named `@if` could never be called. Inside its own body `@button` is the
1304+function too, so a function that wraps the element it is named after,
1305+outside an `@if`, is an error: give it another name.
1306+
424### CSS custom properties
1307### CSS custom properties
425
1308
426−`@let --name value` declares the CSS custom property `--name` on `:root`.
1309+`@let --name value` declares the CSS custom property `--name` on `:root`,
427−`var(--name)` refers to it at run time, so a stylesheet can still override
1310+for the whole page. Its value is written as it is, with no pixels added,
428−it. `$--name` is its value at compile time, for use in expressions:
1311+so a length takes its unit (`@let --radius 8px`). A token is read one way,
1312+`var(--name)`, in the browser, so an element (`[dark:--name value]`, see
1313+[Custom properties](#custom-properties)) or a stylesheet can still
1314+redefine it. It is not a variable: `$--name` is an error that says to
1315+write `var(--name)`. Since `:root` is one place for the whole page, a
1316+`@let --name` goes at the top level of the file, once: inside an element,
1317+an `@if`, a loop or a function's body it is an error (set the property on an
1318+element with `[--name value]` instead), and declaring it again warns.
1319+
1320+A colour variant of a token is CSS too, so it follows the token wherever
1321+it is redefined: `color-mix()` mixes two colours, and a relative colour
1322+(`rgb(from ...)`) keeps a colour's channels except those you write (`/ 0.5`
1323+for its alpha). `in srgb` mixes the red, green and blue channels, as most
1324+colour tools do; `in oklab` gives steps that look more even, a good choice
1325+for new tokens.
1326+
1327+| For | Write |
1328+|---|---|
1329+| A darker colour | `color-mix(in srgb, var(--brand), black 10%)` |
1330+| A lighter colour | `color-mix(in srgb, var(--brand), white 10%)` |
1331+| A transparent colour | `rgb(from var(--brand) r g b / 0.5)` |
1332+| Two colours mixed | `color-mix(in srgb, var(--brand), #f43f5e 50%)` |
429
1333
430```
1334```
431@let --brand #3b82f6
1335@let --brand #3b82f6
432−@el [background var(--brand), border 1 solid ${darken($--brand, 10)}] Themed
1336+@let --radius 8px
1337+@page [dark:--brand #60a5fa] Themed
1338+@el [background var(--brand), border-radius var(--radius), border 1 solid color-mix(in srgb, var(--brand), black 10%)] Themed
1339+@row
1340+ @each $shade in 0, 10, 20, 30
1341+ @el [background color-mix(in srgb, var(--brand), black $shade%)] $shade%
433```
1342```
434
1343
435## Expressions
1344## Expressions
@@ -438,25 +1347,40 @@ Conditions and computed values (`@let x = ...`) are expressions:
438
1347
439| | |
1348| | |
440|---|---|
1349|---|---|
441−| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `$record.field`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
1350+| 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 |
442| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
1351| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
443−| Comparison | `== != < > <= >=` (numeric when both sides are numbers) |
1352+| Comparison | `== != < > <= >=`: numbers as numbers, text exactly, lists and records item by item (see [Values](#values)) |
444−| Logic | `and`, `or`, `not`. An empty value, `false` and `0` are false |
1353+| Logic | `and`, `or`, `not`. `false`, empty text, `0` and an empty list are false |
445−| Choice | `if(CONDITION, A, B)` |
1354+| Choice | `if(CONDITION, A, B)`, where `B` may be left out (empty) |
446| Tests | `contains(s, x)` (in a text, or as an item of a list), `starts-with(s, x)`, `ends-with(s, x)` |
1355| Tests | `contains(s, x)` (in a text, or as an item of a list), `starts-with(s, x)`, `ends-with(s, x)` |
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)` |
1356+| 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) |
448−| Color | `lighten(c, pct)`, `darken(c, pct)`, `alpha(c, a)`, `mix(c1, c2, pct)` |
1357+
1358+Colour maths is CSS's, not an expression's: see
1359+[CSS custom properties](#css-custom-properties). A CSS function such as
1360+`color-mix()` goes in an attribute's value as it is, or in a plain
1361+`@let` value (`@let hover color-mix(in srgb, var(--brand), black 8%)`),
1362+not in `@let x = ...` or `${...}`.
449
1363
450Variables are looked up during evaluation, so a value that contains `==` or
1364Variables are looked up during evaluation, so a value that contains `==` or
451−spaces is still one value. An invalid expression is a compile error.
1365+spaces is still one value. An invalid expression is a compile error, and so
1366+is an undefined variable. A text function given a list is an error too,
1367+instead of a guess: `uppercase()` of `Fast, simple` (a list, because of its
1368+comma) says so, and the text is written `"Fast, simple"` or `Fast\, simple`.
1369+A variable's name ends at `..`, so `${$from..$to}` is a range.
1370+
1371+Only what decides the result is evaluated: `if()` evaluates the branch it
1372+takes, and `and` and `or` stop at the side that decides. So
1373+`${if($n != 0, 10 / $n, 0)}` and `@if $n != 0 and 10 / $n > 1` work when
1374+`$n` is 0. The rest is still read, and its syntax and function names are
1375+checked.
452
1376
453−In text and attribute values, `$name` inserts a variable, and `${EXPR}`
1377+In text, values and file paths, `${EXPR}` inserts the value of any
454−inserts the value of any expression:
1378+expression (see [Variables](#variables)):
455
1379
456```
1380```
457@let name htmlang
1381@let name htmlang
458−@let base #3b82f6
1382+@let size 12
459−@text [color ${darken($base, 10)}] ${uppercase($name)} has ${length($name)} letters
1383+@text [font-size ${$size + 4}] ${uppercase($name)} has ${length($name)} letters
460```
1384```
461
1385
462## Control flow
1386## Control flow
@@ -475,11 +1399,15 @@ All control flow runs at compile time.
475 @text Few
1399 @text Few
476```
1400```
477
1401
478−`@each $item in LIST` repeats its body for each item. A list can be a list
1402+`@each $item in LIST` repeats its body for each item. Its variables are
479−loaded with `@data`, a text split on commas, or a range. A range counts down
1403+written with `$`, like `@data $name`. LIST is a value, read like the value
480−when its start is greater than its end, and `step` sets the increment. An
1404+of a `@let`: items written with commas (a quoted item or one with `\,` is
481−optional second variable is the index, starting from 0. `@else` gives the
1405+one text), a range `A..B` (which counts down when its start is greater than
482−content to show when the list is empty.
1406+its end, and takes `step N`), or one `$name` or `${...}` that holds a list,
1407+such as one loaded with `@data` or `${reverse(1..5)}`. A text is one item,
1408+and empty text none. Each item is bound whole, so a record keeps its fields.
1409+An optional second variable is the index, starting from 0. `@else` gives
1410+the content to show when the list is empty.
483
1411
484```
1412```
485@let fruits apple, banana, cherry
1413@let fruits apple, banana, cherry
@@ -500,10 +1428,10 @@ content to show when the list is empty.
500| `@data $name file.json` | Load a JSON file. Objects are records (`$name.key`) and arrays are lists |
1428| `@data $name file.json` | Load a JSON file. Objects are records (`$name.key`) and arrays are lists |
501| `@data $name [...]` / `@data $name {...}` | Inline JSON, which may span several lines |
1429| `@data $name [...]` / `@data $name {...}` | Inline JSON, which may span several lines |
502| `@data $name dir/*.json` | A list with one record per file, in name order. `$item.file` is the file's name |
1430| `@data $name dir/*.json` | A list with one record per file, in name order. `$item.file` is the file's name |
503−| `@data $name env:NAME [default]` | An environment variable |
1431+| `@data $name env:NAME DEFAULT` | An environment variable, with an optional default |
504−| `@include file.hl` | Insert another file: its content and its definitions. A file that holds only `@let`s outputs nothing |
1432+| `@include file.hl` | Insert another file: its content and its definitions. A file that holds only definitions outputs nothing (a [library](#page-layouts)) |
505| `@markdown` / `@markdown file.md` | Markdown (an indented body or a file), converted to HTML |
1433| `@markdown` / `@markdown file.md` | Markdown (an indented body or a file), converted to HTML |
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 |
1434+| `@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 |
507
1435
508Records keep their values whole, even when a value contains spaces or
1436Records keep their values whole, even when a value contains spaces or
509commas:
1437commas:
@@ -521,17 +1449,18 @@ commas:
521@text ${length($team)} people
1449@text ${length($team)} people
522```
1450```
523
1451
524−### Layouts
1452+### Page layouts
525
1453
526A layout is an ordinary function in a file of its own. It marks where content
1454A layout is an ordinary function in a file of its own. It marks where content
527−goes with `@slot NAME` (named blocks, with default content) and `@children`
1455+goes with `@slot NAME` (named blocks) and `@children` (everything in the call
528−(everything in the call that is outside a `@slot` block).
1456+that is outside a `@slot` block), each with fallback content indented under
1457+it.
529
1458
530```
1459```
531-- layout.hl
1460-- layout.hl
532−@let layout
1461+@let @layout
533− @page My Site
1462+ @page [lang=en, background #f8fafc] My Site
534− @el [max-width 800, center-x, spacing 24]
1463+ @el [width fill, max-width 800, center-x, spacing 24]
535 @header
1464 @header
536 @slot header
1465 @slot header
537 @text Default header
1466 @text Default header
@@ -548,28 +1477,65 @@ goes with `@slot NAME` (named blocks, with default content) and `@children`
548 @paragraph This fills @children.
1477 @paragraph This fills @children.
549```
1478```
550
1479
1480+The layout holds the page's `@page`, whose attributes can come from its
1481+parameters (`@page [background $bg] $title`). A page has one `@page`, so a
1482+second one is an error (`duplicate-page`): the page's own next to its
1483+layout's, or a layout called twice. The same `@meta` tag twice is written
1484+once. A file that holds only `@let`s, such as `layout.hl`, is a library
1485+(it may also read `@data` and `@include` other libraries): its definitions
1486+aren't reported unused, in it or in a page that includes it and uses only
1487+some of them, and `htmlang build`, `serve` and `watch` don't build it into
1488+a page of its own. An included file's unused definitions are never
1489+reported in the file that includes it.
1490+
551## Page and head
1491## Page and head
552
1492
553`@page TITLE` makes the output a full HTML document. Without it, the output
1493`@page TITLE` makes the output a full HTML document. Without it, the output
554−is a fragment. The attributes of `@page` set `lang`, and `favicon` (the file
1494+is a fragment, which has no `<head>`: `@meta` and `@head` in it are a
555−is embedded in the page). `@meta NAME VALUE` adds a meta tag, and names that
1495+warning. `--partial` writes a fragment of a page too, and warns that
556−start with `og:` become Open Graph `property` tags. `@head` holds any other
1496+`@page`, `@meta` and `@head` are left out. The page is the root element, and `@page`'s attributes are
557−raw HTML for the `<head>`, such as a font link, a canonical URL or JSON-LD.
1497+checked like any element's:
1498+
1499+- Styles style `<body>`, prefixes included, so a page's colour, font and
1500+ dark background go on `@page` and cover the whole window.
1501+- `key=value` attributes go on `<html>`: `lang=en`, `dir=rtl`, `class=x`.
1502+- `favicon FILE` is its one word of htmlang's own: the file is put into
1503+ the page as its icon (or linked, when it can't be read).
1504+
1505+`<body>` is a column that fills the window (`display: flex;
1506+flex-direction: column; min-height: 100dvh`, from the reset), so the
1507+page's top-level elements are laid out like the children of an `@el`:
1508+they stack, even a line of text or an inline element such as `@text` or
1509+`@link`, and each takes the full width unless it says otherwise. A
1510+centred column at the top writes `width fill` like anywhere else (see
1511+[Rows, columns and text](#rows-columns-and-text)).
1512+
1513+`@meta NAME VALUE` adds a meta tag, and names that start with `og:` become
1514+Open Graph `property` tags; a `@meta viewport` replaces the usual one.
1515+`@head` holds any other raw HTML for the `<head>`, such as a font link, a
1516+canonical URL or JSON-LD, on its line or in its indented block.
558
1517
559```
1518```
560−@page [lang en, favicon favicon.png] My Site
1519+@page [lang=en, favicon favicon.png, color #1f2937, dark:background #0b1220, dark:color #e5e7eb] My Site
561@meta description A small site
1520@meta description A small site
562@meta og:title My Site
1521@meta og:title My Site
1522+@head <link rel="canonical" href="https://example.com/">
563@head
1523@head
564− <link rel="canonical" href="https://example.com/">
1524+ <script type="application/ld+json">{"@type": "WebSite"}</script>
1525+@header [padding 16] My Site
1526+@main [height fill, padding 16]
1527+ The main part takes the rest of the window.
1528+@footer [padding 16] © 2026
565```
1529```
566
1530
567−Translations are a JSON file per locale: `@data $t locales/$lang.json`.
1531+Translations are a JSON file per locale: `@data $t locales/$lang.json`
1532+loads `locales/fr.json` when `$lang` is `fr`.
568
1533
569## CSS
1534## CSS
570
1535
571`@style` holds raw CSS, including at-rules such as `@keyframes`,
1536`@style` holds raw CSS, including at-rules such as `@keyframes`,
572−`@font-face`, `@property` and `@starting-style`:
1537+`@font-face`, `@property` and `@starting-style`, in its indented block or,
1538+for a single rule, on its line:
573
1539
574```
1540```
575@style
1541@style
@@ -578,12 +1544,51 @@ Translations are a JSON file per locale: `@data $t locales/$lang.json`.
578 to { opacity: 1; }
1544 to { opacity: 1; }
579 }
1545 }
580 .note { color: gray; }
1546 .note { color: gray; }
1547+@style .quiet { opacity: 0.6; }
581@el [animation fade-in 0.3s ease, class=note] Fades in
1548@el [animation fade-in 0.3s ease, class=note] Fades in
582```
1549```
583
1550
584−Each element gets a short generated class for its styles, and elements with
1551+Each element with styles gets a short generated class, `hl-a`, `hl-b`, ...,
585−the same styles share one. The generated rules live in `@layer htmlang`. A
1552+and elements with the same styles share one. The `hl-` prefix is htmlang's:
586−small reset (`box-sizing`, body margin, block images, unstyled links, a
1553+a class of your own that starts with it gets a warning, since the generated
587−focus outline) lives in `@layer hl-reset` before it. CSS outside any layer
1554+rules would apply to it too. Every style goes into the class under its own
588−takes precedence over both, so rules in `@style` or `@raw` override the
1555+name, with its value as written (plus pixels, see
589−generated ones.
1556+[CSS properties](#css-properties)); only the layout words, and
1557+`line-clamp`'s fallback, write more than one declaration.
1558+
1559+An element's defaults go with its own class: its layout (a column is
1560+`display: flex`), the margins browsers give headings, paragraphs, lists,
1561+figures and blockquotes are 0, lists have no markers, `@fieldset` has a
1562+thin border and padding, `@code`, `@kbd` and `@pre` are monospace, `@link`
1563+has no underline and takes its parent's text colour, and `@image` is a
1564+block. They apply only to htmlang's own elements, so HTML that comes from
1565+`@markdown` or `@raw` keeps the browser's defaults: a Markdown list keeps
1566+its bullets and a Markdown link its underline.
1567+
1568+The generated rules live in three cascade layers, after a small reset in
1569+the layer `hl-reset`, and a later layer wins over an earlier one whatever
1570+the specificity:
1571+
1572+| Layer | Holds |
1573+|---|---|
1574+| `hl-reset` | The reset |
1575+| `hl-kind` | The defaults of each element's kind, as `:where(.hl-a)` |
1576+| `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` |
1577+| `htmlang` | Each element's own styles, and its parent's `children:` styles |
1578+
1579+So an element's own styles win over everything else htmlang writes for
1580+it; its parent's `children:` styles and the element prefixes of the
1581+elements around it win over its defaults.
1582+
1583+A page's reset sets `box-sizing: border-box` everywhere and makes
1584+`<body>` a column that fills the window. A fragment (a file
1585+without `@page`, or `--partial` output) goes into a page it doesn't own, so
1586+its reset touches only htmlang's own elements (those with an `hl-` class),
1587+which get `box-sizing: border-box`; no other element on the page is
1588+touched. In both, an element
1589+htmlang lays out stays hidden while it has `hidden`, while an `@dialog` is
1590+closed, and while a popover isn't showing, even though its generated
1591+`display` would otherwise beat the browser's `display: none`. A page with
1592+links, buttons or form fields also gives htmlang's ones a focus outline.
1593+CSS outside any layer takes precedence over every layer, so rules in
1594+`@style` or `@raw` override the generated ones.
README.md+124−28
@@ -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 My Site
10+@page [lang=en, background #f8fafc] 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 [max-width 800, center-x, padding 40, spacing 24]
18+@el [width fill, 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,23 +32,90 @@ 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, and `{...}`
35+ elements. Text after an element's attributes is its content, read like
36− puts an element inside a line of text.
36+ any line of text, and `{...}` puts an element inside a line of text
37−- **Layout comes from elm-ui.** `@el` lays out its children in a column and
37+ (`@h1 Hello {@text [color red] world}`). The text of `@code` and
38− `@row` in a row, and every other container is a column too. Layout
38+ `@textarea` is shown as written, and the lines indented under them are
39− attributes (`spacing`, `width fill`, `center-x`, `align-right`, `wrap`) say
39+ verbatim, HTML-escaped with their line breaks kept, so `@pre > @code`
40− how an element sits inside its parent.
40+ over an indented block shows a code sample. `@raw`, `@style` and `@head`
41+ take the rest of their line or an indented block, kept as written
42+ (`@style .note { color: gray; }`). An element that points at a URL or a
43+ file takes it as its first word, and the rest is its content: `@link
44+ /about About us`, `@image logo.png`, `@form /subscribe`, `@script app.js`.
45+- **Layout comes from elm-ui.** Every element has one layout. `@el` and the
46+ containers (`@section`, `@nav`, `@ul`, `@li`, ...) are columns, `@row` is a
47+ row and `@grid` a grid: in them each line of text is a child, and
48+ `spacing` is the gap between the children. Text elements (`@paragraph`,
49+ `@h1`, `@button`, `@td`) let their lines flow, joined with spaces, and an
50+ `@el` inside text is laid out inline. Layout attributes (`width fill`,
51+ `center-x`, `align-right`, `wrap`) say how an element sits inside its
52+ parent, and follow the direction the parent's CSS sets: `@nav
53+ [flex-direction row]` is a row, and `md:flex-direction row` makes a
54+ column a row from that width up, children included. The page is the root
55+ element: `@page`'s styles go on `<body>`, a column that fills the window,
56+ and its `key=value` attributes on `<html>` (`@page [lang=en, background
57+ #f8fafc] Home`).
41- **Styling is CSS.** Any other attribute is a CSS property with its CSS
58- **Styling is CSS.** Any other attribute is a CSS property with its CSS
42 name and value: `padding 20`, `border 1 solid #eee`,
59 name and value: `padding 20`, `border 1 solid #eee`,
43− `grid-template-columns 1fr 2fr`. In lengths, a bare number means pixels.
60+ `grid-template-columns 1fr 2fr`. In a length property, every bare
44− Prefixes make a style conditional: `hover:`, `md:`, `dark:`, `first:`.
61+ number is pixels (`box-shadow 0 2 4 black` is `0 2px 4px black`); numbers
45−- **HTML stays HTML.** Elements have their HTML names (`@nav`, `@ul`, `@form`,
62+ in any other property (`flex 1`, `z-index 2`) and in custom properties
46− `@details`), and HTML attributes are written `key=value` (`id=main`,
63+ stay numbers.
47− `type=email`) or bare (`required`).
64+ A custom property is a style like any other: `@let --surface white`
65+ declares it for the whole page, and `[--gap 16px, md:--gap 24px]` sets
66+ it on an element and everything inside it, so dark mode redefines a
67+ token once (`@page [dark:--surface #0b1220]`) instead of every colour
68+ that uses it. A token is read one way, `var(--surface)`, and a colour
69+ variant is CSS's too (`color-mix(in srgb, var(--brand), black 8%)`,
70+ `rgb(from var(--brand) r g b / 0.5)`), so it follows the token.
71+ Prefixes make a style conditional: `hover:`, `md:`, `dark:`, and CSS's
72+ common pseudo-classes and pseudo-elements under their CSS names
73+ (`first-child:`, `nth-child(odd):`, `has(> img):`, `marker:`,
74+ `backdrop:`; the list is in DESIGN.md).
75+ They stack (`md:dark:background`, `hover:children:opacity`), and one
76+ prefix covers a group or a bundle (`md:[padding 32, font-size 20]`,
77+ `md:$card`).
78+ An element prefix styles every such element inside, at any depth:
79+ `@table [@td:padding 8]` pads every cell, and
80+ `@article [@h2:font-size 20, @code:background #f1f5f9]` styles what
81+ `@markdown` writes. An element's own attributes still win, and the
82+ nearest element that styles it wins over one further out.
83+ A comma separates attributes, so a comma inside a value is written `\,`
84+ (`transition opacity 0.3s\, transform 0.3s`), and quoted text keeps its
85+ quotes only in CSS (`before:content "→ "`).
86+- **Names are checked, values are CSS's.** A misspelled element, prefix,
87+ parameter or slot is an error. A CSS property htmlang doesn't know is
88+ passed through with a "did you mean" warning, and values go to the CSS as
89+ written (`outline 2 solid red` is `outline: 2px solid red`, nothing
90+ added). Nothing you write is left out of the page without an error.
91+- **The generated CSS stays on htmlang's elements.** Its classes are
92+ `hl-a`, `hl-b`, ..., in cascade layers (`hl-reset`, `hl-kind`,
93+ `hl-inside`, `htmlang`), so your own CSS wins. An element's defaults (no
94+ heading margins, no list markers, links in the text colour) go with its
95+ own class, so `@markdown` and `@raw` HTML, and a page that embeds
96+ `--partial` output, keep theirs.
97+- **HTML stays HTML.** An element that isn't about layout or text has its
98+ HTML name (`@nav`, `@ul`, `@form`, `@details`, `@strong`, `@em`, `@br`,
99+ `@caption`), from a fixed list, so a typo is an error rather than a new
100+ tag. HTML attributes are written `key=value` (`id=main`, `type=email`) or
101+ bare (`required`).
48- **`@let` defines everything.** It defines values, computed values,
102- **`@let` defines everything.** It defines values, computed values,
49− attribute bundles and functions. A layout is just a function with slots.
103+ attribute bundles and functions. A function's body puts a call's content
50−- **Data comes in as lists and records.** `@data` loads JSON, and `@each` and
104+ where `@children` is and fills `@slot NAME` from the call's `@slot NAME`
51− `@if` run at compile time.
105+ blocks, and content that would go nowhere (a misspelled slot, content for
106+ a function without `@children`) is an error. A layout is just a function
107+ with slots.
108+ `$name` fills in a value exactly where it is written (a line of text, an
109+ attribute's value, a file path), whole attributes come from a bundle or
110+ from `if()` (`if($active, [font-weight bold, aria-current=page])`), and
111+ an undefined name is an error where the code runs.
112+- **Values are typed: text, numbers, `true`/`false`, lists and records.**
113+ Commas make a list (`@let fruits apple, banana`), `1..5` is a range,
114+ `@data` loads JSON as records and lists, and a value passed as one `$name`
115+ keeps its type, so a record can go to a function (`@post-card [post $p]`).
116+ `@each` and `@if` run at compile time. A definition is visible from its
117+ line to the end of its block, and a function sees what is visible where
118+ it is defined.
52
119
53## Install
120## Install
54
121
@@ -67,12 +134,12 @@ htmlang build src -o dist # compile a whole site
67
134
68| Command | Purpose |
135| Command | Purpose |
69|---|---|
136|---|---|
70−| `build <dir> [-o out] [--minify] [--strict]` | Compile every `.hl` file under a directory (into `out/` by default) |
137+| `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 |
71| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload |
138| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload |
72| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
139| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
73| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
140| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
74| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
141| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
75−| `fmt <file.hl>` | Format a file in place |
142+| `fmt <file.hl>...` | Format files in place |
76| `lsp` | Run the language server over stdio |
143| `lsp` | Run the language server over stdio |
77
144
78Compiling a file directly also takes `-w` / `--watch`, `--dev` (readable
145Compiling a file directly also takes `-w` / `--watch`, `--dev` (readable
@@ -94,29 +161,50 @@ is an HTML attribute:
94```
161```
95
162
96A prefix applies a style only in a state, from a screen width up, or under a
163A prefix applies a style only in a state, from a screen width up, or under a
97−media condition:
164+media condition. Prefixes stack, and a prefix before a `[group]` applies to
165+each style in it:
98
166
99```
167```
100−@el [padding 16, md:padding 32, background #3b82f6, hover:background #2563eb, dark:background #1e3a8a]
168+@el [padding 16, md:[padding 32, font-size 20], background #3b82f6, hover:background #2563eb, dark:[background #1e3a8a, hover:background #1e40af]]
101 @text [color white] Click me
169 @text [color white] Click me
102```
170```
103
171
104−`@let` defines values, bundles and functions. A function is called like an
172+An element prefix, `@td:`, styles every such element inside, so rows a
105−element: its parameters are attributes, and any other attributes style its
173+loop writes and HTML from `@markdown` need no attributes of their own:
106−root element.
174+
175+```
176+@table [border-collapse collapse, @th:[padding 8, text-align left], @td:padding 8, @tr:nth-child(even):background #f8fafc]
177+ @tr
178+ @th Name
179+ @tr
180+ @td Ada
181+```
182+
183+`@let` defines values, bundles and functions, in one namespace. A function
184+has `@` before its name and its parameters in brackets (`label`, or
185+`href #` with a default). It is called like an element, on its own line,
186+in a chain or inline in text: its parameters are attributes, `name value`
187+(a name alone is `true`), a parameter without a default must be passed,
188+and any other attributes style its root element and are checked like
189+attributes written there.
107
190
108```
191```
109@let --primary #3b82f6
192@let --primary #3b82f6
110@let gap = 8 * 2
193@let gap = 8 * 2
111@let rounded [border-radius 8, overflow hidden]
194@let rounded [border-radius 8, overflow hidden]
112
195
113−@let button $label $href=#
196+@let @cta [label, href #]
114 @link [$rounded, padding 10 16, background var(--primary), color white] $href
197 @link [$rounded, padding 10 16, background var(--primary), color white] $href
115 $label
198 $label
199+@let @key
200+ @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
201+ @children
116
202
117@row [spacing $gap]
203@row [spacing $gap]
118− @button [label Sign up]
204+ @cta [label Sign up]
119− @button [label Learn more, href /about, background #64748b]
205+ @cta [label Learn more, href /about, background #64748b]
206+@paragraph
207+ Press {@key Ctrl+K} to search.
120```
208```
121
209
122Data, loops and conditions run at compile time:
210Data, loops and conditions run at compile time:
@@ -132,7 +220,7 @@ Data, loops and conditions run at compile time:
132@nav
220@nav
133 @row [spacing 16]
221 @row [spacing 16]
134 @each $link in $links
222 @each $link in $links
135− @link [if($link.url == $current, font-weight bold)] $link.url $link.label
223+ @link [if($link.url == $current, [font-weight bold, aria-current=page])] $link.url $link.label
136```
224```
137
225
138The [`examples/`](examples) directory has complete pages (a landing page, a
226The [`examples/`](examples) directory has complete pages (a landing page, a
@@ -145,3 +233,11 @@ The VS Code extension in [`editors/vscode`](editors/vscode) provides syntax
145highlighting, snippets, and the language server (`htmlang-lsp`). The server
233highlighting, snippets, and the language server (`htmlang-lsp`). The server
146gives diagnostics, completions, hover documentation, go to definition, rename
234gives diagnostics, completions, hover documentation, go to definition, rename
147and formatting.
235and formatting.
236+
237+The compiler, the formatter and the language server read a file with the
238+same parser, so the editor sees exactly what the compiler sees, relative
239+`@include` and `@data` paths included. Every diagnostic has a stable code
240+(`error[unknown-element]` on the command line, a `code` field in
241+`--format json`), and the whole file is checked, including branches and
242+functions that don't run (a `$name` there is checked only where the code
243+runs, since it may come from data).
crates/htmlang-core/src/ast.rs+614−99
@@ -3,19 +3,35 @@ use std::collections::HashMap;
3#[derive(Debug)]
3#[derive(Debug)]
4#[allow(dead_code)]
4#[allow(dead_code)]
5pub struct Document {
5pub struct Document {
6− pub page_title: Option<String>,
6+ /// The page `@page` makes: without one, the output is a fragment.
7− pub lang: Option<String>,
7+ pub page: Option<Page>,
8− pub favicon: Option<String>,
9 pub meta_tags: Vec<(String, String)>,
8 pub meta_tags: Vec<(String, String)>,
10 pub head_blocks: Vec<String>,
9 pub head_blocks: Vec<String>,
11 pub variables: HashMap<String, String>,
10 pub variables: HashMap<String, String>,
12 pub defines: HashMap<String, Vec<Attribute>>,
11 pub defines: HashMap<String, Vec<Attribute>>,
13 pub css_vars: Vec<(String, String)>,
12 pub css_vars: Vec<(String, String)>,
14 pub custom_css: Vec<String>,
13 pub custom_css: Vec<String>,
14+ /// The scoped `@style` of each function called, nested under its
15+ /// `.hl-fn-NAME` class, for `@layer hl-inside`.
16+ pub scoped_css: Vec<String>,
15 pub og_tags: Vec<(String, String)>,
17 pub og_tags: Vec<(String, String)>,
16 pub nodes: Vec<Node>,
18 pub nodes: Vec<Node>,
17}
19}
18
20
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+
19#[derive(Debug, Clone)]
35#[derive(Debug, Clone)]
20pub enum Node {
36pub enum Node {
21 Element(Element),
37 Element(Element),
@@ -33,17 +49,40 @@ pub enum TextSegment {
33pub struct Element {
49pub struct Element {
34 pub kind: ElementKind,
50 pub kind: ElementKind,
35 pub attrs: Vec<Attribute>,
51 pub attrs: Vec<Attribute>,
52+ /// The value of the element's leading attribute (see
53+ /// [`ElementKind::arg`]): the first token of `@link /about About`, or
54+ /// its `href=` when it has no argument. For `@slot`, the slot's name.
55+ /// Any other text after the attributes is content, a first child.
36 pub argument: Option<String>,
56 pub argument: Option<String>,
37 pub children: Vec<Node>,
57 pub children: Vec<Node>,
58+ /// The line it is written on; for an element a function's body wrote,
59+ /// the line of the call, since a function is an element at its call.
38 pub line_num: usize,
60 pub line_num: usize,
61+ /// The function whose body wrote it, when it comes from a call.
62+ pub function: Option<String>,
39}
63}
40
64
41#[derive(Debug, Clone)]
65#[derive(Debug, Clone)]
42pub struct Attribute {
66pub struct Attribute {
43 pub key: String,
67 pub key: String,
68+ /// The value as it goes into the output: a style's with the quotes of
69+ /// quoted text, an HTML attribute's without them.
44 pub value: Option<String>,
70 pub value: Option<String>,
45 /// Written `key=value`: an HTML attribute rather than a style.
71 /// Written `key=value`: an HTML attribute rather than a style.
46 pub html: bool,
72 pub html: bool,
73+ /// When the value is quoted text (`"..."`, or a variable holding
74+ /// quoted text), both of its forms, for a parameter it binds.
75+ pub quoted: Option<Quoted>,
76+}
77+
78+/// Quoted text, which remembers that it was quoted: a CSS value writes it
79+/// with its quotes, text and HTML attribute values without them.
80+#[derive(Debug, Clone, PartialEq)]
81+pub struct Quoted {
82+ /// What it says: `a"b` for `"a\"b"`.
83+ pub text: String,
84+ /// As CSS writes it, quotes included: `"a\"b"`.
85+ pub css: String,
47}
86}
48
87
49#[derive(Debug, Clone, PartialEq)]
88#[derive(Debug, Clone, PartialEq)]
@@ -56,7 +95,6 @@ pub enum ElementKind {
56 Link,
95 Link,
57 Image,
96 Image,
58 // Elements generated in a special way
97 // Elements generated in a special way
59− Script,
60 Fragment,
98 Fragment,
61 // Function bodies: placeholders for the caller's content
99 // Function bodies: placeholders for the caller's content
62 Children,
100 Children,
@@ -75,7 +113,6 @@ impl ElementKind {
75 ElementKind::Paragraph => "paragraph",
113 ElementKind::Paragraph => "paragraph",
76 ElementKind::Link => "link",
114 ElementKind::Link => "link",
77 ElementKind::Image => "image",
115 ElementKind::Image => "image",
78− ElementKind::Script => "script",
79 ElementKind::Fragment => "fragment",
116 ElementKind::Fragment => "fragment",
80 ElementKind::Children => "children",
117 ElementKind::Children => "children",
81 ElementKind::Slot(_) => "slot",
118 ElementKind::Slot(_) => "slot",
@@ -90,13 +127,84 @@ impl ElementKind {
90 }
127 }
91 }
128 }
92
129
93− /// Does this element lay its children out in a flex column (`@el` and
130+ /// How the element lays out what is inside it. `@fragment`, `@children`
94− /// the semantic containers)?
131+ /// and `@slot` have no element of their own: their content lands in the
95− pub fn is_column(&self) -> bool {
132+ /// element around them and takes its layout.
96− matches!(self, ElementKind::El)
133+ pub fn layout(&self) -> Layout {
97− || self
134+ match self {
98− .spec()
135+ ElementKind::Row => Layout::Row,
99− .is_some_and(|spec| spec.css.contains("flex-direction:column"))
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)
100 }
208 }
101
209
102 /// Is this the table element named `name` (e.g. `"main"`)?
210 /// Is this the table element named `name` (e.g. `"main"`)?
@@ -113,7 +221,6 @@ impl ElementKind {
113 "paragraph" => ElementKind::Paragraph,
221 "paragraph" => ElementKind::Paragraph,
114 "link" => ElementKind::Link,
222 "link" => ElementKind::Link,
115 "image" => ElementKind::Image,
223 "image" => ElementKind::Image,
116− "script" => ElementKind::Script,
117 "fragment" => ElementKind::Fragment,
224 "fragment" => ElementKind::Fragment,
118 "children" => ElementKind::Children,
225 "children" => ElementKind::Children,
119 "slot" => ElementKind::Slot(String::new()),
226 "slot" => ElementKind::Slot(String::new()),
@@ -130,7 +237,6 @@ impl ElementKind {
130 "paragraph",
237 "paragraph",
131 "link",
238 "link",
132 "image",
239 "image",
133− "script",
134 "fragment",
240 "fragment",
135 "children",
241 "children",
136 "slot",
242 "slot",
@@ -143,12 +249,90 @@ impl ElementKind {
143/// What an element does with the text after its name and attributes.
249/// What an element does with the text after its name and attributes.
144#[derive(Debug, Clone, Copy, PartialEq)]
250#[derive(Debug, Clone, Copy, PartialEq)]
145pub enum TagArg {
251pub enum TagArg {
146− /// Leading text content (`@section Hello`).
252+ /// Its content: the first line of its text, parsed like any text
253+ /// (`@h1 Hello {@text [color red] world}`). The element's layout
254+ /// decides how it combines with the lines under it.
147 Child,
255 Child,
148− /// Printed as the element's text (`@li First`).
256+ /// Its leading attribute: the first token fills this HTML attribute
149− Text,
257+ /// (`@iframe URL` sets `src`), and the rest of the text is content, or
150− /// Emitted as this HTML attribute (`@iframe URL` sets `src`).
258+ /// an error on an element without content. The token ends at a space
259+ /// outside `"..."` and `${...}`.
151 Attr(&'static str),
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,
309+ /// HTML's own layout, which htmlang leaves alone: tables, `@select`,
310+ /// `@picture`, media and form controls. Text lines are separated by a
311+ /// line break, which HTML shows as a space except in `@pre` and
312+ /// `@textarea`.
313+ Native,
314+ /// No content at all (`@input`, `@hr`, `@image`).
315+ Void,
316+}
317+
318+impl Layout {
319+ /// A row, column or grid: an element that lays out its children, so
320+ /// `spacing`, `wrap` and `grid-cols` work on it.
321+ pub fn is_container(self) -> bool {
322+ matches!(self, Layout::Column | Layout::Row | Layout::Grid)
323+ }
324+
325+ /// The name used in messages and the reference: `column`, `text`, ...
326+ pub fn name(self) -> &'static str {
327+ match self {
328+ Layout::Column => "column",
329+ Layout::Row => "row",
330+ Layout::Grid => "grid",
331+ Layout::Text => "text",
332+ Layout::Native => "native",
333+ Layout::Void => "void",
334+ }
335+ }
152}
336}
153
337
154/// How an element compiles: the data behind [`ElementKind::Tag`].
338/// How an element compiles: the data behind [`ElementKind::Tag`].
@@ -158,17 +342,19 @@ pub struct TagSpec {
158 pub name: &'static str,
342 pub name: &'static str,
159 /// HTML element emitted.
343 /// HTML element emitted.
160 pub html: &'static str,
344 pub html: &'static str,
161− /// Default CSS (flex column, margin reset, ...).
345+ /// Default CSS besides the layout's `display` (margin reset, ...).
162 pub css: &'static str,
346 pub css: &'static str,
163 pub arg: TagArg,
347 pub arg: TagArg,
164− /// Bare text children are wrapped in `<span>` (flex containers).
348+ /// How it lays out its content.
165− pub wraps_text: bool,
349+ pub layout: Layout,
166− /// Accepts layout attributes for its children (`spacing`, ...).
350+ /// Its text is shown as written (`@code`, `@textarea`). On its line and
167− pub container: bool,
351+ /// inline, a `{@...}` is text, not an inline element (escapes and
168− /// Void element: no children, no closing tag (`<hr>`, `<input>`).
352+ /// `$names` still work). Its indented body is verbatim: nothing in it is
169− pub void: bool,
353+ /// parsed, and it is shown HTML-escaped, its lines and indentation kept.
170− /// Children are joined with spaces, like a paragraph (headings).
354+ pub literal: bool,
171− pub inline: bool,
355+ /// Its indented body is foreign text written into the page exactly as
356+ /// it is (`@script`'s JavaScript): nothing in it is parsed as htmlang.
357+ pub verbatim: bool,
172}
358}
173
359
174impl TagSpec {
360impl TagSpec {
@@ -177,84 +363,413 @@ impl TagSpec {
177 html: "",
363 html: "",
178 css: "",
364 css: "",
179 arg: TagArg::Child,
365 arg: TagArg::Child,
180− wraps_text: false,
366+ layout: Layout::Native,
181− container: false,
367+ literal: false,
182− void: false,
368+ verbatim: false,
183− inline: false,
184 };
369 };
185}
370}
186
371
187/// Every element besides the core ones in [`ElementKind`].
372/// Every element besides the core ones in [`ElementKind`].
188#[rustfmt::skip]
373#[rustfmt::skip]
189pub static TAGS: &[TagSpec] = &[
374pub static TAGS: &[TagSpec] = &[
190− TagSpec { name: "nav", html: "nav", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
375+ TagSpec { name: "nav", html: "nav", layout: Layout::Column, ..TagSpec::DEFAULT },
191− TagSpec { name: "header", html: "header", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
376+ TagSpec { name: "header", html: "header", layout: Layout::Column, ..TagSpec::DEFAULT },
192− TagSpec { name: "footer", html: "footer", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
377+ TagSpec { name: "footer", html: "footer", layout: Layout::Column, ..TagSpec::DEFAULT },
193− TagSpec { name: "main", html: "main", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
378+ TagSpec { name: "main", html: "main", layout: Layout::Column, ..TagSpec::DEFAULT },
194− TagSpec { name: "section", html: "section", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
379+ TagSpec { name: "section", html: "section", layout: Layout::Column, ..TagSpec::DEFAULT },
195− TagSpec { name: "article", html: "article", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
380+ TagSpec { name: "article", html: "article", layout: Layout::Column, ..TagSpec::DEFAULT },
196− TagSpec { name: "aside", html: "aside", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
381+ TagSpec { name: "aside", html: "aside", layout: Layout::Column, ..TagSpec::DEFAULT },
197− TagSpec { name: "details", html: "details", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
382+ TagSpec { name: "details", html: "details", layout: Layout::Column, ..TagSpec::DEFAULT },
198− TagSpec { name: "dialog", html: "dialog", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
383+ TagSpec { name: "dialog", html: "dialog", layout: Layout::Column, ..TagSpec::DEFAULT },
199− TagSpec { name: "search", html: "search", wraps_text: true, container: true, ..TagSpec::DEFAULT },
384+ TagSpec { name: "search", html: "search", layout: Layout::Column, ..TagSpec::DEFAULT },
200− TagSpec { name: "address", html: "address", wraps_text: true, container: true, ..TagSpec::DEFAULT },
385+ TagSpec { name: "address", html: "address", layout: Layout::Column, ..TagSpec::DEFAULT },
201− TagSpec { name: "noscript", html: "noscript", wraps_text: true, container: true, ..TagSpec::DEFAULT },
386+ TagSpec { name: "noscript", html: "noscript", layout: Layout::Column, ..TagSpec::DEFAULT },
202− TagSpec { name: "form", html: "form", css: "display:flex;flex-direction:column;", arg: TagArg::Attr("action"), wraps_text: true, container: true, ..TagSpec::DEFAULT },
387+ TagSpec { name: "form", html: "form", arg: TagArg::Attr("action"), layout: Layout::Column, ..TagSpec::DEFAULT },
203− TagSpec { name: "figure", html: "figure", css: "display:flex;flex-direction:column;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
388+ TagSpec { name: "figure", html: "figure", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
204− TagSpec { name: "blockquote", html: "blockquote", 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 },
205− 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 },
390+ TagSpec { name: "fieldset", html: "fieldset", css: "border:1px solid currentColor;padding:8px;margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
206− TagSpec { name: "datalist", html: "datalist", wraps_text: true, container: true, ..TagSpec::DEFAULT },
391+ TagSpec { name: "datalist", html: "datalist", layout: Layout::Native, ..TagSpec::DEFAULT },
207− TagSpec { name: "ul", html: "ul", css: "margin:0;padding-left:0;list-style:none;", container: true, ..TagSpec::DEFAULT },
392+ TagSpec { name: "ul", html: "ul", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
208− TagSpec { name: "ol", html: "ol", 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 },
209− TagSpec { name: "li", html: "li", css: "display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
394+ TagSpec { name: "li", html: "li", layout: Layout::Column, ..TagSpec::DEFAULT },
210− TagSpec { name: "dl", html: "dl", css: "margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
395+ TagSpec { name: "dl", html: "dl", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
211− TagSpec { name: "dt", html: "dt", arg: TagArg::Text, container: true, ..TagSpec::DEFAULT },
396+ TagSpec { name: "dt", html: "dt", layout: Layout::Text, ..TagSpec::DEFAULT },
212− TagSpec { name: "dd", html: "dd", css: "margin:0;display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
397+ TagSpec { name: "dd", html: "dd", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
213− TagSpec { name: "table", html: "table", ..TagSpec::DEFAULT },
398+ TagSpec { name: "table", html: "table", layout: Layout::Native, ..TagSpec::DEFAULT },
214− TagSpec { name: "thead", html: "thead", ..TagSpec::DEFAULT },
399+ TagSpec { name: "thead", html: "thead", layout: Layout::Native, ..TagSpec::DEFAULT },
215− TagSpec { name: "tbody", html: "tbody", ..TagSpec::DEFAULT },
400+ TagSpec { name: "tbody", html: "tbody", layout: Layout::Native, ..TagSpec::DEFAULT },
216− TagSpec { name: "tr", html: "tr", ..TagSpec::DEFAULT },
401+ TagSpec { name: "tr", html: "tr", layout: Layout::Native, ..TagSpec::DEFAULT },
217− TagSpec { name: "select", html: "select", ..TagSpec::DEFAULT },
402+ TagSpec { name: "select", html: "select", layout: Layout::Native, ..TagSpec::DEFAULT },
218− TagSpec { name: "picture", html: "picture", ..TagSpec::DEFAULT },
403+ TagSpec { name: "picture", html: "picture", layout: Layout::Native, ..TagSpec::DEFAULT },
219− TagSpec { name: "progress", html: "progress", ..TagSpec::DEFAULT },
404+ TagSpec { name: "progress", html: "progress", layout: Layout::Native, ..TagSpec::DEFAULT },
220− TagSpec { name: "meter", html: "meter", ..TagSpec::DEFAULT },
405+ TagSpec { name: "meter", html: "meter", layout: Layout::Native, ..TagSpec::DEFAULT },
221− TagSpec { name: "output", html: "output", ..TagSpec::DEFAULT },
406+ TagSpec { name: "output", html: "output", layout: Layout::Native, ..TagSpec::DEFAULT },
222− TagSpec { name: "canvas", html: "canvas", container: true, ..TagSpec::DEFAULT },
407+ TagSpec { name: "canvas", html: "canvas", layout: Layout::Native, ..TagSpec::DEFAULT },
223− TagSpec { name: "td", html: "td", arg: TagArg::Text, ..TagSpec::DEFAULT },
408+ TagSpec { name: "td", html: "td", layout: Layout::Text, ..TagSpec::DEFAULT },
224− TagSpec { name: "th", html: "th", arg: TagArg::Text, ..TagSpec::DEFAULT },
409+ TagSpec { name: "th", html: "th", layout: Layout::Text, ..TagSpec::DEFAULT },
225− TagSpec { name: "button", html: "button", arg: TagArg::Text, ..TagSpec::DEFAULT },
410+ TagSpec { name: "button", html: "button", layout: Layout::Text, ..TagSpec::DEFAULT },
226− TagSpec { name: "label", html: "label", arg: TagArg::Text, ..TagSpec::DEFAULT },
411+ TagSpec { name: "label", html: "label", layout: Layout::Text, ..TagSpec::DEFAULT },
227− TagSpec { name: "option", html: "option", arg: TagArg::Text, ..TagSpec::DEFAULT },
412+ TagSpec { name: "option", html: "option", layout: Layout::Native, ..TagSpec::DEFAULT },
228− TagSpec { name: "textarea", html: "textarea", arg: TagArg::Text, ..TagSpec::DEFAULT },
413+ TagSpec { name: "textarea", html: "textarea", layout: Layout::Native, literal: true, ..TagSpec::DEFAULT },
229− TagSpec { name: "summary", html: "summary", arg: TagArg::Text, ..TagSpec::DEFAULT },
414+ TagSpec { name: "summary", html: "summary", layout: Layout::Text, ..TagSpec::DEFAULT },
230− TagSpec { name: "cite", html: "cite", arg: TagArg::Text, ..TagSpec::DEFAULT },
415+ TagSpec { name: "cite", html: "cite", layout: Layout::Text, ..TagSpec::DEFAULT },
231− TagSpec { name: "figcaption", html: "figcaption", arg: TagArg::Text, ..TagSpec::DEFAULT },
416+ TagSpec { name: "figcaption", html: "figcaption", layout: Layout::Text, ..TagSpec::DEFAULT },
232− TagSpec { name: "legend", html: "legend", arg: TagArg::Text, ..TagSpec::DEFAULT },
417+ TagSpec { name: "legend", html: "legend", layout: Layout::Text, ..TagSpec::DEFAULT },
233− TagSpec { name: "time", html: "time", arg: TagArg::Text, ..TagSpec::DEFAULT },
418+ TagSpec { name: "time", html: "time", layout: Layout::Text, ..TagSpec::DEFAULT },
234− TagSpec { name: "mark", html: "mark", arg: TagArg::Text, ..TagSpec::DEFAULT },
419+ TagSpec { name: "mark", html: "mark", layout: Layout::Text, ..TagSpec::DEFAULT },
235− TagSpec { name: "abbr", html: "abbr", arg: TagArg::Text, ..TagSpec::DEFAULT },
420+ TagSpec { name: "abbr", html: "abbr", layout: Layout::Text, ..TagSpec::DEFAULT },
236− TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
421+ TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, literal: true, ..TagSpec::DEFAULT },
237− TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
422+ TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, ..TagSpec::DEFAULT },
238− TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", ..TagSpec::DEFAULT },
423+ TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", layout: Layout::Native, ..TagSpec::DEFAULT },
239− TagSpec { name: "h1", html: "h1", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
424+ TagSpec { name: "h1", html: "h1", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
240− TagSpec { name: "h2", html: "h2", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
425+ TagSpec { name: "h2", html: "h2", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
241− TagSpec { name: "h3", html: "h3", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
426+ TagSpec { name: "h3", html: "h3", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
242− TagSpec { name: "h4", html: "h4", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
427+ TagSpec { name: "h4", html: "h4", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
243− TagSpec { name: "h5", html: "h5", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
428+ TagSpec { name: "h5", html: "h5", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
244− TagSpec { name: "h6", html: "h6", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
429+ TagSpec { name: "h6", html: "h6", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
245− TagSpec { name: "input", html: "input", void: true, ..TagSpec::DEFAULT },
430+ TagSpec { name: "input", html: "input", layout: Layout::Void, ..TagSpec::DEFAULT },
246− TagSpec { name: "hr", html: "hr", void: true, ..TagSpec::DEFAULT },
431+ TagSpec { name: "hr", html: "hr", layout: Layout::Void, ..TagSpec::DEFAULT },
247− TagSpec { name: "source", html: "source", arg: TagArg::Attr("src"), void: true, ..TagSpec::DEFAULT },
432+ TagSpec { name: "source", html: "source", arg: TagArg::Source, layout: Layout::Void, ..TagSpec::DEFAULT },
248− TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
433+ TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
249− TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
434+ TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
250− TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), container: true, ..TagSpec::DEFAULT },
435+ TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
251− TagSpec { name: "grid", html: "div", css: "display:grid;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
436+ TagSpec { name: "script", html: "script", arg: TagArg::Attr("src"), layout: Layout::Native, verbatim: true, ..TagSpec::DEFAULT },
252− TagSpec { name: "in-front", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;", container: true, ..TagSpec::DEFAULT },
437+ // Flow containers
253− TagSpec { name: "behind", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;z-index:-1;", container: true, ..TagSpec::DEFAULT },
438+ TagSpec { name: "hgroup", html: "hgroup", layout: Layout::Column, ..TagSpec::DEFAULT },
439+ TagSpec { name: "menu", html: "menu", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
440+ // Phrasing: text with the browser's own style, and no CSS of htmlang's
441+ TagSpec { name: "b", html: "b", layout: Layout::Text, ..TagSpec::DEFAULT },
442+ TagSpec { name: "i", html: "i", layout: Layout::Text, ..TagSpec::DEFAULT },
443+ TagSpec { name: "strong", html: "strong", layout: Layout::Text, ..TagSpec::DEFAULT },
444+ TagSpec { name: "em", html: "em", layout: Layout::Text, ..TagSpec::DEFAULT },
445+ TagSpec { name: "small", html: "small", layout: Layout::Text, ..TagSpec::DEFAULT },
446+ TagSpec { name: "s", html: "s", layout: Layout::Text, ..TagSpec::DEFAULT },
447+ TagSpec { name: "u", html: "u", layout: Layout::Text, ..TagSpec::DEFAULT },
448+ TagSpec { name: "sub", html: "sub", layout: Layout::Text, ..TagSpec::DEFAULT },
449+ TagSpec { name: "sup", html: "sup", layout: Layout::Text, ..TagSpec::DEFAULT },
450+ TagSpec { name: "q", html: "q", layout: Layout::Text, ..TagSpec::DEFAULT },
451+ TagSpec { name: "var", html: "var", layout: Layout::Text, ..TagSpec::DEFAULT },
452+ TagSpec { name: "samp", html: "samp", layout: Layout::Text, ..TagSpec::DEFAULT },
453+ TagSpec { name: "dfn", html: "dfn", layout: Layout::Text, ..TagSpec::DEFAULT },
454+ TagSpec { name: "bdi", html: "bdi", layout: Layout::Text, ..TagSpec::DEFAULT },
455+ TagSpec { name: "bdo", html: "bdo", layout: Layout::Text, ..TagSpec::DEFAULT },
456+ TagSpec { name: "ins", html: "ins", layout: Layout::Text, ..TagSpec::DEFAULT },
457+ TagSpec { name: "del", html: "del", layout: Layout::Text, ..TagSpec::DEFAULT },
458+ TagSpec { name: "br", html: "br", layout: Layout::Void, ..TagSpec::DEFAULT },
459+ TagSpec { name: "wbr", html: "wbr", layout: Layout::Void, ..TagSpec::DEFAULT },
460+ // HTML's own display: table parts, option groups, ruby, embedded content
461+ TagSpec { name: "caption", html: "caption", layout: Layout::Native, ..TagSpec::DEFAULT },
462+ TagSpec { name: "colgroup", html: "colgroup", layout: Layout::Native, ..TagSpec::DEFAULT },
463+ TagSpec { name: "col", html: "col", layout: Layout::Void, ..TagSpec::DEFAULT },
464+ TagSpec { name: "tfoot", html: "tfoot", layout: Layout::Native, ..TagSpec::DEFAULT },
465+ TagSpec { name: "optgroup", html: "optgroup", arg: TagArg::Attr("label"), layout: Layout::Native, ..TagSpec::DEFAULT },
466+ TagSpec { name: "ruby", html: "ruby", layout: Layout::Native, ..TagSpec::DEFAULT },
467+ TagSpec { name: "rt", html: "rt", layout: Layout::Native, ..TagSpec::DEFAULT },
468+ TagSpec { name: "rp", html: "rp", layout: Layout::Native, ..TagSpec::DEFAULT },
469+ TagSpec { name: "track", html: "track", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
470+ TagSpec { name: "embed", html: "embed", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
471+ TagSpec { name: "object", html: "object", arg: TagArg::Attr("data"), layout: Layout::Native, ..TagSpec::DEFAULT },
472+ TagSpec { name: "map", html: "map", layout: Layout::Native, ..TagSpec::DEFAULT },
473+ TagSpec { name: "area", html: "area", arg: TagArg::Attr("href"), layout: Layout::Void, ..TagSpec::DEFAULT },
474+ // htmlang's own
475+ TagSpec { name: "grid", html: "div", layout: Layout::Grid, ..TagSpec::DEFAULT },
476+ TagSpec { name: "in-front", html: "div", css: "position:absolute;inset:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
477+ TagSpec { name: "behind", html: "div", css: "position:absolute;inset:0;z-index:-1;", layout: Layout::Column, ..TagSpec::DEFAULT },
254];
478];
255
479
256−/// Elements that print their argument as their text (`@text Hello`,
480+/// HTML elements htmlang writes under another name. The HTML name isn't an
257−/// `@li First`).
481+/// element: the unknown-element error for `@a` suggests `@link`. (Not a
258−pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
482+/// second spelling: only the suggestion reads this.)
259− *kind == ElementKind::Text || kind.spec().is_some_and(|spec| spec.arg == TagArg::Text)
483+pub const HTML_NAMES_WRITTEN_OTHERWISE: &[(&str, &str)] = &[
484+ ("a", "link"),
485+ ("img", "image"),
486+ ("span", "text"),
487+ ("p", "paragraph"),
488+ ("div", "el"),
489+];
490+
491+/// Whether the element named `name` (without the `@`) shows its text as
492+/// written: `{@code {@link /x y}}` prints the braces, and its indented body
493+/// is verbatim text, HTML-escaped. The syntax tree reads this, so every
494+/// tool agrees on it.
495+pub fn has_literal_text(name: &str) -> bool {
496+ ElementKind::from_name(name)
497+ .and_then(|kind| kind.spec())
498+ .is_some_and(|spec| spec.literal)
499+}
500+
501+/// How a directive reads the rest of its line.
502+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503+pub enum ArgGrammar {
504+ /// Free text, taken as written (`@include file.hl`, `@meta name value`).
505+ /// For a directive with a verbatim body it is the one-line form of the
506+ /// body (`@raw <hr>`, `@style .a { color: red }`) or, for `@markdown`,
507+ /// the file.
508+ Text,
509+ /// An optional `[attributes]` list, then text (`@page [lang=en] Title`).
510+ AttrsText,
511+ /// An expression (`@if $count > 2`).
512+ Expression,
513+ /// A name, then a value, `= EXPR`, `"text"`, `[attributes]` or the
514+ /// parameters of a function (`@let`).
515+ Definition,
516+ /// Nothing, or `if EXPR` (`@else`, `@else if`).
517+ Else,
518+ /// `$item in LIST` or `$item, $index in LIST` (`@each`).
519+ Loop,
520+ /// `$name SOURCE`; inline JSON may span several lines (`@data`).
521+ Data,
522+}
523+
524+/// What the lines indented under a directive or element are.
525+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526+pub enum BodyKind {
527+ /// Nothing: indented lines are an error.
528+ None,
529+ /// htmlang: elements, text and directives.
530+ Htmlang,
531+ /// Foreign text (CSS, HTML, JavaScript, Markdown, a code sample), kept
532+ /// exactly as written: nothing in it is parsed, and `--` is not a
533+ /// comment. The text on the header's line is its one-line form (or a
534+ /// file or URL: `@markdown notes.md`, `@script app.js`); the line and a
535+ /// body together are an error. A verbatim directive takes no
536+ /// attributes.
537+ Verbatim,
538+}
539+
540+/// A directive: a `@name` that isn't an element. The syntax tree
541+/// (`syntax.rs`), the evaluator, the formatter and the editor tools all
542+/// read the directives' shapes from this table.
543+#[derive(Debug, PartialEq)]
544+pub struct DirectiveSpec {
545+ /// Name in htmlang source, without the `@`.
546+ pub name: &'static str,
547+ pub args: ArgGrammar,
548+ pub body: BodyKind,
549+}
550+
551+/// Every directive.
552+#[rustfmt::skip]
553+pub static DIRECTIVES: &[DirectiveSpec] = &[
554+ DirectiveSpec { name: "page", args: ArgGrammar::AttrsText, body: BodyKind::None },
555+ DirectiveSpec { name: "let", args: ArgGrammar::Definition, body: BodyKind::Htmlang },
556+ DirectiveSpec { name: "include", args: ArgGrammar::Text, body: BodyKind::None },
557+ DirectiveSpec { name: "data", args: ArgGrammar::Data, body: BodyKind::None },
558+ DirectiveSpec { name: "meta", args: ArgGrammar::Text, body: BodyKind::None },
559+ DirectiveSpec { name: "if", args: ArgGrammar::Expression, body: BodyKind::Htmlang },
560+ DirectiveSpec { name: "else", args: ArgGrammar::Else, body: BodyKind::Htmlang },
561+ DirectiveSpec { name: "each", args: ArgGrammar::Loop, body: BodyKind::Htmlang },
562+ DirectiveSpec { name: "style", args: ArgGrammar::Text, body: BodyKind::Verbatim },
563+ DirectiveSpec { name: "head", args: ArgGrammar::Text, body: BodyKind::Verbatim },
564+ DirectiveSpec { name: "raw", args: ArgGrammar::Text, body: BodyKind::Verbatim },
565+ DirectiveSpec { name: "markdown", args: ArgGrammar::Text, body: BodyKind::Verbatim },
566+];
567+
568+/// The directive named `name` (without the `@`).
569+pub fn directive(name: &str) -> Option<&'static DirectiveSpec> {
570+ DIRECTIVES.iter().find(|d| d.name == name)
571+}
572+
573+/// What the lines indented under `@name` are: the directive's body kind,
574+/// or an element's (verbatim for a row that says so: `@script`, and
575+/// `@code` and `@textarea`, whose text is literal; htmlang for every other
576+/// element).
577+pub fn body_kind(name: &str) -> BodyKind {
578+ let verbatim = |spec: &TagSpec| spec.verbatim || spec.literal;
579+ match directive(name) {
580+ Some(spec) => spec.body,
581+ None if ElementKind::from_name(name)
582+ .and_then(|kind| kind.spec())
583+ .is_some_and(verbatim) =>
584+ {
585+ BodyKind::Verbatim
586+ }
587+ None => BodyKind::Htmlang,
588+ }
589+}
590+
591+#[cfg(test)]
592+mod tests {
593+ use super::{ElementKind, Layout};
594+
595+ fn layout(name: &str) -> Layout {
596+ ElementKind::from_name(name).unwrap().layout()
597+ }
598+
599+ #[test]
600+ fn every_element_has_the_layout_it_is_documented_with() {
601+ let text = [
602+ "text",
603+ "paragraph",
604+ "link",
605+ "h1",
606+ "h2",
607+ "h3",
608+ "h4",
609+ "h5",
610+ "h6",
611+ "button",
612+ "td",
613+ "th",
614+ "label",
615+ "summary",
616+ "legend",
617+ "figcaption",
618+ "dt",
619+ "cite",
620+ "code",
621+ "kbd",
622+ "mark",
623+ "abbr",
624+ "time",
625+ "b",
626+ "i",
627+ "strong",
628+ "em",
629+ "small",
630+ "sub",
631+ "sup",
632+ "q",
633+ "del",
634+ ];
635+ for name in text {
636+ assert_eq!(layout(name), Layout::Text, "@{}", name);
637+ }
638+ let columns = [
639+ "el", "li", "dd", "ul", "ol", "dl", "search", "address", "noscript", "section", "form",
640+ "in-front", "behind", "hgroup", "menu",
641+ ];
642+ for name in columns {
643+ assert_eq!(layout(name), Layout::Column, "@{}", name);
644+ }
645+ assert_eq!(layout("row"), Layout::Row);
646+ assert_eq!(layout("grid"), Layout::Grid);
647+ for name in [
648+ "table", "tr", "select", "option", "picture", "video", "pre", "canvas", "caption",
649+ "colgroup", "tfoot", "optgroup", "ruby", "rt", "object",
650+ ] {
651+ assert_eq!(layout(name), Layout::Native, "@{}", name);
652+ }
653+ for name in [
654+ "image", "input", "hr", "source", "br", "wbr", "col", "track", "embed", "area",
655+ ] {
656+ assert_eq!(layout(name), Layout::Void, "@{}", name);
657+ }
658+ }
659+
660+ #[test]
661+ fn html_names_written_otherwise_are_not_elements() {
662+ for (html, htmlang) in super::HTML_NAMES_WRITTEN_OTHERWISE {
663+ assert!(ElementKind::from_name(html).is_none(), "@{}", html);
664+ let kind = ElementKind::from_name(htmlang).unwrap();
665+ let written = match kind {
666+ ElementKind::El => "div",
667+ ElementKind::Text => "span",
668+ ElementKind::Paragraph => "p",
669+ ElementKind::Link => "a",
670+ ElementKind::Image => "img",
671+ _ => panic!("@{} is not one of htmlang's own", htmlang),
672+ };
673+ assert_eq!(written, *html);
674+ }
675+ // No two rows share a name, and none is a directive's
676+ let mut names: Vec<&str> = ElementKind::all_names().collect();
677+ let count = names.len();
678+ names.sort_unstable();
679+ names.dedup();
680+ assert_eq!(names.len(), count, "an element name is listed twice");
681+ for name in names {
682+ assert!(super::directive(name).is_none(), "@{}", name);
683+ }
684+ }
685+
686+ #[test]
687+ fn each_row_names_at_most_one_leading_attribute() {
688+ let leading = |name: &str, in_picture: bool| {
689+ ElementKind::from_name(name)
690+ .unwrap()
691+ .leading_attribute(in_picture)
692+ };
693+ for (name, attr) in [
694+ ("link", "href"),
695+ ("area", "href"),
696+ ("image", "src"),
697+ ("script", "src"),
698+ ("iframe", "src"),
699+ ("video", "src"),
700+ ("audio", "src"),
701+ ("track", "src"),
702+ ("embed", "src"),
703+ ("form", "action"),
704+ ("object", "data"),
705+ ("optgroup", "label"),
706+ ] {
707+ assert_eq!(leading(name, false), Some(attr), "@{}", name);
708+ assert_eq!(leading(name, true), Some(attr), "@{}", name);
709+ }
710+ assert_eq!(leading("source", false), Some("src"));
711+ assert_eq!(leading("source", true), Some("srcset"));
712+ for name in ["el", "text", "h1", "button", "slot", "fragment", "picture"] {
713+ assert_eq!(leading(name, false), None, "@{}", name);
714+ }
715+ // @script is an ordinary row whose body is kept as written
716+ let script = ElementKind::from_name("script").unwrap();
717+ assert!(script.is_verbatim() && script.spec().is_some());
718+ assert_eq!(super::body_kind("script"), super::BodyKind::Verbatim);
719+ assert_eq!(super::body_kind("el"), super::BodyKind::Htmlang);
720+ // @code and @textarea: a verbatim body, shown as text; @pre is an
721+ // ordinary element, so `@pre > @code` and wrapper functions work
722+ for name in ["code", "textarea"] {
723+ assert_eq!(
724+ super::body_kind(name),
725+ super::BodyKind::Verbatim,
726+ "@{}",
727+ name
728+ );
729+ assert!(super::has_literal_text(name), "@{}", name);
730+ }
731+ assert_eq!(super::body_kind("pre"), super::BodyKind::Htmlang);
732+ // Every verbatim directive takes its line as its one-line body or file
733+ for spec in super::DIRECTIVES {
734+ if spec.body == super::BodyKind::Verbatim {
735+ assert_eq!(spec.args, super::ArgGrammar::Text, "@{}", spec.name);
736+ }
737+ }
738+ }
739+
740+ #[test]
741+ fn an_element_has_a_tag_of_its_own_when_no_other_element_writes_it() {
742+ let own = |name: &str| ElementKind::from_name(name).unwrap().own_tag();
743+ for shared in [
744+ "el", "row", "grid", "in-front", "behind", "text", "script", "fragment",
745+ ] {
746+ assert_eq!(own(shared), None, "@{}", shared);
747+ }
748+ assert_eq!(own("td"), Some("td"));
749+ assert_eq!(own("link"), Some("a"));
750+ assert_eq!(own("paragraph"), Some("p"));
751+ assert_eq!(own("image"), Some("img"));
752+ // Every tag of its own is written by that one element
753+ let names: Vec<&str> = ElementKind::all_names().collect();
754+ for name in &names {
755+ let Some(tag) = own(name) else { continue };
756+ let writers: Vec<&&str> = names
757+ .iter()
758+ .filter(|other| ElementKind::from_name(other).unwrap().tag() == Some(tag))
759+ .collect();
760+ assert_eq!(writers, vec![name], "<{}>", tag);
761+ }
762+ }
763+
764+ #[test]
765+ fn a_layout_s_display_is_not_repeated_in_the_row_s_css() {
766+ for spec in super::TAGS {
767+ assert!(
768+ !spec.css.contains("display"),
769+ "@{}: {}",
770+ spec.name,
771+ spec.css
772+ );
773+ }
774+ }
260}
775}
crates/htmlang-core/src/codegen.rs+1418−1202
@@ -2,6 +2,7 @@ use std::collections::HashMap;
2use std::hash::{Hash, Hasher};
2use std::hash::{Hash, Hasher};
3
3
4use crate::ast::*;
4use crate::ast::*;
5+use crate::vocab::with_px;
5
6
6// ---------------------------------------------------------------------------
7// ---------------------------------------------------------------------------
7// Style collector: deduplicates CSS and assigns class names
8// Style collector: deduplicates CSS and assigns class names
@@ -16,6 +17,10 @@ const BREAKPOINTS: &[(&str, &str)] = &[
16 ("2xl", "1536px"),
17 ("2xl", "1536px"),
17];
18];
18
19
20+/// The prefix of every generated class (`hl-a`, `hl-b`, ...), which keeps
21+/// them out of the author's own class names.
22+pub const CLASS_PREFIX: &str = "hl-";
23+
19/// Generate short CSS class names: a..z, then aa..a9, ba..b9, ..., z9, then
24/// Generate short CSS class names: a..z, then aa..a9, ba..b9, ..., z9, then
20/// aaa, ... The first character is always a letter; later ones are drawn from
25/// aaa, ... The first character is always a letter; later ones are drawn from
21/// [a-z0-9]. The mapping is a bijection, so distinct indices never collide.
26/// [a-z0-9]. The mapping is a bijection, so distinct indices never collide.
@@ -41,23 +46,228 @@ pub(crate) fn short_class_name(idx: usize) -> String {
41 name
46 name
42}
47}
43
48
49+/// When a style applies: the at-rule prefixes it is under (their ranks,
50+/// see [`crate::vocab::at_rule_rank`], sorted, so `dark:md:` and `md:dark:`
51+/// are one condition) and its selector prefixes, left to right as the
52+/// selector reads (`hover:children:` is `.a:hover > *`).
53+#[derive(Clone, Default, PartialEq, Eq, Hash, Debug)]
54+struct Condition {
55+ at: Vec<usize>,
56+ selector: Vec<String>,
57+}
58+
59+impl Condition {
60+ /// The condition of an attribute's key, and the name after its
61+ /// prefixes.
62+ fn of(key: &str) -> (Condition, &str) {
63+ let (prefixes, name) = crate::vocab::split_prefixes(key);
64+ let mut condition = Condition::default();
65+ for prefix in prefixes {
66+ match crate::vocab::at_rule_rank(prefix) {
67+ Some(rank) => condition.at.push(rank),
68+ None => condition.selector.push(prefix.to_string()),
69+ }
70+ }
71+ condition.at.sort_unstable();
72+ condition.at.dedup();
73+ (condition, name)
74+ }
75+
76+ /// Only the at-rule part: a direction under `hover:` is the one without
77+ /// it.
78+ fn at_only(at: &[usize]) -> Condition {
79+ Condition {
80+ at: at.to_vec(),
81+ selector: Vec::new(),
82+ }
83+ }
84+
85+ /// The styles go on the element's children (`children:`).
86+ fn on_children(&self) -> bool {
87+ self.selector.iter().any(|p| p == crate::vocab::CHILDREN)
88+ }
89+
90+ /// The styles go on the elements of one kind inside the element
91+ /// (`@td:`): where the element prefix is in the selector chain.
92+ fn inside(&self) -> Option<usize> {
93+ self.selector
94+ .iter()
95+ .position(|p| crate::vocab::element_prefix(p).is_some())
96+ }
97+
98+ /// Whether this condition holds wherever `other` does: its at-rules
99+ /// are implied by `other`'s, and it has no selector or `other`'s.
100+ fn holds_at(&self, other: &Condition) -> bool {
101+ (self.selector.is_empty() || self.selector == other.selector)
102+ && self.at.iter().all(|&a| {
103+ other
104+ .at
105+ .iter()
106+ .any(|&b| crate::vocab::at_rule_implied(a, b))
107+ })
108+ }
109+
110+ /// The order the rules of each condition are written in, which the
111+ /// cascade follows: no prefix, then selectors, then each at-rule
112+ /// block (see [`at_order`]), with its selectors inside it.
113+ fn order(&self) -> (AtOrder, SelectorOrder) {
114+ (at_order(&self.at), selector_order(&self.selector))
115+ }
116+}
117+
118+/// Where a block of at-rules is written: after the rules without one; by
119+/// its last (innermost) prefix in the order of
120+/// [`crate::vocab::at_rule_prefixes`] (widths, media, container widths), so
121+/// `md:dark:` comes with `dark:`, after it; then by length.
122+type AtOrder = (bool, usize, usize, Vec<usize>);
123+
124+fn at_order(at: &[usize]) -> AtOrder {
125+ (
126+ !at.is_empty(),
127+ at.last().copied().unwrap_or(0),
128+ at.len(),
129+ at.to_vec(),
130+ )
131+}
132+
133+/// Selector chains in the order of [`crate::vocab::PSEUDOS`] (position,
134+/// then form state, then `hover:`, `focus:`, `active:`, `disabled:`), and
135+/// by their argument's text for the same pseudo-class; a chain before the
136+/// longer ones it starts. A chain through `children:` comes after the
137+/// others, ordered by what follows its last `children:` and then by what
138+/// comes before it: only that tail counts toward its specificity (see
139+/// [`selector`]), so `hover:children:` follows `children:` and wins over it
140+/// on hover.
141+type SelectorOrder = (bool, Vec<(usize, String)>, Vec<(usize, String)>);
142+
143+fn selector_order(selector: &[String]) -> SelectorOrder {
144+ let table = crate::vocab::PSEUDOS;
145+ let ranked = |chain: &[String]| -> Vec<(usize, String)> {
146+ chain
147+ .iter()
148+ .map(|p| {
149+ let name = crate::vocab::pseudo_name(p);
150+ let rank = table
151+ .iter()
152+ .position(|(q, _)| *q == name)
153+ .unwrap_or(table.len());
154+ (rank, p.clone())
155+ })
156+ .collect()
157+ };
158+ match selector.iter().rposition(|p| p == crate::vocab::CHILDREN) {
159+ None => (false, ranked(selector), Vec::new()),
160+ Some(i) => (true, ranked(&selector[i + 1..]), ranked(&selector[..i])),
161+ }
162+}
163+
164+/// The order of the chains through an element prefix (`@td:`, see
165+/// [`scoped_selector`]): by the prefixes before it, which select the root
166+/// of the scope and add nothing to the specificity, so `hover:@td:` comes
167+/// after `@td:` and wins on hover; then by the element; then by the
168+/// prefixes after it.
169+fn inside_order(chain: &[String]) -> (SelectorOrder, String, SelectorOrder) {
170+ let at = chain
171+ .iter()
172+ .position(|p| crate::vocab::element_prefix(p).is_some())
173+ .unwrap_or(chain.len());
174+ (
175+ selector_order(&chain[..at]),
176+ chain.get(at).cloned().unwrap_or_default(),
177+ selector_order(chain.get(at + 1..).unwrap_or_default()),
178+ )
179+}
180+
181+/// The selector for `class` under a chain of selector prefixes, read left
182+/// to right. Everything before the last `children:` goes in `:where()`, so
183+/// a parent's `children:` styles have no specificity, and a child's own
184+/// attributes win over them: `children:hover:` is `:where(.a)>*:hover`,
185+/// `hover:children:` is `:where(.a:hover)>*`.
186+fn selector(class: &str, chain: &[String]) -> String {
187+ let last_children = chain.iter().rposition(|p| p == crate::vocab::CHILDREN);
188+ let mut out = format!(".{}", class);
189+ for (i, prefix) in chain.iter().enumerate() {
190+ if prefix == crate::vocab::CHILDREN {
191+ if Some(i) == last_children {
192+ out = format!(":where({})>*", out);
193+ } else {
194+ out.push_str(">*");
195+ }
196+ } else if let Some(pseudo) = crate::vocab::pseudo_selector(prefix) {
197+ out.push_str(&pseudo);
198+ }
199+ }
200+ out
201+}
202+
203+/// The rule for the elements an element prefix picks out inside the
204+/// element with the class `class` (see [`Condition::inside`]): the scope's
205+/// root, from the selector prefixes before it (`hover:@td:` is inside a
206+/// hovered element), and the selector for the elements, from those after
207+/// it (`@td:hover:` is a hovered cell). `:scope td` leaves out the root
208+/// itself, so `@ul [@ul:...]` styles the lists inside, not its own. `None`
209+/// when the name has no tag of its own (the parser reports it).
210+fn scoped_selector(class: &str, chain: &[String], at: usize) -> Option<(String, String)> {
211+ let name = crate::vocab::element_prefix(&chain[at])?;
212+ let tag = ElementKind::from_name(name)?.own_tag()?;
213+ let root = selector(class, &chain[..at]);
214+ let mut target = format!(":scope {}", tag);
215+ for prefix in &chain[at + 1..] {
216+ if let Some(pseudo) = crate::vocab::pseudo_selector(prefix) {
217+ target.push_str(&pseudo);
218+ }
219+ }
220+ Some((root, target))
221+}
222+
223+/// The `@media` or `@container` rule an at-rule prefix stands for, as
224+/// written in readable and in compact output.
225+fn at_rule(rank: usize, dev: bool) -> String {
226+ let prefix = crate::vocab::at_rule_prefixes()
227+ .nth(rank)
228+ .unwrap_or_default();
229+ let name = prefix.trim_end_matches(':');
230+ let width = |name: &str| {
231+ BREAKPOINTS
232+ .iter()
233+ .find(|(n, _)| *n == name)
234+ .map_or("0", |(_, w)| *w)
235+ };
236+ let (kind, feature, value) = if let Some(size) = name.strip_prefix("cq-") {
237+ ("@container", "min-width", width(size))
238+ } else {
239+ match name {
240+ "print" => {
241+ return "@media print".to_string();
242+ }
243+ "dark" => ("@media", "prefers-color-scheme", "dark"),
244+ "motion-safe" => ("@media", "prefers-reduced-motion", "no-preference"),
245+ "motion-reduce" => ("@media", "prefers-reduced-motion", "reduce"),
246+ "landscape" | "portrait" => ("@media", "orientation", name),
247+ _ => ("@media", "min-width", width(name)),
248+ }
249+ };
250+ if dev {
251+ format!("{} ({}: {})", kind, feature, value)
252+ } else {
253+ format!("{}({}:{})", kind, feature, value)
254+ }
255+}
256+
44struct StyleEntry {
257struct StyleEntry {
45 class_name: String,
258 class_name: String,
46− base: String,
259+ /// The defaults of the element's kind (its layout, a heading's
47− /// (CSS selector suffix, css_rules) — e.g. (":hover", "color:red;")
260+ /// `margin:0`, ...), less what its own attributes set. They are written
48− pseudo: Vec<(String, String)>,
261+ /// as `:where(.hl-a)` in the layer `hl-kind`, so an element prefix
49− /// Responsive overrides: (breakpoint_prefix, css)
262+ /// around it (`@h2:`), its parent's `children:` styles and its own
50− responsive: Vec<(String, String)>,
263+ /// attributes, all in later layers, win over them.
51− /// Dark mode overrides
264+ defaults: String,
52− dark: String,
265+ /// The element's rules: under each condition, its declarations, in the
53− /// Print overrides
266+ /// order they are written
54− print: String,
267+ rules: Vec<(Condition, String)>,
55− motion_safe: String,
268+ /// What else tells two elements with the same CSS apart: the rules
56− motion_reduce: String,
269+ /// keyed on this class (see [`StyleCollector::keyed`]). Not written.
57− landscape: String,
270+ distinct: String,
58− portrait: String,
59− /// Container query overrides: (breakpoint_prefix, css)
60− container: Vec<(String, String)>,
61}
271}
62
272
63struct StyleCollector {
273struct StyleCollector {
@@ -66,6 +276,12 @@ struct StyleCollector {
66 /// Using u64 as the key keeps lookups allocation-free; on the rare case of
276 /// 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.
277 /// a hash collision we fall back to a full equality check against the entry.
68 index: HashMap<u64, Vec<usize>>,
278 index: HashMap<u64, Vec<usize>>,
279+ /// Rules for an element's children, keyed on its class and written in
280+ /// the block of an at-rule condition: `(at-rules, selector, body)`, e.g.
281+ /// `([md], ":where(.a)>.b", "flex:1;")` (see [`Flow`]).
282+ keyed: Vec<(Vec<usize>, String, String)>,
283+ /// The keyed rules already added
284+ keyed_seen: std::collections::HashSet<(Vec<usize>, String, String)>,
69}
285}
70
286
71impl StyleCollector {
287impl StyleCollector {
@@ -73,317 +289,270 @@ impl StyleCollector {
73 StyleCollector {
289 StyleCollector {
74 entries: Vec::new(),
290 entries: Vec::new(),
75 index: HashMap::new(),
291 index: HashMap::new(),
292+ keyed: Vec::new(),
293+ keyed_seen: std::collections::HashSet::new(),
294+ }
295+ }
296+
297+ /// Add a rule for `selector` under the at-rules `at`, once.
298+ fn add_keyed(&mut self, at: Vec<usize>, selector: String, body: String) {
299+ let rule = (at, selector, body);
300+ if self.keyed_seen.insert(rule.clone()) {
301+ self.keyed.push(rule);
76 }
302 }
77 }
303 }
78
304
79 /// Returns a class name for this style combination, or None if all empty.
305 /// Returns a class name for this style combination, or None if all empty.
80− #[allow(clippy::too_many_arguments)]
81 fn get_class(
306 fn get_class(
82 &mut self,
307 &mut self,
83− base: String,
308+ defaults: String,
84− pseudo: Vec<(String, String)>,
309+ mut rules: Vec<(Condition, String)>,
85− responsive: Vec<(String, String)>,
310+ distinct: String,
86− dark: String,
87− print: String,
88− motion_safe: String,
89− motion_reduce: String,
90− landscape: String,
91− portrait: String,
92− container: Vec<(String, String)>,
93 ) -> Option<String> {
311 ) -> Option<String> {
94− if base.is_empty()
312+ rules.retain(|(_, body)| !body.is_empty());
95− && pseudo.is_empty()
313+ if defaults.is_empty() && rules.is_empty() {
96− && responsive.is_empty()
97− && dark.is_empty()
98− && print.is_empty()
99− && motion_safe.is_empty()
100− && motion_reduce.is_empty()
101− && landscape.is_empty()
102− && portrait.is_empty()
103− && container.is_empty()
104− {
105 return None;
314 return None;
106 }
315 }
316+ rules.sort_by_cached_key(|(condition, _)| condition.order());
107 use std::collections::hash_map::DefaultHasher;
317 use std::collections::hash_map::DefaultHasher;
108 let mut h = DefaultHasher::new();
318 let mut h = DefaultHasher::new();
109− base.hash(&mut h);
319+ defaults.hash(&mut h);
110− pseudo.hash(&mut h);
320+ rules.hash(&mut h);
111− responsive.hash(&mut h);
321+ distinct.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();
322 let sig = h.finish();
120
323
121 if let Some(indices) = self.index.get(&sig) {
324 if let Some(indices) = self.index.get(&sig) {
122 for &idx in indices {
325 for &idx in indices {
123 let e = &self.entries[idx];
326 let e = &self.entries[idx];
124− if e.base == base
327+ if e.defaults == defaults && e.rules == rules && e.distinct == distinct {
125− && e.pseudo == pseudo
126− && e.responsive == responsive
127− && e.dark == dark
128− && e.print == print
129− && e.motion_safe == motion_safe
130− && e.motion_reduce == motion_reduce
131− && e.landscape == landscape
132− && e.portrait == portrait
133− && e.container == container
134− {
135 return Some(e.class_name.clone());
328 return Some(e.class_name.clone());
136 }
329 }
137 }
330 }
138 }
331 }
139 let idx = self.entries.len();
332 let idx = self.entries.len();
140− let name = short_class_name(idx);
333+ let name = format!("{}{}", CLASS_PREFIX, short_class_name(idx));
141 self.entries.push(StyleEntry {
334 self.entries.push(StyleEntry {
142 class_name: name.clone(),
335 class_name: name.clone(),
143− base,
336+ defaults,
144− pseudo,
337+ rules,
145− responsive,
338+ distinct,
146− dark,
147− print,
148− motion_safe,
149− motion_reduce,
150− landscape,
151− portrait,
152− container,
153 });
339 });
154 self.index.entry(sig).or_default().push(idx);
340 self.index.entry(sig).or_default().push(idx);
155 Some(name)
341 Some(name)
156 }
342 }
157
343
158− /// All generated rules, wrapped in `@layer htmlang`.
344+ /// All generated rules, in three layers (see [`LAYERS`]): the element
159− fn to_css_formatted(&self, dev: bool) -> String {
345+ /// kinds' defaults in `hl-kind` (`:where(.hl-a)`), the rules for the
160− let mut css = String::new();
346+ /// elements an element prefix picks out inside an element (`@td:`), and
161− let inner_indent = if dev { " " } else { "" };
347+ /// a function's scoped `@style`, in `hl-inside`, and the elements' own
162− css.push_str(if dev {
348+ /// rules in `htmlang`. They are written in a fixed order, so which rule
163− "@layer htmlang {\n"
349+ /// wins never depends on where in the page an element is: the rules
164− } else {
350+ /// without a prefix, then those under selector prefixes, then each block
165− "@layer htmlang{"
351+ /// of at-rules (see [`at_order`]), in which the rules keyed on a parent
166− });
352+ /// come first, then the element's own, then its selectors (see
167−
353+ /// [`selector_order`]).
168− // Non-responsive rules. Base and pseudo rules are merged separately by
354+ fn to_css_formatted(&self, dev: bool, scoped: &[String]) -> String {
169− // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
355+ // The defaults: `:where(.hl-a,.hl-b){...}`
170− // replaces two identical rules. Pseudo variants are grouped per selector
356+ let mut kind = String::new();
171− // suffix (`:hover` with `:hover`, etc.) to keep each pseudo's cascade
357+ let mut defaults: Vec<(&str, Vec<&str>)> = Vec::new();
172− // position independent of others.
358+ for e in self.entries.iter().filter(|e| !e.defaults.is_empty()) {
173− let mut base_pairs: Vec<(&str, &str)> = Vec::with_capacity(self.entries.len());
359+ match defaults.iter_mut().find(|(body, _)| *body == e.defaults) {
174− for e in &self.entries {
360+ Some((_, classes)) => classes.push(&e.class_name),
175− if !e.base.is_empty() {
361+ None => defaults.push((&e.defaults, vec![&e.class_name])),
176− base_pairs.push((e.class_name.as_str(), e.base.as_str()));
362+ }
363+ }
364+ for (body, classes) in defaults {
365+ let classes: Vec<String> = classes.iter().map(|c| format!(".{}", c)).collect();
366+ let selector = format!(":where({})", classes.join(","));
367+ if dev {
368+ kind.push_str(&format!(" {} {{{}}}\n", selector, body));
369+ } else {
370+ kind.push_str(&format!("{}{{{}}}", selector, body));
177 }
371 }
178 }
372 }
179− emit_grouped_rules(&mut css, &base_pairs, "", inner_indent, dev);
180
373
181− // Collect pseudo rules grouped by selector suffix, preserving the order
374+ let mut inside = self.blocks(true, dev);
182− // in which suffixes first appear across entries.
375+ // A function's scoped `@style`, nested under its class
183− let mut pseudo_order: Vec<&str> = Vec::new();
376+ for block in scoped {
184− let mut pseudo_buckets: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
377+ if dev {
185− for e in &self.entries {
378+ inside.push_str(block);
186− for (selector, body) in &e.pseudo {
379+ inside.push('\n');
187− if body.is_empty() {
380+ } else {
188− continue;
381+ inside.push_str(&compact_css(block));
189− }
190− let key = selector.as_str();
191− if !pseudo_buckets.contains_key(key) {
192− pseudo_order.push(key);
193− }
194− pseudo_buckets
195− .entry(key)
196− .or_default()
197− .push((e.class_name.as_str(), body.as_str()));
198 }
382 }
199 }
383 }
200− for selector in &pseudo_order {
384+
201− if let Some(pairs) = pseudo_buckets.get(selector) {
385+ let mut css = String::new();
202− emit_grouped_rules(&mut css, pairs, selector, inner_indent, dev);
386+ for (name, rules) in [
387+ ("hl-kind", kind),
388+ ("hl-inside", inside),
389+ ("htmlang", self.blocks(false, dev)),
390+ ] {
391+ if rules.is_empty() {
392+ continue;
393+ }
394+ if dev {
395+ css.push_str(&format!("@layer {} {{\n{}}}\n", name, rules));
396+ } else {
397+ css.push_str(&format!("@layer {}{{{}}}", name, rules));
203 }
398 }
204 }
399 }
400+ css
401+ }
205
402
206− // Responsive rules grouped by breakpoint
403+ /// The rules of every block of at-rules, in order: those for the
207− for &(bp_name, bp_width) in BREAKPOINTS {
404+ /// elements inside (`inside`: rules under an element prefix, each in
208− let mut bp_pairs: Vec<(&str, &str)> = Vec::new();
405+ /// an `@scope` on its element's class), or the elements' own.
209− for e in &self.entries {
406+ fn blocks(&self, inside: bool, dev: bool) -> String {
210− for (bp, rule_css) in &e.responsive {
407+ let mut css = String::new();
211− if bp == bp_name && !rule_css.is_empty() {
408+ let rules = || {
212− bp_pairs.push((e.class_name.as_str(), rule_css.as_str()));
409+ self.entries.iter().flat_map(move |e| {
213− }
410+ e.rules
214− }
411+ .iter()
412+ .filter(move |(c, _)| c.inside().is_some() == inside)
413+ .map(move |(c, body)| (e, c, body))
414+ })
415+ };
416+ let mut blocks: Vec<&[usize]> = vec![&[]];
417+ let keyed = self.keyed.iter().filter(|_| !inside);
418+ let every = rules()
419+ .map(|(_, c, _)| c.at.as_slice())
420+ .chain(keyed.map(|(at, _, _)| at.as_slice()));
421+ for at in every {
422+ if !blocks.contains(&at) {
423+ blocks.push(at);
215 }
424 }
216− emit_media_block(
425+ }
217− &mut css,
426+ blocks.sort_by_key(|at| at_order(at));
218− &format!("@media (min-width: {})", bp_width),
427+
219− &format!("@media(min-width:{})", bp_width),
428+ for at in blocks {
220− &bp_pairs,
429+ let depth = at.len();
221− "",
430+ // Readable output indents a rule inside `@layer` and inside
222− dev,
431+ // each at-rule but the first
223− );
432+ let indent = if dev {
224− }
433+ " ".repeat(depth.max(1))
225−
434+ } else {
226− // Dark mode rules
435+ String::new()
227− let dark_pairs: Vec<(&str, &str)> = self
436+ };
228− .entries
437+ let mut inner = String::new();
229− .iter()
438+ // Rules keyed on a parent's class come first: they have the
230− .filter(|e| !e.dark.is_empty())
439+ // specificity of one class, like a class rule, so the child's
231− .map(|e| (e.class_name.as_str(), e.dark.as_str()))
440+ // own rules in the block still win
232− .collect();
441+ if !inside {
233− emit_media_block(
442+ let keyed: Vec<(&str, &str)> = self
234− &mut css,
443+ .keyed
235− "@media (prefers-color-scheme: dark)",
444+ .iter()
236− "@media(prefers-color-scheme:dark)",
445+ .filter(|(a, _, _)| a.as_slice() == at)
237− &dark_pairs,
446+ .map(|(_, selector, body)| (selector.as_str(), body.as_str()))
238− "",
447+ .collect();
239− dev,
448+ emit_selector_rules(&mut inner, &keyed, &indent, dev);
240− );
449+ }
241−
450+ // The selector chains of this block, in order
242− // Print rules
451+ let mut chains: Vec<&[String]> = Vec::new();
243− let print_pairs: Vec<(&str, &str)> = self
452+ for (_, condition, _) in rules() {
244− .entries
453+ if condition.at == at && !chains.contains(&condition.selector.as_slice()) {
245− .iter()
454+ chains.push(&condition.selector);
246− .filter(|e| !e.print.is_empty())
455+ }
247− .map(|e| (e.class_name.as_str(), e.print.as_str()))
456+ }
248− .collect();
457+ if inside {
249− emit_media_block(
458+ chains.sort_by_cached_key(|chain| inside_order(chain));
250− &mut css,
459+ // One `@scope` for each root, its rules in the order of
251− "@media print",
460+ // the chains: rules for one element under two roots (`.a`
252− "@media print",
461+ // and `.a:hover`) tie on specificity and proximity, so the
253− &print_pairs,
462+ // later one wins
254− "",
463+ let mut scopes: Vec<(String, Vec<(String, &str)>)> = Vec::new();
255− dev,
464+ for chain in chains {
256− );
465+ let Some(element) = chain
257−
466+ .iter()
258− // Motion safe rules
467+ .position(|p| crate::vocab::element_prefix(p).is_some())
259− let motion_safe_pairs: Vec<(&str, &str)> = self
468+ else {
260− .entries
469+ continue;
261− .iter()
470+ };
262− .filter(|e| !e.motion_safe.is_empty())
471+ for (e, _, body) in
263− .map(|e| (e.class_name.as_str(), e.motion_safe.as_str()))
472+ rules().filter(|(_, c, _)| c.at == at && c.selector == chain)
264− .collect();
473+ {
265− emit_media_block(
474+ let Some((root, target)) = scoped_selector(&e.class_name, chain, element)
266− &mut css,
475+ else {
267− "@media (prefers-reduced-motion: no-preference)",
476+ continue;
268− "@media(prefers-reduced-motion:no-preference)",
477+ };
269− &motion_safe_pairs,
478+ match scopes.iter_mut().find(|(r, _)| *r == root) {
270− "",
479+ Some((_, targets)) => targets.push((target, body)),
271− dev,
480+ None => scopes.push((root, vec![(target, body)])),
272− );
481+ }
273−
274− // Motion reduce rules
275− let motion_reduce_pairs: Vec<(&str, &str)> = self
276− .entries
277− .iter()
278− .filter(|e| !e.motion_reduce.is_empty())
279− .map(|e| (e.class_name.as_str(), e.motion_reduce.as_str()))
280− .collect();
281− emit_media_block(
282− &mut css,
283− "@media (prefers-reduced-motion: reduce)",
284− "@media(prefers-reduced-motion:reduce)",
285− &motion_reduce_pairs,
286− "",
287− dev,
288− );
289−
290− // Landscape rules
291− let landscape_pairs: Vec<(&str, &str)> = self
292− .entries
293− .iter()
294− .filter(|e| !e.landscape.is_empty())
295− .map(|e| (e.class_name.as_str(), e.landscape.as_str()))
296− .collect();
297− emit_media_block(
298− &mut css,
299− "@media (orientation: landscape)",
300− "@media(orientation:landscape)",
301− &landscape_pairs,
302− "",
303− dev,
304− );
305−
306− // Portrait rules
307− let portrait_pairs: Vec<(&str, &str)> = self
308− .entries
309− .iter()
310− .filter(|e| !e.portrait.is_empty())
311− .map(|e| (e.class_name.as_str(), e.portrait.as_str()))
312− .collect();
313− emit_media_block(
314− &mut css,
315− "@media (orientation: portrait)",
316− "@media(orientation:portrait)",
317− &portrait_pairs,
318− "",
319− dev,
320− );
321−
322− // Container query rules grouped by breakpoint
323− for &(bp_name, bp_width) in BREAKPOINTS {
324− let mut cq_pairs: Vec<(&str, &str)> = Vec::new();
325− for e in &self.entries {
326− for (bp, rule_css) in &e.container {
327− if bp == bp_name && !rule_css.is_empty() {
328− cq_pairs.push((e.class_name.as_str(), rule_css.as_str()));
329 }
482 }
330 }
483 }
484+ let inner_indent = if dev {
485+ format!("{} ", indent)
486+ } else {
487+ String::new()
488+ };
489+ for (root, targets) in scopes {
490+ let targets: Vec<(&str, &str)> =
491+ targets.iter().map(|(t, b)| (t.as_str(), *b)).collect();
492+ inner.push_str(&indent);
493+ inner.push_str(&format!("@scope ({})", root));
494+ inner.push_str(if dev { " {\n" } else { "{" });
495+ emit_selector_rules(&mut inner, &targets, &inner_indent, dev);
496+ inner.push_str(&indent);
497+ inner.push_str(if dev { "}\n" } else { "}" });
498+ }
499+ } else {
500+ chains.sort_by_cached_key(|chain| selector_order(chain));
501+ for chain in chains {
502+ let pairs: Vec<(String, &str)> = rules()
503+ .filter(|(_, c, _)| c.at == at && c.selector == chain)
504+ .map(|(e, _, body)| (selector(&e.class_name, chain), body.as_str()))
505+ .collect();
506+ emit_grouped_rules(&mut inner, &pairs, &indent, dev);
507+ }
508+ }
509+ if inner.is_empty() {
510+ continue;
511+ }
512+ // Each at-rule wraps the next, outermost first
513+ for (level, &rank) in at.iter().enumerate().rev() {
514+ let pad = if dev {
515+ " ".repeat(level)
516+ } else {
517+ String::new()
518+ };
519+ inner = if dev {
520+ format!("{pad}{} {{\n{inner}{pad}}}\n", at_rule(rank, true))
521+ } else {
522+ format!("{}{{{inner}}}", at_rule(rank, false))
523+ };
331 }
524 }
332− emit_media_block(
525+ css.push_str(&inner);
333− &mut css,
334− &format!("@container (min-width: {})", bp_width),
335− &format!("@container(min-width:{})", bp_width),
336− &cq_pairs,
337− "",
338− dev,
339− );
340 }
526 }
341−
342− css.push_str(if dev { "}\n" } else { "}" });
343 css
527 css
344 }
528 }
345}
529}
346
530
347−/// Emit CSS rules from `(class_name, body)` pairs, merging identical bodies
531+/// Emit CSS rules from `(selector, body)` pairs, merging identical bodies
348/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
532/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
349/// of each distinct body determines ordering, so output stays deterministic
533/// of each distinct body determines ordering, so output stays deterministic
350−/// across runs. `selector_suffix` is appended to each class (e.g. `":hover"`,
534+/// across runs. `indent` is prepended to each rule line.
351−/// or `""` for plain class rules). `indent` is prepended to each rule line.
535+fn emit_grouped_rules(out: &mut String, pairs: &[(String, &str)], indent: &str, dev: bool) {
352−fn emit_grouped_rules(
353− out: &mut String,
354− pairs: &[(&str, &str)],
355− selector_suffix: &str,
356− indent: &str,
357− dev: bool,
358−) {
359 if pairs.is_empty() {
536 if pairs.is_empty() {
360 return;
537 return;
361 }
538 }
362 // Group in first-occurrence order.
539 // Group in first-occurrence order.
363 let mut order: Vec<&str> = Vec::new();
540 let mut order: Vec<&str> = Vec::new();
364 let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
541 let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
365− for &(name, body) in pairs {
542+ for (selector, body) in pairs {
366 if body.is_empty() {
543 if body.is_empty() {
367 continue;
544 continue;
368 }
545 }
369 if !groups.contains_key(body) {
546 if !groups.contains_key(body) {
370 order.push(body);
547 order.push(body);
371 }
548 }
372− groups.entry(body).or_default().push(name);
549+ groups.entry(body).or_default().push(selector);
373 }
550 }
374 let sp = if dev { " " } else { "" };
551 let sp = if dev { " " } else { "" };
375 let nl = if dev { "\n" } else { "" };
552 let nl = if dev { "\n" } else { "" };
376 for body in &order {
553 for body in &order {
377− let names = &groups[body];
378 out.push_str(indent);
554 out.push_str(indent);
379− for (i, n) in names.iter().enumerate() {
555+ out.push_str(&groups[body].join(","));
380− if i > 0 {
381− out.push(',');
382− }
383− out.push('.');
384− out.push_str(n);
385− out.push_str(selector_suffix);
386− }
387 out.push_str(sp);
556 out.push_str(sp);
388 out.push('{');
557 out.push('{');
389 out.push_str(body);
558 out.push_str(body);
@@ -392,29 +561,29 @@ fn emit_grouped_rules(
392 }
561 }
393}
562}
394
563
395−/// Emit an `@media` / `@container` block containing grouped class rules.
564+/// Emit `(selector, body)` rules in their order, merging a run of rules
396−/// Skips the block entirely if no non-empty bodies are present.
565+/// with the same body into one rule with a selector list (the order
397−fn emit_media_block(
566+/// matters: two rules may apply to one element).
398− out: &mut String,
567+fn emit_selector_rules(out: &mut String, rules: &[(&str, &str)], indent: &str, dev: bool) {
399− header_dev: &str,
568+ let mut i = 0;
400− header_min: &str,
569+ while i < rules.len() {
401− pairs: &[(&str, &str)],
570+ let body = rules[i].1;
402− selector_suffix: &str,
571+ let run = rules[i..].iter().take_while(|(_, b)| *b == body).count();
403− dev: bool,
572+ if !body.is_empty() {
404−) {
573+ let selectors: Vec<&str> = rules[i..i + run].iter().map(|(s, _)| *s).collect();
405− if pairs.iter().all(|(_, body)| body.is_empty()) {
574+ out.push_str(indent);
406− return;
575+ out.push_str(&selectors.join(","));
407− }
576+ if dev {
408− let mut inner = String::new();
577+ out.push(' ');
409− let inner_indent = if dev { " " } else { "" };
578+ }
410− emit_grouped_rules(&mut inner, pairs, selector_suffix, inner_indent, dev);
579+ out.push('{');
411− if inner.is_empty() {
580+ out.push_str(body);
412− return;
581+ out.push('}');
413− }
582+ if dev {
414− if dev {
583+ out.push('\n');
415− out.push_str(&format!("{} {{\n{}}}\n", header_dev, inner));
584+ }
416− } else {
585+ }
417− out.push_str(&format!("{}{{{}}}", header_min, inner));
586+ i += run;
418 }
587 }
419}
588}
420
589
@@ -422,6 +591,14 @@ struct GenContext {
422 dev: bool,
591 dev: bool,
423 depth: usize,
592 depth: usize,
424 has_interactive: bool,
593 has_interactive: bool,
594+ /// Writing inside a text element, at any depth: htmlang's own `<div>`s
595+ /// are `<span>`s there, which text can hold.
596+ in_text: bool,
597+ /// How the element whose children are being written lays them out
598+ flow: Flow,
599+ /// Writing the children of a `@picture`, where `@source`'s leading
600+ /// argument is its `srcset`
601+ in_picture: bool,
425}
602}
426
603
427impl GenContext {
604impl GenContext {
@@ -586,189 +763,135 @@ fn minify_html(html: &str) -> String {
586}
763}
587
764
588fn generate_full_inner(doc: &Document, dev: bool) -> String {
765fn generate_full_inner(doc: &Document, dev: bool) -> String {
766+ // Without `@page` the output is a fragment
767+ let Some(page) = &doc.page else {
768+ return generate_partial_inner(doc, dev);
769+ };
589 let mut styles = StyleCollector::new();
770 let mut styles = StyleCollector::new();
590 let mut ctx = GenContext {
771 let mut ctx = GenContext {
591 dev,
772 dev,
592 depth: 0,
773 depth: 0,
593 has_interactive: false,
774 has_interactive: false,
775+ in_text: false,
776+ flow: Flow::default(),
777+ in_picture: false,
594 };
778 };
595
779
596− let mut body = String::new();
780+ // The page is the root element: `<body>` is a column (the reset makes
597−
781+ // it one, filling the viewport), `@page`'s styles are its class, and
598− for node in &doc.nodes {
782+ // the page's top-level elements are its children
599− generate_node(node, None, &mut body, &mut styles, &mut ctx);
783+ let root = Element {
600− }
784+ kind: ElementKind::El,
601−
785+ attrs: page.styles.clone(),
602− let element_css = build_element_css(doc, &styles, dev);
786+ argument: None,
603−
787+ children: Vec::new(),
604− // Build meta tags string
788+ line_num: 0,
605− let meta_html = if doc.meta_tags.is_empty() {
789+ function: None,
606− String::new()
607− } else {
608− let mut m = String::new();
609− for (name, content) in &doc.meta_tags {
610− if dev {
611− m.push_str(&format!(
612− "<meta name=\"{}\" content=\"{}\">\n",
613− html_escape(name),
614− html_escape(content)
615− ));
616− } else {
617− m.push_str(&format!(
618− "<meta name=\"{}\" content=\"{}\">",
619− html_escape(name),
620− html_escape(content)
621− ));
622− }
623− }
624− m
625 };
790 };
626−
791+ let mut own = Flow::of(&root);
627− // Build OG meta tags
792+ let top = Flow::default();
628− let og_html = if doc.og_tags.is_empty() {
793+ let site = Site {
629− String::new()
794+ kind: &root.kind,
630− } else {
795+ parent: &top,
631− let mut o = String::new();
796+ has_overlay_children: holds_overlays(&doc.nodes),
632− for (property, content) in &doc.og_tags {
797+ inline: false,
633− if dev {
798+ root: true,
634− o.push_str(&format!(
635− "<meta property=\"og:{}\" content=\"{}\">\n",
636− html_escape(property),
637− html_escape(content)
638− ));
639− } else {
640− o.push_str(&format!(
641− "<meta property=\"og:{}\" content=\"{}\">",
642− html_escape(property),
643− html_escape(content)
644− ));
645− }
646− }
647− o
648 };
799 };
800+ let body_class = element_class(&root, &site, &mut styles);
801+ own.class = body_class.clone();
802+ ctx.flow = own;
649
803
650− // Build head blocks string
804+ let mut body = String::new();
651− let head_html = if doc.head_blocks.is_empty() {
805+ generate_children(
652− String::new()
806+ &doc.nodes,
653− } else {
807+ Some(Layout::Column),
654− let mut h = String::new();
808+ &mut body,
655− for block in &doc.head_blocks {
809+ &mut styles,
656− h.push_str(block);
810+ &mut ctx,
657− if dev {
811+ );
658− h.push('\n');
659− }
660− }
661− h
662− };
663
812
664− let lang_attr = match &doc.lang {
813+ let element_css = build_element_css(doc, &styles, dev);
665− Some(lang) => format!(" lang=\"{}\"", html_escape(lang)),
814+ let nl = if dev { "\n" } else { "" };
666− None => String::new(),
667− };
668
815
669− let favicon_html = match &doc.favicon {
816+ let mut meta_html = String::new();
670− Some(path) => {
817+ // A `@meta viewport` of the page's own replaces the usual one
671− // Try to read and inline the favicon
818+ if !doc.meta_tags.iter().any(|(name, _)| name == "viewport") {
672− if let Ok(data) = std::fs::read(path) {
819+ meta_html.push_str(&format!(
673− let mime = if path.ends_with(".ico") {
820+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">{}",
674− "image/x-icon"
821+ nl
675− } else if path.ends_with(".png") {
822+ ));
676− "image/png"
823+ }
677− } else if path.ends_with(".svg") {
824+ meta_html.push_str(&format!(
678− "image/svg+xml"
825+ "<title>{}</title>{}",
679− } else {
826+ html_escape(&page.title),
680− "image/x-icon"
827+ nl
681− };
828+ ));
682− let b64 = base64_encode(&data);
829+ for (name, content) in &doc.meta_tags {
683− if dev {
830+ meta_html.push_str(&format!(
684− format!(
831+ "<meta name=\"{}\" content=\"{}\">{}",
685− "<link rel=\"icon\" href=\"data:{};base64,{}\">\n",
832+ html_escape(name),
686− mime, b64
833+ html_escape(content),
687− )
834+ nl
688− } else {
835+ ));
689− format!("<link rel=\"icon\" href=\"data:{};base64,{}\">", mime, b64)
836+ }
690− }
837+ for (property, content) in &doc.og_tags {
691− } else {
838+ meta_html.push_str(&format!(
692− // Fall back to href
839+ "<meta property=\"og:{}\" content=\"{}\">{}",
693− if dev {
840+ html_escape(property),
694− format!("<link rel=\"icon\" href=\"{}\">\n", html_escape(path))
841+ html_escape(content),
695− } else {
842+ nl
696− format!("<link rel=\"icon\" href=\"{}\">", html_escape(path))
843+ ));
697− }
844+ }
698− }
845+ if let Some(path) = &page.favicon {
846+ meta_html.push_str(&format!(
847+ "<link rel=\"icon\" href=\"{}\">{}",
848+ html_escape(path),
849+ nl
850+ ));
851+ }
852+ for block in &doc.head_blocks {
853+ meta_html.push_str(block);
854+ meta_html.push_str(nl);
855+ }
856+
857+ // `@page`'s HTML attributes go on `<html>`
858+ let mut html_attrs = String::new();
859+ for attr in &page.html_attrs {
860+ html_attrs.push(' ');
861+ html_attrs.push_str(&attr.key);
862+ if attr.html {
863+ html_attrs.push_str("=\"");
864+ html_attrs.push_str(&html_escape(attr.value.as_deref().unwrap_or("")));
865+ html_attrs.push('"');
699 }
866 }
700− None => String::new(),
867+ }
701− };
868+ let mut body_attrs = String::new();
869+ emit_class_attr(&mut body_attrs, body_class.as_deref(), None);
702
870
703 // Focus-visible CSS for interactive elements (accessibility)
871 // Focus-visible CSS for interactive elements (accessibility)
704 let focus_visible_css = if ctx.has_interactive {
872 let focus_visible_css = if ctx.has_interactive {
873+ let focusable = ":where(a,button,input,select,textarea):focus-visible";
705 if dev {
874 if dev {
706− "a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n"
875+ format!(
876+ "{}{} {{ outline: 2px solid currentColor; outline-offset: 2px; }}\n",
877+ OWN_ELEMENTS, focusable
878+ )
707 } else {
879 } else {
708− "a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}"
880+ format!(
881+ "{}{}{{outline:2px solid currentColor;outline-offset:2px}}",
882+ OWN_ELEMENTS, focusable
883+ )
709 }
884 }
710 } else {
885 } else {
711− ""
886+ String::new()
712 };
887 };
888+ let reset_css = reset_css(dev, true, &focus_visible_css);
713
889
714− let reset_css = reset_css(dev, focus_visible_css);
890+ format!(
715−
891+ "<!DOCTYPE html>{nl}<html{html_attrs}>{nl}<head>{nl}<meta charset=\"utf-8\">{nl}\
716− match &doc.page_title {
892+ {meta_html}<style>{nl}{reset_css}{element_css}</style>{nl}</head>{nl}\
717− Some(title) => {
893+ <body{body_attrs}>{nl}{body}</body>{nl}</html>{nl}",
718− if dev {
894+ )
719− format!(
720− "\
721−<!DOCTYPE html>
722−<html{lang_attr}>
723−<head>
724−<meta charset=\"utf-8\">
725−<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
726−<title>{title}</title>
727−{meta_html}{og_html}{favicon_html}{head_html}\
728−<style>
729−{reset_css}{element_css}\
730−</style>
731−</head>
732−<body>
733−{body}\
734−</body>
735−</html>
736−",
737− title = html_escape(title),
738− lang_attr = lang_attr,
739− meta_html = meta_html,
740− favicon_html = favicon_html,
741− head_html = head_html,
742− og_html = og_html,
743− reset_css = reset_css,
744− element_css = element_css,
745− body = body,
746− )
747− } else {
748− format!(
749− "<!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>",
750− title = html_escape(title),
751− lang_attr = lang_attr,
752− meta_html = meta_html,
753− og_html = og_html,
754− favicon_html = favicon_html,
755− head_html = head_html,
756− reset_css = reset_css,
757− element_css = element_css,
758− body = body,
759− )
760− }
761− }
762− None => {
763− if element_css.is_empty() {
764− body
765− } else if dev {
766− format!("<style>\n{}</style>\n{}", element_css, body)
767− } else {
768− format!("<style>{}</style>{}", element_css, body)
769− }
770− }
771− }
772}
895}
773
896
774/// Assemble every CSS block the document needs (font faces, custom
897/// Assemble every CSS block the document needs (font faces, custom
@@ -784,9 +907,9 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
784 root_vars.push((name.clone(), value.clone()));
907 root_vars.push((name.clone(), value.clone()));
785 }
908 }
786
909
787− // Generated rules always go in `@layer htmlang`, so unlayered user CSS
910+ // Generated rules always go in htmlang's layers, so unlayered user CSS
788 // (`@style`, `@raw`) overrides them regardless of specificity.
911 // (`@style`, `@raw`) overrides them regardless of specificity.
789− let styles_css = styles.to_css_formatted(dev);
912+ let styles_css = styles.to_css_formatted(dev, &doc.scoped_css);
790
913
791 // Emit the :root block first so the cascade picks up the custom
914 // Emit the :root block first so the cascade picks up the custom
792 // properties before the class rules consume them.
915 // properties before the class rules consume them.
@@ -817,31 +940,90 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
817 element_css.push_str(block);
940 element_css.push_str(block);
818 element_css.push('\n');
941 element_css.push('\n');
819 } else {
942 } else {
820− let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
943+ element_css.push_str(&compact_css(block));
821− element_css.push_str(&minified);
822 }
944 }
823 }
945 }
824
946
825 element_css
947 element_css
826}
948}
827
949
950+/// An `@style` body on one line: each line trimmed, and the lines joined with
951+/// a space, so a value that goes on over two lines (`margin: 0` then `auto;`)
952+/// keeps its words apart.
953+fn compact_css(block: &str) -> String {
954+ block
955+ .lines()
956+ .map(str::trim)
957+ .filter(|l| !l.is_empty())
958+ .collect::<Vec<_>>()
959+ .join(" ")
960+}
961+
962+/// htmlang's own elements: those with a generated class, or the class of a
963+/// function's scoped `@style`. The reset's rules for elements are kept to
964+/// them, so `@raw` and `@markdown` content, and the page a fragment is put
965+/// into, keep their own.
966+const OWN_ELEMENTS: &str = ":where([class^=\"hl-\"],[class*=\" hl-\"])";
967+
968+/// htmlang's cascade layers, from the one that loses to the one that wins:
969+/// the reset; the defaults of each element's kind (a heading's `margin:0`,
970+/// a column's `display:flex`); the styles an element gets from an
971+/// element prefix on an element around it (`@td:padding 8`) and from a
972+/// function's scoped `@style`; and the element's own styles. So a
973+/// scope's styles win over the defaults, and what an element says about
974+/// itself wins over both. CSS outside any layer (`@style`, `@raw`) wins
975+/// over all of them.
976+const LAYERS: [&str; 4] = ["hl-reset", "hl-kind", "hl-inside", "htmlang"];
977+
828/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
978/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
829−/// every layer, so an unlayered `a{color:inherit}` would override
979+/// every layer, so an unlayered rule here would override the generated
830−/// `@link [color red]`.
980+/// ones. A page resets `box-sizing` everywhere and makes `<body>` a column
831−fn reset_css(dev: bool, focus_visible_css: &str) -> String {
981+/// that fills the window; a fragment, which goes into a page it doesn't
832− let base = if dev {
982+/// own, touches only htmlang's own elements. In both, an element htmlang
833− "*, *::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"
983+/// lays out stays hidden while it is `hidden`, a closed `<dialog>` or a
984+/// popover that isn't showing: its generated `display` would otherwise
985+/// beat the browser's `display: none`.
986+fn reset_css(dev: bool, page: bool, focus_visible_css: &str) -> String {
987+ let own = OWN_ELEMENTS;
988+ let mut rules = Vec::new();
989+ if page {
990+ rules.push(if dev {
991+ "*, *::before, *::after { box-sizing: border-box; }".to_string()
992+ } else {
993+ "*,*::before,*::after{box-sizing:border-box}".to_string()
994+ });
995+ rules.push(if dev {
996+ "body { margin: 0; font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; min-height: 100dvh; }".to_string()
997+ } else {
998+ "body{margin:0;font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;min-height:100dvh}".to_string()
999+ });
834 } else {
1000 } else {
835− "*,*::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}"
1001+ rules.push(if dev {
836− };
1002+ format!("{own}, {own}::before, {own}::after {{ box-sizing: border-box; }}")
837− let rules = format!("{}{}", base, focus_visible_css);
1003+ } else {
1004+ format!("{own},{own}::before,{own}::after{{box-sizing:border-box}}")
1005+ });
1006+ }
1007+ let hidden = "[hidden]:not([hidden=\"until-found\"]),dialog:not([open]):not(:popover-open),\
1008+ [popover]:not(:popover-open):not(dialog[open])";
1009+ rules.push(if dev {
1010+ format!("{own}:where({hidden}) {{ display: none !important; }}")
1011+ } else {
1012+ format!("{own}:where({hidden}){{display:none!important}}")
1013+ });
1014+ let mut rules = rules.join(if dev { "\n" } else { "" });
1015+ if dev {
1016+ rules.push('\n');
1017+ }
1018+ rules.push_str(focus_visible_css);
838 if dev {
1019 if dev {
839 format!(
1020 format!(
840− "@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n",
1021+ "@layer {};\n@layer hl-reset {{\n{}}}\n",
1022+ LAYERS.join(", "),
841 rules
1023 rules
842 )
1024 )
843 } else {
1025 } else {
844− format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
1026+ format!("@layer {};@layer hl-reset{{{}}}", LAYERS.join(","), rules)
845 }
1027 }
846}
1028}
847
1029
@@ -852,14 +1034,20 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
852 dev,
1034 dev,
853 depth: 0,
1035 depth: 0,
854 has_interactive: false,
1036 has_interactive: false,
1037+ in_text: false,
1038+ flow: Flow::default(),
1039+ in_picture: false,
855 };
1040 };
856 let mut body = String::new();
1041 let mut body = String::new();
857
1042
858− for node in &doc.nodes {
1043+ generate_children(&doc.nodes, None, &mut body, &mut styles, &mut ctx);
859− generate_node(node, None, &mut body, &mut styles, &mut ctx);
860− }
861
1044
862− let element_css = build_element_css(doc, &styles, dev);
1045+ let mut element_css = build_element_css(doc, &styles, dev);
1046+ // htmlang's own elements get the reset's rules for elements (which
1047+ // also puts htmlang's layers in order)
1048+ if !styles.entries.is_empty() || !doc.scoped_css.is_empty() {
1049+ element_css.insert_str(0, &reset_css(dev, false, ""));
1050+ }
863
1051
864 if element_css.is_empty() {
1052 if element_css.is_empty() {
865 body
1053 body
@@ -874,20 +1062,20 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
874// Node generation
1062// Node generation
875// ---------------------------------------------------------------------------
1063// ---------------------------------------------------------------------------
876
1064
1065+/// Write `node`, which is inside an element with the layout `parent`
1066+/// (`None` at the top of the page).
877fn generate_node(
1067fn generate_node(
878 node: &Node,
1068 node: &Node,
879− parent_kind: Option<&ElementKind>,
1069+ parent: Option<Layout>,
880 out: &mut String,
1070 out: &mut String,
881 styles: &mut StyleCollector,
1071 styles: &mut StyleCollector,
882 ctx: &mut GenContext,
1072 ctx: &mut GenContext,
883) {
1073) {
884 match node {
1074 match node {
885− Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
1075+ Node::Element(elem) => generate_element(elem, parent, out, styles, ctx),
886 Node::Text(segments) => {
1076 Node::Text(segments) => {
887− let needs_wrap = matches!(parent_kind, Some(ElementKind::Row | ElementKind::El))
1077+ // In a row, column or grid each line of text is a child
888− || parent_kind
1078+ let needs_wrap = parent.is_some_and(Layout::is_container);
889− .and_then(ElementKind::spec)
890− .is_some_and(|spec| spec.wraps_text);
891 if needs_wrap {
1079 if needs_wrap {
892 out.push_str(&ctx.indent());
1080 out.push_str(&ctx.indent());
893 out.push_str("<span>");
1081 out.push_str("<span>");
@@ -906,23 +1094,109 @@ fn generate_node(
906 }
1094 }
907}
1095}
908
1096
909−/// Emit the argument of an element whose argument is an HTML attribute
1097+/// What goes between two things written one after the other in an element
910−/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`).
1098+/// with this layout (`None`: the top of the page): text flows, so its lines
911−fn emit_argument_attr(out: &mut String, elem: &Element) {
1099+/// and children are joined with a space; in HTML's own layout, and at the
912− if let Some(TagSpec {
1100+/// top of the page, two lines of text are separated by a line break (a
913− arg: TagArg::Attr(attr),
1101+/// space, except where whitespace is kept, as in `@pre`).
914− ..
1102+fn separator(layout: Option<Layout>, previous_is_text: bool, next: &Node) -> Option<char> {
915− }) = elem.kind.spec()
1103+ match layout {
916− && let Some(value) = &elem.argument
1104+ Some(Layout::Text) => Some(' '),
917− {
1105+ Some(Layout::Native) | None if previous_is_text && matches!(next, Node::Text(_)) => {
918− out.push(' ');
1106+ Some('\n')
919− out.push_str(attr);
1107+ }
920− out.push_str("=\"");
1108+ _ => None,
921− out.push_str(&html_escape(value));
1109+ }
922− out.push('"');
1110+}
1111+
1112+/// Write the children of an element (or of a `@fragment`, which has no
1113+/// element of its own) laid out as `layout` (`None`: the top of the page).
1114+/// The first line of an element's text is its first child.
1115+fn generate_children(
1116+ children: &[Node],
1117+ layout: Option<Layout>,
1118+ out: &mut String,
1119+ styles: &mut StyleCollector,
1120+ ctx: &mut GenContext,
1121+) {
1122+ generate_run(children, layout, &mut None, out, styles, ctx);
1123+}
1124+
1125+/// [`generate_children`], with `previous` saying whether what was written
1126+/// last was a line of text (`None`: nothing yet). A `@fragment`'s children
1127+/// are written as if they stood in its place, so its lines are separated
1128+/// from the lines around it like any other lines.
1129+fn generate_run(
1130+ children: &[Node],
1131+ layout: Option<Layout>,
1132+ previous: &mut Option<bool>,
1133+ out: &mut String,
1134+ styles: &mut StyleCollector,
1135+ ctx: &mut GenContext,
1136+) {
1137+ for child in children {
1138+ if let Node::Element(elem) = child
1139+ && elem.kind == ElementKind::Fragment
1140+ {
1141+ generate_run(&elem.children, layout, previous, out, styles, ctx);
1142+ continue;
1143+ }
1144+ let start = out.len();
1145+ if let Some(previous_is_text) = *previous
1146+ && let Some(sep) = separator(layout, previous_is_text, child)
1147+ {
1148+ out.push(sep);
1149+ }
1150+ let body = out.len();
1151+ generate_node(child, layout, out, styles, ctx);
1152+ // A child that writes nothing (an empty `@fragment`) takes no
1153+ // separator either, so text never gets two spaces in a row
1154+ if out.len() == body {
1155+ out.truncate(start);
1156+ continue;
1157+ }
1158+ *previous = Some(matches!(child, Node::Text(_)));
923 }
1159 }
924}
1160}
925
1161
1162+/// Emit the element's leading argument as its attribute (`@iframe URL` →
1163+/// `src="URL"`, `@form /submit` → `action="/submit"`, `@link /a` →
1164+/// `href="/a"`), first, before the class. `@image [inline] photo.png` puts
1165+/// the file itself into the page.
1166+fn emit_argument_attr(out: &mut String, elem: &Element, in_picture: bool) {
1167+ let (Some(attr), Some(value)) = (elem.kind.leading_attribute(in_picture), &elem.argument)
1168+ else {
1169+ return;
1170+ };
1171+ // `@image [inline]`'s file is already a `data:` URI (see the parser)
1172+ let value = html_escape(value);
1173+ out.push(' ');
1174+ out.push_str(attr);
1175+ out.push_str("=\"");
1176+ out.push_str(&value);
1177+ out.push('"');
1178+}
1179+
1180+/// A file's bytes as a `data:` URI, its type from its extension.
1181+pub(crate) fn data_uri(path: &str, data: &[u8]) -> String {
1182+ let mime = match path
1183+ .rsplit('.')
1184+ .next()
1185+ .map(str::to_ascii_lowercase)
1186+ .as_deref()
1187+ {
1188+ Some("png") => "image/png",
1189+ Some("jpg" | "jpeg") => "image/jpeg",
1190+ Some("gif") => "image/gif",
1191+ Some("webp") => "image/webp",
1192+ Some("avif") => "image/avif",
1193+ Some("svg") => "image/svg+xml",
1194+ Some("ico") => "image/x-icon",
1195+ _ => "application/octet-stream",
1196+ };
1197+ format!("data:{};base64,{}", mime, base64_encode(data))
1198+}
1199+
926/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1200/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
927/// are emitted with the generated class) and bare booleans like `required`.
1201/// are emitted with the generated class) and bare booleans like `required`.
928fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1202fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
@@ -932,9 +1206,8 @@ fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
932 out.push(' ');
1206 out.push(' ');
933 out.push_str(key);
1207 out.push_str(key);
934 out.push_str("=\"");
1208 out.push_str("=\"");
935− out.push_str(&html_escape(strip_string_quotes(
1209+ // Quoted text has already lost its quotes (see parser.rs)
936− attr.value.as_deref().unwrap_or(""),
1210+ out.push_str(&html_escape(attr.value.as_deref().unwrap_or("")));
937− )));
938 out.push('"');
1211 out.push('"');
939 } else if !attr.html
1212 } else if !attr.html
940 && attr.value.is_none()
1213 && attr.value.is_none()
@@ -946,32 +1219,15 @@ fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
946 }
1219 }
947}
1220}
948
1221
949−/// If the value is wrapped in a single pair of matching quotes (e.g.
950−/// `"Avatar"`), return the inner content. Quotes act as source-level
951−/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute
952−/// values. Multi-quoted values like `"h h" "s m"` are left unchanged —
953−/// those are real string tokens (used e.g. by CSS `grid-template-areas`).
954−fn strip_string_quotes(val: &str) -> &str {
955− let bytes = val.as_bytes();
956− if bytes.len() < 2 {
957− return val;
958− }
959− let first = bytes[0];
960− let last = bytes[bytes.len() - 1];
961− if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
962− let inner = &val[1..val.len() - 1];
963− if !inner.as_bytes().contains(&first) {
964− return inner;
965− }
966− }
967− val
968−}
969−
970// True if any direct child is `@in-front` or `@behind`. Such children render
1222// True if any direct child is `@in-front` or `@behind`. Such children render
971// as absolutely positioned overlays, so the parent automatically becomes a
1223// as absolutely positioned overlays, so the parent automatically becomes a
972// positioning context (position:relative + isolation:isolate).
1224// positioning context (position:relative + isolation:isolate).
973fn has_overlay_children(elem: &Element) -> bool {
1225fn has_overlay_children(elem: &Element) -> bool {
974− elem.children.iter().any(|child| {
1226+ holds_overlays(&elem.children)
1227+}
1228+
1229+fn holds_overlays(children: &[Node]) -> bool {
1230+ children.iter().any(|child| {
975 matches!(
1231 matches!(
976 child,
1232 child,
977 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
1233 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
@@ -981,48 +1237,32 @@ fn has_overlay_children(elem: &Element) -> bool {
981
1237
982fn generate_element(
1238fn generate_element(
983 elem: &Element,
1239 elem: &Element,
984− parent_kind: Option<&ElementKind>,
1240+ parent: Option<Layout>,
985 out: &mut String,
1241 out: &mut String,
986 styles: &mut StyleCollector,
1242 styles: &mut StyleCollector,
987 ctx: &mut GenContext,
1243 ctx: &mut GenContext,
988) {
1244) {
989 // Self-closing elements
1245 // Self-closing elements
990− if elem.kind == ElementKind::Image || elem.kind.spec().is_some_and(|spec| spec.void) {
1246+ if elem.kind.layout() == Layout::Void {
991− generate_self_closing(elem, parent_kind, out, styles, ctx);
1247+ generate_self_closing(elem, parent, out, styles, ctx);
992 return;
1248 return;
993 }
1249 }
994− // @script renders as <script> with raw body content (no HTML escaping)
1250+ // A verbatim element (@script) writes its body as it is, with no HTML
995− if elem.kind == ElementKind::Script {
1251+ // escaping; it isn't shown, so it has no class (its styles are an error)
1252+ if elem.kind.is_verbatim() {
1253+ let tag = elem.kind.spec().map_or("script", |spec| spec.html);
996 out.push_str(&ctx.indent());
1254 out.push_str(&ctx.indent());
997− out.push_str("<script");
1255+ out.push('<');
998− // Pass through src, type, defer, async, etc.
1256+ out.push_str(tag);
999− for attr in &elem.attrs {
1257+ emit_argument_attr(out, elem, false);
1000− let key = attr.key.as_str();
1258+ let (id, class) = extract_id_class(&elem.attrs);
1001− if matches!(
1259+ for (key, value) in [("id", id), ("class", class)] {
1002− key,
1260+ if let Some(value) = value {
1003− "src"
1261+ out.push_str(&format!(" {}=\"{}\"", key, html_escape(&value)));
1004− | "type"
1005− | "defer"
1006− | "async"
1007− | "crossorigin"
1008− | "integrity"
1009− | "nomodule"
1010− | "id"
1011− ) {
1012− if let Some(val) = &attr.value {
1013− out.push(' ');
1014− out.push_str(key);
1015− out.push_str("=\"");
1016− out.push_str(&html_escape(val));
1017− out.push('"');
1018− } else {
1019− out.push(' ');
1020− out.push_str(key);
1021− }
1022 }
1262 }
1023 }
1263 }
1264+ emit_html_attrs(out, &elem.attrs);
1024 out.push('>');
1265 out.push('>');
1025− // Children are raw JS code, not HTML
1026 for child in &elem.children {
1266 for child in &elem.children {
1027 match child {
1267 match child {
1028 Node::Text(segments) => {
1268 Node::Text(segments) => {
@@ -1036,7 +1276,9 @@ fn generate_element(
1036 _ => {}
1276 _ => {}
1037 }
1277 }
1038 }
1278 }
1039− out.push_str("</script>");
1279+ out.push_str("</");
1280+ out.push_str(tag);
1281+ out.push('>');
1040 out.push_str(ctx.nl());
1282 out.push_str(ctx.nl());
1041 return;
1283 return;
1042 }
1284 }
@@ -1047,13 +1289,13 @@ fn generate_element(
1047 return;
1289 return;
1048 }
1290 }
1049 if elem.kind == ElementKind::Fragment {
1291 if elem.kind == ElementKind::Fragment {
1050− // Render children without a wrapper element
1292+ // Render children without a wrapper element, as if they were
1051− for child in &elem.children {
1293+ // written where the fragment is
1052− generate_node(child, parent_kind, out, styles, ctx);
1294+ generate_children(&elem.children, parent, out, styles, ctx);
1053− }
1054 return;
1295 return;
1055 }
1296 }
1056
1297
1298+ let mut own = Flow::of(elem);
1057 let tag = match &elem.kind {
1299 let tag = match &elem.kind {
1058 ElementKind::Row | ElementKind::El => "div",
1300 ElementKind::Row | ElementKind::El => "div",
1059 ElementKind::Text => "span",
1301 ElementKind::Text => "span",
@@ -1062,6 +1304,11 @@ fn generate_element(
1062 ElementKind::Tag(spec) => spec.html,
1304 ElementKind::Tag(spec) => spec.html,
1063 _ => "",
1305 _ => "",
1064 };
1306 };
1307+ // Inside text, htmlang's own `<div>` is a `<span>`, which text can
1308+ // hold (directly in text, a row, column or grid is also laid out
1309+ // inline: see `Site::inline`)
1310+ let in_text = ctx.in_text || parent == Some(Layout::Text);
1311+ let tag = if in_text && tag == "div" { "span" } else { tag };
1065 let kind_label = elem.kind.name();
1312 let kind_label = elem.kind.name();
1066
1313
1067 // Track interactive elements for focus-visible CSS
1314 // Track interactive elements for focus-visible CSS
@@ -1072,14 +1319,8 @@ fn generate_element(
1072 }
1319 }
1073
1320
1074 // Compute CSS for each state and get a class name
1321 // Compute CSS for each state and get a class name
1075− let overlay_children = has_overlay_children(elem);
1322+ let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1076− let gen_class = compute_class(
1323+ own.class = gen_class.clone();
1077− &elem.attrs,
1078− &elem.kind,
1079− parent_kind,
1080− styles,
1081− overlay_children,
1082− );
1083 let (id, user_class) = extract_id_class(&elem.attrs);
1324 let (id, user_class) = extract_id_class(&elem.attrs);
1084
1325
1085 if ctx.dev && elem.line_num > 0 {
1326 if ctx.dev && elem.line_num > 0 {
@@ -1093,15 +1334,7 @@ fn generate_element(
1093 out.push('<');
1334 out.push('<');
1094 out.push_str(tag);
1335 out.push_str(tag);
1095
1336
1096− if elem.kind == ElementKind::Link
1337+ emit_argument_attr(out, elem, ctx.in_picture);
1097− && let Some(url) = &elem.argument
1098− {
1099− out.push_str(" href=\"");
1100− out.push_str(&html_escape(url));
1101− out.push('"');
1102− }
1103−
1104− emit_argument_attr(out, elem);
1105
1338
1106 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1339 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1107
1340
@@ -1122,28 +1355,27 @@ fn generate_element(
1122 }
1355 }
1123
1356
1124 out.push('>');
1357 out.push('>');
1358+ // In `<pre>` and `<textarea>` every space and line break shows, so
1359+ // readable (dev) output adds none there: a code sample's lines stay
1360+ // exactly as written
1361+ let outer_dev = ctx.dev;
1362+ ctx.dev &= !matches!(tag, "pre" | "textarea");
1125 out.push_str(ctx.nl());
1363 out.push_str(ctx.nl());
1126
1364
1127− // Inline text argument
1365+ let layout = elem.kind.layout();
1128− if renders_argument_as_text(&elem.kind)
1129− && let Some(text) = &elem.argument
1130− {
1131− out.push_str(&html_escape(text));
1132− }
1133−
1134− // Children
1135 ctx.depth += 1;
1366 ctx.depth += 1;
1136− let is_paragraph =
1367+ let outer_in_text = ctx.in_text;
1137− elem.kind == ElementKind::Paragraph || elem.kind.spec().is_some_and(|spec| spec.inline);
1368+ ctx.in_text = in_text || layout == Layout::Text;
1138− for (i, child) in elem.children.iter().enumerate() {
1369+ let outer_flow = std::mem::replace(&mut ctx.flow, own);
1139− generate_node(child, Some(&elem.kind), out, styles, ctx);
1370+ let outer_picture = std::mem::replace(&mut ctx.in_picture, elem.kind.is_tag("picture"));
1140− if is_paragraph && i < elem.children.len() - 1 {
1371+ generate_children(&elem.children, Some(layout), out, styles, ctx);
1141− out.push(' ');
1372+ ctx.in_picture = outer_picture;
1142− }
1373+ ctx.flow = outer_flow;
1143− }
1374+ ctx.in_text = outer_in_text;
1144 ctx.depth -= 1;
1375 ctx.depth -= 1;
1145
1376
1146 out.push_str(&ctx.indent());
1377 out.push_str(&ctx.indent());
1378+ ctx.dev = outer_dev;
1147 out.push_str("</");
1379 out.push_str("</");
1148 out.push_str(tag);
1380 out.push_str(tag);
1149 out.push('>');
1381 out.push('>');
@@ -1152,12 +1384,12 @@ fn generate_element(
1152
1384
1153fn generate_self_closing(
1385fn generate_self_closing(
1154 elem: &Element,
1386 elem: &Element,
1155− parent_kind: Option<&ElementKind>,
1387+ parent: Option<Layout>,
1156 out: &mut String,
1388 out: &mut String,
1157 styles: &mut StyleCollector,
1389 styles: &mut StyleCollector,
1158 ctx: &mut GenContext,
1390 ctx: &mut GenContext,
1159) {
1391) {
1160− let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1392+ let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1161 let (id, user_class) = extract_id_class(&elem.attrs);
1393 let (id, user_class) = extract_id_class(&elem.attrs);
1162
1394
1163 let (tag, kind_label) = match &elem.kind {
1395 let (tag, kind_label) = match &elem.kind {
@@ -1176,44 +1408,7 @@ fn generate_self_closing(
1176 out.push_str(&ctx.indent());
1408 out.push_str(&ctx.indent());
1177 out.push('<');
1409 out.push('<');
1178 out.push_str(tag);
1410 out.push_str(tag);
1179− emit_argument_attr(out, elem);
1411+ emit_argument_attr(out, elem, ctx.in_picture);
1180−
1181− // Image src (with optional non-SVG base64 inlining)
1182− if elem.kind == ElementKind::Image {
1183− let src = elem.argument.as_deref().unwrap_or("");
1184− let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
1185− if is_inline && !src.is_empty() && !src.ends_with(".svg") {
1186− let mime = if src.ends_with(".png") {
1187− "image/png"
1188− } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
1189− "image/jpeg"
1190− } else if src.ends_with(".gif") {
1191− "image/gif"
1192− } else if src.ends_with(".webp") {
1193− "image/webp"
1194− } else if src.ends_with(".avif") {
1195− "image/avif"
1196− } else {
1197− "application/octet-stream"
1198− };
1199− if let Ok(data) = std::fs::read(src) {
1200− let b64 = base64_encode(&data);
1201− out.push_str(" src=\"data:");
1202− out.push_str(mime);
1203− out.push_str(";base64,");
1204− out.push_str(&b64);
1205− out.push('"');
1206− } else {
1207− out.push_str(" src=\"");
1208− out.push_str(&html_escape(src));
1209− out.push('"');
1210− }
1211− } else {
1212− out.push_str(" src=\"");
1213− out.push_str(&html_escape(src));
1214− out.push('"');
1215− }
1216− }
1217
1412
1218 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1413 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1219
1414
@@ -1248,7 +1443,10 @@ fn generate_text_segments(
1248 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1443 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1249 TextSegment::Inline(elem) => {
1444 TextSegment::Inline(elem) => {
1250 let mut buf = String::new();
1445 let mut buf = String::new();
1251− generate_element(elem, None, &mut buf, styles, ctx);
1446+ // An element inside a line of text is in text
1447+ let outer_flow = std::mem::take(&mut ctx.flow);
1448+ generate_element(elem, Some(Layout::Text), &mut buf, styles, ctx);
1449+ ctx.flow = outer_flow;
1252 out.push_str(buf.trim_end());
1450 out.push_str(buf.trim_end());
1253 }
1451 }
1254 }
1452 }
@@ -1261,146 +1459,97 @@ fn generate_text_segments(
1261
1459
1262fn compute_class(
1460fn compute_class(
1263 attrs: &[Attribute],
1461 attrs: &[Attribute],
1264− kind: &ElementKind,
1462+ site: &Site,
1265− parent_kind: Option<&ElementKind>,
1266 styles: &mut StyleCollector,
1463 styles: &mut StyleCollector,
1267− has_overlay_children: bool,
1464+ distinct: String,
1268) -> Option<String> {
1465) -> Option<String> {
1269− let base = attrs_to_css(attrs, "", kind, parent_kind, has_overlay_children);
1466+ // Every condition an attribute is under, and none
1270−
1467+ let mut conditions = vec![Condition::default()];
1271− // Collect pseudo-state overrides
1468+ for attr in attrs.iter().filter(|a| !a.html) {
1272− let mut pseudo = Vec::new();
1469+ let (condition, _) = Condition::of(&attr.key);
1273− for &(prefix, selector) in crate::vocab::PSEUDO_PREFIXES {
1470+ if !conditions.contains(&condition) {
1274− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1471+ conditions.push(condition);
1275− if !css.is_empty() {
1276− pseudo.push((selector.to_string(), css));
1277− }
1278− }
1279−
1280− // Collect nth:EXPR: dynamic pseudo selectors
1281− let mut nth_prefixes: Vec<String> = Vec::new();
1282− for attr in attrs {
1283− if attr.key.starts_with("nth:") {
1284− let rest = &attr.key[4..];
1285− if let Some(colon_pos) = rest.find(':') {
1286− let prefix = format!("nth:{}:", &rest[..colon_pos]);
1287− if !nth_prefixes.contains(&prefix) {
1288− nth_prefixes.push(prefix);
1289− }
1290− }
1291− }
1292− }
1293− for prefix in &nth_prefixes {
1294− let expr = &prefix[4..prefix.len() - 1];
1295− let selector = format!(":nth-child({})", expr);
1296− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1297− if !css.is_empty() {
1298− pseudo.push((selector, css));
1299 }
1472 }
1300 }
1473 }
1474+ // Dedupe: if a property is declared twice within a single rule, keep only
1475+ // the last occurrence.
1476+ let rules: Vec<(Condition, String)> = conditions
1477+ .into_iter()
1478+ .map(|condition| {
1479+ let css = dedupe_declarations(&attrs_to_css(attrs, &condition, site));
1480+ (condition, css)
1481+ })
1482+ .collect();
1483+ // The kind's defaults, less what the element sets itself (a user
1484+ // `[list-style disc]` wins over the default `list-style:none` anyway,
1485+ // so there is no need to ship both)
1486+ let own = rules
1487+ .iter()
1488+ .find(|(condition, _)| *condition == Condition::default())
1489+ .map_or("", |(_, css)| css.as_str());
1490+ let own: Vec<&str> = declarations(own).map(|(name, _)| name).collect();
1491+ let defaults: String = declarations(&dedupe_declarations(&default_css(site)))
1492+ .filter(|(name, _)| !own.contains(name))
1493+ .map(|(_, declaration)| declaration)
1494+ .collect();
1495+ styles.get_class(defaults, rules, distinct)
1496+}
1301
1497
1302− // Collect has(...): dynamic pseudo selectors
1498+/// The defaults of an element's kind where it is: an overlay's positioning
1303− let mut has_prefixes: Vec<String> = Vec::new();
1499+/// context, its layout, and its kind's own CSS (a heading's `margin:0`).
1304− for attr in attrs {
1500+fn default_css(site: &Site) -> String {
1305− if attr.key.starts_with("has(")
1501+ let mut css = String::new();
1306− && let Some(close) = attr.key.find("):")
1502+ // Elements with @in-front / @behind children become positioning
1307− {
1503+ // contexts
1308− let prefix = format!("{}:", &attr.key[..close + 1]);
1504+ if site.has_overlay_children {
1309− if !has_prefixes.contains(&prefix) {
1505+ css.push_str("position:relative;isolation:isolate;");
1310− has_prefixes.push(prefix);
1311− }
1312− }
1313 }
1506 }
1314− for prefix in &has_prefixes {
1507+ if !site.root {
1315− let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
1508+ css.push_str(layout_css(site.kind.layout(), site.inline));
1316− let selector = format!(":has({})", inner);
1317− let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1318− if !css.is_empty() {
1319− pseudo.push((selector, css));
1320− }
1321 }
1509 }
1510+ css.push_str(site.kind.css());
1511+ css
1512+}
1322
1513
1323− // Collect responsive overrides
1514+/// The declarations of a rule body, as (property, `property:value;`), for
1324− let mut responsive = Vec::new();
1515+/// bodies htmlang wrote: every declaration ends in `;`, and a `;` inside
1325− for &(bp_name, _) in BREAKPOINTS {
1516+/// quotes or parentheses doesn't end one.
1326− let prefix = format!("{}:", bp_name);
1517+fn declarations(css: &str) -> impl Iterator<Item = (&str, &str)> {
1327− let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1518+ let mut rest = css;
1328− if !css.is_empty() {
1519+ std::iter::from_fn(move || {
1329− responsive.push((bp_name.to_string(), css));
1520+ if rest.is_empty() {
1521+ return None;
1330 }
1522 }
1331− }
1523+ let (mut depth, mut quote, mut escaped) = (0i32, None, false);
1332−
1524+ let mut end = rest.len();
1333− // Collect container query overrides
1525+ for (i, c) in rest.char_indices() {
1334− let mut container = Vec::new();
1526+ match c {
1335− for &(bp_name, _) in BREAKPOINTS {
1527+ _ if escaped => escaped = false,
1336− let prefix = format!("cq-{}:", bp_name);
1528+ '\\' => escaped = true,
1337− let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1529+ c if Some(c) == quote => quote = None,
1338− if !css.is_empty() {
1530+ _ if quote.is_some() => {}
1339− container.push((bp_name.to_string(), css));
1531+ '"' | '\'' => quote = Some(c),
1532+ '(' => depth += 1,
1533+ ')' => depth -= 1,
1534+ ';' if depth == 0 => {
1535+ end = i + 1;
1536+ break;
1537+ }
1538+ _ => {}
1539+ }
1340 }
1540 }
1341− }
1541+ let (declaration, after) = rest.split_at(end);
1342−
1542+ rest = after;
1343− let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
1543+ let name = declaration.split(':').next().unwrap_or("").trim();
1344− let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
1544+ Some((name, declaration))
1345− let motion_safe = attrs_to_css(
1545+ })
1346− attrs,
1347− "motion-safe:",
1348− kind,
1349− parent_kind,
1350− has_overlay_children,
1351− );
1352− let motion_reduce = attrs_to_css(
1353− attrs,
1354− "motion-reduce:",
1355− kind,
1356− parent_kind,
1357− has_overlay_children,
1358− );
1359− let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
1360− let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
1361−
1362− // Dedupe: if a property is declared twice within a single rule, keep only
1363− // the last occurrence (element-kind defaults are written before
1364− // user attributes, so a user [list-style disc] correctly overrides the
1365− // default list-style:none, and we don't need to ship both).
1366− let base = dedupe_declarations(&base);
1367− let pseudo: Vec<(String, String)> = pseudo
1368− .into_iter()
1369− .map(|(sel, css)| (sel, dedupe_declarations(&css)))
1370− .collect();
1371− let responsive: Vec<(String, String)> = responsive
1372− .into_iter()
1373− .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1374− .collect();
1375− let dark = dedupe_declarations(&dark);
1376− let print = dedupe_declarations(&print);
1377− let motion_safe = dedupe_declarations(&motion_safe);
1378− let motion_reduce = dedupe_declarations(&motion_reduce);
1379− let landscape = dedupe_declarations(&landscape);
1380− let portrait = dedupe_declarations(&portrait);
1381− let container: Vec<(String, String)> = container
1382− .into_iter()
1383− .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1384− .collect();
1385−
1386− styles.get_class(
1387− base,
1388− pseudo,
1389− responsive,
1390− dark,
1391− print,
1392− motion_safe,
1393− motion_reduce,
1394− landscape,
1395− portrait,
1396− container,
1397− )
1398}
1546}
1399
1547
1400/// Dedupe CSS declarations within a single rule body: for any property
1548/// Dedupe CSS declarations within a single rule body: for any property
1401/// declared more than once, keep only the last occurrence. Unparseable
1549/// declared more than once, keep only the last occurrence. Unparseable
1402−/// segments (no `:`) are preserved as-is. Semicolons inside parentheses are
1550+/// segments (no `:`) are preserved as-is. Semicolons inside parentheses or
1403−/// treated as part of a value, not as declaration separators.
1551+/// quoted strings are treated as part of a value, not as declaration
1552+/// separators.
1404fn dedupe_declarations(css: &str) -> String {
1553fn dedupe_declarations(css: &str) -> String {
1405 if css.is_empty() {
1554 if css.is_empty() {
1406 return String::new();
1555 return String::new();
@@ -1415,8 +1564,26 @@ fn dedupe_declarations(css: &str) -> String {
1415 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1564 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1416 let mut current = String::new();
1565 let mut current = String::new();
1417 let mut depth: i32 = 0;
1566 let mut depth: i32 = 0;
1567+ // A quoted string's `;` (`content "a;b"`) is text; a backslash escapes
1568+ // the character after it
1569+ let mut quote: Option<char> = None;
1570+ let mut escaped = false;
1418 for ch in css.chars() {
1571 for ch in css.chars() {
1419− if ch == '(' {
1572+ if escaped {
1573+ escaped = false;
1574+ current.push(ch);
1575+ } else if ch == '\\' {
1576+ escaped = true;
1577+ current.push(ch);
1578+ } else if let Some(q) = quote {
1579+ if ch == q {
1580+ quote = None;
1581+ }
1582+ current.push(ch);
1583+ } else if ch == '"' || ch == '\'' {
1584+ quote = Some(ch);
1585+ current.push(ch);
1586+ } else if ch == '(' {
1420 depth += 1;
1587 depth += 1;
1421 current.push(ch);
1588 current.push(ch);
1422 } else if ch == ')' {
1589 } else if ch == ')' {
@@ -1498,463 +1665,553 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
1498// Attribute → CSS mapping
1665// Attribute → CSS mapping
1499// ---------------------------------------------------------------------------
1666// ---------------------------------------------------------------------------
1500
1667
1501−/// (htmlang prefix, CSS selector suffix)
1668+/// Where an element is written, which its CSS depends on.
1502−/// `display:flex;flex-direction:column;` — base layout for `@el` and every
1669+struct Site<'a> {
1503−/// semantic wrapper that behaves like a column.
1670+ kind: &'a ElementKind,
1504−const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
1671+ /// How its parent lays out its children: its `width fill`, `height
1505−
1672+ /// fill` and `shrink` compile against it.
1506−fn attrs_to_css(
1673+ parent: &'a Flow,
1507− attrs: &[Attribute],
1674+ /// It has `@in-front` / `@behind` children.
1508− state_prefix: &str,
1509− kind: &ElementKind,
1510− parent_kind: Option<&ElementKind>,
1511 has_overlay_children: bool,
1675 has_overlay_children: bool,
1512−) -> String {
1676+ /// A row, column or grid inside text, laid out inline.
1513− let mut css = String::new();
1677+ inline: bool,
1678+ /// The page's `<body>`, which the reset already makes a column.
1679+ root: bool,
1680+}
1514
1681
1515− // Base element styles only for the default (non-state) pass
1682+impl Site<'_> {
1516− if state_prefix.is_empty() {
1683+ /// Where an element in a parent with the layout `layout` and the flow
1517− // Elements with @in-front / @behind children become positioning
1684+ /// `parent` is.
1518− // contexts. Pushed before user attrs so an explicit `position` wins
1685+ fn new<'a>(elem: &'a Element, layout: Option<Layout>, parent: &'a Flow) -> Site<'a> {
1519− // via dedupe (only the last declaration of a property is kept).
1686+ Site {
1520− if has_overlay_children {
1687+ kind: &elem.kind,
1521− css.push_str("position:relative;isolation:isolate;");
1688+ parent,
1522− }
1689+ has_overlay_children: has_overlay_children(elem),
1523− match kind {
1690+ inline: layout == Some(Layout::Text) && elem.kind.layout().is_container(),
1524− ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
1691+ root: false,
1525− ElementKind::El => css.push_str(FLEX_COLUMN),
1526− ElementKind::Paragraph => css.push_str("margin:0;"),
1527− ElementKind::Tag(spec) => css.push_str(spec.css),
1528− _ => {}
1529 }
1692 }
1530 }
1693 }
1694+}
1531
1695
1532− for attr in attrs {
1696+// ---------------------------------------------------------------------------
1533− if attr.html {
1697+// Direction: what a child's `fill` and `shrink` compile against
1534− continue;
1698+// ---------------------------------------------------------------------------
1535− }
1536− // Determine the effective key for this pass
1537− let effective_key = if state_prefix.is_empty() {
1538− if crate::vocab::is_prefixed(&attr.key) {
1539− continue;
1540− }
1541− attr.key.as_str()
1542− } else {
1543− match attr.key.strip_prefix(state_prefix) {
1544− Some(k) => k,
1545− None => continue,
1546− }
1547− };
1548
1699
1549− let val = attr.value.as_deref();
1700+/// The way a flex row or column lays out its children.
1701+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1702+enum Axis {
1703+ Row,
1704+ Column,
1705+}
1550
1706
1551− match effective_key {
1707+impl Axis {
1552− // Layout
1708+ /// The direction a `flex-direction` or `flex-flow` value sets, if it
1553− "spacing" | "gap" => {
1709+ /// names one (not `var(--dir)`, which only the browser knows).
1554− if let Some(v) = val {
1710+ fn of_value(value: &str) -> Option<Axis> {
1555− push_css(&mut css, "gap", &css_px(v));
1711+ value.split_whitespace().find_map(|word| match word {
1556− }
1712+ "row" | "row-reverse" => Some(Axis::Row),
1557− }
1713+ "column" | "column-reverse" => Some(Axis::Column),
1558− "padding" => {
1714+ _ => None,
1559− if let Some(v) = val {
1715+ })
1560− push_css(&mut css, "padding", &css_px_multi(v));
1716+ }
1561− }
1717+}
1562− }
1563− "padding-top" => {
1564− if let Some(v) = val {
1565− push_css(&mut css, "padding-top", &css_px(v));
1566− }
1567− }
1568− "padding-bottom" => {
1569− if let Some(v) = val {
1570− push_css(&mut css, "padding-bottom", &css_px(v));
1571− }
1572− }
1573− "padding-left" => {
1574− if let Some(v) = val {
1575− push_css(&mut css, "padding-left", &css_px(v));
1576− }
1577− }
1578− "padding-right" => {
1579− if let Some(v) = val {
1580− push_css(&mut css, "padding-right", &css_px(v));
1581− }
1582− }
1583
1718
1584− // Sizing
1719+/// How an element lays out its children, as its CSS sets it: the
1585− "width" => {
1720+/// direction its children's `width`/`height` `fill` and `shrink` compile
1586− if let Some(v) = val {
1721+/// against. That is the direction of `@row` or of a column, or the one its
1587− match v {
1722+/// own `flex-direction` (or `flex-flow`) sets, with or without media or
1588− "fill" => match parent_kind {
1723+/// container prefixes. Like elm-ui's `.r > .wf`, the rules for a direction
1589− Some(ElementKind::Row) => {
1724+/// set under a prefix are keyed on the element's class: `@media (...) {
1590− push_css(&mut css, "flex", "1");
1725+/// :where(.a)>.b {...} }`.
1591− push_css(&mut css, "min-width", "0");
1726+#[derive(Clone, Default)]
1592− }
1727+struct Flow {
1593− _ => push_css(&mut css, "width", "100%"),
1728+ /// Without a prefix; `None` when the element isn't a flex row or column
1594− },
1729+ /// (a grid, text, HTML's own layout, the top of the page)
1595− "shrink" => push_css(&mut css, "flex-shrink", "0"),
1730+ base: Option<Axis>,
1596− _ => push_css(&mut css, "width", &css_px(v)),
1731+ /// The direction under each chain of at-rule prefixes that sets one, in
1597− }
1732+ /// the order their blocks are written
1598− }
1733+ changes: Vec<(Vec<usize>, Axis)>,
1599− }
1734+ /// The element's generated class, which the rules for the changes are
1600− "height" => {
1735+ /// keyed on
1601− if let Some(v) = val {
1736+ class: Option<String>,
1602− match v {
1737+}
1603− "fill" => match parent_kind {
1738+
1604− Some(parent) if parent.is_column() => {
1739+impl Flow {
1605− push_css(&mut css, "flex", "1");
1740+ fn of(elem: &Element) -> Flow {
1606− push_css(&mut css, "min-height", "0");
1741+ let mut base = match elem.kind.layout() {
1607− }
1742+ Layout::Row => Axis::Row,
1608− _ => push_css(&mut css, "height", "100%"),
1743+ Layout::Column => Axis::Column,
1609− },
1744+ _ => return Flow::default(),
1610− "shrink" => push_css(&mut css, "flex-shrink", "0"),
1745+ };
1611− _ => push_css(&mut css, "height", &css_px(v)),
1746+ let mut changes: Vec<(Vec<usize>, Axis)> = Vec::new();
1612− }
1747+ for attr in elem.attrs.iter().filter(|a| !a.html) {
1613− }
1748+ let (condition, name) = Condition::of(&attr.key);
1614− }
1749+ if !matches!(name, "flex-direction" | "flex-flow") || !condition.selector.is_empty() {
1615− "min-width" => {
1750+ continue;
1616− if let Some(v) = val {
1617− push_css(&mut css, "min-width", &css_px(v));
1618− }
1619− }
1620− "max-width" => {
1621− if let Some(v) = val {
1622− push_css(&mut css, "max-width", &css_px(v));
1623− }
1624− }
1625− "min-height" => {
1626− if let Some(v) = val {
1627− push_css(&mut css, "min-height", &css_px(v));
1628− }
1629 }
1751 }
1630− "max-height" => {
1752+ let Some(axis) = attr.value.as_deref().and_then(Axis::of_value) else {
1631− if let Some(v) = val {
1753+ continue;
1632− push_css(&mut css, "max-height", &css_px(v));
1754+ };
1633− }
1755+ if condition.at.is_empty() {
1756+ base = axis;
1757+ } else {
1758+ // The later one wins, as in the CSS
1759+ changes.retain(|(at, _)| *at != condition.at);
1760+ changes.push((condition.at, axis));
1634 }
1761 }
1762+ }
1763+ changes.sort_by_key(|(at, _)| at_order(at));
1764+ Flow {
1765+ base: Some(base),
1766+ changes,
1767+ class: None,
1768+ }
1769+ }
1635
1770
1636− // Alignment
1771+ /// The direction under the at-rules `at`: the latest one set among
1637− "center-x" => match parent_kind {
1772+ /// the conditions that hold there (`md:` holds at `lg:`), else the one
1638− Some(parent) if parent.is_column() => {
1773+ /// without a prefix.
1639− push_css(&mut css, "align-self", "center");
1774+ fn at(&self, at: &[usize]) -> Option<Axis> {
1640− }
1775+ let base = self.base?;
1641− _ => {
1776+ let here = Condition::at_only(at);
1642− push_css(&mut css, "margin-left", "auto");
1777+ let changed = self
1643− push_css(&mut css, "margin-right", "auto");
1778+ .changes
1644− }
1779+ .iter()
1645− },
1780+ .rev()
1646− "center-y" => match parent_kind {
1781+ .find(|(chain, _)| Condition::at_only(chain).holds_at(&here))
1647− Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
1782+ .map(|&(_, axis)| axis);
1648− _ => {
1783+ Some(changed.unwrap_or(base))
1649− push_css(&mut css, "margin-top", "auto");
1784+ }
1650− push_css(&mut css, "margin-bottom", "auto");
1785+}
1651− }
1652− },
1653− "align-left" => match parent_kind {
1654− Some(parent) if parent.is_column() => {
1655− push_css(&mut css, "align-self", "flex-start");
1656− }
1657− _ => push_css(&mut css, "margin-right", "auto"),
1658− },
1659− "align-right" => match parent_kind {
1660− Some(parent) if parent.is_column() => {
1661− push_css(&mut css, "align-self", "flex-end");
1662− }
1663− _ => push_css(&mut css, "margin-left", "auto"),
1664− },
1665− "align-top" => match parent_kind {
1666− Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
1667− _ => push_css(&mut css, "margin-bottom", "auto"),
1668− },
1669− "align-bottom" => match parent_kind {
1670− Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
1671− _ => push_css(&mut css, "margin-top", "auto"),
1672− },
1673−
1674− // Typography
1675− "letter-spacing" => {
1676− if let Some(v) = val {
1677− push_css(&mut css, "letter-spacing", &css_px(v));
1678− }
1679− }
1680
1786
1681− // Overflow & positioning
1787+/// `width` or `height`.
1682− "top" => {
1788+#[derive(Clone, Copy, PartialEq, Eq)]
1683− if let Some(v) = val {
1789+enum Dim {
1684− push_css(&mut css, "top", &css_px(v));
1790+ Width,
1685− }
1791+ Height,
1686− }
1792+}
1687− "right" => {
1688− if let Some(v) = val {
1689− push_css(&mut css, "right", &css_px(v));
1690− }
1691− }
1692− "bottom" => {
1693− if let Some(v) = val {
1694− push_css(&mut css, "bottom", &css_px(v));
1695− }
1696− }
1697− "left" => {
1698− if let Some(v) = val {
1699− push_css(&mut css, "left", &css_px(v));
1700− }
1701− }
1702
1793
1703− // Effects
1794+/// What a `width` or `height` says.
1795+#[derive(Clone, Copy, PartialEq, Eq)]
1796+enum Size {
1797+ Fill,
1798+ Shrink,
1799+ /// A size of its own (`width 300`)
1800+ Set,
1801+}
1704
1802
1705− // Flow
1803+impl Size {
1706− "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
1804+ fn of(value: &str) -> Size {
1805+ match value.trim() {
1806+ "fill" => Size::Fill,
1807+ "shrink" => Size::Shrink,
1808+ _ => Size::Set,
1809+ }
1810+ }
1811+}
1707
1812
1708− // Grid
1813+/// The declarations `fill` and `shrink` make in a parent laid out along
1709− "grid-cols" => {
1814+/// `axis` (`None`: not a flex row or column). Along the parent's direction,
1710− if let Some(v) = val {
1815+/// `fill` takes the remaining space and `shrink` keeps the content's size;
1711− if let Ok(n) = v.parse::<u32>() {
1816+/// across it (or outside flex), `fill` is the full size and `shrink` fits
1712− push_css(
1817+/// the content.
1713− &mut css,
1818+fn sizing(dim: Dim, size: Size, axis: Option<Axis>) -> &'static [(&'static str, &'static str)] {
1714− "grid-template-columns",
1819+ match (dim, size, axis) {
1715− &format!("repeat({},1fr)", n),
1820+ (_, Size::Set, _) => &[],
1716− );
1821+ (Dim::Width, Size::Fill, Some(Axis::Row)) => &[("flex", "1"), ("min-width", "0")],
1717− } else {
1822+ (Dim::Width, Size::Fill, _) => &[("width", "100%")],
1718− push_css(&mut css, "grid-template-columns", v);
1823+ (Dim::Width, Size::Shrink, Some(Axis::Row)) => &[("flex-shrink", "0")],
1719− }
1824+ (Dim::Width, Size::Shrink, _) => &[("width", "fit-content")],
1720− }
1825+ (Dim::Height, Size::Fill, Some(Axis::Column)) => &[("flex", "1"), ("min-height", "0")],
1721− }
1826+ (Dim::Height, Size::Fill, _) => &[("height", "100%")],
1722− "grid-rows" => {
1827+ (Dim::Height, Size::Shrink, Some(Axis::Column)) => &[("flex-shrink", "0")],
1723− if let Some(v) = val {
1828+ (Dim::Height, Size::Shrink, _) => &[("height", "fit-content")],
1724− if let Ok(n) = v.parse::<u32>() {
1829+ }
1725− push_css(
1830+}
1726− &mut css,
1727− "grid-template-rows",
1728− &format!("repeat({},1fr)", n),
1729− );
1730− } else {
1731− push_css(&mut css, "grid-template-rows", v);
1732− }
1733− }
1734− }
1735− "col-span" => {
1736− if let Some(v) = val {
1737− push_css(&mut css, "grid-column", &format!("span {}", v));
1738− }
1739− }
1740− "row-span" => {
1741− if let Some(v) = val {
1742− push_css(&mut css, "grid-row", &format!("span {}", v));
1743− }
1744− }
1745
1831
1746− // CSS containment for rendering performance
1832+/// Every property `fill` and `shrink` of `dim` can set, with its initial
1747− "contain" => {
1833+/// value (`flex` covers `flex-shrink`).
1748− if let Some(v) = val {
1834+fn sizing_properties(dim: Dim) -> [(&'static str, &'static str); 3] {
1749− push_css(&mut css, "contain", v);
1835+ match dim {
1750− } else {
1836+ Dim::Width => [
1751− push_css(&mut css, "contain", "layout style paint");
1837+ ("flex", "0 1 auto"),
1752− }
1838+ ("min-width", "auto"),
1839+ ("width", "auto"),
1840+ ],
1841+ Dim::Height => [
1842+ ("flex", "0 1 auto"),
1843+ ("min-height", "auto"),
1844+ ("height", "auto"),
1845+ ],
1846+ }
1847+}
1848+
1849+/// What an element's attributes under some prefixes say about its size.
1850+#[derive(Default)]
1851+struct Sizes {
1852+ /// The last `width` and `height`
1853+ width: Option<Size>,
1854+ height: Option<Size>,
1855+ /// A `fill` or `shrink` for the width / height is among them (a later
1856+ /// size may replace it)
1857+ width_word: bool,
1858+ height_word: bool,
1859+ /// It writes `flex`, one of its longhands (`flex-grow`, `flex-shrink`,
1860+ /// `flex-basis`), `min-width` or `min-height` itself
1861+ flex: bool,
1862+ flex_grow: bool,
1863+ flex_shrink: bool,
1864+ flex_basis: bool,
1865+ min_width: bool,
1866+ min_height: bool,
1867+}
1868+
1869+impl Sizes {
1870+ /// Read the attributes under the conditions that hold wherever `at`
1871+ /// does, a later condition over an earlier one.
1872+ fn of(attrs: &[Attribute], at: &Condition) -> Sizes {
1873+ let mut sizes = Sizes::default();
1874+ let mut read: Vec<(Condition, &str, &str)> = Vec::new();
1875+ for attr in attrs.iter().filter(|a| !a.html) {
1876+ let (condition, name) = Condition::of(&attr.key);
1877+ let Some(value) = attr.value.as_deref().filter(|v| !v.trim().is_empty()) else {
1878+ continue;
1879+ };
1880+ if condition.holds_at(at) {
1881+ read.push((condition, name, value));
1753 }
1882 }
1754− "content-visibility" => {
1883+ }
1755− if let Some(v) = val {
1884+ read.sort_by_cached_key(|(condition, _, _)| condition.order());
1756− push_css(&mut css, "content-visibility", v);
1885+ for (_, name, value) in read {
1757− } else {
1886+ match name {
1758− push_css(&mut css, "content-visibility", "auto");
1887+ "width" => {
1759− }
1888+ let size = Size::of(value);
1889+ sizes.width = Some(size);
1890+ sizes.width_word |= size != Size::Set;
1891+ }
1892+ "height" => {
1893+ let size = Size::of(value);
1894+ sizes.height = Some(size);
1895+ sizes.height_word |= size != Size::Set;
1896+ }
1897+ "flex" => sizes.flex = true,
1898+ "flex-grow" => sizes.flex_grow = true,
1899+ "flex-shrink" => sizes.flex_shrink = true,
1900+ "flex-basis" => sizes.flex_basis = true,
1901+ "min-width" => sizes.min_width = true,
1902+ "min-height" => sizes.min_height = true,
1903+ _ => {}
1760 }
1904 }
1905+ }
1906+ sizes
1907+ }
1908+
1909+ /// The element writes `property` itself: a layout word leaves it alone.
1910+ fn writes(&self, property: &str) -> bool {
1911+ match property {
1912+ "flex" => self.flex,
1913+ "flex-grow" => self.flex || self.flex_grow,
1914+ "flex-shrink" => self.flex || self.flex_shrink,
1915+ "flex-basis" => self.flex || self.flex_basis,
1916+ "min-width" => self.min_width,
1917+ "min-height" => self.min_height,
1918+ "width" => self.width == Some(Size::Set),
1919+ "height" => self.height == Some(Size::Set),
1920+ _ => false,
1921+ }
1922+ }
1761
1923
1762− // Outline (like border but doesn't affect layout)
1924+ /// Write a layout word's `property: value`, unless the element writes
1763− "outline" => {
1925+ /// the property itself. When it writes some of `flex`'s longhands, a
1764− if let Some(v) = val {
1926+ /// `flex` is written as the others (`flex-shrink 0, width fill` still
1765− let parts: Vec<&str> = v.splitn(2, ' ').collect();
1927+ /// grows).
1766− if parts.len() == 2 {
1928+ fn push(&self, css: &mut String, property: &str, value: &str) {
1767− push_css(
1929+ if property == "flex" && !self.flex {
1768− &mut css,
1930+ let longhands = match value {
1769− "outline",
1931+ "1" => [
1770− &format!("{} solid {}", css_px(parts[0]), parts[1]),
1932+ ("flex-grow", "1"),
1771− );
1933+ ("flex-shrink", "1"),
1772− } else {
1934+ ("flex-basis", "0%"),
1773− push_css(
1935+ ],
1774− &mut css,
1936+ // The reset, `0 1 auto`
1775− "outline",
1937+ _ => [
1776− &format!("{} solid currentColor", css_px(parts[0])),
1938+ ("flex-grow", "0"),
1777− );
1939+ ("flex-shrink", "1"),
1940+ ("flex-basis", "auto"),
1941+ ],
1942+ };
1943+ if longhands.iter().any(|&(p, _)| self.writes(p)) {
1944+ for (longhand, value) in longhands {
1945+ if !self.writes(longhand) {
1946+ push_css(css, longhand, value);
1778 }
1947 }
1779 }
1948 }
1949+ return;
1780 }
1950 }
1951+ }
1952+ if !self.writes(property) {
1953+ push_css(css, property, value);
1954+ }
1955+ }
1956+}
1781
1957
1782− // Logical properties (i18n-aware)
1958+/// The rule that gives an element's `fill` and `shrink`, as its attributes
1783− "padding-inline" => {
1959+/// under the at-rules `at` set them, their meaning in a parent laid out
1784− if let Some(v) = val {
1960+/// along `axis`. Every other property a layout word could have set is put
1785− push_css(&mut css, "padding-inline", &css_px_multi(v));
1961+/// back to its initial value, so the rule is right whichever earlier rule
1786− }
1962+/// it overrides. Properties the element writes itself are left alone.
1787− }
1963+fn sizing_rule(attrs: &[Attribute], at: &[usize], axis: Axis) -> String {
1788− "padding-block" => {
1964+ let sizes = Sizes::of(attrs, &Condition::at_only(at));
1789− if let Some(v) = val {
1965+ let mut sets: Vec<(&str, &str)> = Vec::new();
1790− push_css(&mut css, "padding-block", &css_px_multi(v));
1966+ let mut resets: Vec<(&str, &str)> = Vec::new();
1791− }
1967+ for (dim, size, word) in [
1792− }
1968+ (Dim::Width, sizes.width, sizes.width_word),
1793− "margin-inline" => {
1969+ (Dim::Height, sizes.height, sizes.height_word),
1794− if let Some(v) = val {
1970+ ] {
1795− push_css(&mut css, "margin-inline", &css_px_multi(v));
1971+ if !word {
1796− }
1972+ continue;
1797− }
1973+ }
1798− "margin-block" => {
1974+ if let Some(size) = size {
1799− if let Some(v) = val {
1975+ sets.extend_from_slice(sizing(dim, size, Some(axis)));
1800− push_css(&mut css, "margin-block", &css_px_multi(v));
1976+ }
1801− }
1977+ resets.extend_from_slice(&sizing_properties(dim));
1802− }
1978+ }
1979+ let mut css = String::new();
1980+ let mut written: Vec<&str> = Vec::new();
1981+ for &(property, value) in &resets {
1982+ if sets.iter().any(|&(p, _)| p == property) || written.contains(&property) {
1983+ continue;
1984+ }
1985+ written.push(property);
1986+ sizes.push(&mut css, property, value);
1987+ }
1988+ for &(property, value) in &sets {
1989+ sizes.push(&mut css, property, value);
1990+ }
1991+ css
1992+}
1803
1993
1804− // Logical property start/end variants
1994+/// The rules an element's `fill` and `shrink` need under each chain of
1805− "padding-inline-start"
1995+/// at-rules where the direction of `flow` (its parent's) changes: `(at,
1806− | "padding-inline-end"
1996+/// body)`.
1807− | "padding-block-start"
1997+fn sizing_changes(attrs: &[Attribute], flow: &Flow) -> Vec<(Vec<usize>, String)> {
1808− | "padding-block-end"
1998+ flow.changes
1809− | "margin-inline-start"
1999+ .iter()
1810− | "margin-inline-end"
2000+ .map(|(at, axis)| (at.clone(), sizing_rule(attrs, at, *axis)))
1811− | "margin-block-start"
2001+ .filter(|(_, body)| !body.is_empty())
1812− | "margin-block-end" => {
2002+ .collect()
1813− if let Some(v) = val {
2003+}
1814− push_css(&mut css, effective_key, &css_px(v));
1815− }
1816− }
1817
2004
1818− // Logical inset
2005+/// An element's generated class, with the rules keyed on its parent's
1819− "inset-inline" | "inset-block" => {
2006+/// class that its `fill` and `shrink` need where the parent's direction
1820− if let Some(v) = val {
2007+/// changes under a prefix (see [`Flow`]): `:where(.a)>.b`, one class of
1821− push_css(&mut css, effective_key, &css_px_multi(v));
2008+/// specificity like any class rule.
1822− }
2009+fn element_class(elem: &Element, site: &Site, styles: &mut StyleCollector) -> Option<String> {
1823− }
2010+ let as_child = match site.parent.class {
1824− "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
2011+ Some(_) => sizing_changes(&elem.attrs, site.parent),
1825− if let Some(v) = val {
2012+ None => Vec::new(),
1826− push_css(&mut css, effective_key, &css_px(v));
2013+ };
1827− }
2014+ // Two elements with the same CSS but different rules keyed on them
1828− }
2015+ // need two classes
2016+ let mut distinct = String::new();
2017+ for (at, body) in &as_child {
2018+ distinct.push_str(&format!("<{:?}{}", at, body));
2019+ }
2020+ let class = compute_class(&elem.attrs, site, styles, distinct)?;
2021+ if let Some(parent) = &site.parent.class {
2022+ for (at, body) in as_child {
2023+ styles.add_keyed(at, format!(":where(.{})>.{}", parent, class), body);
2024+ }
2025+ }
2026+ Some(class)
2027+}
1829
2028
1830− // Logical border
2029+/// The `display` of a layout (nothing for text, native and void elements,
1831− "border-inline"
2030+/// which keep HTML's own).
1832− | "border-block"
2031+fn layout_css(layout: Layout, inline: bool) -> &'static str {
1833− | "border-inline-start"
2032+ match (layout, inline) {
1834− | "border-inline-end"
2033+ (Layout::Column, false) => "display:flex;flex-direction:column;",
1835− | "border-block-start"
2034+ (Layout::Column, true) => "display:inline-flex;flex-direction:column;",
1836− | "border-block-end" => {
2035+ (Layout::Row, false) => "display:flex;flex-direction:row;",
1837− if let Some(v) = val {
2036+ (Layout::Row, true) => "display:inline-flex;flex-direction:row;",
1838− push_css(&mut css, effective_key, v);
2037+ (Layout::Grid, false) => "display:grid;",
1839− }
2038+ (Layout::Grid, true) => "display:inline-grid;",
1840− }
2039+ _ => "",
2040+ }
2041+}
1841
2042
1842− // Logical border-radius
2043+/// The declarations of the attributes under `condition` (the defaults of
1843− "border-start-start-radius"
2044+/// the element's kind are [`default_css`]'s).
1844− | "border-start-end-radius"
2045+fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> String {
1845− | "border-end-start-radius"
2046+ let mut css = String::new();
1846− | "border-end-end-radius" => {
2047+ // The auto margins of `center-x`, `align-left`, ..., written at the end
1847− if let Some(v) = val {
2048+ let mut aligned = String::new();
1848− push_css(&mut css, effective_key, &css_px(v));
2049+ let kind = site.kind;
1849− }
2050+ // Under `children:` the styles go on the children, whose parent is this
1850− }
2051+ // element: htmlang's words that place an element in its parent are an
2052+ // error there (the parser reports them), and left out
2053+ let on_children = condition.on_children();
2054+ let inside = condition.inside().is_some();
2055+ // The direction `fill` and `shrink` compile against
2056+ let axis = site.parent.at(&condition.at);
2057+
2058+ // htmlang's words for laying out children (`spacing`, `wrap`,
2059+ // `grid-cols`) mean nothing on an element that doesn't lay out its
2060+ // children, which the parser reports; they are left out. Under
2061+ // `children:` they go on the children, whose layout isn't known here.
2062+ let lays_out_children = kind.layout().is_container() || on_children;
2063+ fn under<'k>(key: &'k str, condition: &Condition) -> Option<&'k str> {
2064+ let (c, name) = Condition::of(key);
2065+ (c == *condition).then_some(name)
2066+ }
2067+
2068+ for (index, attr) in attrs.iter().enumerate() {
2069+ if attr.html {
2070+ continue;
2071+ }
2072+ let Some(effective_key) = under(&attr.key, condition) else {
2073+ continue;
2074+ };
2075+ if on_children && crate::vocab::places_in_parent(effective_key, attr.value.as_deref()) {
2076+ continue;
2077+ }
2078+ // Under an element prefix (`@td:`) the styles go on elements whose
2079+ // layout and parent aren't known here: htmlang's own words are an
2080+ // error there (the parser reports them), and left out
2081+ if inside && crate::vocab::is_htmlang_word(effective_key, attr.value.as_deref()) {
2082+ continue;
2083+ }
1851
2084
1852− // Logical scroll margins & padding
2085+ let val = attr.value.as_deref();
1853− "scroll-margin-inline"
2086+ // A style whose value came out empty (a field a record doesn't
1854− | "scroll-margin-block"
2087+ // have, `${if()}` without its other branch) is left out
1855− | "scroll-padding-inline"
2088+ if val.is_some_and(|v| v.trim().is_empty()) {
1856− | "scroll-padding-block" => {
2089+ continue;
1857− if let Some(v) = val {
2090+ }
1858− push_css(&mut css, effective_key, &css_px_multi(v));
1859− }
1860− }
1861
2091
1862− // Logical sizing
2092+ match effective_key {
1863− "inline-size" | "block-size" | "min-inline-size" | "max-inline-size"
2093+ // htmlang's own words: they lay out children or place the
1864− | "min-block-size" | "max-block-size" => {
2094+ // element in its parent, and mean more than one CSS property
2095+ "spacing" if !lays_out_children => {}
2096+ "spacing" => {
1865 if let Some(v) = val {
2097 if let Some(v) = val {
1866− push_css(&mut css, effective_key, &css_px(v));
2098+ push_css(&mut css, "gap", &with_px("gap", v));
1867 }
2099 }
1868 }
2100 }
1869
2101
1870− // Margin
2102+ // Sizing: `fill` and `shrink` compile against the parent's
1871− "margin" => {
2103+ // direction. Only the last `width` (`height`) counts, as for
1872− if let Some(v) = val {
2104+ // any style written twice.
1873− push_css(&mut css, "margin", &css_px_multi(v));
2105+ "width" | "height" if val.is_some_and(|v| Size::of(v) != Size::Set) => {
2106+ let later = attrs[index + 1..].iter().any(|a| {
2107+ !a.html
2108+ && under(&a.key, condition) == Some(effective_key)
2109+ && a.value.as_deref().is_some_and(|v| !v.trim().is_empty())
2110+ });
2111+ if later {
2112+ continue;
1874 }
2113 }
1875− }
2114+ let dim = if effective_key == "width" {
1876− // Container queries
2115+ Dim::Width
1877− "container" => {
2116+ } else {
1878− push_css(&mut css, "container-type", "inline-size");
2117+ Dim::Height
1879− }
2118+ };
1880−
2119+ let size = Size::of(val.unwrap_or_default());
1881− // Inset (shorthand for top/right/bottom/left)
2120+ // What the element writes itself, which a layout word
1882− "inset" => {
2121+ // leaves alone
1883− if let Some(v) = val {
2122+ let sizes = Sizes::of(attrs, condition);
1884− push_css(&mut css, "inset", &css_px(v));
2123+ for &(property, value) in sizing(dim, size, axis) {
2124+ sizes.push(&mut css, property, value);
2125+ }
2126+ }
2127+
2128+ // A size of its own under a prefix, where a `fill` or
2129+ // `shrink` that sets `flex` (along a flex parent's direction)
2130+ // holds too: the word's `flex` and minimum are put back, so the
2131+ // size is the one the element gets (`width fill, lg:width 200`
2132+ // in a row)
2133+ "width" | "height"
2134+ if *condition != Condition::default()
2135+ && attrs.iter().any(|a| {
2136+ let (c, name) = Condition::of(&a.key);
2137+ let along = match site.parent.at(&c.at) {
2138+ Some(Axis::Row) => name == "width",
2139+ Some(Axis::Column) => name == "height",
2140+ None => false,
2141+ };
2142+ !a.html
2143+ && name == effective_key
2144+ && along
2145+ && c != *condition
2146+ && c.holds_at(condition)
2147+ && a.value
2148+ .as_deref()
2149+ .is_some_and(|v| !v.trim().is_empty() && Size::of(v) != Size::Set)
2150+ }) =>
2151+ {
2152+ let dim = if effective_key == "width" {
2153+ Dim::Width
2154+ } else {
2155+ Dim::Height
2156+ };
2157+ let sizes = Sizes::of(attrs, condition);
2158+ for (property, value) in sizing_properties(dim) {
2159+ if property != effective_key {
2160+ sizes.push(&mut css, property, value);
2161+ }
1885 }
2162 }
1886− }
1887−
1888− // Table
1889− "border-spacing" => {
1890 if let Some(v) = val {
2163 if let Some(v) = val {
1891− push_css(&mut css, "border-spacing", &css_px(v));
2164+ push_css(&mut css, effective_key, &with_px(effective_key, v));
1892 }
2165 }
1893 }
2166 }
1894
2167
1895− // Text decoration
2168+ // Alignment: auto margins, which work along either direction.
1896− "text-decoration-thickness" => {
2169+ // They are written last, so a `margin` on the same element
1897− if let Some(v) = val {
2170+ // keeps its other sides (`center-x, margin 20`)
1898− push_css(&mut css, "text-decoration-thickness", &css_px(v));
2171+ "center-x" => {
1899− }
2172+ push_css(&mut aligned, "margin-left", "auto");
2173+ push_css(&mut aligned, "margin-right", "auto");
1900 }
2174 }
1901− "text-underline-offset" => {
2175+ "center-y" => {
1902− if let Some(v) = val {
2176+ push_css(&mut aligned, "margin-top", "auto");
1903− push_css(&mut css, "text-underline-offset", &css_px(v));
2177+ push_css(&mut aligned, "margin-bottom", "auto");
1904− }
1905 }
2178 }
2179+ "align-left" => push_css(&mut aligned, "margin-right", "auto"),
2180+ "align-right" => push_css(&mut aligned, "margin-left", "auto"),
2181+ "align-top" => push_css(&mut aligned, "margin-bottom", "auto"),
2182+ "align-bottom" => push_css(&mut aligned, "margin-top", "auto"),
1906
2183
1907− // Multi-column
2184+ "wrap" if lays_out_children => push_css(&mut css, "flex-wrap", "wrap"),
1908− "column-width" => {
2185+ "wrap" => {}
1909− if let Some(v) = val {
1910− push_css(&mut css, "column-width", &css_px(v));
1911− }
1912− }
1913
2186
1914− // New CSS properties
2187+ // Grid
1915− "column-gap" => {
2188+ "grid-cols" | "grid-rows" if !lays_out_children => {}
1916− if let Some(v) = val {
2189+ "grid-cols" | "grid-rows" => {
1917− push_css(&mut css, "column-gap", &css_px(v));
1918− }
1919− }
1920− "text-indent" => {
1921− if let Some(v) = val {
1922− push_css(&mut css, "text-indent", &css_px(v));
1923− }
1924− }
1925− "flex-basis" => {
1926− if let Some(v) = val {
1927− push_css(&mut css, "flex-basis", &css_px(v));
1928− }
1929− }
1930− "scroll-margin" => {
1931− if let Some(v) = val {
1932− push_css(&mut css, "scroll-margin", &css_px(v));
1933− }
1934− }
1935− "scroll-margin-top"
1936− | "scroll-margin-bottom"
1937− | "scroll-margin-left"
1938− | "scroll-margin-right" => {
1939− if let Some(v) = val {
1940− push_css(&mut css, effective_key, &css_px(v));
1941− }
1942− }
1943− "scroll-padding" => {
1944 if let Some(v) = val {
2190 if let Some(v) = val {
1945− push_css(&mut css, "scroll-padding", &css_px(v));
2191+ let property = if effective_key == "grid-cols" {
2192+ "grid-template-columns"
2193+ } else {
2194+ "grid-template-rows"
2195+ };
2196+ match v.parse::<u32>() {
2197+ Ok(n) => push_css(&mut css, property, &format!("repeat({},1fr)", n)),
2198+ Err(_) => push_css(&mut css, property, &with_px(property, v)),
2199+ }
1946 }
2200 }
1947 }
2201 }
1948− "scroll-padding-top"
2202+ "col-span" | "row-span" => {
1949− | "scroll-padding-bottom"
1950− | "scroll-padding-left"
1951− | "scroll-padding-right" => {
1952 if let Some(v) = val {
2203 if let Some(v) = val {
1953− push_css(&mut css, effective_key, &css_px(v));
2204+ let property = if effective_key == "col-span" {
2205+ "grid-column"
2206+ } else {
2207+ "grid-row"
2208+ };
2209+ push_css(&mut css, property, &format!("span {}", v));
1954 }
2210 }
1955 }
2211 }
1956
2212
1957− // --- CSS Shorthands ---
2213+ // CSS's `line-clamp` has no support yet without its
2214+ // `-webkit-` fallback, which needs a box to clamp
1958 "line-clamp" => {
2215 "line-clamp" => {
1959 if let Some(v) = val {
2216 if let Some(v) = val {
1960 push_css(&mut css, "display", "-webkit-box");
2217 push_css(&mut css, "display", "-webkit-box");
@@ -1964,27 +2221,14 @@ fn attrs_to_css(
1964 }
2221 }
1965 }
2222 }
1966
2223
1967− // Any other standard CSS property is copied through, with `px`
2224+ // Every other name is a CSS property (standard, custom,
1968− // added to bare numbers where the property takes a length.
2225+ // vendor-prefixed, or one htmlang doesn't know, which the
1969− // `font-family "Inter, sans-serif"`: the quotes only keep the
2226+ // parser warned about), written under its own name with the
1970− // stack's commas from splitting the attribute list.
2227+ // value as written, except for `px` after a bare number where
1971− "font-family" => {
2228+ // the property takes a length
1972− if let Some(v) = val {
2229+ key if crate::vocab::is_property_name(key) => {
1973− let v = match v.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
1974− Some(stack) if stack.contains(',') => stack,
1975− _ => v,
1976− };
1977− push_css(&mut css, "font-family", v);
1978− }
1979− }
1980−
1981− key if crate::vocab::is_css_property(key) => {
1982 if let Some(v) = val {
2230 if let Some(v) = val {
1983− if crate::vocab::is_length_property(key) {
2231+ push_css(&mut css, key, &with_px(key, v));
1984− push_css(&mut css, key, &css_px_multi(v));
1985− } else {
1986− push_css(&mut css, key, v);
1987− }
1988 }
2232 }
1989 }
2233 }
1990
2234
@@ -1992,6 +2236,7 @@ fn attrs_to_css(
1992 }
2236 }
1993 }
2237 }
1994
2238
2239+ css.push_str(&aligned);
1995 css
2240 css
1996}
2241}
1997
2242
@@ -2002,35 +2247,6 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
2002 css.push(';');
2247 css.push(';');
2003}
2248}
2004
2249
2005−/// Known CSS units — if a value ends with one, skip appending `px`.
2006−/// Format a length value: a bare number gets `px` appended; anything else
2007−/// (values with units, keywords like `auto`, functions like `calc(...)`) is
2008−/// passed through unchanged.
2009−fn css_px(value: &str) -> String {
2010− let v = value.trim();
2011− if v == "0" {
2012− return "0".to_string();
2013− }
2014− let is_bare_number = !v.is_empty()
2015− && v.bytes()
2016− .all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
2017− && v.parse::<f64>().is_ok();
2018− if is_bare_number {
2019− format!("{}px", v)
2020− } else {
2021− v.to_string()
2022− }
2023−}
2024−
2025−/// Format multiple space-separated values, each getting px if needed.
2026−fn css_px_multi(value: &str) -> String {
2027− value
2028− .split_whitespace()
2029− .map(css_px)
2030− .collect::<Vec<_>>()
2031− .join(" ")
2032−}
2033−
2034fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2250fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2035 let mut id = None;
2251 let mut id = None;
2036 let mut class = None;
2252 let mut class = None;
crates/htmlang-core/src/diagnostic.rsnew file+229
@@ -0,0 +1,229 @@
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+ pub const UNEXPECTED_BODY: &str = "unexpected-body";
81+ pub const UNEXPECTED_ARGUMENT: &str = "unexpected-argument";
82+ pub const MISSING_ARGUMENT: &str = "missing-argument";
83+ pub const INVALID_LOOP: &str = "invalid-loop";
84+ /// A `@let` whose name, parameters or body don't fit its kind.
85+ pub const INVALID_DEFINITION: &str = "invalid-definition";
86+ pub const STRAY_ELSE: &str = "stray-else";
87+ /// Indentation with tabs and spaces mixed, which nests lines in ways
88+ /// the eye doesn't see.
89+ pub const MIXED_INDENTATION: &str = "mixed-indentation";
90+
91+ // Names
92+ pub const UNKNOWN_ELEMENT: &str = "unknown-element";
93+ /// An attribute name htmlang doesn't know: a warning when CSS could
94+ /// have the name (it is written to the CSS as it is), an error otherwise.
95+ pub const UNKNOWN_ATTRIBUTE: &str = "unknown-attribute";
96+ /// A misspelled prefix (`hovr:color red`).
97+ pub const UNKNOWN_PREFIX: &str = "unknown-prefix";
98+ /// A prefix the attribute can't take: a second one (`md:hover:`), or
99+ /// one on an HTML attribute (`hover:required`).
100+ pub const INVALID_PREFIX: &str = "invalid-prefix";
101+ /// A style written without its value (`[padding]`).
102+ pub const MISSING_VALUE: &str = "missing-value";
103+ pub const HTML_ATTRIBUTE_FORM: &str = "html-attribute-form";
104+ /// A second `@page`: a page has one root, so one `@page`.
105+ pub const DUPLICATE_PAGE: &str = "duplicate-page";
106+ pub const UNDEFINED_VARIABLE: &str = "undefined-variable";
107+ /// An attribute, or an attribute's name, made from a variable.
108+ pub const ATTRIBUTE_FROM_VARIABLE: &str = "attribute-from-variable";
109+ pub const DUPLICATE_ATTRIBUTE: &str = "duplicate-attribute";
110+
111+ // Values
112+ /// A value that can't be written into the page's CSS (an error), or
113+ /// one that can't mean what it says (a warning).
114+ pub const INVALID_VALUE: &str = "invalid-value";
115+ pub const INVALID_COLOR: &str = "invalid-color";
116+ pub const INVALID_EXPRESSION: &str = "invalid-expression";
117+ pub const INVALID_JSON: &str = "invalid-json";
118+
119+ // Definitions and functions
120+ pub const UNUSED_VARIABLE: &str = "unused-variable";
121+ pub const UNUSED_BUNDLE: &str = "unused-bundle";
122+ pub const UNUSED_FUNCTION: &str = "unused-function";
123+ pub const RECURSIVE_CALL: &str = "recursive-call";
124+ pub const NO_SINGLE_ROOT: &str = "no-single-root";
125+ /// A call that leaves out a parameter without a default.
126+ pub const MISSING_PARAMETER: &str = "missing-parameter";
127+ /// A parameter passed `name=value`: parameters are written `name value`.
128+ pub const PARAMETER_FORM: &str = "parameter-form";
129+ /// A function named like a built-in element (which it replaces) or a
130+ /// directive (which it can't replace).
131+ pub const SHADOWS_BUILT_IN: &str = "shadows-built-in";
132+ /// A `@slot NAME` block at a call whose function has no such slot.
133+ pub const UNKNOWN_SLOT: &str = "unknown-slot";
134+ /// Content passed to a function whose body has no `@children`.
135+ pub const UNEXPECTED_CONTENT: &str = "unexpected-content";
136+ /// A `@slot` or `@children` where it marks or fills nothing: outside
137+ /// a function's body, inside an element at a call, or inline in text.
138+ pub const MISPLACED_SLOT: &str = "misplaced-slot";
139+ /// A slot name that isn't one name (`@slot my footer`).
140+ pub const INVALID_SLOT_NAME: &str = "invalid-slot-name";
141+
142+ // Files
143+ pub const UNREADABLE_FILE: &str = "unreadable-file";
144+ pub const CIRCULAR_INCLUDE: &str = "circular-include";
145+ pub const UNSET_ENVIRONMENT: &str = "unset-environment";
146+
147+ // Context: an attribute or element where it has no effect
148+ /// An attribute the element ignores (a warning), or a layout word such
149+ /// as `spacing` on an element that doesn't lay out its children (an
150+ /// error: it is left out).
151+ pub const NO_EFFECT: &str = "no-effect";
152+ /// An element whose HTML ends a `<p>`, written inside a `@paragraph`.
153+ pub const BLOCK_IN_PARAGRAPH: &str = "block-in-paragraph";
154+
155+ // Accessibility
156+ pub const MISSING_ALT: &str = "missing-alt";
157+ pub const MISSING_INPUT_TYPE: &str = "missing-input-type";
158+ pub const MISSING_LINK_TEXT: &str = "missing-link-text";
159+ pub const MISSING_LABEL: &str = "missing-label";
160+ pub const MISSING_TITLE: &str = "missing-title";
161+ pub const MISSING_BUTTON_TEXT: &str = "missing-button-text";
162+ pub const MISSING_CAPTIONS: &str = "missing-captions";
163+ pub const LOW_CONTRAST: &str = "low-contrast";
164+ pub const POSITIVE_TABINDEX: &str = "positive-tabindex";
165+
166+ // `htmlang lint`
167+ pub const DEEP_NESTING: &str = "deep-nesting";
168+ pub const EMPTY_CONTAINER: &str = "empty-container";
169+ pub const MISSING_BUTTON_TYPE: &str = "missing-button-type";
170+
171+ /// A bug in the compiler itself.
172+ pub const INTERNAL: &str = "internal";
173+
174+ /// Every code, for tools and tests.
175+ pub const ALL: &[&str] = &[
176+ UNCLOSED_BRACKET,
177+ UNEXPECTED_BODY,
178+ UNEXPECTED_ARGUMENT,
179+ MISSING_ARGUMENT,
180+ INVALID_LOOP,
181+ INVALID_DEFINITION,
182+ STRAY_ELSE,
183+ MIXED_INDENTATION,
184+ UNKNOWN_ELEMENT,
185+ UNKNOWN_ATTRIBUTE,
186+ UNKNOWN_PREFIX,
187+ INVALID_PREFIX,
188+ MISSING_VALUE,
189+ HTML_ATTRIBUTE_FORM,
190+ DUPLICATE_PAGE,
191+ UNDEFINED_VARIABLE,
192+ ATTRIBUTE_FROM_VARIABLE,
193+ DUPLICATE_ATTRIBUTE,
194+ INVALID_VALUE,
195+ INVALID_COLOR,
196+ INVALID_EXPRESSION,
197+ INVALID_JSON,
198+ UNUSED_VARIABLE,
199+ UNUSED_BUNDLE,
200+ UNUSED_FUNCTION,
201+ RECURSIVE_CALL,
202+ NO_SINGLE_ROOT,
203+ MISSING_PARAMETER,
204+ PARAMETER_FORM,
205+ SHADOWS_BUILT_IN,
206+ UNKNOWN_SLOT,
207+ UNEXPECTED_CONTENT,
208+ MISPLACED_SLOT,
209+ INVALID_SLOT_NAME,
210+ UNREADABLE_FILE,
211+ CIRCULAR_INCLUDE,
212+ UNSET_ENVIRONMENT,
213+ NO_EFFECT,
214+ BLOCK_IN_PARAGRAPH,
215+ MISSING_ALT,
216+ MISSING_INPUT_TYPE,
217+ MISSING_LINK_TEXT,
218+ MISSING_LABEL,
219+ MISSING_TITLE,
220+ MISSING_BUTTON_TEXT,
221+ MISSING_CAPTIONS,
222+ LOW_CONTRAST,
223+ POSITIVE_TABINDEX,
224+ DEEP_NESTING,
225+ EMPTY_CONTAINER,
226+ MISSING_BUTTON_TYPE,
227+ INTERNAL,
228+ ];
229+}
crates/htmlang-core/src/expr.rs+475−313
@@ -1,16 +1,23 @@
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 `if(cond, a, b)`.
2+//! values (`@let gap = $base * 2`) and `${...}` in text and values
3+//! (`${if($featured, 24, 0)}`).
3//!
4//!
4//! Expressions are tokenized and evaluated as a whole. Variables are looked
5//! Expressions are tokenized and evaluated as a whole. Variables are looked
5//! up while evaluating, never pasted into the text first, so a value that
6//! up while evaluating, never pasted into the text first, so a value that
6//! happens to contain `==` or spaces can't change how an expression parses.
7//! happens to contain `==` or spaces can't change how an expression parses.
7//!
8//!
9+//! Only what decides the result is evaluated: `if()` evaluates the branch
10+//! it takes, `and` and `or` stop at the first side that decides. The rest
11+//! is still parsed (and its function names and argument counts checked),
12+//! so `$n != 0 and 10 / $n > 1` is false for `$n` = 0, not an error.
13+//!
8//! ```text
14//! ```text
9//! expr := or
15//! expr := or
10//! or := and ("or" and)*
16//! or := and ("or" and)*
11//! and := not ("and" not)*
17//! and := not ("and" not)*
12//! not := "not" not | compare
18//! not := "not" not | compare
13−//! compare := sum (("==" | "!=" | "<" | ">" | "<=" | ">=") sum)?
19+//! compare := range (("==" | "!=" | "<" | ">" | "<=" | ">=") range)?
20+//! range := sum (".." sum ("step" sum)?)?
14//! sum := product (("+" | "-") product)*
21//! sum := product (("+" | "-") product)*
15//! product := unary (("*" | "/" | "%") unary)*
22//! product := unary (("*" | "/" | "%") unary)*
16//! unary := "-" unary | primary
23//! unary := "-" unary | primary
@@ -18,75 +25,89 @@
18//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
25//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
19//! ```
26//! ```
20//!
27//!
21−//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string. A variable
28+//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is text. Values are
22−//! loaded from a JSON array is a list: `length` counts its items and
29+//! typed (see `value.rs`, which also defines truthiness and comparison):
23−//! `contains` tests membership. Functions:
30+//! `$name` keeps whatever it holds, a list or a record included, and
24−//! `if(cond, a, b)`, tests (`contains(s, x)`, `starts-with(s, x)`,
31+//! `A..B` (with `step N`) is a list of whole numbers. Functions:
25−//! `ends-with(s, x)`), text (`uppercase`, `lowercase`, `capitalize`, `trim`,
32+//! `if(cond, a, b)` (`b` may be left out: empty), tests (`contains(s, x)`,
26−//! `length`, `reverse`, `truncate(s, n)`, `replace(s, old, new)`,
33+//! `starts-with(s, x)`, `ends-with(s, x)`), text (`uppercase`, `lowercase`,
27−//! `default(s, fallback)`) and colors (`lighten(c, pct)`, `darken(c, pct)`,
34+//! `capitalize`, `trim`, `length`, `reverse`, `truncate(s, n)`,
28−//! `alpha(c, a)`, `mix(c1, c2, pct)`). In text, `${expr}` interpolates an
35+//! `replace(s, old, new)`, `default(s, fallback)`). A colour variant is
29−//! expression.
36+//! CSS, not a function here: `color-mix(in srgb, var(--brand), black 8%)`
37+//! is an attribute's value, written as it is.
38+//! `length`, `contains` and `reverse` work on the items of a list; the other
39+//! text functions take text, and a list is an error (a text with commas in
40+//! it is written in quotes or with `\,`, so it isn't a list).
41+//!
42+//! A `$name` ends as it does in text (see `interp.rs`), so `$post.title`
43+//! reads a field and `$a..$b` is two names. An undefined name is an error.
30
44
31use std::fmt;
45use std::fmt;
32
46
47+use crate::interp::{self, Problem, Reference, Scope};
48+
49+pub use crate::value::Value;
50+
51+/// Why an expression has no value.
33#[derive(Debug, Clone, PartialEq)]
52#[derive(Debug, Clone, PartialEq)]
34−pub enum Value {
53+pub enum Error {
35− Num(f64),
54+ /// `$name` has no definition; `offset` is where the `$` is.
36− Str(String),
55+ Undefined { name: String, offset: usize },
37− Bool(bool),
56+ /// Anything else: a syntax error, a type error, division by zero.
38− /// A list's items as text (from a JSON array).
57+ Invalid(String),
39− List(Vec<String>),
40}
58}
41
59
42−impl Value {
60+impl Error {
43− /// Empty strings, `false`, `0` and `"false"` / `"0"` are false.
61+ /// This error as a problem of a text in which the expression starts
44− pub fn truthy(&self) -> bool {
62+ /// at `base`.
63+ pub(crate) fn at(self, base: usize) -> Problem {
45 match self {
64 match self {
46− Value::Bool(b) => *b,
65+ Error::Undefined { name, offset } => Problem::Undefined {
47− Value::Num(n) => *n != 0.0,
66+ name,
48− Value::Str(s) => !s.is_empty() && s != "false" && s != "0",
67+ offset: base + offset,
49− Value::List(items) => !items.is_empty(),
68+ },
69+ Error::Invalid(message) => Problem::Invalid {
70+ message,
71+ offset: base,
72+ },
50 }
73 }
51 }
74 }
75+}
52
76
53− fn as_num(&self) -> Option<f64> {
77+impl From<String> for Error {
54− match self {
78+ fn from(message: String) -> Self {
55− Value::Num(n) => Some(*n),
79+ Error::Invalid(message)
56− Value::Str(s) => s.trim().parse().ok(),
57− Value::Bool(_) | Value::List(_) => None,
58− }
59 }
80 }
60}
81}
61
82
62−impl fmt::Display for Value {
83+impl fmt::Display for Error {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 match self {
85 match self {
65− Value::Num(n) if n.fract() == 0.0 && n.abs() < 1e15 => write!(f, "{}", *n as i64),
86+ Error::Undefined { name, .. } => write!(f, "undefined variable '${}'", name),
66− Value::Num(n) => write!(f, "{}", n),
87+ Error::Invalid(message) => f.write_str(message),
67− Value::Str(s) => f.write_str(s),
68− Value::Bool(b) => write!(f, "{}", b),
69− Value::List(items) => f.write_str(&items.join(", ")),
70 }
88 }
71 }
89 }
72}
90}
73
91
74−/// Resolves a variable name (the text after `$`). Returns `None` for
92+/// Evaluate `src` with the variables of `scope`. An undefined variable is
75−/// undefined variables.
93+/// an error; a field that a record doesn't have is empty.
76−pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<Value>;
94+pub fn eval(src: &str, scope: &dyn Scope) -> Result<Value, Error> {
77−
95+ let mut tokens = Vec::new();
78−/// Evaluate `src`. Undefined variables are empty strings.
96+ tokenize(src, scope, 0, &mut tokens)?;
79−pub fn eval(src: &str, resolve: Resolver) -> Result<Value, String> {
80− let tokens = tokenize(src)?;
81 let mut parser = Parser {
97 let mut parser = Parser {
82 tokens: &tokens,
98 tokens: &tokens,
83 pos: 0,
99 pos: 0,
84− resolve,
100+ scope,
101+ skip: false,
85 };
102 };
86 let value = parser.expr()?;
103 let value = parser.expr()?;
87 match parser.tokens.get(parser.pos) {
104 match parser.tokens.get(parser.pos) {
88 None => Ok(value),
105 None => Ok(value),
89− Some(tok) => Err(format!("unexpected {} in `{}`", tok, src.trim())),
106+ Some((tok, _)) => Err(Error::Invalid(format!(
107+ "unexpected {} in `{}`",
108+ tok,
109+ src.trim()
110+ ))),
90 }
111 }
91}
112}
92
113
@@ -117,84 +138,116 @@ impl fmt::Display for Token {
117 }
138 }
118}
139}
119
140
120−const OPERATORS: &[&str] = &["==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%"];
141+const OPERATORS: &[&str] = &[
121−
142+ "..", "==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%",
122−fn tokenize(src: &str) -> Result<Vec<Token>, String> {
143+];
123− let chars: Vec<char> = src.chars().collect();
144+
124− let mut tokens = Vec::new();
145+/// Read `src` into tokens, each with its byte offset (plus `base`).
125− let mut i = 0;
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> {
126 let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
154 let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
127− while i < chars.len() {
155+ let mut i = 0;
128− let c = chars[i];
156+ while let Some(c) = src[i..].chars().next() {
157+ let at = base + i;
129 if c.is_whitespace() {
158 if c.is_whitespace() {
130− i += 1;
159+ i += c.len_utf8();
131 } else if c == '(' {
160 } else if c == '(' {
132− tokens.push(Token::LParen);
161+ tokens.push((Token::LParen, at));
133 i += 1;
162 i += 1;
134 } else if c == ')' {
163 } else if c == ')' {
135− tokens.push(Token::RParen);
164+ tokens.push((Token::RParen, at));
136 i += 1;
165 i += 1;
137 } else if c == ',' {
166 } else if c == ',' {
138− tokens.push(Token::Comma);
167+ tokens.push((Token::Comma, at));
139 i += 1;
168 i += 1;
140 } else if c == '"' {
169 } else if c == '"' {
141 let start = i + 1;
170 let start = i + 1;
142− let end = chars[start..]
171+ let end = src[start..]
143− .iter()
172+ .find('"')
144− .position(|&c| c == '"')
145 .map(|p| start + p)
173 .map(|p| start + p)
146 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
174 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
147− tokens.push(Token::Str(chars[start..end].iter().collect()));
175+ tokens.push((Token::Str(src[start..end].to_string()), at));
148 i = end + 1;
176 i = end + 1;
149 } else if c == '$' {
177 } else if c == '$' {
150− let start = i + 1;
178+ match interp::reference(&src[i + 1..], scope) {
151− let mut end = start;
179+ Some((Reference::Var(path), len)) => {
152− while end < chars.len() && is_var_char(chars[end]) {
180+ tokens.push((Token::Var(path), at));
153− end += 1;
181+ i += 1 + len;
154− }
182+ }
155− while end > start && chars[end - 1] == '.' {
183+ Some((Reference::Expr(inner), len)) => {
156− end -= 1;
184+ tokens.push((Token::LParen, at));
157− }
185+ tokenize(inner, scope, at + 2, tokens)?;
158− if end == start {
186+ tokens.push((Token::RParen, at + len));
159− return Err(format!("`$` without a variable name in `{}`", src.trim()));
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+ }
160 }
196 }
161− tokens.push(Token::Var(chars[start..end].iter().collect()));
162− i = end;
163 } else if let Some(op) = OPERATORS
197 } else if let Some(op) = OPERATORS
164 .iter()
198 .iter()
165− .find(|op| chars[i..].iter().take(op.len()).copied().eq(op.chars()))
199+ .find(|op| src[i..].starts_with(**op))
166 // A `-` followed by a letter starts a word (`-webkit-box`);
200 // A `-` followed by a letter starts a word (`-webkit-box`);
167 // inside words (`ease-in`) it is consumed by the word itself.
201 // inside words (`ease-in`) it is consumed by the word itself.
168− .filter(|op| **op != "-" || !chars.get(i + 1).is_some_and(|c| c.is_alphabetic()))
202+ .filter(|op| **op != "-" || !src[i + 1..].starts_with(char::is_alphabetic))
169 {
203 {
170− tokens.push(Token::Op(op));
204+ tokens.push((Token::Op(op), at));
171 i += op.len();
205 i += op.len();
172 } else if is_word_char(c) {
206 } else if is_word_char(c) {
173 let start = i;
207 let start = i;
174− while i < chars.len() && is_word_char(chars[i]) {
208+ // A word ends at `..`, so `1..5` is a range
175− i += 1;
209+ while let Some(c) = src[i..]
210+ .chars()
211+ .next()
212+ .filter(|&c| is_word_char(c) && !src[i..].starts_with(".."))
213+ {
214+ i += c.len_utf8();
176 }
215 }
177− let word: String = chars[start..i].iter().collect();
216+ let word = &src[start..i];
178− tokens.push(match word.parse::<f64>() {
217+ tokens.push((
179− Ok(n) if !word.ends_with('.') => Token::Num(n),
218+ match word.parse::<f64>() {
180− _ => Token::Word(word),
219+ Ok(n) if !word.ends_with('.') => Token::Num(n),
181− });
220+ _ => Token::Word(word.to_string()),
221+ },
222+ at,
223+ ));
182 } else {
224 } else {
183− return Err(format!("unexpected `{}` in `{}`", c, src.trim()));
225+ return Err(Error::Invalid(format!(
226+ "unexpected `{}` in `{}`",
227+ c,
228+ src.trim()
229+ )));
184 }
230 }
185 }
231 }
186− Ok(tokens)
232+ Ok(())
187}
233}
188
234
189struct Parser<'a> {
235struct Parser<'a> {
190− tokens: &'a [Token],
236+ tokens: &'a [(Token, usize)],
191 pos: usize,
237 pos: usize,
192− resolve: Resolver<'a>,
238+ scope: &'a dyn Scope,
239+ /// Parsing a part whose value isn't needed (the branch `if()` doesn't
240+ /// take, the side of `and`/`or` that doesn't decide): it is read and
241+ /// checked for syntax, not evaluated.
242+ skip: bool,
193}
243}
194
244
245+/// The value a skipped part stands for; nothing reads it.
246+const SKIPPED: Value = Value::Bool(false);
247+
195impl Parser<'_> {
248impl Parser<'_> {
196 fn peek(&self) -> Option<&Token> {
249 fn peek(&self) -> Option<&Token> {
197− self.tokens.get(self.pos)
250+ self.tokens.get(self.pos).map(|(token, _)| token)
198 }
251 }
199
252
200 fn eat_word(&mut self, word: &str) -> bool {
253 fn eat_word(&mut self, word: &str) -> bool {
@@ -217,108 +270,167 @@ impl Parser<'_> {
217 }
270 }
218 }
271 }
219
272
220− fn expect(&mut self, token: Token) -> Result<(), String> {
273+ fn expect(&mut self, token: Token) -> Result<(), Error> {
221 match self.peek() {
274 match self.peek() {
222 Some(t) if *t == token => {
275 Some(t) if *t == token => {
223 self.pos += 1;
276 self.pos += 1;
224 Ok(())
277 Ok(())
225 }
278 }
226− Some(t) => Err(format!("expected {} but found {}", token, t)),
279+ Some(t) => Err(format!("expected {} but found {}", token, t).into()),
227− None => Err(format!("expected {} at the end", token)),
280+ None => Err(format!("expected {} at the end", token).into()),
228 }
281 }
229 }
282 }
230
283
231− fn expr(&mut self) -> Result<Value, String> {
284+ /// Parse with `f`, skipping evaluation when `skip` (or when already
285+ /// skipping).
286+ fn skipping(
287+ &mut self,
288+ skip: bool,
289+ f: impl FnOnce(&mut Self) -> Result<Value, Error>,
290+ ) -> Result<Value, Error> {
291+ let outer = self.skip;
292+ self.skip |= skip;
293+ let value = f(self);
294+ self.skip = outer;
295+ value
296+ }
297+
298+ fn expr(&mut self) -> Result<Value, Error> {
232 let mut left = self.and()?;
299 let mut left = self.and()?;
233 while self.eat_word("or") {
300 while self.eat_word("or") {
234− let right = self.and()?;
301+ let decided = left.truthy();
235− left = Value::Bool(left.truthy() || right.truthy());
302+ let right = self.skipping(decided, Self::and)?;
303+ left = Value::Bool(decided || right.truthy());
236 }
304 }
237 Ok(left)
305 Ok(left)
238 }
306 }
239
307
240− fn and(&mut self) -> Result<Value, String> {
308+ fn and(&mut self) -> Result<Value, Error> {
241 let mut left = self.not()?;
309 let mut left = self.not()?;
242 while self.eat_word("and") {
310 while self.eat_word("and") {
243− let right = self.not()?;
311+ let decided = !left.truthy();
244− left = Value::Bool(left.truthy() && right.truthy());
312+ let right = self.skipping(decided, Self::not)?;
313+ left = Value::Bool(!decided && right.truthy());
245 }
314 }
246 Ok(left)
315 Ok(left)
247 }
316 }
248
317
249− fn not(&mut self) -> Result<Value, String> {
318+ fn not(&mut self) -> Result<Value, Error> {
250 if self.eat_word("not") {
319 if self.eat_word("not") {
251 return Ok(Value::Bool(!self.not()?.truthy()));
320 return Ok(Value::Bool(!self.not()?.truthy()));
252 }
321 }
253 self.compare()
322 self.compare()
254 }
323 }
255
324
256− fn compare(&mut self) -> Result<Value, String> {
325+ fn compare(&mut self) -> Result<Value, Error> {
257− let left = self.sum()?;
326+ let left = self.range()?;
258 let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
327 let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
259 return Ok(left);
328 return Ok(left);
260 };
329 };
261− let right = self.sum()?;
330+ let right = self.range()?;
262− let numbers = left.as_num().zip(right.as_num());
331+ if self.skip {
263− let (l, r) = (left.to_string(), right.to_string());
332+ return Ok(SKIPPED);
264− Ok(Value::Bool(match (op, numbers) {
333+ }
265− ("==", Some((a, b))) => a == b,
334+ Ok(Value::Bool(left.compare(op, &right)?))
266− ("!=", Some((a, b))) => a != b,
335+ }
267− ("<", Some((a, b))) => a < b,
336+
268− (">", Some((a, b))) => a > b,
337+ /// `A..B` or `A..B step N`: the whole numbers from A to B.
269− ("<=", Some((a, b))) => a <= b,
338+ fn range(&mut self) -> Result<Value, Error> {
270− (">=", Some((a, b))) => a >= b,
339+ let start = self.sum()?;
271− ("==", None) => l == r,
340+ if self.eat_op(&[".."]).is_none() {
272− ("!=", None) => l != r,
341+ return Ok(start);
273− ("<", None) => l < r,
342+ }
274− (">", None) => l > r,
343+ let end = self.sum()?;
275− ("<=", None) => l <= r,
344+ let step = match self.eat_word("step") {
276− (">=", None) => l >= r,
345+ true => Some(self.sum()?),
277− _ => unreachable!(),
346+ false => None,
278− }))
347+ };
279− }
348+ if self.skip {
280−
349+ return Ok(SKIPPED);
281− fn sum(&mut self) -> Result<Value, String> {
350+ }
351+ let number = |value: &Value| {
352+ value.as_num().ok_or_else(|| {
353+ Error::Invalid(format!(
354+ "a range goes from one number to another, not from {} (`{}`)",
355+ value.describe(),
356+ value
357+ ))
358+ })
359+ };
360+ let step = match &step {
361+ Some(step) => number(step)?,
362+ None => 1.0,
363+ };
364+ Ok(crate::value::range(number(&start)?, number(&end)?, step)?)
365+ }
366+
367+ fn sum(&mut self) -> Result<Value, Error> {
282 let mut left = self.product()?;
368 let mut left = self.product()?;
283 while let Some(op) = self.eat_op(&["+", "-"]) {
369 while let Some(op) = self.eat_op(&["+", "-"]) {
284 let right = self.product()?;
370 let right = self.product()?;
285− left = arithmetic(op, &left, &right)?;
371+ left = self.arithmetic(op, &left, &right)?;
286 }
372 }
287 Ok(left)
373 Ok(left)
288 }
374 }
289
375
290− fn product(&mut self) -> Result<Value, String> {
376+ fn product(&mut self) -> Result<Value, Error> {
291 let mut left = self.unary()?;
377 let mut left = self.unary()?;
292 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
378 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
293 let right = self.unary()?;
379 let right = self.unary()?;
294− left = arithmetic(op, &left, &right)?;
380+ left = self.arithmetic(op, &left, &right)?;
295 }
381 }
296 Ok(left)
382 Ok(left)
297 }
383 }
298
384
299− fn unary(&mut self) -> Result<Value, String> {
385+ fn unary(&mut self) -> Result<Value, Error> {
300 if self.eat_op(&["-"]).is_some() {
386 if self.eat_op(&["-"]).is_some() {
301 let value = self.unary()?;
387 let value = self.unary()?;
388+ if self.skip {
389+ return Ok(SKIPPED);
390+ }
302 return value
391 return value
303 .as_num()
392 .as_num()
304 .map(|n| Value::Num(-n))
393 .map(|n| Value::Num(-n))
305− .ok_or_else(|| format!("cannot negate `{}`", value));
394+ .ok_or_else(|| Error::Invalid(format!("cannot negate `{}`", value)));
306 }
395 }
307 self.primary()
396 self.primary()
308 }
397 }
309
398
310− fn primary(&mut self) -> Result<Value, String> {
399+ fn primary(&mut self) -> Result<Value, Error> {
311− let token = self
400+ let (token, at) = self
312− .peek()
401+ .tokens
402+ .get(self.pos)
313 .cloned()
403 .cloned()
314 .ok_or_else(|| "expected a value at the end".to_string())?;
404 .ok_or_else(|| "expected a value at the end".to_string())?;
315 self.pos += 1;
405 self.pos += 1;
316 match token {
406 match token {
317 Token::Num(n) => Ok(Value::Num(n)),
407 Token::Num(n) => Ok(Value::Num(n)),
318− Token::Str(s) => Ok(Value::Str(interpolate(&s, self.resolve))),
408+ Token::Str(_) | Token::Var(_) if self.skip => Ok(SKIPPED),
319− Token::Var(name) => Ok((self.resolve)(&name).unwrap_or(Value::Str(String::new()))),
409+ Token::Str(s) => {
410+ let (text, problems) = interp::interpolate(&s, self.scope);
411+ match problems.into_iter().next() {
412+ None => Ok(Value::Str(text)),
413+ Some(Problem::Undefined { name, offset }) => Err(Error::Undefined {
414+ name,
415+ offset: at + 1 + offset,
416+ }),
417+ Some(Problem::Invalid { message, .. } | Problem::Record { message, .. }) => {
418+ Err(Error::Invalid(message))
419+ }
420+ Some(Problem::CustomProperty { name, .. }) => {
421+ Err(Error::Invalid(interp::custom_property_message(&name)))
422+ }
423+ }
424+ }
425+ Token::Var(name) => {
426+ interp::resolve(&name, self.scope).ok_or(Error::Undefined { name, offset: at })
427+ }
320 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
428 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
321 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
429 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
430+ Token::Word(name) if name == "if" && self.peek() == Some(&Token::LParen) => {
431+ self.pos += 1;
432+ self.choice()
433+ }
322 Token::Word(name) if self.peek() == Some(&Token::LParen) => {
434 Token::Word(name) if self.peek() == Some(&Token::LParen) => {
323 self.pos += 1;
435 self.pos += 1;
324 let mut args = Vec::new();
436 let mut args = Vec::new();
@@ -330,7 +442,11 @@ impl Parser<'_> {
330 }
442 }
331 }
443 }
332 self.expect(Token::RParen)?;
444 self.expect(Token::RParen)?;
333− call(&name, args)
445+ check_call(&name, args.len())?;
446+ if self.skip {
447+ return Ok(SKIPPED);
448+ }
449+ Ok(call(&name, args)?)
334 }
450 }
335 Token::Word(w) => Ok(Value::Str(w)),
451 Token::Word(w) => Ok(Value::Str(w)),
336 Token::LParen => {
452 Token::LParen => {
@@ -338,11 +454,46 @@ impl Parser<'_> {
338 self.expect(Token::RParen)?;
454 self.expect(Token::RParen)?;
339 Ok(value)
455 Ok(value)
340 }
456 }
341− other => Err(format!("unexpected {}", other)),
457+ other => Err(format!("unexpected {}", other).into()),
342 }
458 }
343 }
459 }
344}
460}
345
461
462+impl Parser<'_> {
463+ /// `if(cond, a, b)` after its `(`: the branch that `cond` picks is
464+ /// evaluated, the other only read. Without `b`, a false `cond` gives
465+ /// an empty value.
466+ fn choice(&mut self) -> Result<Value, Error> {
467+ let arguments =
468+ || Error::Invalid("if() takes 2 or 3 arguments: if(CONDITION, A, B)".into());
469+ let condition = self.expr()?;
470+ match self.peek() {
471+ Some(Token::RParen) | None => return Err(arguments()),
472+ _ => self.expect(Token::Comma)?,
473+ }
474+ let taken = condition.truthy();
475+ let then = self.skipping(!taken, Self::expr)?;
476+ let otherwise = if self.peek() == Some(&Token::Comma) {
477+ self.pos += 1;
478+ self.skipping(taken, Self::expr)?
479+ } else {
480+ Value::Str(String::new())
481+ };
482+ if self.peek() == Some(&Token::Comma) {
483+ return Err(arguments());
484+ }
485+ self.expect(Token::RParen)?;
486+ Ok(if taken { then } else { otherwise })
487+ }
488+
489+ fn arithmetic(&self, op: &str, left: &Value, right: &Value) -> Result<Value, Error> {
490+ if self.skip {
491+ return Ok(SKIPPED);
492+ }
493+ Ok(arithmetic(op, left, right)?)
494+ }
495+}
496+
346fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
497fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
347 let (Some(a), Some(b)) = (left.as_num(), right.as_num()) else {
498 let (Some(a), Some(b)) = (left.as_num(), right.as_num()) else {
348 return Err(format!("`{} {} {}` needs numbers", left, op, right));
499 return Err(format!("`{} {} {}` needs numbers", left, op, right));
@@ -359,215 +510,137 @@ fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
359 }))
510 }))
360}
511}
361
512
362−fn is_var_char(c: char) -> bool {
513+/// The built-in functions (other than `if`) and how many arguments each
363− c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
514+/// takes.
515+const FUNCTIONS: &[(&str, usize)] = &[
516+ ("contains", 2),
517+ ("starts-with", 2),
518+ ("ends-with", 2),
519+ ("uppercase", 1),
520+ ("lowercase", 1),
521+ ("capitalize", 1),
522+ ("trim", 1),
523+ ("length", 1),
524+ ("reverse", 1),
525+ ("truncate", 2),
526+ ("replace", 3),
527+ ("default", 2),
528+];
529+
530+/// Check that `name` is a built-in function and gets `count` arguments.
531+fn check_call(name: &str, count: usize) -> Result<(), String> {
532+ let Some(&(_, arity)) = FUNCTIONS.iter().find(|(n, _)| *n == name) else {
533+ return Err(format!("unknown function `{}()`", name));
534+ };
535+ if count == arity {
536+ Ok(())
537+ } else {
538+ Err(format!(
539+ "{}() takes {} argument{}",
540+ name,
541+ arity,
542+ if arity == 1 { "" } else { "s" }
543+ ))
544+ }
364}
545}
365
546
366−/// Call a built-in function.
547+/// Call a built-in function with the right number of arguments (see
548+/// [`check_call`]). The text functions take text (a number or `true` and
549+/// `false` count as their text); a list or a record is an error, except
550+/// for `length`, `contains`, `reverse` and `default`, which also work on
551+/// a list's items.
367fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
552fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
368− let text = |i: usize| args.get(i).map(|v| v.to_string()).unwrap_or_default();
553+ let text = |i: usize| -> Result<String, String> {
554+ match &args[i] {
555+ value @ Value::List(_) => Err(format!(
556+ "{}() takes text, but `{}` is {}: to write one text with commas in \
557+ it, quote it or write `\\,`",
558+ name,
559+ value,
560+ value.describe()
561+ )),
562+ Value::Record(_) => Err(format!(
563+ "{}() takes text, but argument {} is a record: use one of its fields",
564+ name,
565+ i + 1
566+ )),
567+ value => Ok(value.to_string()),
568+ }
569+ };
369 let num = |i: usize| {
570 let num = |i: usize| {
370 args.get(i)
571 args.get(i)
371 .and_then(Value::as_num)
572 .and_then(Value::as_num)
372 .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
573 .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
373 };
574 };
374− let arity = |n: usize| {
375− if args.len() == n {
376− Ok(())
377− } else {
378− Err(format!(
379− "{}() takes {} argument{}",
380− name,
381− n,
382− if n == 1 { "" } else { "s" }
383− ))
384− }
385− };
386− let color = |rgb: (u8, u8, u8)| Value::Str(format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2));
387 Ok(match name {
575 Ok(match name {
388− "if" => {
576+ "contains" => Value::Bool(match &args[0] {
389− arity(3)?;
577+ Value::List(list) => list.items.iter().any(|item| item.equals(&args[1])),
390− let mut args = args;
578+ _ => text(0)?.contains(&text(1)?),
391− let otherwise = args.pop().unwrap();
579+ }),
392− let then = args.pop().unwrap();
580+ "starts-with" => Value::Bool(text(0)?.starts_with(&text(1)?)),
393− if args[0].truthy() { then } else { otherwise }
581+ "ends-with" => Value::Bool(text(0)?.ends_with(&text(1)?)),
394− }
582+ "uppercase" => Value::Str(text(0)?.to_uppercase()),
395− "contains" => {
583+ "lowercase" => Value::Str(text(0)?.to_lowercase()),
396− arity(2)?;
397− Value::Bool(match &args[0] {
398− Value::List(items) => items.contains(&text(1)),
399− other => other.to_string().contains(&text(1)),
400− })
401− }
402− "starts-with" => {
403− arity(2)?;
404− Value::Bool(text(0).starts_with(&text(1)))
405− }
406− "ends-with" => {
407− arity(2)?;
408− Value::Bool(text(0).ends_with(&text(1)))
409− }
410− "uppercase" => {
411− arity(1)?;
412− Value::Str(text(0).to_uppercase())
413− }
414− "lowercase" => {
415− arity(1)?;
416− Value::Str(text(0).to_lowercase())
417− }
418 "capitalize" => {
584 "capitalize" => {
419− arity(1)?;
585+ let s = text(0)?;
420− let s = text(0);
421 let mut chars = s.chars();
586 let mut chars = s.chars();
422 Value::Str(match chars.next() {
587 Value::Str(match chars.next() {
423 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
588 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
424 None => String::new(),
589 None => String::new(),
425 })
590 })
426 }
591 }
427− "trim" => {
592+ "trim" => Value::Str(text(0)?.trim().to_string()),
428− arity(1)?;
593+ "length" => Value::Num(match &args[0] {
429− Value::Str(text(0).trim().to_string())
594+ Value::List(list) => list.items.len(),
430− }
595+ Value::Record(fields) => fields.len(),
431− "length" => {
596+ _ => text(0)?.chars().count(),
432− arity(1)?;
597+ } as f64),
433− Value::Num(match &args[0] {
598+ "reverse" => match &args[0] {
434− Value::List(items) => items.len(),
599+ Value::List(list) => Value::list(list.items.iter().rev().cloned().collect()),
435− other => other.to_string().chars().count(),
600+ _ => Value::Str(text(0)?.chars().rev().collect()),
436− } as f64)
601+ },
437− }
438− "reverse" => {
439− arity(1)?;
440− Value::Str(text(0).chars().rev().collect())
441− }
442 "truncate" => {
602 "truncate" => {
443− arity(2)?;
603+ let (s, n) = (text(0)?, num(1)? as usize);
444− let (s, n) = (text(0), num(1)? as usize);
445 Value::Str(if s.chars().count() > n {
604 Value::Str(if s.chars().count() > n {
446 format!("{}...", s.chars().take(n).collect::<String>())
605 format!("{}...", s.chars().take(n).collect::<String>())
447 } else {
606 } else {
448 s
607 s
449 })
608 })
450 }
609 }
451− "replace" => {
610+ "replace" => Value::Str(text(0)?.replace(&text(1)?, &text(2)?)),
452− arity(3)?;
453− Value::Str(text(0).replace(&text(1), &text(2)))
454− }
455 "default" => {
611 "default" => {
456− arity(2)?;
612+ let empty = match &args[0] {
457− let s = text(0);
613+ Value::List(list) => list.items.is_empty(),
458− Value::Str(if s.is_empty() { text(1) } else { s })
614+ Value::Record(_) => false,
459− }
615+ value => value.to_string().is_empty(),
460− "lighten" | "darken" | "alpha" | "mix" => {
461− let expected = if name == "mix" { 3 } else { 2 };
462− arity(expected)?;
463− let Some(rgb) = parse_hex_rgb(&text(0)) else {
464− return Err(format!("{}() needs a hex color, got `{}`", name, text(0)));
465 };
616 };
466− match name {
617+ let mut args = args;
467− "lighten" => color(lighten_color(rgb, num(1)? / 100.0)),
618+ args.swap_remove(usize::from(empty))
468− "darken" => color(darken_color(rgb, num(1)? / 100.0)),
469− "alpha" => {
470− let a = (num(1)?.clamp(0.0, 1.0) * 255.0) as u8;
471− Value::Str(format!("#{:02x}{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2, a))
472− }
473− _ => {
474− let Some(other) = parse_hex_rgb(&text(1)) else {
475− return Err(format!("mix() needs a hex color, got `{}`", text(1)));
476− };
477− color(mix_colors(rgb, other, num(2)? / 100.0))
478− }
479− }
480 }
619 }
481 _ => return Err(format!("unknown function `{}()`", name)),
620 _ => return Err(format!("unknown function `{}()`", name)),
482 })
621 })
483}
622}
484
623
485−pub(crate) fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
486− let s = s.strip_prefix('#')?;
487− if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
488− return None;
489− }
490− match s.len() {
491− 3 => {
492− let r = u8::from_str_radix(&s[0..1], 16).ok()?;
493− let g = u8::from_str_radix(&s[1..2], 16).ok()?;
494− let b = u8::from_str_radix(&s[2..3], 16).ok()?;
495− Some((r * 17, g * 17, b * 17))
496− }
497− 6 => {
498− let r = u8::from_str_radix(&s[0..2], 16).ok()?;
499− let g = u8::from_str_radix(&s[2..4], 16).ok()?;
500− let b = u8::from_str_radix(&s[4..6], 16).ok()?;
501− Some((r, g, b))
502− }
503− _ => None,
504− }
505−}
506−
507−fn lighten_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
508− let r = rgb.0 as f64 + (255.0 - rgb.0 as f64) * amount.clamp(0.0, 1.0);
509− let g = rgb.1 as f64 + (255.0 - rgb.1 as f64) * amount.clamp(0.0, 1.0);
510− let b = rgb.2 as f64 + (255.0 - rgb.2 as f64) * amount.clamp(0.0, 1.0);
511− (r.round() as u8, g.round() as u8, b.round() as u8)
512−}
513−
514−fn darken_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
515− let factor = 1.0 - amount.clamp(0.0, 1.0);
516− let r = (rgb.0 as f64 * factor).round() as u8;
517− let g = (rgb.1 as f64 * factor).round() as u8;
518− let b = (rgb.2 as f64 * factor).round() as u8;
519− (r, g, b)
520−}
521−
522−fn mix_colors(c1: (u8, u8, u8), c2: (u8, u8, u8), weight: f64) -> (u8, u8, u8) {
523− let w = weight.clamp(0.0, 1.0);
524− let r = (c1.0 as f64 * (1.0 - w) + c2.0 as f64 * w).round() as u8;
525− let g = (c1.1 as f64 * (1.0 - w) + c2.1 as f64 * w).round() as u8;
526− let b = (c1.2 as f64 * (1.0 - w) + c2.2 as f64 * w).round() as u8;
527− (r, g, b)
528−}
529−
530−/// Replace `$name` references inside a string literal.
531−fn interpolate(s: &str, resolve: Resolver) -> String {
532− let mut out = String::with_capacity(s.len());
533− let mut rest = s;
534− while let Some(pos) = rest.find('$') {
535− out.push_str(&rest[..pos]);
536− let after = &rest[pos + 1..];
537− let end = after.find(|c: char| !is_var_char(c)).unwrap_or(after.len());
538− if end == 0 {
539− out.push('$');
540− } else {
541− out.push_str(
542− &resolve(&after[..end])
543− .map(|v| v.to_string())
544− .unwrap_or_default(),
545− );
546− }
547− rest = &after[end..];
548− }
549− out.push_str(rest);
550− out
551−}
552−
553#[cfg(test)]
624#[cfg(test)]
554mod tests {
625mod tests {
555 use super::*;
626 use super::*;
556
627
628+ use crate::interp::tests::{Map, record, text_list};
629+
630+ fn scope() -> Map {
631+ Map::new(&[
632+ ("count", "3"),
633+ ("name", "World"),
634+ ("theme", "dark"),
635+ ("tricky", "a == b"),
636+ ("empty", ""),
637+ ])
638+ .with("tags", text_list(&["rust", "web dev"]))
639+ .with("post", record(&[("title", "Hi")]))
640+ }
641+
557 fn ev(src: &str) -> Value {
642 fn ev(src: &str) -> Value {
558− let resolve = |name: &str| {
643+ eval(src, &scope()).unwrap()
559− let text = |s: &str| Some(Value::Str(s.to_string()));
560− match name {
561− "tags" => Some(Value::List(vec!["rust".into(), "web dev".into()])),
562− "count" => text("3"),
563− "name" => text("World"),
564− "theme" => text("dark"),
565− "tricky" => text("a == b"),
566− "empty" => text(""),
567− _ => None,
568− }
569− };
570− eval(src, &resolve).unwrap()
571 }
644 }
572
645
573 #[test]
646 #[test]
@@ -602,7 +675,62 @@ mod tests {
602 fn strings_and_if() {
675 fn strings_and_if() {
603 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
676 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
604 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
677 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
605− assert_eq!(ev("$missing").to_string(), "");
678+ assert_eq!(ev("${name}").to_string(), "World");
679+ assert_eq!(ev("${$count + 1} * 2").to_string(), "8");
680+ }
681+
682+ #[test]
683+ fn only_the_branch_taken_is_evaluated() {
684+ let zero = Map::new(&[("n", "0"), ("on", "true")]);
685+ let ev0 = |src: &str| eval(src, &zero).map(|v| v.to_string());
686+ assert_eq!(ev0("if($n != 0, 10 / $n, 0)"), Ok("0".into()));
687+ assert_eq!(ev0("if($n == 0, none, 10 / $n)"), Ok("none".into()));
688+ assert_eq!(ev0("if($on, 24)"), Ok("24".into()));
689+ assert_eq!(ev0("if(not $on, 24)"), Ok("".into()));
690+ assert_eq!(
691+ ev0("if($on, \"#10b981\", \"var(--muted)\")"),
692+ Ok("#10b981".into())
693+ );
694+ // and / or stop at the side that decides
695+ assert_eq!(ev0("$n != 0 and 10 / $n > 1"), Ok("false".into()));
696+ assert_eq!(ev0("$n == 0 or 10 / $n > 1"), Ok("true".into()));
697+ assert_eq!(ev0("$on or truncate(abc, x)"), Ok("true".into()));
698+ // The branch not taken is still read: its syntax, function names and
699+ // argument counts are checked; its variables and values are not
700+ assert_eq!(ev0("if($on, 1, $undefined)"), Ok("1".into()));
701+ assert!(eval("if($on, 1, (2)", &zero).is_err());
702+ assert!(eval("if($on, 1, nope(2))", &zero).is_err());
703+ assert!(eval("if($on, 1, uppercase(a, b))", &zero).is_err());
704+ // if() itself takes 2 or 3 arguments
705+ assert!(eval("if($on)", &zero).is_err());
706+ assert!(eval("if($on, 1, 2, 3)", &zero).is_err());
707+ // Something else after the condition is what is reported
708+ let error = eval("if($n.x, 1, 2)", &zero).unwrap_err().to_string();
709+ assert!(error.contains("`.x`"), "{}", error);
710+ // The taken branch still reports its errors
711+ assert!(eval("if($n == 0, 10 / $n, 0)", &zero).is_err());
712+ assert!(eval("$n == 0 and 10 / $n > 1", &zero).is_err());
713+ }
714+
715+ #[test]
716+ fn names_and_fields() {
717+ assert_eq!(ev("$post.title").to_string(), "Hi");
718+ // A field the record doesn't have is empty
719+ assert_eq!(ev("default($post.draft, no)").to_string(), "no");
720+ assert_eq!(
721+ eval("$missing == 1", &scope()),
722+ Err(Error::Undefined {
723+ name: "missing".into(),
724+ offset: 0
725+ })
726+ );
727+ assert_eq!(
728+ eval("\"a $nope\"", &scope()),
729+ Err(Error::Undefined {
730+ name: "nope".into(),
731+ offset: 3
732+ })
733+ );
606 }
734 }
607
735
608 #[test]
736 #[test]
@@ -612,9 +740,6 @@ mod tests {
612 assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
740 assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
613 assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
741 assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
614 assert_eq!(ev("default($empty, none)").to_string(), "none");
742 assert_eq!(ev("default($empty, none)").to_string(), "none");
615− assert_eq!(ev("darken(#ffffff, 50)").to_string(), "#808080");
616− assert_eq!(ev("mix(#000000, #ffffff, 50)").to_string(), "#808080");
617− assert_eq!(ev("alpha(#3b82f6, 0.5)").to_string(), "#3b82f67f");
618 }
743 }
619
744
620 #[test]
745 #[test]
@@ -623,17 +748,54 @@ mod tests {
623 assert!(ev("contains($tags, \"web dev\")").truthy());
748 assert!(ev("contains($tags, \"web dev\")").truthy());
624 assert!(!ev("contains($tags, web)").truthy());
749 assert!(!ev("contains($tags, web)").truthy());
625 assert_eq!(ev("$tags").to_string(), "rust, web dev");
750 assert_eq!(ev("$tags").to_string(), "rust, web dev");
751+ assert_eq!(ev("reverse($tags)").to_string(), "web dev, rust");
752+ assert_eq!(ev("default($tags, none)").to_string(), "rust, web dev");
753+ // Text functions don't guess what a list means
754+ let error = eval("uppercase($tags)", &scope()).unwrap_err().to_string();
755+ assert!(error.contains("a list of 2 items"), "{}", error);
756+ assert!(eval("truncate($post, 2)", &scope()).is_err());
757+ }
758+
759+ #[test]
760+ fn ranges() {
761+ assert_eq!(ev("1..5").to_string(), "1, 2, 3, 4, 5");
762+ assert_eq!(ev("reverse(1..5)").to_string(), "5, 4, 3, 2, 1");
763+ assert_eq!(ev("length(0..10 step 5)").to_string(), "3");
764+ assert_eq!(ev("$count..1").to_string(), "3, 2, 1");
765+ assert_eq!(ev("1..$count + 1").to_string(), "1, 2, 3, 4");
766+ assert!(ev("contains(1..3, 2)").truthy());
767+ assert!(eval("1..x", &scope()).is_err());
768+ assert!(eval("1..5 step 0", &scope()).is_err());
769+ }
770+
771+ #[test]
772+ fn truthiness_and_comparison_follow_the_types() {
773+ let zero = Map::new(&[("z", "0.0"), ("upper", "#FFF")]);
774+ assert!(!eval("$z", &zero).unwrap().truthy());
775+ assert!(eval("$z == 0", &zero).unwrap().truthy());
776+ assert!(!eval("$upper == #fff", &zero).unwrap().truthy());
777+ assert_eq!(ev("100 / 3").to_string(), "33.3333");
778+ assert!(eval("1..3 < 2", &scope()).is_err());
626 }
779 }
627
780
628 #[test]
781 #[test]
629 fn errors_are_reported() {
782 fn errors_are_reported() {
630− let none = |_: &str| None;
783+ let none = Map::new(&[]);
631 assert!(eval("dark * 2", &none).is_err());
784 assert!(eval("dark * 2", &none).is_err());
632 assert!(eval("1 +", &none).is_err());
785 assert!(eval("1 +", &none).is_err());
633 assert!(eval("(1", &none).is_err());
786 assert!(eval("(1", &none).is_err());
634 assert!(eval("1 / 0", &none).is_err());
787 assert!(eval("1 / 0", &none).is_err());
635 assert!(eval("nope(1)", &none).is_err());
788 assert!(eval("nope(1)", &none).is_err());
636 assert!(eval("uppercase(a, b)", &none).is_err());
789 assert!(eval("uppercase(a, b)", &none).is_err());
637− assert!(eval("darken(red, 10)", &none).is_err());
790+ // Colour maths is CSS's (`color-mix()`), and a custom property is
791+ // read with `var()`
792+ for gone in ["darken", "lighten", "alpha", "mix"] {
793+ let error = eval(&format!("{}(red, 10)", gone), &none).unwrap_err();
794+ assert!(error.to_string().contains("unknown function"), "{}", error);
795+ }
796+ let error = eval("$--brand == red", &none).unwrap_err().to_string();
797+ assert!(error.contains("`var(--brand)`"), "{}", error);
798+ let error = eval("\"a $--brand\"", &none).unwrap_err().to_string();
799+ assert!(error.contains("`var(--brand)`"), "{}", error);
638 }
800 }
639}
801}
crates/htmlang-core/src/interp.rsnew file+651
@@ -0,0 +1,651 @@
1+//! Variables in htmlang strings: `$name`, `$record.field` and `${...}`.
2+//!
3+//! The syntax tree finds every slot first: a run of text, an attribute's
4+//! value, an element's argument, a `@let` value, a file path, the title of
5+//! `@page`, the value of `@meta`. Each slot is then interpolated here, once.
6+//! A reference fills its own slot, and what it inserts is never read again
7+//! as htmlang: a value can't become an attribute name, a whole attribute, a
8+//! comma between attributes, markup or another `$name`.
9+//!
10+//! - `$` followed by a letter or `_` starts a name, which goes on with
11+//! letters, digits, `_` and `-` (a `-` only when a name character follows
12+//! it).
13+//! - `$--name` is an error: a custom property is read one way, with
14+//! `var(--name)`, in the browser, so it follows a token redefined under
15+//! `dark:`. See [`custom_property_len`].
16+//! - `.field` continues the name only when the value is a record or a list
17+//! (`$post.title`, `$tags.0`). After `@let lang fr`, `$lang.json` is
18+//! `fr.json`, and `$a..$b` is two names.
19+//! - `${name}` is the same name, delimited (`${size}px`), and `${EXPR}`
20+//! inserts the value of an expression (see `expr.rs`).
21+//! - A `$` before anything else is text: `$5`, `$$`, a `$` at the end.
22+//! - An undefined name is an error. A field that a record doesn't have is
23+//! empty, and so are its fields (`$post.author.name` when `$post` has no
24+//! `author`), so optional fields of `@data` records work.
25+//! - Quoted text (`@let arrow "→ "`, `@card [quote "→ "]`) remembers its
26+//! quotes: a CSS value gets them (`content $arrow` is `content:"→ "`),
27+//! text and HTML attribute values don't. See [`Sink`].
28+
29+use crate::expr;
30+use crate::value::Value;
31+
32+/// The variables in scope, as the evaluator stores them.
33+pub trait Scope {
34+ /// The value of a variable, by its name (without fields).
35+ fn get(&self, name: &str) -> Option<Value>;
36+}
37+
38+/// Where a slot's value goes, which decides what quoted text inserts.
39+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40+pub enum Sink {
41+ /// Text, an HTML attribute value, a path, a parameter: quoted text
42+ /// inserts what it says, without the quotes.
43+ Text,
44+ /// A CSS value: quoted text inserts itself with its quotes, or, inside a
45+ /// quoted string of the value, what it says with `"` escaped as `\"`.
46+ Css,
47+}
48+
49+/// Whether a CSS value is inside a quoted string after `text`, when it
50+/// was (`inside`) before it. A backslash inside a string escapes the next
51+/// character.
52+fn css_string_after(text: &str, mut inside: bool) -> bool {
53+ let mut chars = text.chars();
54+ while let Some(c) = chars.next() {
55+ match c {
56+ '\\' if inside => {
57+ chars.next();
58+ }
59+ '"' => inside = !inside,
60+ _ => {}
61+ }
62+ }
63+ inside
64+}
65+
66+/// `text` as it goes inside a CSS string: `"` escaped as `\"`. A backslash
67+/// and the character after it are a CSS escape and stay as written
68+/// (`\201C`), but a backslash at the end is doubled, so it can't escape
69+/// the rest of the string (`a\` gives `a\\`).
70+pub fn css_string_body(text: &str) -> String {
71+ let mut out = String::with_capacity(text.len());
72+ let mut chars = text.chars();
73+ while let Some(c) = chars.next() {
74+ match c {
75+ '\\' => {
76+ out.push('\\');
77+ out.push(chars.next().unwrap_or('\\'));
78+ }
79+ '"' => out.push_str("\\\""),
80+ _ => out.push(c),
81+ }
82+ }
83+ out
84+}
85+
86+/// A problem found while interpolating, at a byte offset into the text.
87+#[derive(Debug, Clone, PartialEq)]
88+pub enum Problem {
89+ /// A name with no definition.
90+ Undefined { name: String, offset: usize },
91+ /// `$--name`: a custom property, which is read with `var(--name)`.
92+ CustomProperty { name: String, offset: usize },
93+ /// A `${...}` whose expression doesn't evaluate.
94+ Invalid { message: String, offset: usize },
95+ /// A record put where text goes, which it has none of: it would print
96+ /// nothing.
97+ Record { message: String, offset: usize },
98+}
99+
100+/// The problem with putting `value`, written `written`, where text goes:
101+/// a record has no text.
102+fn no_text(written: &str, value: &Value, offset: usize) -> Option<Problem> {
103+ let name = written.strip_prefix('$').map(|name| {
104+ name.strip_prefix('{')
105+ .and_then(|n| n.strip_suffix('}'))
106+ .map_or(name, str::trim)
107+ });
108+ let fields = match value {
109+ Value::Record(fields) => fields,
110+ // A list of records, which would print only what isn't a record
111+ Value::List(list)
112+ if list.written.is_none()
113+ && list
114+ .items
115+ .iter()
116+ .any(|item| matches!(item, Value::Record(_))) =>
117+ {
118+ let example = match (name, list.items.first()) {
119+ (Some(name), Some(Value::Record(fields))) if is_path(name) => {
120+ match fields.first() {
121+ Some((field, _)) => format!(", such as `${}.0.{}`", name, field),
122+ None => String::new(),
123+ }
124+ }
125+ _ => String::new(),
126+ };
127+ return Some(Problem::Record {
128+ message: format!(
129+ "'{}' is a list of records, which has no text of its own: loop over it \
130+ with @each, or write a field of one of its items{}",
131+ written, example
132+ ),
133+ offset,
134+ });
135+ }
136+ _ => return None,
137+ };
138+ let example = match (name, fields.first()) {
139+ (Some(name), Some((field, _))) if is_path(name) => {
140+ format!(", such as `${}.{}`", name, field)
141+ }
142+ _ => String::new(),
143+ };
144+ Some(Problem::Record {
145+ message: format!(
146+ "'{}' is a record, which has no text of its own: write one of its fields{}",
147+ written, example
148+ ),
149+ offset,
150+ })
151+}
152+
153+fn is_name_char(c: char) -> bool {
154+ c.is_alphanumeric() || c == '_'
155+}
156+
157+/// The length of a word at the start of `s` whose first character passes
158+/// `first`: name characters, with `-` between them.
159+fn word_len(s: &str, first: impl Fn(char) -> bool) -> usize {
160+ let Some(c) = s.chars().next().filter(|&c| first(c)) else {
161+ return 0;
162+ };
163+ let mut end = c.len_utf8();
164+ let mut i = end;
165+ while let Some(c) = s[i..].chars().next() {
166+ if is_name_char(c) {
167+ i += c.len_utf8();
168+ end = i;
169+ } else if c == '-' {
170+ let rest = &s[i..];
171+ let dashes = rest.len() - rest.trim_start_matches('-').len();
172+ if !rest[dashes..].starts_with(is_name_char) {
173+ break;
174+ }
175+ i += dashes;
176+ } else {
177+ break;
178+ }
179+ }
180+ end
181+}
182+
183+/// The length of the variable name at the start of `s` (the text after a
184+/// `$`), by syntax alone, or 0 when `s` doesn't start one.
185+pub fn name_len(s: &str) -> usize {
186+ word_len(s, |c| c.is_alphabetic() || c == '_')
187+}
188+
189+/// The length of the custom property's name `--name` at the start of `s`
190+/// (the text after a `$`), or 0. `$--name` is not a variable and not text
191+/// either: it is an error that says to write `var(--name)`.
192+pub fn custom_property_len(s: &str) -> usize {
193+ match s.strip_prefix("--") {
194+ Some(rest) => match word_len(rest, |c| c.is_alphanumeric() || c == '_') {
195+ 0 => 0,
196+ n => n + 2,
197+ },
198+ None => 0,
199+ }
200+}
201+
202+/// What is wrong with `$NAME`, where `name` is a custom property's name
203+/// (`--brand`).
204+pub fn custom_property_message(name: &str) -> String {
205+ format!(
206+ "`${}` is not a variable: a custom property is read with `var({})`",
207+ name, name
208+ )
209+}
210+
211+/// Whether `s` is a whole name path, `name` or `name.field.field`.
212+fn is_path(s: &str) -> bool {
213+ let n = name_len(s);
214+ if n == 0 || matches!(&s[..n], "true" | "false" | "not" | "and" | "or") {
215+ return false;
216+ }
217+ let mut rest = &s[n..];
218+ while let Some(field) = rest.strip_prefix('.') {
219+ let f = word_len(field, is_name_char);
220+ if f == 0 {
221+ return false;
222+ }
223+ rest = &field[f..];
224+ }
225+ rest.is_empty()
226+}
227+
228+/// What a name path leads to so far: a value, or a field that a record
229+/// (or an item that a list) doesn't have, which is empty, and so is every
230+/// field of it.
231+#[derive(Clone)]
232+enum Step {
233+ Value(Value),
234+ Missing,
235+}
236+
237+impl Step {
238+ /// The field `name` of this step, or `None` when it has no fields
239+ /// (text, a number, true or false).
240+ fn field(&self, name: &str) -> Option<Step> {
241+ let Step::Value(value) = self else {
242+ return Some(Step::Missing);
243+ };
244+ match value {
245+ Value::Record(fields) => Some(
246+ fields
247+ .iter()
248+ .find(|(key, _)| key == name)
249+ .map_or(Step::Missing, |(_, value)| Step::Value(value.clone())),
250+ ),
251+ Value::List(list) => Some(
252+ name.parse::<usize>()
253+ .ok()
254+ .and_then(|i| list.items.get(i).cloned())
255+ .map_or(Step::Missing, Step::Value),
256+ ),
257+ _ => None,
258+ }
259+ }
260+
261+ fn value(self) -> Value {
262+ match self {
263+ Step::Value(value) => value,
264+ Step::Missing => Value::empty(),
265+ }
266+ }
267+}
268+
269+/// The name path at the start of `s` (after a `$`) and its length: the
270+/// name, and each `.field` while the value so far is a record or a list,
271+/// or a field one of them doesn't have.
272+fn path_at(s: &str, scope: &dyn Scope) -> Option<(String, usize)> {
273+ let n = name_len(s);
274+ if n == 0 {
275+ return None;
276+ }
277+ let mut path = s[..n].to_string();
278+ let mut len = n;
279+ let mut step = scope.get(&path).map(Step::Value);
280+ while let Some(rest) = s[len..].strip_prefix('.') {
281+ let f = word_len(rest, is_name_char);
282+ let Some(field) = step
283+ .as_ref()
284+ .filter(|_| f > 0)
285+ .and_then(|v| v.field(&rest[..f]))
286+ else {
287+ break;
288+ };
289+ path = format!("{}.{}", path, &rest[..f]);
290+ len += 1 + f;
291+ step = Some(field);
292+ }
293+ Some((path, len))
294+}
295+
296+/// A reference, after its `$`.
297+#[derive(Debug, Clone, PartialEq)]
298+pub enum Reference<'a> {
299+ /// `$name`, `$name.field` or `${name}`: the name path.
300+ Var(String),
301+ /// `${EXPR}`: the expression.
302+ Expr(&'a str),
303+}
304+
305+/// The reference that `s` (the text after a `$`) starts with, and its
306+/// length. `None` when the `$` is text.
307+pub fn reference<'a>(s: &'a str, scope: &dyn Scope) -> Option<(Reference<'a>, usize)> {
308+ if s.starts_with('{') {
309+ let close = matching_brace(s)?;
310+ let inner = &s[1..close];
311+ let reference = if is_path(inner.trim()) {
312+ Reference::Var(inner.trim().to_string())
313+ } else {
314+ Reference::Expr(inner)
315+ };
316+ return Some((reference, close + 1));
317+ }
318+ let (path, len) = path_at(s, scope)?;
319+ Some((Reference::Var(path), len))
320+}
321+
322+/// The value of a name path, or `None` when it is undefined. A field that
323+/// a record doesn't have is empty, and so is any field of it.
324+pub fn resolve(path: &str, scope: &dyn Scope) -> Option<Value> {
325+ let mut parts = path.split('.');
326+ let mut step = Step::Value(scope.get(parts.next()?)?);
327+ for field in parts {
328+ step = step.field(field)?;
329+ }
330+ Some(step.value())
331+}
332+
333+/// Fill in the `$name`s and `${...}`s of one slot's text, for text (see
334+/// [`Sink::Text`]). Each one that can't be filled is left as written and
335+/// reported.
336+pub fn interpolate(text: &str, scope: &dyn Scope) -> (String, Vec<Problem>) {
337+ interpolate_for(text, scope, Sink::Text)
338+}
339+
340+/// [`interpolate`] for a slot whose value goes to `sink`.
341+pub fn interpolate_for(text: &str, scope: &dyn Scope, sink: Sink) -> (String, Vec<Problem>) {
342+ let mut problems = Vec::new();
343+ if !text.contains('$') {
344+ return (text.to_string(), problems);
345+ }
346+ let mut out = String::with_capacity(text.len());
347+ let mut pos = 0;
348+ // Whether the CSS value is inside a quoted string at `pos`
349+ let mut in_string = false;
350+ while let Some(found) = text[pos..].find('$') {
351+ let dollar = pos + found;
352+ out.push_str(&text[pos..dollar]);
353+ if sink == Sink::Css {
354+ in_string = css_string_after(&text[pos..dollar], in_string);
355+ }
356+ let after = &text[dollar + 1..];
357+ let Some((reference, len)) = reference(after, scope) else {
358+ let n = custom_property_len(after);
359+ if n > 0 {
360+ problems.push(Problem::CustomProperty {
361+ name: after[..n].to_string(),
362+ offset: dollar,
363+ });
364+ }
365+ out.push_str(&text[dollar..dollar + 1 + n]);
366+ pos = dollar + 1 + n;
367+ continue;
368+ };
369+ let written = &text[dollar..dollar + 1 + len];
370+ let insert = |value: String, quoted: Option<String>| match (sink, quoted) {
371+ (Sink::Css, Some(css)) if in_string => {
372+ // The string's body, without its quotes
373+ let body = css.strip_prefix('"').unwrap_or(&css);
374+ body.strip_suffix('"').unwrap_or(body).to_string()
375+ }
376+ (Sink::Css, Some(css)) => css,
377+ (Sink::Css, None) if in_string => css_string_body(&value),
378+ _ => value,
379+ };
380+ match reference {
381+ Reference::Var(path) => match resolve(&path, scope) {
382+ Some(value) => match no_text(written, &value, dollar) {
383+ Some(problem) => problems.push(problem),
384+ None => out.push_str(&insert(
385+ value.to_string(),
386+ value.quoted_css().map(str::to_string),
387+ )),
388+ },
389+ None => {
390+ problems.push(Problem::Undefined {
391+ name: path,
392+ offset: dollar,
393+ });
394+ out.push_str(written);
395+ }
396+ },
397+ Reference::Expr(source) => match expr::eval(source, scope) {
398+ Ok(value) => match no_text(written, &value, dollar) {
399+ Some(problem) => problems.push(problem),
400+ None => out.push_str(&insert(value.to_string(), None)),
401+ },
402+ Err(error) => {
403+ problems.push(match error {
404+ // At the `${`
405+ expr::Error::Invalid(message) => Problem::Invalid {
406+ message,
407+ offset: dollar,
408+ },
409+ // At the name, inside the braces
410+ error => error.at(dollar + 2),
411+ });
412+ out.push_str(written);
413+ }
414+ },
415+ }
416+ pos = dollar + 1 + len;
417+ }
418+ out.push_str(&text[pos..]);
419+ (out, problems)
420+}
421+
422+/// The names `text` refers to, by syntax alone: the first name of each
423+/// path, including those inside `${...}`.
424+pub fn names(text: &str) -> Vec<&str> {
425+ name_spans(text)
426+ .into_iter()
427+ .map(|span| &text[span])
428+ .collect()
429+}
430+
431+/// Where the names of [`names`] are in `text`, as byte ranges of the name
432+/// alone: after the `$` of `$name`, inside the braces of `${name}`.
433+pub fn name_spans(text: &str) -> Vec<std::ops::Range<usize>> {
434+ let mut found = Vec::new();
435+ collect_name_spans(text, 0, &mut found);
436+ found
437+}
438+
439+fn collect_name_spans(text: &str, base: usize, found: &mut Vec<std::ops::Range<usize>>) {
440+ let mut pos = 0;
441+ while let Some(i) = text[pos..].find('$') {
442+ let after = pos + i + 1;
443+ let rest = &text[after..];
444+ pos = after;
445+ // `\$` is a dollar sign (and `\\$` a backslash before a name)
446+ let backslashes = text[..after - 1].len() - text[..after - 1].trim_end_matches('\\').len();
447+ if backslashes % 2 == 1 {
448+ continue;
449+ }
450+ if rest.starts_with('{')
451+ && let Some(close) = matching_brace(rest)
452+ {
453+ let inner = &rest[1..close];
454+ let trimmed = inner.trim_start();
455+ if is_path(trimmed.trim_end()) {
456+ let start = base + after + 1 + inner.len() - trimmed.len();
457+ found.push(start..start + name_len(trimmed));
458+ } else {
459+ collect_name_spans(inner, base + after + 1, found);
460+ }
461+ pos = after + close + 1;
462+ continue;
463+ }
464+ let n = name_len(rest);
465+ if n > 0 {
466+ found.push(base + after..base + after + n);
467+ pos = after + n;
468+ }
469+ }
470+}
471+
472+/// Index of the `}` matching the `{` that `s` starts with (skipping
473+/// braces inside string literals).
474+pub(crate) fn matching_brace(s: &str) -> Option<usize> {
475+ let mut depth = 0;
476+ let mut in_string = false;
477+ for (i, c) in s.char_indices() {
478+ match c {
479+ '"' => in_string = !in_string,
480+ _ if in_string => {}
481+ '{' => depth += 1,
482+ '}' => {
483+ depth -= 1;
484+ if depth == 0 {
485+ return Some(i);
486+ }
487+ }
488+ _ => {}
489+ }
490+ }
491+ None
492+}
493+
494+#[cfg(test)]
495+pub(crate) mod tests {
496+ use super::*;
497+ use std::collections::HashMap;
498+
499+ /// Variables by name: text from `new`, any value from `with`.
500+ pub(crate) struct Map(pub HashMap<String, Value>);
501+
502+ impl Map {
503+ pub(crate) fn new(pairs: &[(&str, &str)]) -> Self {
504+ Map(pairs
505+ .iter()
506+ .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
507+ .collect())
508+ }
509+
510+ pub(crate) fn with(mut self, name: &str, value: Value) -> Self {
511+ self.0.insert(name.to_string(), value);
512+ self
513+ }
514+ }
515+
516+ impl Scope for Map {
517+ fn get(&self, name: &str) -> Option<Value> {
518+ self.0.get(name).cloned()
519+ }
520+ }
521+
522+ pub(crate) fn text_list(items: &[&str]) -> Value {
523+ Value::list(items.iter().map(|s| Value::Str(s.to_string())).collect())
524+ }
525+
526+ pub(crate) fn record(fields: &[(&str, &str)]) -> Value {
527+ Value::Record(std::rc::Rc::new(
528+ fields
529+ .iter()
530+ .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
531+ .collect(),
532+ ))
533+ }
534+
535+ fn vars() -> Map {
536+ Map::new(&[("lang", "fr"), ("n", "3"), ("n-1", "two")])
537+ .with("post", record(&[("title", "Hello")]))
538+ .with("tags", text_list(&["a", "b"]))
539+ }
540+
541+ fn fill(text: &str) -> String {
542+ interpolate(text, &vars()).0
543+ }
544+
545+ #[test]
546+ fn names_end_where_they_must() {
547+ assert_eq!(fill("locales/$lang.json"), "locales/fr.json");
548+ assert_eq!(fill("$post.title!"), "Hello!");
549+ assert_eq!(fill("$tags.1 and $tags"), "b and a, b");
550+ assert_eq!(fill("${lang}uage"), "fruage");
551+ assert_eq!(fill("$lang- $lang:"), "fr- fr:");
552+ assert_eq!(fill("$n-1"), "two");
553+ assert_eq!(fill("1..$n"), "1..3");
554+ // A name ends at `..`
555+ assert_eq!(fill("$n..$n $post.title..x"), "3..3 Hello..x");
556+ }
557+
558+ #[test]
559+ fn a_dollar_before_anything_else_is_text() {
560+ assert_eq!(fill("$5 and $$ and $"), "$5 and $$ and $");
561+ assert_eq!(fill("a $-x ${"), "a $-x ${");
562+ }
563+
564+ #[test]
565+ fn a_custom_property_is_not_a_variable() {
566+ let (out, problems) = interpolate("x $--brand-dark $-- $--1", &vars());
567+ assert_eq!(out, "x $--brand-dark $-- $--1");
568+ assert_eq!(
569+ problems,
570+ [
571+ Problem::CustomProperty {
572+ name: "--brand-dark".into(),
573+ offset: 2
574+ },
575+ Problem::CustomProperty {
576+ name: "--1".into(),
577+ offset: 20
578+ }
579+ ]
580+ );
581+ assert!(custom_property_message("--brand").contains("`var(--brand)`"));
582+ assert_eq!(names("$--e $f"), ["f"]);
583+ }
584+
585+ #[test]
586+ fn missing_fields_are_empty_and_undefined_names_are_errors() {
587+ assert_eq!(fill("[$post.summary]"), "[]");
588+ let (out, problems) = interpolate("x $nope y", &vars());
589+ assert_eq!(out, "x $nope y");
590+ assert_eq!(
591+ problems,
592+ [Problem::Undefined {
593+ name: "nope".into(),
594+ offset: 2
595+ }]
596+ );
597+ }
598+
599+ #[test]
600+ fn expressions() {
601+ assert_eq!(fill("${$n * 2}px"), "6px");
602+ let (_, problems) = interpolate("a ${$n + $m}", &vars());
603+ assert_eq!(
604+ problems,
605+ [Problem::Undefined {
606+ name: "m".into(),
607+ offset: 9
608+ }]
609+ );
610+ }
611+
612+ #[test]
613+ fn quoted_text_keeps_its_quotes_only_in_css() {
614+ let quoted = Value::Quoted(crate::ast::Quoted {
615+ text: r#"a"b"#.into(),
616+ css: r#""a\"b""#.into(),
617+ });
618+ let scope = Map::new(&[("n", r#"x"y"#)]).with("q", quoted);
619+ let fill = |text: &str, sink| interpolate_for(text, &scope, sink).0;
620+ assert_eq!(fill("$q", Sink::Text), r#"a"b"#);
621+ assert_eq!(fill("$q", Sink::Css), r#""a\"b""#);
622+ // Inside a CSS string, what it says, escaped as the string needs
623+ assert_eq!(
624+ fill(r#""($q) $n" $q"#, Sink::Css),
625+ r#""(a\"b) x\"y" "a\"b""#
626+ );
627+ assert_eq!(fill(r#""\" $q""#, Sink::Css), r#""\" a\"b""#);
628+ // What an unquoted value holds goes into a string that stays valid
629+ let scope = Map::new(&[("end", r"a\"), ("esc", r"\201C"), ("q", r#"x\"y"#)]);
630+ let fill = |text: &str| interpolate_for(text, &scope, Sink::Css).0;
631+ assert_eq!(fill(r#""$end""#), r#""a\\""#);
632+ assert_eq!(fill(r#""$esc""#), r#""\201C""#);
633+ assert_eq!(fill(r#""$q""#), r#""x\"y""#);
634+ }
635+
636+ #[test]
637+ fn inserted_values_are_never_read_again() {
638+ let scope = Map::new(&[("a", "$b"), ("b", "no")]);
639+ assert_eq!(interpolate("$a", &scope).0, "$b");
640+ }
641+
642+ #[test]
643+ fn names_by_syntax() {
644+ assert_eq!(names("$a.b ${c} ${upper($d)} $5 $--e"), ["a", "c", "d"]);
645+ assert_eq!(names(r"\$a \\$b \\\$c"), ["b"]);
646+ assert_eq!(
647+ name_spans("x ${ size }px ${$n + ${m}} $a- b"),
648+ [5..9, 17..18, 23..24, 28..29]
649+ );
650+ }
651+}
crates/htmlang-core/src/lib.rs+4−1
@@ -1,8 +1,11 @@
1pub mod ast;
1pub mod ast;
2pub mod codegen;
2pub mod codegen;
3+pub mod diagnostic;
3pub mod expr;
4pub mod expr;
5+pub mod interp;
4pub mod parser;
6pub mod parser;
5−mod syntax;
7+pub mod syntax;
8+pub mod value;
6pub mod vocab;
9pub mod vocab;
7
10
8#[cfg(test)]
11#[cfg(test)]
crates/htmlang-core/src/parser.rs+5957−2084
@@ -1,48 +1,26 @@
1use std::collections::{HashMap, HashSet};
1use std::collections::{HashMap, HashSet};
2−use std::fmt;
3use std::path::{Path, PathBuf};
2use std::path::{Path, PathBuf};
3+use std::rc::Rc;
4
4
5use crate::ast::*;
5use crate::ast::*;
6−use crate::syntax::{self, Syntax};
6+use crate::diagnostic::code;
7+pub use crate::diagnostic::{Diagnostic, Severity};
8+use crate::interp::{self, Sink};
9+use crate::syntax::{self, DirectiveArgs, LetForm, NodeKind, Segment, Tree};
10+use crate::value::{self, Value};
7
11
8// ---------------------------------------------------------------------------
12// ---------------------------------------------------------------------------
9// Public types
13// Public types
10// ---------------------------------------------------------------------------
14// ---------------------------------------------------------------------------
11
15
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−
29pub struct ParseResult {
16pub struct ParseResult {
30 pub document: Document,
17 pub document: Document,
31 pub diagnostics: Vec<Diagnostic>,
18 pub diagnostics: Vec<Diagnostic>,
32 pub included_files: Vec<PathBuf>,
19 pub included_files: Vec<PathBuf>,
33}
20}
34
21
35−#[derive(Debug)]
22+/// An error that stops one line from being evaluated.
36−struct ParseError {
23+type ParseError = Diagnostic;
37− line: usize,
38− message: String,
39−}
40−
41−impl fmt::Display for ParseError {
42− fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
43− write!(f, "line {}: {}", self.line, self.message)
44− }
45−}
46
24
47// ---------------------------------------------------------------------------
25// ---------------------------------------------------------------------------
48// Internal types
26// Internal types
@@ -50,27 +28,166 @@ impl fmt::Display for ParseError {
50
28
51#[derive(Clone)]
29#[derive(Clone)]
52struct FnDef {
30struct FnDef {
53− params: Vec<String>,
31+ /// The parameters in the order they are declared; one without a
54− defaults: HashMap<String, String>,
32+ /// default is required.
55− body: Vec<Syntax>,
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+ source: Option<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`, nested under its `.hl-fn-NAME` class: written
48+ /// into the page once the function is called (see
49+ /// `Document::scoped_css`).
50+ scoped: Option<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+}
60+
61+impl FnDef {
62+ fn is_param(&self, name: &str) -> bool {
63+ self.params.iter().any(|p| p.name == name)
64+ }
65+}
66+
67+/// What a name means: every `@let`, parameter, loop variable and `@data`
68+/// binds one, and they share one namespace.
69+#[derive(Clone)]
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+
163+impl interp::Scope for Env {
164+ fn get(&self, name: &str) -> Option<Value> {
165+ self.value(name).cloned()
166+ }
56}
167}
57
168
58struct ParseContext {
169struct ParseContext {
59 /// Source line currently being parsed, for diagnostics raised deep
170 /// Source line currently being parsed, for diagnostics raised deep
60 /// inside helpers that don't take a line number.
171 /// inside helpers that don't take a line number.
61 current_line: usize,
172 current_line: usize,
62− /// Functions whose body has a scoped @style block.
173+ /// What the `@page` said, once one has run
63− scoped_functions: std::collections::HashSet<String>,
174+ page: Option<Page>,
64− page_title: Option<String>,
175+ /// Where that `@page` is, for the error a second one gets
65− lang: Option<String>,
176+ page_at: Option<String>,
66− favicon: Option<String>,
67 meta_tags: Vec<(String, String)>,
177 meta_tags: Vec<(String, String)>,
68 head_blocks: Vec<String>,
178 head_blocks: Vec<String>,
69− variables: HashMap<String, String>,
179+ /// The first `@meta` or `@head` that ran, for the warning a file
70− defines: HashMap<String, Vec<Attribute>>,
180+ /// without `@page` gets: a fragment has no `<head>` for them.
71− functions: HashMap<String, FnDef>,
181+ head_at: Option<usize>,
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>>,
72 css_vars: Vec<(String, String)>,
187 css_vars: Vec<(String, String)>,
73 custom_css: Vec<String>,
188 custom_css: Vec<String>,
189+ /// The scoped `@style` of each function called so far
190+ scoped_css: Vec<String>,
74 og_tags: Vec<(String, String)>,
191 og_tags: Vec<(String, String)>,
75 diagnostics: Vec<Diagnostic>,
192 diagnostics: Vec<Diagnostic>,
76 base_path: Option<PathBuf>,
193 base_path: Option<PathBuf>,
@@ -90,35 +207,550 @@ struct ParseContext {
90 fn_lines: HashMap<String, usize>,
207 fn_lines: HashMap<String, usize>,
91 /// Line numbers of attribute bundle definitions (name -> line)
208 /// Line numbers of attribute bundle definitions (name -> line)
92 define_lines: HashMap<String, usize>,
209 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+ })
93}
320}
94
321
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+
95/// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes.
326/// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes.
96struct Evaluator;
327struct Evaluator;
97
328
98impl ParseContext {
329impl ParseContext {
99− /// Evaluate an expression (see `expr.rs`), reporting errors at `line`.
330+ /// Bind a value to `name` in the innermost block.
100− fn eval(&mut self, src: &str, line: usize) -> Option<crate::expr::Value> {
331+ fn bind(&mut self, name: &str, value: 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> {
101 track_var_refs(src, &mut self.used_variables);
343 track_var_refs(src, &mut self.used_variables);
102− let vars = &self.variables;
344+ match crate::expr::eval(src, &self.env) {
103− let result = crate::expr::eval(src, &|name: &str| lookup(vars, name));
104− match result {
105 Ok(value) => Some(value),
345 Ok(value) => Some(value),
106− Err(message) => {
346+ Err(error) => {
107− self.diagnostics.push(Diagnostic {
347+ // Without the line to point into, show the expression
108− line,
348+ let shown = matches!(&self.current_source, (current, Some(_)) if *current == line);
109− column: None,
349+ let before = self.diagnostics.len();
110− message: format!("invalid expression: {}", message),
350+ self.report(error.at(0), line, column);
111− severity: Severity::Error,
351+ if !shown && let Some(diagnostic) = self.diagnostics.get_mut(before) {
112− source_line: Some(src.to_string()),
352+ diagnostic.source_line = Some(src.into());
113− });
353+ }
114 None
354 None
115 }
355 }
116 }
356 }
117 }
357 }
118
358
119 /// Evaluate a condition; an invalid one is reported and counts as false.
359 /// Evaluate a condition; an invalid one is reported and counts as false.
120− fn condition(&mut self, src: &str, line: usize) -> bool {
360+ fn condition(&mut self, src: &str, line: usize, column: Option<usize>) -> bool {
121− self.eval(src, line).is_some_and(|value| value.truthy())
361+ self.eval(src, line, column)
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+ }
122 }
754 }
123}
755}
124
756
@@ -127,11 +759,19 @@ impl ParseContext {
127const PRELUDE: &str = include_str!("std.hl");
759const PRELUDE: &str = include_str!("std.hl");
128
760
129fn load_prelude(ctx: &mut ParseContext) {
761fn load_prelude(ctx: &mut ParseContext) {
130− let _ = Evaluator.eval_block(&syntax::parse(PRELUDE), ctx);
762+ // The library's definitions are in a block of their own, around the
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;
131 // Library definitions aren't the file's own: never report them unused.
770 // Library definitions aren't the file's own: never report them unused.
132 ctx.fn_lines.clear();
771 ctx.fn_lines.clear();
133 ctx.define_lines.clear();
772 ctx.define_lines.clear();
134 ctx.let_lines.clear();
773 ctx.let_lines.clear();
774+ ctx.env.push();
135}
775}
136
776
137pub fn parse(input: &str) -> ParseResult {
777pub fn parse(input: &str) -> ParseResult {
@@ -139,20 +779,18 @@ pub fn parse(input: &str) -> ParseResult {
139}
779}
140
780
141pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
781pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
142− let tree = syntax::parse(input);
143 let mut ctx = ParseContext {
782 let mut ctx = ParseContext {
144 current_line: 0,
783 current_line: 0,
145− scoped_functions: std::collections::HashSet::new(),
784+ page: None,
146− page_title: None,
785+ page_at: None,
147− lang: None,
148− favicon: None,
149 meta_tags: Vec::new(),
786 meta_tags: Vec::new(),
150 head_blocks: Vec::new(),
787 head_blocks: Vec::new(),
151− variables: HashMap::new(),
788+ head_at: None,
152− defines: HashMap::new(),
789+ env: Env::default(),
153− functions: HashMap::new(),
790+ defined_functions: HashMap::new(),
154 css_vars: Vec::new(),
791 css_vars: Vec::new(),
155 custom_css: Vec::new(),
792 custom_css: Vec::new(),
793+ scoped_css: Vec::new(),
156 og_tags: Vec::new(),
794 og_tags: Vec::new(),
157 diagnostics: Vec::new(),
795 diagnostics: Vec::new(),
158 base_path: base_path.map(|p| p.to_path_buf()),
796 base_path: base_path.map(|p| p.to_path_buf()),
@@ -166,22 +804,84 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
166 let_lines: HashMap::new(),
804 let_lines: HashMap::new(),
167 fn_lines: HashMap::new(),
805 fn_lines: HashMap::new(),
168 define_lines: HashMap::new(),
806 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,
169 };
819 };
170 load_prelude(&mut ctx);
820 load_prelude(&mut ctx);
171− let nodes = Evaluator.eval_block(&tree, &mut ctx);
821+ let tree = ctx.parse_tree(input);
172− validate_tree(&nodes, None, &mut ctx.diagnostics);
822+ ctx.diagnostics.extend(tree.diagnostics.iter().cloned());
173− check_unused(&mut ctx);
823+ ctx.trees.push((tree.clone(), None));
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+ }
174 ParseResult {
875 ParseResult {
175 document: Document {
876 document: Document {
176− page_title: ctx.page_title,
877+ page: ctx.page,
177− lang: ctx.lang,
178− favicon: ctx.favicon,
179 meta_tags: ctx.meta_tags,
878 meta_tags: ctx.meta_tags,
180 head_blocks: ctx.head_blocks,
879 head_blocks: ctx.head_blocks,
181− variables: ctx.variables,
880+ variables,
182− defines: ctx.defines,
881+ defines,
183 css_vars: ctx.css_vars,
882 css_vars: ctx.css_vars,
184 custom_css: ctx.custom_css,
883 custom_css: ctx.custom_css,
884+ scoped_css: ctx.scoped_css,
185 og_tags: ctx.og_tags,
885 og_tags: ctx.og_tags,
186 nodes,
886 nodes,
187 },
887 },
@@ -191,458 +891,873 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
191}
891}
192
892
193// ---------------------------------------------------------------------------
893// ---------------------------------------------------------------------------
194−// Preprocessing: strip comments/blanks, collapse @raw blocks
894+// Code that never runs: an untaken branch, an uncalled function, a loop over
195−// ---------------------------------------------------------------------------
895+// an empty list
196−
197−// ---------------------------------------------------------------------------
198−// Evaluation
199// ---------------------------------------------------------------------------
896// ---------------------------------------------------------------------------
200
897
201−impl Evaluator {
898+/// Collect the functions defined anywhere in `nodes` and in the files they
202− fn eval_block(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec<Node> {
899+/// include (by a literal path), into `ctx.namespace`.
203− let mut nodes = Vec::new();
900+fn collect_namespace(
204− for node in block {
901+ nodes: &[syntax::Node],
205− match self.eval(node, ctx) {
902+ base: Option<PathBuf>,
206− Ok(Some(new_nodes)) => nodes.extend(new_nodes),
903+ seen: &mut HashSet<PathBuf>,
207− Ok(None) => {}
904+ ctx: &mut ParseContext,
208− // Record the error and go on, to report several in one pass
905+) {
209− Err(e) => ctx.diagnostics.push(Diagnostic {
906+ let mut includes = Vec::new();
210− line: e.line,
907+ for node in nodes {
211− column: None,
908+ node.walk(&mut |node| match node.directive().map(|d| &d.args) {
212− message: e.message,
909+ Some(DirectiveArgs::Let(def)) if matches!(def.form, LetForm::Function(_)) => {
213− severity: Severity::Error,
910+ ctx.namespace.insert(def.name.clone());
214− source_line: Some(node.source()),
215− }),
216 }
911 }
912+ Some(DirectiveArgs::Text(Some(path))) if node.is_directive("include") => {
913+ includes.push(path.raw.clone());
914+ }
915+ _ => {}
916+ });
917+ }
918+ for path in includes {
919+ let path = path.trim_matches('"');
920+ if path.contains('$') {
921+ continue;
217 }
922 }
218− nodes
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+ }
930+ let Ok(text) = ctx.read_file(&resolved) else {
931+ continue;
932+ };
933+ let tree = syntax::parse(&text);
934+ let parent = resolved.parent().map(Path::to_path_buf);
935+ collect_namespace(&tree.nodes, parent, seen, ctx);
219 }
936 }
937+}
220
938
221− /// Evaluate a block in its own scope: `@let` inside doesn't leak out.
939+/// Check the lines that were never evaluated: element and function names,
222− fn eval_scoped(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec<Node> {
940+/// and the attributes of built-in elements (those that don't depend on a
223− let saved_vars = ctx.variables.clone();
941+/// variable). Names that come from data are only checked when evaluated.
224− let nodes = self.eval_block(block, ctx);
942+fn check_unevaluated(ctx: &mut ParseContext) {
225− ctx.variables = saved_vars;
943+ let trees = std::mem::take(&mut ctx.trees);
226− nodes
944+ let mut seen = HashSet::new();
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);
227 }
959 }
960+ ctx.trees = trees;
961+}
228
962
229− fn eval(
963+fn check_unevaluated_node(node: &syntax::Node, ctx: &mut ParseContext) {
230− &mut self,
964+ if !ctx.visited.contains(&node.id) {
231− node: &Syntax,
965+ let line = node.span.line;
232− ctx: &mut ParseContext,
966+ // A reference from code that doesn't run still counts as a use
233− ) -> Result<Option<Vec<Node>>, ParseError> {
967+ track_var_refs(&node.source, &mut ctx.used_variables);
234− let line_num = node.line();
968+ let before = ctx.diagnostics.len();
235− ctx.current_line = line_num;
969+ match &node.kind {
236− let (content, current_indent, children) = match node {
970+ NodeKind::Element(element) => {
237− Syntax::Raw { text, .. } => return Ok(Some(vec![Node::Raw(text.clone())])),
971+ for head in &element.chain {
238− Syntax::Function {
972+ check_head(head, line, ctx);
239− name,
973+ }
240− params,
974+ check_unevaluated_content(node, element, ctx);
241− defaults,
975+ if let Some(text) = &element.text {
242− body,
976+ check_inline_heads(text, line, ctx);
243− ..
244− } => {
245− self.define_function(name, params, defaults, body, line_num, ctx);
246− return Ok(None);
247− }
248− Syntax::If { branches } => {
249− // Every condition is checked (and its errors reported);
250− // the first that holds picks the branch.
251− let mut chosen = None;
252− for branch in branches {
253− let holds = match &branch.condition {
254− Some(condition) => ctx.condition(condition, branch.line),
255− None => true,
256− };
257− if holds && chosen.is_none() {
258− chosen = Some(&branch.body);
259− }
260 }
977 }
261− return Ok(chosen.map(|body| self.eval_scoped(body, ctx)));
262 }
978 }
263− Syntax::Each {
979+ NodeKind::Text(text) => check_inline_heads(text, line, ctx),
264− header,
980+ // `@page`'s attributes are checked like an element's (a layout's
265− body,
981+ // `@page` is in a function that may not run in this file)
266− empty,
982+ NodeKind::Directive(syntax::Directive {
983+ args:
984+ DirectiveArgs::Page {
985+ attrs: Some(list), ..
986+ },
267 ..
987 ..
268− } => {
988+ }) => {
269− return self.eval_each(header, body, empty, line_num, ctx).map(Some);
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);
270 }
997 }
271− Syntax::Line {
998+ _ => {}
272− text,
273− indent,
274− children,
275− ..
276− } => (text.clone(), *indent, children),
277− };
278−
279− // --- Directives ---
280−
281− // @page [lang en, favicon /f.png] Title
282− if let Some(rest) = content.strip_prefix("@page ") {
283− let rest = rest.trim_start();
284− let title = if rest.starts_with('[') {
285− let (attrs, title) = parse_attr_brackets_no_validate(rest, line_num, ctx)?;
286− for attr in attrs {
287− let value = attr.value.unwrap_or_default();
288− match attr.key.as_str() {
289− "lang" => ctx.lang = Some(value),
290− "favicon" => ctx.favicon = Some(value),
291− other => ctx.diagnostics.push(Diagnostic {
292− line: line_num,
293− column: None,
294− message: format!(
295− "unknown @page attribute '{}' (expected lang or favicon)",
296− other
297− ),
298− severity: Severity::Warning,
299− source_line: Some(content.clone()),
300− }),
301− }
302− }
303− title
304− } else {
305− rest.to_string()
306− };
307− ctx.page_title = Some(substitute_vars(title.trim(), &ctx.variables));
308− return Ok(None);
309 }
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+}
310
1009
311− if let Some(rest) = content.strip_prefix("@let ") {
1010+/// Check the content passed to the calls of a line that was never
312− let rest = rest.trim();
1011+/// evaluated: its `@slot NAME` blocks, and, for a function without
313−
1012+/// `@children`, any other content.
314− if !children.is_empty() {
1013+fn check_unevaluated_content(
315− return Err(ParseError {
1014+ node: &syntax::Node,
316− line: line_num,
1015+ element: &syntax::ElementLine,
317− message: "@let with body requires a name".to_string(),
1016+ ctx: &mut ParseContext,
318− });
1017+) {
319− }
1018+ let calls: Vec<Option<(String, Rc<FnDef>)>> = element
320−
1019+ .chain
321− if let Some((name, value)) = rest.split_once(' ') {
1020+ .iter()
322− let value = value.trim();
1021+ .map(|head| {
323− // `@let name = EXPR` computes its value (see expr.rs); any
1022+ let function = ctx.defined_functions.get(&head.name)?;
324− // other value is literal text with `$var` interpolation.
1023+ Some((head.name.clone(), function.clone()))
325− if let Some(expression) = value.strip_prefix('=') {
1024+ })
326− let value = ctx
1025+ .collect();
327− .eval(expression.trim(), line_num)
1026+ check_fillers(node, element, &calls, ctx);
328− .map(|v| v.to_string())
1027+ let last = calls.len() - 1;
329− .unwrap_or_default();
1028+ for (i, call) in calls.iter().enumerate() {
330− if name.starts_with("--") {
1029+ let Some((name, function)) = call else {
331− ctx.css_vars.push((name.to_string(), value.clone()));
1030+ continue;
332− }
1031+ };
333− ctx.variables.insert(name.to_string(), value);
1032+ if function.has_children || function.wraps_itself {
334− ctx.let_lines.entry(name.to_string()).or_insert(line_num);
1033+ continue;
335− return Ok(None);
1034+ }
336− }
1035+ let content = if i == last {
337−
1036+ element.text.is_some() || holds_content(&node.children, ctx)
338− // Attribute bundle: @let name [attr1, attr2, ...]
1037+ } else {
339− if value.starts_with('[') {
1038+ element.chain[i + 1].name != "slot" || !slot_is_built_in(ctx)
340− let (attrs, _) = parse_attr_brackets(value, line_num, ctx)?;
1039+ };
341− ctx.defines.insert(name.to_string(), attrs);
1040+ if content {
342− ctx.define_lines.entry(name.to_string()).or_insert(line_num);
1041+ let head = &element.chain[i];
343− return Ok(None);
1042+ ctx.push_once(no_children(
344− }
1043+ name,
1044+ node.span.line,
1045+ name_column(head, node.span.line),
1046+ ));
1047+ }
1048+ }
1049+}
345
1050
346− let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
1051+/// Check a head that was never evaluated: its name, and its attributes
347− // Support quoted string interpolation: @let greeting "Hello $name"
1052+/// (for a call, see [`check_call`]).
348− &value[1..value.len() - 1]
1053+fn check_head(head: &syntax::Head, line: usize, ctx: &mut ParseContext) {
349− } else {
1054+ let is_function =
350− value
1055+ ctx.namespace.contains(&head.name) || ctx.defined_functions.contains_key(&head.name);
351− };
1056+ if is_function {
352− track_var_refs(value, &mut ctx.used_variables);
1057+ ctx.used_functions.insert(head.name.clone());
353− let value = substitute_vars(value, &ctx.variables);
1058+ if let Some(function) = ctx.defined_functions.get(&head.name).cloned()
354− if name.starts_with("--") {
1059+ && !function.wraps_itself
355− // CSS custom property
1060+ {
356− ctx.css_vars.push((name.to_string(), value.clone()));
1061+ check_call(head, &function, line, ctx);
1062+ }
1063+ return;
1064+ }
1065+ let kind = match parse_element_kind(&head.name, line, ctx) {
1066+ Ok(kind) => kind,
1067+ Err(e) => {
1068+ ctx.diagnostics.push(e);
1069+ return;
1070+ }
1071+ };
1072+ if let Some(list) = &head.attrs {
1073+ check_attrs(&list.attrs, line, ctx, &[]);
1074+ // A word for laying out children on an element that doesn't lay
1075+ // them out, as where the code runs (see `validate_tree`)
1076+ if !kind.layout().is_container() && !has_no_element(&kind) {
1077+ for attr in &list.attrs {
1078+ let base = crate::vocab::base_attribute(&attr.key);
1079+ if !attr.html
1080+ && attr.choice.is_none()
1081+ && attr.prefixed.is_none()
1082+ && crate::vocab::CONTAINER_ATTRIBUTES.contains(&base)
1083+ && !crate::vocab::split_prefixes(&attr.key)
1084+ .0
1085+ .contains(&"children:")
1086+ {
1087+ ctx.push_once(not_a_container(&kind, line, base).column(attr.span.column));
357 }
1088 }
358− ctx.variables.insert(name.to_string(), value);
359− ctx.let_lines.entry(name.to_string()).or_insert(line_num);
360 }
1089 }
361− return Ok(None);
362 }
1090 }
1091+ }
1092+}
363
1093
364− // @meta NAME VALUE; `og:` names become Open Graph property tags
1094+/// The attributes of a list that is never evaluated that can be checked
365− if let Some(rest) = content.strip_prefix("@meta ") {
1095+/// together: not bundles or `if()`s. Names are checked; a value with a
366− let rest = rest.trim();
1096+/// variable in it depends on what the variable holds, so only a literal
367− if let Some((name, value)) = rest.split_once(' ') {
1097+/// value is (the other is checked as an empty value).
368− let value = substitute_vars(value.trim(), &ctx.variables);
1098+/// `@page`'s own rules (see `check_page_attr`) for a list that isn't
369− match name.trim().strip_prefix("og:") {
1099+/// evaluated, as written: a value made from a variable counts as given.
370− Some(property) => ctx.og_tags.push((property.to_string(), value)),
1100+fn check_page_tokens(tokens: &[syntax::Attr], line: usize, ctx: &mut ParseContext) {
371− None => ctx.meta_tags.push((name.trim().to_string(), value)),
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);
372 }
1106 }
373 }
1107 }
374− return Ok(None);
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+ }
375 }
1125 }
1126+ }
1127+}
376
1128
377− if content == "@head" || content.starts_with("@head ") {
1129+fn literal_attrs(tokens: &[syntax::Attr]) -> Vec<syntax::Attr> {
378− let trimmed = block_text(children).trim().to_string();
1130+ // A prefixed group is checked with its prefixes, as far as its own
379− if !trimmed.is_empty() {
1131+ // attributes can be; a prefixed bundle or `if()` depends on what it holds
380− ctx.head_blocks.push(trimmed);
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);
381 }
1162 }
382− return Ok(None);
1163+ a
1164+ })
1165+ .chain(groups)
1166+ .collect()
1167+}
1168+
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));
383 }
1191 }
1192+ }
1193+}
384
1194
385− // --- @style block (raw CSS) ---
1195+/// Report each parameter a call's list writes `name=value`, with the value
386− if content.trim() == "@style" {
1196+/// as written and its column; returns their names, so the evaluated call
387− let trimmed = block_text(children).trim().to_string();
1197+/// doesn't report them again.
388− if !trimmed.is_empty() {
1198+fn written_parameter_forms(
389− ctx.custom_css.push(trimmed);
1199+ name: &str,
390− }
1200+ function: &FnDef,
391− return Ok(None);
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;
392 }
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+}
393
1225
394− // --- @markdown block or file (convert markdown to HTML) ---
1226+fn check_inline_heads(text: &syntax::Text, line: usize, ctx: &mut ParseContext) {
395− if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
1227+ for segment in &text.segments {
396− let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
1228+ if let Segment::Inline(inline) = segment {
397− if arg.is_empty() {
1229+ check_head(&inline.head, line, ctx);
398− // Inline markdown block: indented children
1230+ if let Some(text) = &inline.text {
399− let md_lines: Vec<String> =
1231+ check_inline_heads(text, line, ctx);
400− block_text(children).lines().map(String::from).collect();
401− let html = markdown_to_html(&md_lines);
402− return Ok(Some(vec![Node::Raw(html)]));
403− } else {
404− // External markdown file: @markdown file.md
405− let filename = substitute_vars(arg, &ctx.variables);
406− let resolved = match &ctx.base_path {
407− Some(base) => base.join(&filename),
408− None => PathBuf::from(&filename),
409− };
410− let md_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
411− cached.clone()
412− } else {
413− match std::fs::read_to_string(&resolved) {
414− Ok(text) => {
415− ctx.file_cache.insert(resolved.clone(), text.clone());
416− text
417− }
418− Err(e) => {
419− ctx.diagnostics.push(Diagnostic {
420− line: line_num,
421− column: None,
422− message: format!("cannot read markdown '{}': {}", filename, e),
423− severity: Severity::Error,
424− source_line: Some(content.clone()),
425− });
426− return Ok(None);
427− }
428− }
429− };
430− ctx.included_files.push(resolved);
431− let md_lines: Vec<String> = md_text.lines().map(|l| l.to_string()).collect();
432− let html = markdown_to_html(&md_lines);
433− return Ok(Some(vec![Node::Raw(html)]));
434 }
1232 }
435 }
1233 }
1234+ }
1235+}
436
1236
437− if let Some(rest) = content.strip_prefix("@include ") {
1237+// ---------------------------------------------------------------------------
438− let rest = rest.trim();
1238+// Evaluation
439− let filename = rest
1239+// ---------------------------------------------------------------------------
440− .strip_prefix('"')
441− .and_then(|f| f.strip_suffix('"'))
442− .unwrap_or(rest);
443− let filename = substitute_vars(filename, &ctx.variables);
444
1240
445− let resolved = match &ctx.base_path {
1241+/// The `@else` lines that continue the `@if` or `@each` before `from`, and
446− Some(base) => base.join(&filename),
1242+/// the position after them.
447− None => PathBuf::from(&filename),
1243+fn else_branches(block: &[syntax::Node], from: usize, is_if: bool) -> (Vec<&syntax::Node>, usize) {
448− };
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+}
449
1268
450− if ctx.include_stack.contains(&resolved) {
1269+impl Evaluator {
451− let cycle_chain = format_include_chain(&ctx.include_stack);
1270+ fn eval_block(&mut self, block: &[syntax::Node], ctx: &mut ParseContext) -> Vec<Node> {
452− ctx.diagnostics.push(Diagnostic {
1271+ let mut nodes = Vec::new();
453− line: line_num,
1272+ let mut i = 0;
454− column: None,
1273+ while i < block.len() {
455− message: format!(
1274+ let node = &block[i];
456− "circular include '{}' (cycle: {} → {})",
1275+ i += 1;
457− filename, cycle_chain, filename
1276+ if node.is_trivia() {
458− ),
1277+ continue;
459− severity: Severity::Error,
460− source_line: Some(content.clone()),
461− });
462− return Ok(None);
463 }
1278 }
464−
1279+ let result = match node.directive().map(|d| d.name()) {
465− let imported_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1280+ Some(name @ ("if" | "each")) => {
466− cached.clone()
1281+ let (branches, next) = else_branches(block, i, name == "if");
467− } else {
1282+ i = next;
468− match std::fs::read_to_string(&resolved) {
1283+ if name == "if" {
469− Ok(text) => {
1284+ Ok(Some(self.eval_if(node, &branches, ctx)))
470− ctx.file_cache.insert(resolved.clone(), text.clone());
1285+ } else {
471− text
1286+ self.eval_each(node, branches.first().copied(), ctx)
472− }
473− Err(e) => {
474− ctx.diagnostics.push(Diagnostic {
475− line: line_num,
476− column: None,
477− message: format!("cannot include '{}': {}", filename, e),
478− severity: Severity::Error,
479− source_line: Some(content.clone()),
480− });
481− return Ok(None);
482 }
1287 }
483 }
1288 }
1289+ // Reported as a syntax error
1290+ Some("else") => {
1291+ ctx.visited.insert(node.id);
1292+ Ok(None)
1293+ }
1294+ _ => self.eval(node, ctx),
484 };
1295 };
485−
1296+ match result {
486− ctx.included_files.push(resolved.clone());
1297+ Ok(Some(new_nodes)) => nodes.extend(new_nodes),
487− ctx.include_stack.push(resolved.clone());
1298+ Ok(None) => {}
488− let saved_base = ctx.base_path.clone();
1299+ // Record the error and go on, to report several in one pass
489− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1300+ Err(mut e) => {
490−
1301+ e.source_line
491− let diag_count_before = ctx.diagnostics.len();
1302+ .get_or_insert_with(|| node.source.as_str().into());
492−
1303+ ctx.diagnostics.push(e);
493− let included_nodes = self.eval_block(&syntax::parse(&imported_text), ctx);
1304+ }
494−
495− // Annotate new diagnostics with import chain
496− let import_chain = format_include_chain(&ctx.include_stack);
497− for d in &mut ctx.diagnostics[diag_count_before..] {
498− d.message = format!("{}\n in {}", d.message, import_chain);
499 }
1305 }
500−
501− ctx.base_path = saved_base;
502− ctx.include_stack.pop();
503− return Ok(Some(included_nodes));
504 }
1306 }
1307+ nodes
1308+ }
505
1309
506− // --- @data (load JSON file into variables) ---
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+ }
507
1318
508− // @data $name file.json values as $name.key
1319+ /// `@if` with its `@else if` / `@else` branches. Every condition is
509− // @data $name dir/*.json a list of the files' records
1320+ /// checked (and its errors reported); the first that holds picks the
510− // @data $name [...] / {...} inline JSON
1321+ /// branch.
511− // @data $name env:NAME [DEFAULT] an environment variable
1322+ fn eval_if(
512− if let Some(rest) = content.strip_prefix("@data ") {
1323+ &mut self,
513− let rest = rest.trim();
1324+ node: &syntax::Node,
514− let Some((prefix, filename)) = rest
1325+ branches: &[&syntax::Node],
515− .strip_prefix('$')
1326+ ctx: &mut ParseContext,
516− .and_then(|named| named.split_once(char::is_whitespace))
1327+ ) -> Vec<Node> {
517− .map(|(name, source)| (name.to_string(), source.trim().to_string()))
1328+ let mut chosen = None;
518− else {
1329+ for branch in std::iter::once(node).chain(branches.iter().copied()) {
519− return Err(ParseError {
1330+ ctx.visited.insert(branch.id);
520− line: line_num,
1331+ ctx.enter(branch);
521− message: format!(
1332+ let holds = match branch.directive().map(|d| &d.args) {
522− "@data needs a name: write `@data $name {}` and use `$name.key`",
1333+ Some(DirectiveArgs::Condition(condition))
523− rest.trim_start_matches('$')
1334+ | Some(DirectiveArgs::Else {
524− ),
1335+ condition: Some(condition),
525− });
1336+ }) => {
1337+ !condition.raw.is_empty()
1338+ && ctx.condition(
1339+ &condition.raw,
1340+ branch.span.line,
1341+ Some(condition.span.column),
1342+ )
1343+ }
1344+ Some(DirectiveArgs::Else { condition: None }) => true,
1345+ // A syntax error, already reported
1346+ _ => false,
526 };
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+
1355+ fn eval(
1356+ &mut self,
1357+ node: &syntax::Node,
1358+ ctx: &mut ParseContext,
1359+ ) -> Result<Option<Vec<Node>>, ParseError> {
1360+ ctx.visited.insert(node.id);
1361+ ctx.enter(node);
1362+ match &node.kind {
1363+ NodeKind::Blank | NodeKind::Comment => Ok(None),
1364+ // `@code` and `@textarea` show their body as text; `@script`'s
1365+ // goes into the page as it is
1366+ NodeKind::Verbatim(body) if body.escaped => {
1367+ let text = body.text.trim_matches('\n').to_string();
1368+ Ok(Some(vec![Node::Text(vec![TextSegment::Plain(text)])]))
1369+ }
1370+ NodeKind::Verbatim(body) => Ok(Some(vec![Node::Raw(body.text.clone())])),
1371+ NodeKind::Directive(directive) => {
1372+ let mut nodes = self.eval_directive(node, directive, ctx)?;
1373+ // A directive that takes no body: the lines indented under
1374+ // it (a syntax error) are evaluated as its siblings. A
1375+ // @let's body belongs to it even under a value (also an
1376+ // error), so it is never evaluated in place.
1377+ let takes_body = directive.spec.body != BodyKind::None;
1378+ if !takes_body {
1379+ let siblings = self.eval_block(&node.children, ctx);
1380+ nodes.get_or_insert_with(Vec::new).extend(siblings);
1381+ }
1382+ Ok(nodes)
1383+ }
1384+ NodeKind::Element(element) => self.eval_element(node, element, ctx).map(Some),
1385+ NodeKind::Text(text) => {
1386+ let mut nodes = vec![Node::Text(text_segments(text, ctx))];
1387+ // Text takes no body: lines indented under it are its
1388+ // siblings
1389+ nodes.extend(self.eval_block(&node.children, ctx));
1390+ Ok(Some(nodes))
1391+ }
1392+ }
1393+ }
527
1394
528− if let Some(env) = filename.strip_prefix("env:") {
1395+ fn eval_directive(
529− let (var, default) = match env.split_once(char::is_whitespace) {
1396+ &mut self,
530− Some((var, default)) => (var, Some(default.trim())),
1397+ node: &syntax::Node,
531− None => (env, None),
1398+ directive: &syntax::Directive,
1399+ ctx: &mut ParseContext,
1400+ ) -> Result<Option<Vec<Node>>, ParseError> {
1401+ let line_num = node.span.line;
1402+ let content = &node.source;
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),
532 };
1412 };
533− let value = std::env::var(var)
1413+ if let Some(first) = &ctx.page_at {
534− .ok()
1414+ let again = if *first == here {
535− .or_else(|| default.map(|d| substitute_vars(d, &ctx.variables)));
1415+ "this @page runs a second time (in a loop, or in a function called \
536− if value.is_none() {
1416+ twice)"
537− ctx.diagnostics.push(Diagnostic {
1417+ .to_string()
538− line: line_num,
1418+ } else {
539− column: None,
1419+ format!("a second @page (this page already has one, on {})", first)
540− message: format!(
1420+ };
541− "environment variable '{}' is not set and has no default",
1421+ let diagnostic = Diagnostic::error(
542− var
1422+ code::DUPLICATE_PAGE,
1423+ line_num,
1424+ format!(
1425+ "{}: a page has one @page, the root element that styles its <body>",
1426+ again
543 ),
1427 ),
544− severity: Severity::Warning,
1428+ )
545− source_line: Some(content.clone()),
1429+ .source(content.clone())
546− });
1430+ .subject("@page");
1431+ ctx.diagnostics.push(diagnostic);
1432+ } else {
1433+ ctx.page_at = Some(here);
1434+ ctx.page = Some(page);
547 }
1435 }
548− ctx.variables.insert(prefix, value.unwrap_or_default());
1436+ Ok(None)
549− return Ok(None);
550 }
1437 }
551
1438
552− // Inline JSON: @data $links [{"label": "Home", "url": "/"}]
1439+ ("let", DirectiveArgs::Let(def)) => {
553− let (json_text, source) = if filename.starts_with(['[', '{']) {
1440+ let name = def.name.as_str();
554− (filename, "the inline data".to_string())
1441+ // Values, bundles and functions share one namespace: a
555− } else {
1442+ // definition replaces whatever the name meant in its block
556− let filename = substitute_vars(&filename, &ctx.variables);
1443+ match &def.form {
557− if filename.contains('*') {
1444+ LetForm::Function(function) => {
558− self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
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+
1482+ // @meta NAME VALUE; `og:` names become Open Graph property tags
1483+ ("meta", DirectiveArgs::Text(Some(arg))) => {
1484+ let Some((name, value)) = arg.raw.split_once(char::is_whitespace) else {
1485+ ctx.diagnostics.push(
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+ );
559 return Ok(None);
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+ }
1511+
1512+ ("head", _) => {
1513+ let text = verbatim_content(node);
1514+ if !text.trim().is_empty() {
1515+ ctx.head_blocks.push(text.trim().to_string());
1516+ ctx.head_at.get_or_insert(line_num);
1517+ }
1518+ Ok(None)
1519+ }
1520+
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());
560 }
1537 }
561− let resolved = match &ctx.base_path {
1538+ Ok(None)
562− Some(base) => base.join(&filename),
1539+ }
563− None => PathBuf::from(&filename),
1540+
1541+ // @markdown: an indented block, or a file
1542+ ("markdown", DirectiveArgs::Text(file)) => {
1543+ let Some(file) = file else {
1544+ let md_lines: Vec<String> = verbatim_text(&node.children)
1545+ .lines()
1546+ .map(String::from)
1547+ .collect();
1548+ return Ok(Some(vec![Node::Raw(markdown_to_html(&md_lines))]));
564 };
1549 };
565− match std::fs::read_to_string(&resolved) {
1550+ let filename = ctx.interpolate_text(&file.raw, line_num, Some(file.span.column));
566− Ok(text) => {
1551+ let resolved = ctx.resolve(&filename);
567− ctx.included_files.push(resolved);
1552+ let md_text = match ctx.read_file(&resolved) {
568− (text, format!("'{}'", filename))
1553+ Ok(text) => text,
569− }
570 Err(e) => {
1554 Err(e) => {
571− ctx.diagnostics.push(Diagnostic {
1555+ ctx.diagnostics.push(
572− line: line_num,
1556+ Diagnostic::error(
573− column: None,
1557+ code::UNREADABLE_FILE,
574− message: format!("cannot load data '{}': {}", filename, e),
1558+ line_num,
575− severity: Severity::Error,
1559+ format!("cannot read markdown '{}': {}", filename, e),
576− source_line: Some(content.clone()),
1560+ )
577− });
1561+ .source(content.clone()),
1562+ );
578 return Ok(None);
1563 return Ok(None);
579 }
1564 }
580− }
1565+ };
581− };
1566+ ctx.included_files.push(resolved);
582− match parse_json_with_error(&json_text) {
1567+ let md_lines: Vec<String> = md_text.lines().map(|l| l.to_string()).collect();
583− Ok(json) => flatten_json(&prefix, &json, &mut ctx.variables),
1568+ Ok(Some(vec![Node::Raw(markdown_to_html(&md_lines))]))
584− Err(detail) => ctx.diagnostics.push(Diagnostic {
585− line: line_num,
586− column: None,
587− message: format!("invalid JSON in {}: {}", source, detail),
588− severity: Severity::Error,
589− source_line: Some(content.clone()),
590− }),
591 }
1569 }
592− return Ok(None);
593− }
594
1570
595− // --- @if / @else ---
1571+ ("include", DirectiveArgs::Text(Some(path))) => {
1572+ self.eval_include(node.id, path, line_num, content, ctx)
1573+ }
596
1574
597− if content.trim() == "@else" || content.trim().starts_with("@else if ") {
1575+ ("data", DirectiveArgs::Data { name, source, .. }) => {
598− return Err(ParseError {
1576+ self.eval_data(name, source, line_num, content, ctx);
599− line: line_num,
1577+ Ok(None)
600− message: "@else without matching @if".to_string(),
1578+ }
601− });
602− }
603
1579
604− // --- @each loop ---
1580+ // @raw: the rest of its line, or its indented body, verbatim
1581+ ("raw", _) => {
1582+ let text = verbatim_content(node);
1583+ let text = text.trim_end_matches('\n');
1584+ Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())]))
1585+ }
605
1586
606− // @raw: its indented body, or the rest of the line, verbatim
1587+ // A header with a syntax error (reported), or a directive
607− if content == "@raw" {
1588+ // without its argument
608− let text = block_text(children);
1589+ _ => Ok(None),
609− let text = text.trim_end_matches('\n');
610− return Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())]));
611− }
612− if let Some(rest) = content.strip_prefix("@raw ") {
613− return Ok(Some(vec![Node::Raw(rest.to_string())]));
614 }
1590 }
1591+ }
615
1592
616− // --- Function call ---
1593+ fn eval_include(
1594+ &mut self,
1595+ id: usize,
1596+ path: &syntax::Arg,
1597+ line_num: usize,
1598+ content: &str,
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: {} → {})",
1617+ filename, cycle_chain, filename
1618+ ),
1619+ )
1620+ .source(content),
1621+ );
1622+ return Ok(None);
1623+ }
617
1624
618− if content.starts_with('@') {
1625+ let imported_text = match ctx.read_file(&resolved) {
619− let name = extract_element_name(&content);
1626+ Ok(text) => text,
620− if ctx.functions.contains_key(name) {
1627+ Err(e) => {
621− ctx.used_functions.insert(name.to_string());
1628+ ctx.diagnostics.push(
622− let nodes = self.expand_fn_call(name, &content, children, line_num, ctx)?;
1629+ Diagnostic::error(
623− return Ok(Some(nodes));
1630+ code::UNREADABLE_FILE,
1631+ line_num,
1632+ format!("cannot include '{}': {}", filename, e),
1633+ )
1634+ .source(content),
1635+ );
1636+ return Ok(None);
624 }
1637 }
1638+ };
1639+
1640+ ctx.included_files.push(resolved.clone());
1641+ ctx.include_stack.push(resolved.clone());
1642+ let saved_base = ctx.base_path.clone();
1643+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1644+
1645+ let diag_count_before = ctx.diagnostics.len();
1646+ let import_chain = format_include_chain(&ctx.include_stack);
1647+ let tree = ctx.parse_tree(&imported_text);
1648+ ctx.diagnostics.extend(tree.diagnostics.iter().cloned());
1649+ ctx.trees.push((tree.clone(), Some(import_chain.clone())));
1650+ let files = Rc::make_mut(&mut ctx.included_by).entry(id).or_default();
1651+ if !files.iter().any(|(_, chain)| *chain == import_chain) {
1652+ files.push((tree.clone(), import_chain.clone()));
1653+ }
1654+ let saved_file = ctx.current_file.replace(import_chain.clone());
1655+ let defined_before: HashSet<String> = ctx
1656+ .let_lines
1657+ .keys()
1658+ .chain(ctx.define_lines.keys())
1659+ .chain(ctx.fn_lines.keys())
1660+ .cloned()
1661+ .collect();
1662+ let included_nodes = self.eval_block(&tree.nodes, ctx);
1663+ ctx.current_file = saved_file;
1664+ // An included file's definitions aren't reported unused here, where
1665+ // their lines aren't this file's: a library's are there to be
1666+ // picked from, and any other file reports its own when checked
1667+ let keep = |name: &String, _: &mut usize| defined_before.contains(name);
1668+ ctx.let_lines.retain(keep);
1669+ ctx.define_lines.retain(keep);
1670+ ctx.fn_lines.retain(keep);
1671+
1672+ // Annotate new diagnostics with import chain
1673+ for d in &mut ctx.diagnostics[diag_count_before..] {
1674+ d.message = format!("{}\n in {}", d.message, import_chain);
625 }
1675 }
626
1676
627− // --- Elements ---
1677+ ctx.base_path = saved_base;
1678+ ctx.include_stack.pop();
1679+ Ok(Some(included_nodes))
1680+ }
628
1681
629− if content.starts_with('@') {
1682+ /// @data $name file.json values as $name.key
630− let node = self.parse_element_line(&content, children, line_num, ctx)?;
1683+ /// @data $name dir/*.json a list of the files' records
631− return Ok(Some(vec![inline_svg(node, line_num, ctx)]));
1684+ /// @data $name [...] / {...} inline JSON
1685+ /// @data $name env:NAME [DEFAULT] an environment variable
1686+ fn eval_data(
1687+ &mut self,
1688+ prefix: &str,
1689+ source: &syntax::Arg,
1690+ line_num: usize,
1691+ content: &str,
1692+ ctx: &mut ParseContext,
1693+ ) {
1694+ let prefix = prefix.to_string();
1695+ let filename = source.raw.as_str();
1696+ if let Some(env) = filename.strip_prefix("env:") {
1697+ let (var, default) = match env.split_once(char::is_whitespace) {
1698+ Some((var, default)) => (var, Some(default.trim())),
1699+ None => (env, None),
1700+ };
1701+ let default_column = default.map(|d| source.span.column + filename.len() - d.len());
1702+ let value = std::env::var(var)
1703+ .ok()
1704+ .or_else(|| default.map(|d| ctx.interpolate_text(d, line_num, default_column)));
1705+ if value.is_none() {
1706+ ctx.diagnostics.push(
1707+ Diagnostic::warning(
1708+ code::UNSET_ENVIRONMENT,
1709+ line_num,
1710+ format!(
1711+ "environment variable '{}' is not set and has no default",
1712+ var
1713+ ),
1714+ )
1715+ .source(content),
1716+ );
1717+ }
1718+ ctx.bind(&prefix, Value::Str(value.unwrap_or_default()));
1719+ return;
632 }
1720 }
633
1721
634− // --- Bare text ---
1722+ // Inline JSON: @data $links [{"label": "Home", "url": "/"}]
635−
1723+ let (json_text, source) = if filename.starts_with(['[', '{']) {
636− let var_warnings = check_undefined_vars(
1724+ (filename.to_string(), "the inline data".to_string())
637− &protect_escapes(&content),
1725+ } else {
638− &ctx.variables,
1726+ let filename = ctx.interpolate_text(filename, line_num, Some(source.span.column));
639− line_num,
1727+ if filename.contains('*') {
640− current_indent,
1728+ self.load_data_glob(&prefix, &filename, line_num, content, ctx);
641− );
1729+ return;
642− ctx.diagnostics.extend(var_warnings);
1730+ }
643− track_var_refs(&content, &mut ctx.used_variables);
1731+ let resolved = ctx.resolve(&filename);
644− let segments = parse_text_segments(&content, ctx);
1732+ match std::fs::read_to_string(&resolved) {
645− Ok(Some(vec![Node::Text(segments)]))
1733+ Ok(text) => {
1734+ ctx.included_files.push(resolved);
1735+ (text, format!("'{}'", filename))
1736+ }
1737+ Err(e) => {
1738+ ctx.diagnostics.push(
1739+ Diagnostic::error(
1740+ code::UNREADABLE_FILE,
1741+ line_num,
1742+ format!("cannot load data '{}': {}", filename, e),
1743+ )
1744+ .source(content),
1745+ );
1746+ return;
1747+ }
1748+ }
1749+ };
1750+ match parse_json_with_error(&json_text) {
1751+ Ok(json) => ctx.bind(&prefix, json_value(json)),
1752+ Err(detail) => ctx.diagnostics.push(
1753+ Diagnostic::error(
1754+ code::INVALID_JSON,
1755+ line_num,
1756+ format!("invalid JSON in {}: {}", source, detail),
1757+ )
1758+ .source(content),
1759+ ),
1760+ }
646 }
1761 }
647
1762
648 /// `@data $name dir/*.json`: a list with one record per matching file
1763 /// `@data $name dir/*.json`: a list with one record per matching file
@@ -676,13 +1791,15 @@ impl Evaluator {
676 })
1791 })
677 .collect(),
1792 .collect(),
678 Err(e) => {
1793 Err(e) => {
679− ctx.diagnostics.push(Diagnostic {
1794+ ctx.diagnostics.push(
680− line: line_num,
1795+ Diagnostic::new(
681− column: None,
1796+ code::UNREADABLE_FILE,
682− message: format!("cannot read directory for '{}': {}", pattern, e),
1797+ Severity::Error,
683− severity: Severity::Error,
1798+ line_num,
684− source_line: Some(content.to_string()),
1799+ format!("cannot read directory for '{}': {}", pattern, e),
685− });
1800+ )
1801+ .source(content.to_string()),
1802+ );
686 return;
1803 return;
687 }
1804 }
688 };
1805 };
@@ -697,13 +1814,15 @@ impl Evaluator {
697 let text = match std::fs::read_to_string(&file) {
1814 let text = match std::fs::read_to_string(&file) {
698 Ok(text) => text,
1815 Ok(text) => text,
699 Err(e) => {
1816 Err(e) => {
700− ctx.diagnostics.push(Diagnostic {
1817+ ctx.diagnostics.push(
701− line: line_num,
1818+ Diagnostic::new(
702− column: None,
1819+ code::UNREADABLE_FILE,
703− message: format!("cannot load data '{}': {}", file.display(), e),
1820+ Severity::Error,
704− severity: Severity::Error,
1821+ line_num,
705− source_line: Some(content.to_string()),
1822+ format!("cannot load data '{}': {}", file.display(), e),
706− });
1823+ )
1824+ .source(content.to_string()),
1825+ );
707 continue;
1826 continue;
708 }
1827 }
709 };
1828 };
@@ -714,253 +1833,424 @@ impl Evaluator {
714 }
1833 }
715 records.push(JsonValue::Object(pairs));
1834 records.push(JsonValue::Object(pairs));
716 }
1835 }
717− Ok(_) => ctx.diagnostics.push(Diagnostic {
1836+ Ok(_) => ctx.diagnostics.push(
718− line: line_num,
1837+ Diagnostic::new(
719− column: None,
1838+ code::INVALID_JSON,
720− message: format!("'{}' should hold a JSON object", file.display()),
1839+ Severity::Error,
721− severity: Severity::Error,
1840+ line_num,
722− source_line: Some(content.to_string()),
1841+ format!("'{}' should hold a JSON object", file.display()),
723− }),
1842+ )
724− Err(detail) => ctx.diagnostics.push(Diagnostic {
1843+ .source(content.to_string()),
725− line: line_num,
1844+ ),
726− column: None,
1845+ Err(detail) => ctx.diagnostics.push(
727− message: format!("invalid JSON in '{}': {}", file.display(), detail),
1846+ Diagnostic::new(
728− severity: Severity::Error,
1847+ code::INVALID_JSON,
729− source_line: Some(content.to_string()),
1848+ Severity::Error,
730− }),
1849+ line_num,
1850+ format!("invalid JSON in '{}': {}", file.display(), detail),
1851+ )
1852+ .source(content.to_string()),
1853+ ),
731 }
1854 }
732 ctx.included_files.push(file);
1855 ctx.included_files.push(file);
733 }
1856 }
734− flatten_json(name, &JsonValue::Array(records), &mut ctx.variables);
1857+ ctx.bind(name, json_value(JsonValue::Array(records)));
735 }
1858 }
736
1859
737− /// `@each $item in LIST` or `@each $item, $index in LIST`. A LIST from
1860+ /// `@each $item in LIST` or `@each $item, $index in LIST`, with the
738− /// JSON binds each record or value to `$item`; any other list is text,
1861+ /// `@else` that follows it. LIST is a value (see
739− /// split on commas, or a range `A..B [step N]`.
1862+ /// [`ParseContext::slot_value`]): a list written with commas, a range,
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.
740 fn eval_each(
1865 fn eval_each(
741 &mut self,
1866 &mut self,
742− header: &str,
1867+ node: &syntax::Node,
743− body: &[Syntax],
1868+ empty_branch: Option<&syntax::Node>,
744− empty: &[Syntax],
745− line_num: usize,
746 ctx: &mut ParseContext,
1869 ctx: &mut ParseContext,
747− ) -> Result<Vec<Node>, ParseError> {
1870+ ) -> Result<Option<Vec<Node>>, ParseError> {
748− let Some((names, list_src)) = header.split_once(" in ") else {
1871+ ctx.visited.insert(node.id);
749− return Err(ParseError {
1872+ if let Some(branch) = empty_branch {
750− line: line_num,
1873+ ctx.visited.insert(branch.id);
751− message: "@each requires: @each $item in LIST".to_string(),
752− });
753− };
754− let names: Vec<&str> = names
755− .split(',')
756− .map(|v| v.trim().trim_start_matches('$'))
757− .collect();
758− if names.len() > 2 {
759− return Err(ParseError {
760− line: line_num,
761− message: "@each takes `$item` or `$item, $index`: to loop over records, \
762− load them with @data and use `$item.key`"
763− .to_string(),
764− });
765 }
1874 }
766− let (item, index) = (names[0], names.get(1).copied());
1875+ ctx.enter(node);
767− let list_src = list_src.trim();
1876+ // A malformed header is a syntax error, already reported
768− track_var_refs(list_src, &mut ctx.used_variables);
1877+ let Some(DirectiveArgs::Each(header)) = node.directive().map(|d| &d.args) else {
769−
1878+ return Ok(None);
770− // A list loaded from JSON, by name
1879+ };
771− let data_list = list_src.strip_prefix('$').and_then(|name| {
1880+ let body = &node.children;
772− let len = ctx
1881+ let empty: &[syntax::Node] = empty_branch.map_or(&[], |b| &b.children);
773− .variables
1882+ let (item, index) = (header.item.as_str(), header.index.as_deref());
774− .get(&format!("{}#", name))?
1883+ let list = &header.list;
775− .parse::<usize>()
1884+ // `[...]` is an attribute list or JSON elsewhere, not a list here
776− .ok()?;
1885+ let bracketed = list.raw.starts_with('[') && list.raw.ends_with(']');
777− Some((name.to_string(), len))
1886+ let (source, filled) = match bracketed {
778− });
1887+ true => (Value::empty(), false),
779− let undefined = list_src.strip_prefix('$').filter(|name| {
1888+ false => ctx.slot_value(&list.raw, list.span.line, Some(list.span.column)),
780− name.chars()
1889+ };
781− .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
1890+ let not_a_list = |message: String, ctx: &ParseContext| {
782− && !ctx.variables.contains_key(*name)
1891+ let diagnostic = Diagnostic::error(code::INVALID_LOOP, list.span.line, message)
783− });
1892+ .column(list.span.column)
784− let text_items: Vec<String> = match (&data_list, undefined) {
1893+ .subject(list.raw.as_str());
785− (Some(_), _) => Vec::new(),
1894+ match &ctx.current_source {
786− // A missing variable is an empty list: quietly for a record's
1895+ (current, Some(text)) if *current == list.span.line => {
787− // missing field (`$post.tags`), with a warning otherwise
1896+ diagnostic.source(text.clone())
788− (None, Some(name)) => {
789− let root = name.split('.').next().unwrap_or(name);
790− if !ctx.variables.contains_key(root) {
791− ctx.diagnostics.push(Diagnostic {
792− line: line_num,
793− column: None,
794− message: format!("undefined variable '${}'", name),
795− severity: Severity::Warning,
796− source_line: Some(format!("@each {}", header)),
797− });
798 }
1897 }
1898+ _ => diagnostic,
1899+ }
1900+ };
1901+ if bracketed {
1902+ let inner = list.raw[1..list.raw.len() - 1].trim().to_string();
1903+ let diagnostic = not_a_list(
1904+ format!(
1905+ "@each takes a list written without brackets, `@each ${} in {}`; \
1906+ JSON data goes in `@data`",
1907+ item, inner
1908+ ),
1909+ ctx,
1910+ )
1911+ .suggest(Some(inner));
1912+ ctx.push_once(diagnostic);
1913+ }
1914+ let items: Vec<Value> = match source {
1915+ Value::List(list) => list.items.to_vec(),
1916+ // Reported (an undefined `$name`, brackets): there is nothing
1917+ // to repeat
1918+ _ if !filled => Vec::new(),
1919+ Value::Record(_) => {
1920+ let diagnostic = not_a_list(
1921+ format!(
1922+ "@each repeats for the items of a list, and `{}` is a record: \
1923+ loop over a list of records, or use its fields",
1924+ list.raw
1925+ ),
1926+ ctx,
1927+ );
1928+ ctx.push_once(diagnostic);
799 Vec::new()
1929 Vec::new()
800 }
1930 }
801− (None, None) => text_list_items(&substitute_vars(list_src, &ctx.variables)),
1931+ // Empty text (a field a record doesn't have) is no items; any
1932+ // other value is one
1933+ value if value.to_string().is_empty() => Vec::new(),
1934+ value => vec![value],
802 };
1935 };
803− let count = data_list.as_ref().map_or(text_items.len(), |(_, len)| *len);
804
1936
805− if body.is_empty() {
1937+ if body.iter().all(syntax::Node::is_trivia) {
806− return Ok(Vec::new());
1938+ return Ok(Some(Vec::new()));
807 }
1939 }
808− if count == 0 {
1940+ if items.is_empty() {
809− return Ok(self.eval_scoped(empty, ctx));
1941+ return Ok(Some(self.eval_scoped(empty, ctx)));
810 }
1942 }
811− let saved_vars = ctx.variables.clone();
812 let mut nodes = Vec::new();
1943 let mut nodes = Vec::new();
813− for i in 0..count {
1944+ for (i, value) in items.into_iter().enumerate() {
814− match &data_list {
1945+ ctx.env.push();
815− Some((name, _)) => bind_item(&mut ctx.variables, &format!("{}.{}", name, i), item),
1946+ ctx.bind(item, value);
816− None => {
817− let text = text_items.get(i).cloned().unwrap_or_default();
818− ctx.variables.insert(item.to_string(), text);
819− }
820− }
821 if let Some(index) = index {
1947 if let Some(index) = index {
822− ctx.variables.insert(index.to_string(), i.to_string());
1948+ ctx.bind(index, Value::Num(i as f64));
823 }
1949 }
824 nodes.extend(self.eval_block(body, ctx));
1950 nodes.extend(self.eval_block(body, ctx));
1951+ ctx.env.pop();
825 }
1952 }
826− ctx.variables = saved_vars;
1953+ Ok(Some(nodes))
827− Ok(nodes)
828 }
1954 }
829
1955
830 fn define_function(
1956 fn define_function(
831 &mut self,
1957 &mut self,
832 name: &str,
1958 name: &str,
833− params: &[String],
1959+ params: &[syntax::Param],
834− defaults: &HashMap<String, String>,
1960+ node: &syntax::Node,
835− body: &[Syntax],
836− line_num: usize,
837 ctx: &mut ParseContext,
1961 ctx: &mut ParseContext,
838 ) {
1962 ) {
1963+ let line_num = node.span.line;
1964+ let body = &node.children;
839 // An @style block at the top of the body is scoped to the
1965 // An @style block at the top of the body is scoped to the
840− // function: its rules apply inside a `.hl-NAME` wrapper.
1966+ // function: its rules apply inside a `.hl-fn-NAME` wrapper (a
841− let (style, body): (Vec<&Syntax>, Vec<&Syntax>) = body
1967+ // generated class never has a second `-`, so the two can't meet).
842− .iter()
1968+ // It is written into the page when the function is called.
843− .partition(|node| matches!(node, Syntax::Line { text, .. } if text == "@style"));
1969+ let (style, body): (Vec<&syntax::Node>, Vec<&syntax::Node>) =
844− if !style.is_empty() {
1970+ body.iter().partition(|node| node.is_directive("style"));
1971+ let scoped = (!style.is_empty()).then(|| {
845 let css: String = style
1972 let css: String = style
846 .iter()
1973 .iter()
847− .filter_map(|node| match node {
1974+ .map(|node| {
848− Syntax::Line { children, .. } => Some(block_text(children)),
1975+ ctx.visited.insert(node.id);
849− _ => None,
1976+ verbatim_content(node)
850 })
1977 })
851 .collect();
1978 .collect();
852 // Nested under the scope class, so any CSS works (multi-line
1979 // Nested under the scope class, so any CSS works (multi-line
853 // rules, at-rules)
1980 // rules, at-rules)
854− if !css.trim().is_empty() {
1981+ if css.trim().is_empty() {
855− ctx.custom_css.push(format!(".hl-{} {{\n{}}}", name, css));
1982+ String::new()
1983+ } else {
1984+ format!(".hl-fn-{} {{\n{}}}", name, css)
856 }
1985 }
857− ctx.scoped_functions.insert(name.to_string());
1986+ });
858− }
859
1987
1988+ // A default is filled in only when a call leaves its parameter
1989+ // out, so the names in it count as used here
1990+ for default in params.iter().filter_map(|p| p.default.as_ref()) {
1991+ track_var_refs(default, &mut ctx.used_variables);
1992+ }
860 ctx.fn_lines.entry(name.to_string()).or_insert(line_num);
1993 ctx.fn_lines.entry(name.to_string()).or_insert(line_num);
861− ctx.functions.insert(
1994+ let wraps_itself = ElementKind::from_name(name).is_some()
862− name.to_string(),
1995+ && body.iter().any(|node| calls_unguarded(node, name));
863− FnDef {
1996+ if wraps_itself {
864− params: params.to_vec(),
1997+ ctx.push_once(
865− defaults: defaults.clone(),
1998+ Diagnostic::error(
866− body: body.into_iter().cloned().collect(),
1999+ code::RECURSIVE_CALL,
867− },
2000+ line_num,
868− );
2001+ format!(
2002+ "@{0} in the body of `@let @{0}` calls the function itself, not the \
2003+ element @{0}, so it would never stop: give the function another name \
2004+ (such as @site-{0})",
2005+ name
2006+ ),
2007+ )
2008+ .subject(name),
2009+ );
2010+ }
2011+ for node in &body {
2012+ node.walk(&mut |node| {
2013+ if let Some(d) = marker_in_text(node) {
2014+ ctx.push_once(d);
2015+ }
2016+ });
2017+ }
2018+ let body: Vec<syntax::Node> = body.into_iter().cloned().collect();
2019+ let (slots, has_children) = declared_slots(&body, ctx);
2020+ let function = Rc::new(FnDef {
2021+ params: params.to_vec(),
2022+ line: line_num,
2023+ source: ctx.current_source.1.clone(),
2024+ node: node.id,
2025+ body,
2026+ file: ctx.current_file.clone(),
2027+ slots,
2028+ has_children,
2029+ scoped,
2030+ env: ctx.env.clone(),
2031+ wraps_itself,
2032+ });
2033+ ctx.defined_functions
2034+ .insert(name.to_string(), function.clone());
2035+ ctx.env.define(name, Binding::Function(function));
869 }
2036 }
870
2037
871− fn expand_fn_call(
2038+ /// An element line: a chain of elements and function calls, the last of
2039+ /// which gets the indented children.
2040+ fn eval_element(
872 &mut self,
2041 &mut self,
873− name: &str,
2042+ node: &syntax::Node,
874− content: &str,
2043+ element: &syntax::ElementLine,
875− children: &[Syntax],
876− line_num: usize,
877 ctx: &mut ParseContext,
2044 ctx: &mut ParseContext,
878 ) -> Result<Vec<Node>, ParseError> {
2045 ) -> Result<Vec<Node>, ParseError> {
879− // Recursive function cycle detection
2046+ // An unclosed attribute list is a syntax error, already reported
880− if ctx.fn_call_stack.contains(&name.to_string()) {
2047+ if element
881− return Err(ParseError {
2048+ .chain
882− line: line_num,
2049+ .iter()
883− message: format!(
2050+ .any(|h| h.attrs.as_ref().is_some_and(|a| !a.closed))
884− "recursive function call to @{} (call stack: {})",
2051+ {
885− name,
2052+ return Ok(Vec::new());
886− ctx.fn_call_stack.join(" -> ")
887− ),
888− });
889 }
2053 }
890− ctx.fn_call_stack.push(name.to_string());
2054+ let line_num = node.span.line;
891−
2055+ let last = element.chain.len() - 1;
892− // Parse [param value, ...] arguments
2056+ // Each head is read where it is written, left to right. The
893− let rest = &content[1 + name.len()..];
2057+ // children belong to the last one, so the chain is built right to
894− let rest = rest.trim_start();
2058+ // left, each link wrapping the next.
895−
2059+ let mut links = Vec::new();
896− let (args, trailing_text) = if rest.starts_with('[') {
2060+ for (i, head) in element.chain.iter().enumerate() {
897− parse_attr_brackets_no_validate(rest, line_num, ctx)?
2061+ let text = if i == last {
898− } else {
2062+ element.text.as_ref()
899− (Vec::new(), rest.to_string())
2063+ } else {
900− };
2064+ None
2065+ };
2066+ links.push(resolve(head, text, line_num, &node.source, ctx)?);
2067+ }
2068+ let calls: Vec<Option<(String, Rc<FnDef>)>> = links
2069+ .iter()
2070+ .map(|link| match link {
2071+ Resolved::Call(call) => Some((call.name.clone(), call.function.clone())),
2072+ Resolved::Element(_) => None,
2073+ })
2074+ .collect();
2075+ let mut current = self.eval_scoped(&node.children, ctx);
2076+ // After the lines under the call ran, so the files they include
2077+ // are read
2078+ check_fillers(node, element, &calls, ctx);
2079+ for link in links.into_iter().rev() {
2080+ current = self.complete(link, current, ctx)?;
2081+ }
2082+ Ok(current)
2083+ }
901
2084
902− // Clone function definition (releases borrow on ctx). If the function is
2085+ /// Give a resolved `@name` its children: an element holds them, and a
903− // missing (shouldn't normally happen — callers check ctx.functions.contains_key
2086+ /// function's body puts them where its `@children` is.
904− // first — but we avoid panicking on malformed state).
2087+ fn complete(
905− let fn_def = match ctx.functions.get(name) {
2088+ &mut self,
906− Some(def) => def.clone(),
2089+ resolved: Resolved,
907− None => {
2090+ children: Vec<Node>,
908− ctx.fn_call_stack.pop();
2091+ ctx: &mut ParseContext,
909− return Err(ParseError {
2092+ ) -> Result<Vec<Node>, ParseError> {
910− line: line_num,
2093+ match resolved {
911− message: format!("undefined function @{}", name),
2094+ Resolved::Element(mut element) => {
912− });
2095+ element.children.extend(children);
2096+ let line = element.line_num;
2097+ Ok(vec![inline_svg(Node::Element(element), line, ctx)])
913 }
2098 }
914− };
2099+ Resolved::Call(call) => self.expand_fn_call(*call, children, ctx),
2100+ }
2101+ }
915
2102
916− // Parse caller's children, separating named slots from default children
2103+ fn expand_fn_call(
917− let all_caller_children = self.eval_block(children, ctx);
2104+ &mut self,
2105+ call: Call,
2106+ all_caller_children: Vec<Node>,
2107+ ctx: &mut ParseContext,
2108+ ) -> Result<Vec<Node>, ParseError> {
2109+ let Call {
2110+ name,
2111+ function: fn_def,
2112+ args,
2113+ values,
2114+ text: trailing_text,
2115+ written_form,
2116+ written_keys,
2117+ line: line_num,
2118+ name_at,
2119+ source: content,
2120+ } = call;
2121+ let (name, content) = (name.as_str(), content.as_str());
2122+
2123+ // Separate the caller's `@slot NAME` blocks from its other
2124+ // children. A block for a slot the function doesn't declare was
2125+ // reported where it is written (see `check_fillers`), like one
2126+ // without a name.
918 let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
2127 let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
919 let mut caller_children = Vec::new();
2128 let mut caller_children = Vec::new();
920 for child in all_caller_children {
2129 for child in all_caller_children {
921− if let Node::Element(ref elem) = child
2130+ if let Node::Element(elem) = child {
922− && let ElementKind::Slot(ref slot_name) = elem.kind
2131+ match elem.kind {
923− && !slot_name.is_empty()
2132+ ElementKind::Slot(slot_name) => {
924− {
2133+ slot_contents
925− slot_contents
2134+ .entry(slot_name)
926− .entry(slot_name.clone())
2135+ .or_default()
927− .or_default()
2136+ .extend(elem.children);
928− .extend(elem.children.clone());
2137+ }
2138+ kind => caller_children.push(Node::Element(Element { kind, ..elem })),
2139+ }
929 continue;
2140 continue;
930 }
2141 }
931 caller_children.push(child);
2142 caller_children.push(child);
932 }
2143 }
933 // Text after the call, as in `@card [color red] New`, is content:
2144 // Text after the call, as in `@card [color red] New`, is content:
934− // it goes first among the caller's children.
2145+ // it goes first among the caller's children, and like them it is
935− let trailing_text = trailing_text.trim();
2146+ // evaluated where the call is written.
936− if !trailing_text.is_empty() {
2147+ if let Some(text) = &trailing_text {
937− caller_children.insert(0, Node::Text(parse_text_segments(trailing_text, ctx)));
2148+ caller_children.insert(0, Node::Text(text_segments(text, ctx)));
2149+ }
2150+ // Content goes where the body's `@children` is: without one, it
2151+ // would be dropped
2152+ if !fn_def.has_children && !caller_children.is_empty() {
2153+ let (column, text) = match &name_at {
2154+ Some((column, text)) => (Some(*column), text.as_str()),
2155+ None => (None, content),
2156+ };
2157+ ctx.push_once(no_children(name, line_num, column).source(text));
938 }
2158 }
939
2159
940− // Save variable state, inject function parameters
2160+ if fn_def.wraps_itself {
941− let saved_vars = ctx.variables.clone();
2161+ return Ok(Vec::new());
2162+ }
2163+ // Only the body is inside the call, so a function may appear in the
2164+ // content its caller passes it. A function may call itself, under
2165+ // a condition that stops it; one that doesn't stop goes too deep.
2166+ if ctx.too_deep {
2167+ return Ok(Vec::new());
2168+ }
2169+ if ctx.fn_call_stack.len() >= MAX_CALL_DEPTH {
2170+ ctx.too_deep = true;
2171+ return Err(too_deep(name, &ctx.fn_call_stack, line_num).source(content));
2172+ }
2173+ let caller = (
2174+ ctx.current_line,
2175+ ctx.current_source.clone(),
2176+ ctx.current_node,
2177+ );
2178+ ctx.fn_call_stack.push(name.to_string());
2179+
2180+ // The body runs where the function is defined: it sees what was
2181+ // visible there, the function itself (so it can call itself), and
2182+ // its parameters, bound by name in the order they are declared. A
2183+ // default is filled in at the call, seeing the same, with the
2184+ // parameters before it.
2185+ let caller_env = std::mem::replace(&mut ctx.env, fn_def.env.clone());
2186+ ctx.env.push();
2187+ ctx.env.define(name, Binding::Function(fn_def.clone()));
942 let mut consumed = vec![false; args.len()];
2188 let mut consumed = vec![false; args.len()];
943− for (i, param) in fn_def.params.iter().enumerate() {
2189+ for param in &fn_def.params {
944− let named = args.iter().position(|a| a.key == *param);
2190+ let mut passed = None;
945− // Positional fallback, unless that argument is named for a
2191+ for (j, arg) in args.iter().enumerate() {
946− // different parameter.
2192+ if arg.key != param.name {
947− let positional = args
2193+ continue;
948− .get(i)
2194+ }
949− .filter(|a| !fn_def.params.contains(&a.key) && a.value.is_some())
950− .map(|_| i);
951− let value = named
952− .filter(|&j| args[j].value.is_some())
953− .or(positional)
954− .and_then(|j| {
955− consumed[j] = true;
956− args[j].value.clone()
957− })
958− .or_else(|| fn_def.defaults.get(param).cloned())
959− .unwrap_or_default();
960− if let Some(j) = named {
961 consumed[j] = true;
2195 consumed[j] = true;
2196+ // The later one wins, as for any attribute: one written
2197+ // after a bundle overrides it, and only one written twice
2198+ // in the call's own list is reported
2199+ if passed.is_some() && written_keys.iter().filter(|k| **k == param.name).count() > 1
2200+ {
2201+ ctx.push_once(
2202+ Diagnostic::warning(
2203+ code::DUPLICATE_ATTRIBUTE,
2204+ line_num,
2205+ format!(
2206+ "parameter '{}' of @{} is passed twice: the later one wins",
2207+ param.name, name
2208+ ),
2209+ )
2210+ .source(content)
2211+ .subject(param.name.as_str()),
2212+ );
2213+ }
2214+ // Written `name=value` in the call's own list is reported
2215+ // as written; this is one from a bundle
2216+ if arg.html && !written_form.contains(&arg.key) {
2217+ let value = arg.value.as_deref().unwrap_or("");
2218+ ctx.push_once(parameter_form(name, &arg.key, value, line_num).source(content));
2219+ }
2220+ passed = Some(j);
962 }
2221 }
963− ctx.variables.insert(param.clone(), value);
2222+ let value = match passed {
2223+ // A parameter's name alone is true: `@post-card [featured]`
2224+ Some(j) if args[j].value.is_none() => Value::Bool(true),
2225+ // What the call passed, with its type: a record, a list,
2226+ // quoted text
2227+ Some(j) => values[j].clone().unwrap_or_else(|| {
2228+ let arg = &args[j];
2229+ match &arg.quoted {
2230+ Some(quoted) => Value::Quoted(quoted.clone()),
2231+ None => Value::Str(arg.value.clone().unwrap_or_default()),
2232+ }
2233+ }),
2234+ None => match &param.default {
2235+ Some(default) => {
2236+ let before = ctx.diagnostics.len();
2237+ let value = fill_default(&fn_def, param, default, ctx);
2238+ point_at_call(ctx, before, &fn_def, name, line_num, content);
2239+ value
2240+ }
2241+ None => {
2242+ let (column, text) = match &name_at {
2243+ Some((column, text)) => (Some(*column), text.as_str()),
2244+ None => (None, content),
2245+ };
2246+ ctx.push_once(
2247+ missing_parameter(name, &param.name, line_num, column).source(text),
2248+ );
2249+ Value::empty()
2250+ }
2251+ },
2252+ };
2253+ ctx.bind(&param.name, value);
964 }
2254 }
965 // Arguments that aren't parameters are attributes for the
2255 // Arguments that aren't parameters are attributes for the
966 // function's root element, so a function can be styled like an
2256 // function's root element, so a function can be styled like an
@@ -972,12 +2262,24 @@ impl Evaluator {
972 .map(|(a, _)| a.clone())
2262 .map(|(a, _)| a.clone())
973 .collect();
2263 .collect();
974
2264
975− // Evaluate the body with the parameters in scope
2265+ // Evaluate the body with the parameters in scope, as lines of the
976− let body_nodes = self.eval_block(&fn_def.body, ctx);
2266+ // file that defines the function
2267+ let caller_file = std::mem::replace(&mut ctx.current_file, fn_def.file.clone());
2268+ let before = ctx.diagnostics.len();
2269+ let mut body_nodes = self.eval_block(&fn_def.body, ctx);
2270+ ctx.current_file = caller_file;
2271+ point_at_call(ctx, before, &fn_def, name, line_num, content);
977
2272
978− // Restore variables and call stack
2273+ // Back to the caller's names and call stack
979− ctx.variables = saved_vars;
2274+ ctx.env = caller_env;
980 ctx.fn_call_stack.pop();
2275 ctx.fn_call_stack.pop();
2276+ if ctx.fn_call_stack.is_empty() {
2277+ ctx.too_deep = false;
2278+ }
2279+ (ctx.current_line, ctx.current_source, ctx.current_node) = caller;
2280+
2281+ // What the body wrote is the call's
2282+ belongs_to_call(&mut body_nodes, name, line_num);
981
2283
982 // Replace @children with caller's children and @slot with slot content
2284 // Replace @children with caller's children and @slot with slot content
983 let mut result_nodes =
2285 let mut result_nodes =
@@ -985,10 +2287,7 @@ impl Evaluator {
985
2287
986 // Attributes that aren't parameters style the function's root
2288 // Attributes that aren't parameters style the function's root
987 // element, and a scoped @style's class goes on it too.
2289 // element, and a scoped @style's class goes on it too.
988− let scope_class = ctx
2290+ let scope_class = fn_def.scoped.is_some().then(|| format!("hl-fn-{}", name));
989− .scoped_functions
990− .contains(name)
991− .then(|| format!("hl-{}", name));
992 if !forwarded.is_empty() || scope_class.is_some() {
2291 if !forwarded.is_empty() || scope_class.is_some() {
993 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
2292 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
994 Node::Element(e) => Some(e),
2293 Node::Element(e) => Some(e),
@@ -1001,7 +2300,33 @@ impl Evaluator {
1001 let mut problems = Vec::new();
2300 let mut problems = Vec::new();
1002 match root {
2301 match root {
1003 Some(root) => {
2302 Some(root) => {
1004− root.attrs.extend(forwarded);
2303+ // A call's `class=` adds to the root's own classes
2304+ for attr in forwarded {
2305+ let existing = (attr.html && attr.key == "class")
2306+ .then(|| root.attrs.iter_mut().find(|a| a.html && a.key == "class"))
2307+ .flatten();
2308+ match existing {
2309+ Some(class) => {
2310+ let mut value = class.value.take().unwrap_or_default();
2311+ for name in attr.value.as_deref().unwrap_or("").split_whitespace() {
2312+ if !value.split_whitespace().any(|c| c == name) {
2313+ value.push(' ');
2314+ value.push_str(name);
2315+ }
2316+ }
2317+ class.value = Some(value.trim().to_string());
2318+ }
2319+ None => root.attrs.push(attr),
2320+ }
2321+ }
2322+ // The scoped `@style` goes into the page once, when the
2323+ // function is first called
2324+ if let Some(css) = &fn_def.scoped
2325+ && !css.is_empty()
2326+ && !ctx.scoped_css.contains(css)
2327+ {
2328+ ctx.scoped_css.push(css.clone());
2329+ }
1005 if let Some(class) = scope_class {
2330 if let Some(class) = scope_class {
1006 match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
2331 match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
1007 Some(attr) => {
2332 Some(attr) => {
@@ -1013,6 +2338,7 @@ impl Evaluator {
1013 key: "class".to_string(),
2338 key: "class".to_string(),
1014 value: Some(class),
2339 value: Some(class),
1015 html: true,
2340 html: true,
2341+ quoted: None,
1016 }),
2342 }),
1017 }
2343 }
1018 }
2344 }
@@ -1040,52 +2366,586 @@ impl Evaluator {
1040 }
2366 }
1041 }
2367 }
1042 for message in problems {
2368 for message in problems {
1043− ctx.diagnostics.push(Diagnostic {
2369+ ctx.diagnostics.push(
1044− line: line_num,
2370+ Diagnostic::error(code::NO_SINGLE_ROOT, line_num, message)
1045− column: None,
2371+ .source(content)
1046− message,
2372+ .subject(name),
1047− severity: Severity::Warning,
2373+ );
1048− source_line: Some(content.to_string()),
1049− });
1050 }
2374 }
1051 }
2375 }
1052
2376
1053 Ok(result_nodes)
2377 Ok(result_nodes)
1054 }
2378 }
2379+}
1055
2380
1056− fn parse_element_line(
2381+/// A `@name` read where it is written: a built-in element, or a call.
1057− &mut self,
2382+enum Resolved {
1058− content: &str,
2383+ Element(Element),
1059− children: &[Syntax],
2384+ Call(Box<Call>),
1060− line_num: usize,
2385+}
1061− ctx: &mut ParseContext,
2386+
1062− ) -> Result<Node, ParseError> {
2387+/// A call of a function, with its arguments evaluated where it is written.
1063− let segments = split_chain(content);
2388+struct Call {
2389+ name: String,
2390+ function: Rc<FnDef>,
2391+ args: Vec<Attribute>,
2392+ /// The value of each argument that passes a parameter, with its type
2393+ /// (see [`ParseContext::slot_value`]), in the order of `args`.
2394+ values: Vec<Option<Value>>,
2395+ /// Text after the attributes, which is content.
2396+ text: Option<syntax::Text>,
2397+ /// Parameters written `name=value`, already reported.
2398+ written_form: Vec<String>,
2399+ /// The keys written in the call's own list, not from a bundle.
2400+ written_keys: Vec<String>,
2401+ line: usize,
2402+ /// The column of the call's name and the line it is on, for a missing
2403+ /// parameter.
2404+ name_at: Option<(usize, String)>,
2405+ /// The line as written, for diagnostics.
2406+ source: String,
2407+}
2408+
2409+/// What `@name` is where it is written: a call of the function `name`
2410+/// when one is defined, else the built-in element. A whole line, each link
2411+/// of a chain and an inline element in text all resolve here.
2412+fn resolve(
2413+ head: &syntax::Head,
2414+ text: Option<&syntax::Text>,
2415+ line: usize,
2416+ source: &str,
2417+ ctx: &mut ParseContext,
2418+) -> Result<Resolved, ParseError> {
2419+ let Some(function) = ctx.env.function(&head.name).cloned() else {
2420+ return parse_single_element(head, text, line, ctx).map(Resolved::Element);
2421+ };
2422+ ctx.used_functions.insert(head.name.clone());
2423+ let params: Vec<String> = function.params.iter().map(|p| p.name.clone()).collect();
2424+ let written = head.attrs.as_ref().map_or(&[][..], |list| &list.attrs[..]);
2425+ let written_form = written_parameter_forms(&head.name, &function, written, line, ctx);
2426+ let written_keys = written.iter().map(|a| a.key.clone()).collect();
2427+ // Parameters are bound by name; every other attribute goes to the
2428+ // root element, and is checked like any attribute
2429+ let (args, values) = parse_attrs(written, line, ctx, true, &params)
2430+ .into_iter()
2431+ .unzip();
2432+ let name_at = name_column(head, line).zip(match &ctx.current_source {
2433+ (current, Some(text)) if *current == line => Some(text.clone()),
2434+ _ => None,
2435+ });
2436+ Ok(Resolved::Call(Box::new(Call {
2437+ name: head.name.clone(),
2438+ function,
2439+ args,
2440+ values,
2441+ text: text.cloned(),
2442+ written_form,
2443+ written_keys,
2444+ line,
2445+ name_at,
2446+ source: source.to_string(),
2447+ })))
2448+}
2449+
2450+/// A line of another file (the standard library, an included file) means
2451+/// nothing in this one: the problems reported since `before` while
2452+/// evaluating the function (its body, a default) are moved to the call.
2453+fn point_at_call(
2454+ ctx: &mut ParseContext,
2455+ before: usize,
2456+ function: &FnDef,
2457+ name: &str,
2458+ line: usize,
2459+ source: &str,
2460+) {
2461+ if function.file == ctx.current_file {
2462+ return;
2463+ }
2464+ let defined_in = function
2465+ .file
2466+ .as_deref()
2467+ .unwrap_or("the file being compiled");
2468+ for d in &mut ctx.diagnostics[before..] {
2469+ d.message = format!(
2470+ "{}\n in @{} (line {} of {})",
2471+ d.message, name, d.line, defined_in
2472+ );
2473+ d.line = line;
2474+ d.column = None;
2475+ d.subject = None;
2476+ d.suggestion = None;
2477+ d.source_line = Some(source.into());
2478+ }
2479+}
2480+
2481+/// A call nested deeper than [`MAX_CALL_DEPTH`].
2482+fn too_deep(name: &str, stack: &[String], line: usize) -> Diagnostic {
2483+ let message = if stack.iter().any(|n| n == name) {
2484+ format!(
2485+ "recursive function call to @{} goes more than {} calls deep: a function that \
2486+ calls itself needs a condition that stops it (`@if`)",
2487+ name, MAX_CALL_DEPTH
2488+ )
2489+ } else {
2490+ format!(
2491+ "function calls nest more than {} deep at @{} (starting with {})",
2492+ MAX_CALL_DEPTH,
2493+ name,
2494+ stack
2495+ .iter()
2496+ .take(3)
2497+ .map(|n| format!("@{}", n))
2498+ .collect::<Vec<_>>()
2499+ .join(" → ")
2500+ )
2501+ };
2502+ Diagnostic::error(code::RECURSIVE_CALL, line, message).subject(name)
2503+}
2504+
2505+/// Mark what a function's body wrote as the call's: it gets the call's
2506+/// line, so what is reported about it points at the call, and the name of
2507+/// the function, unless a call inside the body already claimed it.
2508+fn belongs_to_call(nodes: &mut [Node], function: &str, line: usize) {
2509+ fn claim(element: &mut Element, function: &str, line: usize) {
2510+ element.line_num = line;
2511+ element.function.get_or_insert_with(|| function.to_string());
2512+ belongs_to_call(&mut element.children, function, line);
2513+ }
2514+ for node in nodes {
2515+ match node {
2516+ Node::Element(element) => claim(element, function, line),
2517+ Node::Text(segments) => {
2518+ for segment in segments {
2519+ if let TextSegment::Inline(element) = segment {
2520+ claim(element, function, line);
2521+ }
2522+ }
2523+ }
2524+ Node::Raw(_) => {}
2525+ }
2526+ }
2527+}
1064
2528
1065− // Parse each segment into an Element
2529+/// What an inline `{@name ...}` stands for in its line of text: an
1066− let mut elements: Vec<Element> = Vec::new();
2530+/// element; the text of a function whose body is text; or, for a body with
1067− for seg in &segments {
2531+/// several roots, its nodes as a fragment.
1068− let elem = parse_single_element(seg.trim(), line_num, ctx)?;
2532+fn inline_segments(mut nodes: Vec<Node>, line: usize) -> Vec<TextSegment> {
1069− elements.push(elem);
2533+ if let [Node::Element(_)] = nodes.as_slice()
2534+ && let Some(Node::Element(element)) = nodes.pop()
2535+ {
2536+ return vec![TextSegment::Inline(element)];
2537+ }
2538+ if nodes.iter().all(|n| matches!(n, Node::Text(_))) {
2539+ let mut segments = Vec::new();
2540+ for node in nodes {
2541+ if let Node::Text(run) = node {
2542+ if !segments.is_empty() {
2543+ segments.push(TextSegment::Plain(" ".to_string()));
2544+ }
2545+ segments.extend(run);
2546+ }
1070 }
2547 }
2548+ return segments;
2549+ }
2550+ vec![TextSegment::Inline(Element {
2551+ kind: ElementKind::Fragment,
2552+ attrs: Vec::new(),
2553+ argument: None,
2554+ children: nodes,
2555+ line_num: line,
2556+ function: None,
2557+ })]
2558+}
1071
2559
1072− // Parse indented children (belong to the innermost element)
2560+/// Fill in the default of a parameter that a call leaves out, like the
1073− let children = self.eval_block(children, ctx);
2561+/// value of an attribute passed for it: variables are filled in, one
2562+/// `$name` keeps its type and one `"..."` is quoted text. It sees what the
2563+/// body sees: the names visible where the function is defined, and the
2564+/// parameters before it. Its problems are reported at the definition, once.
2565+fn fill_default(
2566+ fn_def: &FnDef,
2567+ param: &syntax::Param,
2568+ default: &str,
2569+ ctx: &mut ParseContext,
2570+) -> Value {
2571+ // A default that uses a later parameter is an error in the
2572+ // definition; the later one isn't bound yet, so don't report it again
2573+ let later = fn_def
2574+ .params
2575+ .iter()
2576+ .skip_while(|p| p.name != param.name)
2577+ .skip(1);
2578+ let names = interp::names(default);
2579+ if later.clone().any(|p| names.contains(&p.name.as_str())) {
2580+ return Value::empty();
2581+ }
2582+ let line = param.span.line;
2583+ let at_head = line == fn_def.line && fn_def.source.is_some();
2584+ let column =
2585+ at_head.then(|| param.span.column + (param.span.end - param.span.start) - default.len());
2586+ let caller = (
2587+ std::mem::replace(
2588+ &mut ctx.current_source,
2589+ (fn_def.line, fn_def.source.clone()),
2590+ ),
2591+ std::mem::replace(&mut ctx.current_node, fn_def.node),
2592+ );
2593+ let (value, _) = ctx.slot_value(default, line, column);
2594+ (ctx.current_source, ctx.current_node) = caller;
2595+ value
2596+}
1074
2597
1075− // Build chain right-to-left: rightmost gets children, each wraps the next
2598+/// Read `@page`'s attribute list and title. The page is the root element,
1076− let mut current_children = children;
2599+/// and its attributes are checked like any element's: `key=value` (and a
1077− for mut elem in elements.into_iter().rev() {
2600+/// bare boolean HTML attribute) go on `<html>`, styles on `<body>`, and
1078− elem.children.extend(current_children);
2601+/// `favicon FILE` is its one word of htmlang's own.
1079− current_children = vec![Node::Element(elem)];
2602+fn read_page(
2603+ attrs: Option<&syntax::AttrList>,
2604+ title: Option<&syntax::Arg>,
2605+ line: usize,
2606+ ctx: &mut ParseContext,
2607+) -> Page {
2608+ let mut page = Page::default();
2609+ if let Some(list) = attrs {
2610+ let words: Vec<String> = crate::vocab::PAGE_WORDS
2611+ .iter()
2612+ .map(|w| w.to_string())
2613+ .collect();
2614+ ctx.in_page = true;
2615+ let read = parse_attr_list(&list.attrs, line, ctx, true, &words);
2616+ ctx.in_page = false;
2617+ // How many of each name have been read, to find where the next
2618+ // one is written
2619+ let mut seen: HashMap<String, usize> = HashMap::new();
2620+ for attr in read {
2621+ let boolean = attr.value.is_none()
2622+ && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&attr.key.as_str());
2623+ // Where it is written, when it is written in the list itself
2624+ let nth = seen.entry(attr.key.clone()).or_default();
2625+ let column = list
2626+ .attrs
2627+ .iter()
2628+ .filter(|token| token.key == attr.key)
2629+ .nth(*nth)
2630+ .map(|token| token.span.column);
2631+ *nth += 1;
2632+ let value = attr
2633+ .quoted
2634+ .as_ref()
2635+ .map(|q| q.text.clone())
2636+ .or(attr.value.clone());
2637+ if !check_page_attr(&attr.key, attr.html, value.as_deref(), line, column, ctx) {
2638+ continue;
2639+ }
2640+ if words.contains(&attr.key) {
2641+ if page.favicon.is_some() {
2642+ let diagnostic = Diagnostic::warning(
2643+ code::DUPLICATE_ATTRIBUTE,
2644+ line,
2645+ "duplicate attribute 'favicon': the later one wins".to_string(),
2646+ )
2647+ .subject("favicon");
2648+ ctx.diagnostics.push(at_attribute(diagnostic, column, ctx));
2649+ }
2650+ // The file itself goes into the page when it can be read
2651+ // (relative to the file); otherwise it is linked
2652+ page.favicon = value.map(|path| match std::fs::read(ctx.resolve(&path)) {
2653+ Ok(data) => crate::codegen::data_uri(&path, &data),
2654+ Err(_) => path,
2655+ });
2656+ } else if attr.html || boolean {
2657+ page.html_attrs.push(attr);
2658+ } else {
2659+ page.styles.push(attr);
2660+ }
1080 }
2661 }
2662+ }
2663+ page.title = match title {
2664+ Some(title) => ctx.interpolate_text(&title.raw, title.span.line, Some(title.span.column)),
2665+ None => String::new(),
2666+ };
2667+ page
2668+}
1081
2669
1082− current_children.into_iter().next().ok_or(ParseError {
2670+/// `@page`'s own rules for one of its attributes, checked where the
1083− line: line_num,
2671+/// `@page` runs and where it doesn't (a layout that isn't called): its
1084− message: "element chain produced no nodes (this is a parser bug)".to_string(),
2672+/// word is written `favicon FILE`, and `inline` belongs to `@image`.
1085− })
2673+/// Returns whether the attribute can be used (a `favicon` with its file,
2674+/// or any other attribute but `inline`).
2675+fn check_page_attr(
2676+ key: &str,
2677+ html: bool,
2678+ value: Option<&str>,
2679+ line: usize,
2680+ column: Option<usize>,
2681+ ctx: &mut ParseContext,
2682+) -> bool {
2683+ let report = |ctx: &mut ParseContext, diagnostic: Diagnostic| {
2684+ let diagnostic = at_attribute(diagnostic, column, ctx);
2685+ ctx.diagnostics.push(diagnostic);
2686+ };
2687+ if crate::vocab::PAGE_WORDS.contains(&key) {
2688+ if html {
2689+ report(
2690+ ctx,
2691+ Diagnostic::error(
2692+ code::PARAMETER_FORM,
2693+ line,
2694+ format!(
2695+ "'{0}' is @page's own word, not an HTML attribute: write `{0} {1}`",
2696+ key,
2697+ value.filter(|v| !v.is_empty()).unwrap_or("FILE")
2698+ ),
2699+ )
2700+ .subject(format!("{}=", key))
2701+ .suggest(Some(format!("{} ", key))),
2702+ );
2703+ }
2704+ if value.is_none_or(|v| v.trim().is_empty()) {
2705+ report(
2706+ ctx,
2707+ Diagnostic::error(
2708+ code::MISSING_VALUE,
2709+ line,
2710+ format!("'{0}' needs its file: `{0} favicon.png`", key),
2711+ )
2712+ .subject(key),
2713+ );
2714+ return false;
2715+ }
2716+ return true;
2717+ }
2718+ if !html && crate::vocab::base_attribute(key) == "inline" {
2719+ report(
2720+ ctx,
2721+ Diagnostic::error(
2722+ code::UNEXPECTED_ARGUMENT,
2723+ line,
2724+ "`inline` puts an image's file into the page, and @page has none: it only \
2725+ goes on @image"
2726+ .to_string(),
2727+ )
2728+ .subject("inline"),
2729+ );
2730+ return false;
2731+ }
2732+ true
2733+}
2734+
2735+/// A parameter passed `name=value`, the form of an HTML attribute.
2736+fn parameter_form(function: &str, key: &str, value: &str, line: usize) -> Diagnostic {
2737+ Diagnostic::error(
2738+ code::PARAMETER_FORM,
2739+ line,
2740+ format!(
2741+ "'{}' is a parameter of @{}: parameters are written `name value`, so write `{} {}`",
2742+ key, function, key, value
2743+ ),
2744+ )
2745+ .subject(format!("{}=", key))
2746+ .suggest(Some(format!("{} ", key)))
2747+}
2748+
2749+/// A call that leaves out a parameter without a default.
2750+fn missing_parameter(
2751+ function: &str,
2752+ param: &str,
2753+ line: usize,
2754+ column: Option<usize>,
2755+) -> Diagnostic {
2756+ let diagnostic = Diagnostic::error(
2757+ code::MISSING_PARAMETER,
2758+ line,
2759+ format!(
2760+ "@{} needs '{}': the parameter has no default, so pass it as `{} VALUE`",
2761+ function, param, param
2762+ ),
2763+ )
2764+ .subject(param);
2765+ match column {
2766+ Some(column) => diagnostic.column(column),
2767+ None => diagnostic,
2768+ }
2769+}
2770+
2771+/// The column of a call's `@name`, when it is on the call's line.
2772+fn name_column(head: &syntax::Head, line: usize) -> Option<usize> {
2773+ (head.name_span.line == line).then_some(head.name_span.column)
2774+}
2775+
2776+/// Define `$name`, or, for `--name`, the CSS custom property on `:root`
2777+/// (which gets quoted text with its quotes, and is read with
2778+/// `var(--name)`, never as a variable). `@let name.field value` gives the
2779+/// record `$name` that field (or the list `$name` a new item at that
2780+/// index), as a new value in this block.
2781+fn set_variable(name: &str, value: Value, line_num: usize, ctx: &mut ParseContext) {
2782+ if name.starts_with("--") {
2783+ let css = value
2784+ .quoted_css()
2785+ .map_or_else(|| value.to_string(), str::to_string);
2786+ let at_line = |diagnostic: Diagnostic, ctx: &ParseContext| match &ctx.current_source {
2787+ (current, Some(text)) if *current == line_num => diagnostic.source(text.clone()),
2788+ _ => diagnostic,
2789+ };
2790+ // `:root` is one place for the whole page: a declaration inside a
2791+ // block (an element, a loop, a function's body) would not be
2792+ // kept to it, and each run would replace the last
2793+ if ctx.env.frames.len() > FILE_FRAMES {
2794+ let diagnostic = Diagnostic::error(
2795+ code::INVALID_DEFINITION,
2796+ line_num,
2797+ format!(
2798+ "`@let {name}` declares {name} on :root, for the whole page, so it goes at \
2799+ the top level of the file: to set it on an element and what is inside \
2800+ it, write `[{name} value]` on that element"
2801+ ),
2802+ )
2803+ .subject(name);
2804+ ctx.push_once(at_line(diagnostic, ctx));
2805+ return;
2806+ }
2807+ match css_breakout(&css) {
2808+ Some(reason) => {
2809+ let diagnostic = Diagnostic::error(
2810+ code::INVALID_VALUE,
2811+ line_num,
2812+ format!(
2813+ "the value of '{}' has {}: `{}` can't be written into the page's CSS",
2814+ name, reason, css
2815+ ),
2816+ )
2817+ .subject(css.as_str());
2818+ ctx.push_once(at_line(diagnostic, ctx));
2819+ }
2820+ None => match ctx.css_vars.iter_mut().find(|(n, _)| n == name) {
2821+ Some(declared) => {
2822+ let diagnostic = Diagnostic::warning(
2823+ code::DUPLICATE_ATTRIBUTE,
2824+ line_num,
2825+ format!(
2826+ "{name} is declared again: :root has one value for the whole page, \
2827+ so every element gets this one, also above this line (a theme \
2828+ redefines a token on an element: `[dark:{name} {css}]`)"
2829+ ),
2830+ )
2831+ .subject(name);
2832+ declared.1 = css;
2833+ ctx.push_once(at_line(diagnostic, ctx));
2834+ }
2835+ None => ctx.css_vars.push((name.to_string(), css)),
2836+ },
2837+ }
2838+ return;
2839+ }
2840+ let mut path = name.split('.');
2841+ let root = path.next().unwrap_or(name);
2842+ let fields: Vec<&str> = path.collect();
2843+ let value = match fields.is_empty() {
2844+ true => value,
2845+ false => match with_field(ctx.env.value(root).cloned(), root, &fields, value) {
2846+ Ok(value) => value,
2847+ Err(message) => {
2848+ let diagnostic =
2849+ Diagnostic::error(code::INVALID_DEFINITION, line_num, message).subject(name);
2850+ let diagnostic = match &ctx.current_source {
2851+ (current, Some(text)) if *current == line_num => {
2852+ diagnostic.source(text.clone())
2853+ }
2854+ _ => diagnostic,
2855+ };
2856+ ctx.push_once(diagnostic);
2857+ // Reported: the name keeps what it held
2858+ ctx.let_lines.entry(root.to_string()).or_insert(line_num);
2859+ return;
2860+ }
2861+ },
2862+ };
2863+ ctx.bind(root, value);
2864+ ctx.let_lines.entry(root.to_string()).or_insert(line_num);
2865+}
2866+
2867+/// `value` (the value of `$name`) with the field at `path` set to `new`:
2868+/// a record's field, or the item of a list at an index it has. A name
2869+/// without a value, or one whose value is empty (a field a record doesn't
2870+/// have), becomes a record; any other value has no fields to set.
2871+fn with_field(
2872+ value: Option<Value>,
2873+ name: &str,
2874+ path: &[&str],
2875+ new: Value,
2876+) -> Result<Value, String> {
2877+ let Some((field, rest)) = path.split_first() else {
2878+ return Ok(new);
2879+ };
2880+ let inner = format!("{}.{}", name, field);
2881+ match value {
2882+ Some(Value::Record(fields)) => {
2883+ let mut fields = fields.as_ref().clone();
2884+ match fields.iter().position(|(key, _)| key == field) {
2885+ Some(i) => {
2886+ let old = std::mem::replace(&mut fields[i].1, Value::empty());
2887+ fields[i].1 = with_field(Some(old), &inner, rest, new)?;
2888+ }
2889+ None => fields.push((field.to_string(), with_field(None, &inner, rest, new)?)),
2890+ }
2891+ Ok(Value::Record(Rc::new(fields)))
2892+ }
2893+ Some(Value::List(list)) => {
2894+ let count = list.items.len();
2895+ let Some(i) = field.parse::<usize>().ok().filter(|&i| i < count) else {
2896+ return Err(format!(
2897+ "`${}` is {}, so `.{}` isn't one of its items (they are `.0` to `.{}`)",
2898+ name,
2899+ Value::List(list).describe(),
2900+ field,
2901+ count.saturating_sub(1)
2902+ ));
2903+ };
2904+ let mut items = list.items.as_ref().clone();
2905+ let old = std::mem::replace(&mut items[i], Value::empty());
2906+ items[i] = with_field(Some(old), &inner, rest, new)?;
2907+ // Its items changed, so it no longer prints as written
2908+ Ok(Value::list(items))
2909+ }
2910+ None => Ok(Value::Record(Rc::new(vec![(
2911+ field.to_string(),
2912+ with_field(None, &inner, rest, new)?,
2913+ )]))),
2914+ Some(value) if value.to_string().is_empty() => with_field(None, name, path, new),
2915+ Some(value) => Err(format!(
2916+ "`@let {}.{}` sets a field of a record, but `${}` is {} (`{}`), which has no fields",
2917+ name,
2918+ path.join("."),
2919+ name,
2920+ value.describe(),
2921+ value
2922+ )),
2923+ }
2924+}
2925+
2926+/// What a verbatim directive holds (`@head`, `@style`, `@raw`): the rest
2927+/// of its line (`@style .a { color: red }`), or else its indented block.
2928+/// The two together are a syntax error, reported by the tree; the line
2929+/// wins.
2930+fn verbatim_content(node: &syntax::Node) -> String {
2931+ match node.directive().map(|d| &d.args) {
2932+ Some(DirectiveArgs::Text(Some(line))) => format!("{}\n", line.raw),
2933+ _ => verbatim_text(&node.children),
1086 }
2934 }
1087}
2935}
1088
2936
2937+/// The text of a verbatim body (`@head`, `@style`, `@markdown`, `@raw`).
2938+fn verbatim_text(children: &[syntax::Node]) -> String {
2939+ let mut text = String::new();
2940+ for child in children {
2941+ if let NodeKind::Verbatim(body) = &child.kind {
2942+ text.push_str(&body.text);
2943+ text.push('\n');
2944+ }
2945+ }
2946+ text
2947+}
2948+
1089// ---------------------------------------------------------------------------
2949// ---------------------------------------------------------------------------
1090// @children replacement
2950// @children replacement
1091// ---------------------------------------------------------------------------
2951// ---------------------------------------------------------------------------
@@ -1130,91 +2990,705 @@ fn replace_children_and_slots(
1130}
2990}
1131
2991
1132// ---------------------------------------------------------------------------
2992// ---------------------------------------------------------------------------
1133−// Element parsing
2993+// Where @slot and @children are written
1134// ---------------------------------------------------------------------------
2994// ---------------------------------------------------------------------------
1135
2995
1136−fn extract_element_name(content: &str) -> &str {
2996+/// The files each `@include` line brought in, by the line's node id, with
1137− let without_at = &content[1..];
2997+/// the include chain that leads to each.
1138− match without_at.find([' ', '[']) {
2998+type IncludedBy = HashMap<usize, Vec<(Rc<Tree>, String)>>;
1139− Some(i) => &without_at[..i],
2999+
1140− None => without_at,
3000+/// The lines of the files an `@include` line brings in, and the include
3001+/// chain of each (for diagnostics).
3002+type Included<'a, 'f> = &'f dyn Fn(&syntax::Node) -> Vec<(&'a [syntax::Node], Option<&'a str>)>;
3003+
3004+/// Whether `name` is a function here: defined so far, or anywhere in the
3005+/// file and the files it includes.
3006+fn is_function(name: &str, ctx: &ParseContext) -> bool {
3007+ ctx.env.function(name).is_some() || ctx.namespace.contains(name)
3008+}
3009+
3010+/// Whether `name` is one slot name: letters, digits, `-` and `_`,
3011+/// starting with a letter (like a function's name).
3012+fn is_slot_name(name: &str) -> bool {
3013+ name.chars().next().is_some_and(char::is_alphabetic)
3014+ && name
3015+ .chars()
3016+ .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
3017+}
3018+
3019+/// What a `@slot` or `@children` line is written in.
3020+#[derive(Clone, Copy)]
3021+enum Place<'a> {
3022+ /// The top of a file or of a function's body, or a built-in element
3023+ /// (by its name).
3024+ Element(Option<&'a str>),
3025+ /// A call: its content.
3026+ Call,
3027+}
3028+
3029+/// Where a line is, for [`slot_uses`].
3030+#[derive(Clone, Copy)]
3031+struct At<'a> {
3032+ /// Inside a function's body.
3033+ body: bool,
3034+ place: Place<'a>,
3035+ /// Outside a body: the call (and its line) whose content it is in.
3036+ call: Option<(&'a str, usize)>,
3037+ /// The include chain of the file it is in, when that isn't the file
3038+ /// being compiled.
3039+ file: Option<&'a str>,
3040+}
3041+
3042+impl At<'_> {
3043+ /// The top of a file, or of a function's body.
3044+ fn top(body: bool) -> Self {
3045+ At {
3046+ body,
3047+ place: Place::Element(None),
3048+ call: None,
3049+ file: None,
3050+ }
1141 }
3051 }
1142}
3052}
1143
3053
1144−fn parse_single_element(
3054+/// What [`slot_uses`] knows about the names and files it walks.
1145− content: &str,
3055+struct Scan<'a, 'f> {
1146− line_num: usize,
3056+ is_function: &'f dyn Fn(&str) -> bool,
1147− ctx: &mut ParseContext,
3057+ included: Included<'a, 'f>,
1148−) -> Result<Element, ParseError> {
3058+ /// Visit the bodies of the functions defined in the lines too.
1149− let (kind, rest) = if let Some(without_at) = content.strip_prefix('@') {
3059+ into_lets: bool,
1150− match without_at.find([' ', '[']) {
3060+}
1151− Some(i) => {
3061+
1152− let kind_str = &without_at[..i];
3062+/// A `@slot` or `@children` written in a file, and where.
1153− let rest = if without_at.as_bytes()[i] == b'[' {
3063+struct SlotUse<'a> {
1154− without_at[i..].to_string()
3064+ node: &'a syntax::Node,
1155− } else {
3065+ head: &'a syntax::Head,
1156− without_at[i + 1..].to_string()
3066+ /// The text after it, which is a slot's name; `None` when it isn't
1157− };
3067+ /// the last head of its chain.
1158− match parse_element_kind(kind_str, line_num) {
3068+ text: Option<&'a syntax::Text>,
1159− Ok(kind) => (kind, rest),
3069+ /// Written inline in a line of text, `{@slot x}`.
1160− Err(mut e) => {
3070+ inline: bool,
1161− // Also suggest user-defined functions
3071+ at: At<'a>,
1162− if let Some(fn_suggestion) = suggest_fn_name(kind_str, ctx) {
3072+}
1163− e.message =
3073+
1164− format!("{}, or did you mean @{}?", e.message, fn_suggestion);
3074+impl SlotUse<'_> {
1165− }
3075+ fn is_slot(&self) -> bool {
1166− return Err(e);
3076+ self.head.name == "slot"
1167− }
3077+ }
3078+
3079+ /// The slot's name as written.
3080+ fn name(&self) -> &str {
3081+ self.text.map_or("", |t| t.raw.trim())
3082+ }
3083+
3084+ /// A `@slot NAME` that marks a place in a function's body (not one
3085+ /// that fills a slot of a call).
3086+ fn declares(&self) -> bool {
3087+ self.is_slot() && !self.inline && !matches!(self.at.place, Place::Call)
3088+ }
3089+}
3090+
3091+/// Where each `@slot` and `@children` in `block` is written. A `@slot`
3092+/// directly under a call (under its `@if`, `@else` and `@each` too) fills
3093+/// one of its slots; anywhere else in a function's body it declares one.
3094+/// An included file's lines are where its `@include` is.
3095+fn slot_uses<'a>(
3096+ block: &'a [syntax::Node],
3097+ at: At<'a>,
3098+ scan: &Scan<'a, '_>,
3099+ out: &mut Vec<SlotUse<'a>>,
3100+) {
3101+ fn inline<'a>(
3102+ text: &'a syntax::Text,
3103+ node: &'a syntax::Node,
3104+ at: At<'a>,
3105+ scan: &Scan<'a, '_>,
3106+ out: &mut Vec<SlotUse<'a>>,
3107+ ) {
3108+ for segment in &text.segments {
3109+ if let Segment::Inline(inline_element) = segment {
3110+ let head = &inline_element.head;
3111+ if matches!(head.name.as_str(), "slot" | "children")
3112+ && !(scan.is_function)(&head.name)
3113+ {
3114+ out.push(SlotUse {
3115+ node,
3116+ head,
3117+ text: inline_element.text.as_ref(),
3118+ inline: true,
3119+ at,
3120+ });
3121+ }
3122+ if let Some(text) = &inline_element.text {
3123+ inline(text, node, at, scan, out);
1168 }
3124 }
1169 }
3125 }
1170− None => match parse_element_kind(without_at, line_num) {
3126+ }
1171− Ok(kind) => (kind, String::new()),
3127+ }
1172− Err(mut e) => {
3128+ for node in block {
1173− if let Some(fn_suggestion) = suggest_fn_name(without_at, ctx) {
3129+ match &node.kind {
1174− e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion);
3130+ NodeKind::Directive(directive) => match &directive.args {
3131+ DirectiveArgs::Let(def) if matches!(def.form, LetForm::Function(_)) => {
3132+ if scan.into_lets {
3133+ let top = At {
3134+ file: at.file,
3135+ ..At::top(true)
3136+ };
3137+ slot_uses(&node.children, top, scan, out);
1175 }
3138 }
1176− return Err(e);
3139+ }
3140+ // A value's body is an error of its own
3141+ DirectiveArgs::Let(_) => {}
3142+ _ if directive.spec.body == BodyKind::Verbatim => {}
3143+ // `@if`, `@else` and `@each` put their lines in place, and
3144+ // `@include` its file's; the lines under a directive that
3145+ // takes no body are its siblings
3146+ _ => {
3147+ for (lines, file) in (scan.included)(node) {
3148+ slot_uses(lines, At { file, ..at }, scan, out);
3149+ }
3150+ slot_uses(&node.children, at, scan, out);
1177 }
3151 }
1178 },
3152 },
3153+ NodeKind::Element(line) => {
3154+ let mut at = at;
3155+ let last = line.chain.len() - 1;
3156+ for (i, head) in line.chain.iter().enumerate() {
3157+ let text = if i == last { line.text.as_ref() } else { None };
3158+ let function = (scan.is_function)(&head.name);
3159+ if !function && matches!(head.name.as_str(), "slot" | "children") {
3160+ out.push(SlotUse {
3161+ node,
3162+ head,
3163+ text,
3164+ inline: false,
3165+ at,
3166+ });
3167+ } else if let Some(text) = text {
3168+ inline(text, node, at, scan, out);
3169+ }
3170+ if function {
3171+ at.place = Place::Call;
3172+ if !at.body {
3173+ at.call = Some((&head.name, node.span.line));
3174+ }
3175+ } else {
3176+ at.place = Place::Element(Some(&head.name));
3177+ }
3178+ }
3179+ slot_uses(&node.children, at, scan, out);
3180+ }
3181+ NodeKind::Text(text) => {
3182+ inline(text, node, at, scan, out);
3183+ slot_uses(&node.children, at, scan, out);
3184+ }
3185+ NodeKind::Blank | NodeKind::Comment | NodeKind::Verbatim(_) => {}
1179 }
3186 }
1180− } else {
3187+ }
1181− return Err(ParseError {
3188+}
1182− line: line_num,
3189+
1183− message: format!("expected @element or [attrs], got: {}", content),
3190+/// The slots a function's body declares, and whether it has a
3191+/// `@children`, when it is defined. The files its `@include` lines bring
3192+/// in count too: they are read now, since they run only at a call.
3193+fn declared_slots(body: &[syntax::Node], ctx: &mut ParseContext) -> (Vec<String>, bool) {
3194+ let mut files = HashMap::new();
3195+ read_includes(
3196+ body,
3197+ ctx.base_path.clone(),
3198+ &mut Vec::new(),
3199+ ctx,
3200+ &mut files,
3201+ );
3202+ let included = |node: &syntax::Node| match files.get(&node.id) {
3203+ Some(tree) => vec![(&tree.nodes[..], None)],
3204+ None => Vec::new(),
3205+ };
3206+ let is_function = |name: &str| is_function(name, ctx);
3207+ slots_of(body, &is_function, &included)
3208+}
3209+
3210+/// Read and parse the files the `@include` lines in `nodes` bring in (by
3211+/// a literal path, relative to `base`), and the files those include, by
3212+/// the node id of each `@include` line.
3213+fn read_includes(
3214+ nodes: &[syntax::Node],
3215+ base: Option<PathBuf>,
3216+ stack: &mut Vec<PathBuf>,
3217+ ctx: &mut ParseContext,
3218+ files: &mut HashMap<usize, Rc<Tree>>,
3219+) {
3220+ let mut includes = Vec::new();
3221+ for node in nodes {
3222+ node.walk(&mut |node| {
3223+ if let Some(DirectiveArgs::Text(Some(path))) = node.directive().map(|d| &d.args)
3224+ && node.is_directive("include")
3225+ {
3226+ includes.push((node.id, path.raw.clone()));
3227+ }
1184 });
3228 });
3229+ }
3230+ for (id, path) in includes {
3231+ let path = path.trim_matches('"');
3232+ if path.contains('$') {
3233+ continue;
3234+ }
3235+ let resolved = match &base {
3236+ Some(base) => base.join(path),
3237+ None => PathBuf::from(path),
3238+ };
3239+ if stack.contains(&resolved) {
3240+ continue;
3241+ }
3242+ let Ok(text) = ctx.read_file(&resolved) else {
3243+ continue;
3244+ };
3245+ let tree = ctx.parse_tree(&text);
3246+ stack.push(resolved.clone());
3247+ let parent = resolved.parent().map(Path::to_path_buf);
3248+ read_includes(&tree.nodes, parent, stack, ctx, files);
3249+ stack.pop();
3250+ files.insert(id, tree);
3251+ }
3252+}
3253+
3254+/// The slots a function's body declares (`@slot NAME`, in order), and
3255+/// whether it has a `@children` for the content of a call. A `@slot`
3256+/// directly under a call in the body fills that call's slot instead, so
3257+/// `is_function` says which names are functions. The files the body
3258+/// includes aren't read.
3259+pub fn function_slots(
3260+ body: &[syntax::Node],
3261+ is_function: &dyn Fn(&str) -> bool,
3262+) -> (Vec<String>, bool) {
3263+ slots_of(body, is_function, &|_| Vec::new())
3264+}
3265+
3266+fn slots_of<'a>(
3267+ body: &'a [syntax::Node],
3268+ is_function: &dyn Fn(&str) -> bool,
3269+ included: Included<'a, '_>,
3270+) -> (Vec<String>, bool) {
3271+ let scan = Scan {
3272+ is_function,
3273+ included,
3274+ into_lets: false,
3275+ };
3276+ let mut uses = Vec::new();
3277+ slot_uses(body, At::top(true), &scan, &mut uses);
3278+ let mut slots: Vec<String> = Vec::new();
3279+ for slot in uses.iter().filter(|u| u.declares()) {
3280+ let name = slot.name();
3281+ if is_slot_name(name) && !slots.iter().any(|s| s == name) {
3282+ slots.push(name.to_string());
3283+ }
3284+ }
3285+ let has_children = uses.iter().any(|u| !u.is_slot() && !u.inline);
3286+ (slots, has_children)
3287+}
3288+
3289+/// The line as written, indented, when it is one physical line, so a
3290+/// column points into it.
3291+fn written_line(node: &syntax::Node) -> String {
3292+ match node.line_count <= 1 {
3293+ true => format!("{}{}", " ".repeat(node.indent), node.source),
3294+ false => node.source.clone(),
3295+ }
3296+}
3297+
3298+/// The lines of the files an `@include` line brought in when it ran, from
3299+/// `included_by`.
3300+fn ran_includes<'a>(
3301+ included_by: &'a IncludedBy,
3302+ node: &syntax::Node,
3303+) -> Vec<(&'a [syntax::Node], Option<&'a str>)> {
3304+ match included_by.get(&node.id) {
3305+ Some(files) => files
3306+ .iter()
3307+ .map(|(tree, chain)| (&tree.nodes[..], Some(chain.as_str())))
3308+ .collect(),
3309+ None => Vec::new(),
3310+ }
3311+}
3312+
3313+/// Check where every `@slot` and `@children` of the file and the files it
3314+/// includes is written, whether or not it runs: a slot has one name, a
3315+/// function's body marks places with them, and a call fills its slots
3316+/// with `@slot` blocks directly under it. An included file's lines are
3317+/// where its `@include` is (in a body, under a call), so an `@include`
3318+/// that never ran leaves its file unchecked.
3319+fn check_slot_places(ctx: &mut ParseContext) {
3320+ let trees = std::mem::take(&mut ctx.trees);
3321+ let included_by = ctx.included_by.clone();
3322+ let is_function = |name: &str| is_function(name, ctx);
3323+ let scan = Scan {
3324+ is_function: &is_function,
3325+ included: &|node| ran_includes(&included_by, node),
3326+ into_lets: true,
1185 };
3327 };
3328+ let mut uses = Vec::new();
3329+ for (tree, _) in trees.iter().filter(|(_, chain)| chain.is_none()) {
3330+ slot_uses(&tree.nodes, At::top(false), &scan, &mut uses);
3331+ }
3332+ let found: Vec<Diagnostic> = uses
3333+ .iter()
3334+ .filter_map(|slot| {
3335+ let mut d = slot_problem(slot)?;
3336+ if d.column.is_none() {
3337+ d = d.column(slot.head.name_span.column);
3338+ }
3339+ d = d.source(written_line(slot.node));
3340+ if let Some(file) = slot.at.file {
3341+ d.message = format!("{}\n in {}", d.message, file);
3342+ }
3343+ Some(d)
3344+ })
3345+ .collect();
3346+ ctx.diagnostics.extend(found);
3347+ ctx.trees = trees;
3348+}
1186
3349
1187− // Parse optional [attrs]
3350+/// What is wrong with a `@slot` or `@children` where it is written.
1188− let (attrs, rest) = if rest.starts_with('[') {
3351+fn slot_problem(slot: &SlotUse) -> Option<Diagnostic> {
1189− parse_attr_brackets(&rest, line_num, ctx)?
3352+ let line = slot.node.span.line;
3353+ let what = if slot.is_slot() {
3354+ match slot.name() {
3355+ "" => "@slot".to_string(),
3356+ name => format!("@slot {}", name),
3357+ }
1190 } else {
3358 } else {
1191− (Vec::new(), rest)
3359+ "@children".to_string()
1192 };
3360 };
3361+ if slot.inline {
3362+ return Some(Diagnostic::error(
3363+ code::MISPLACED_SLOT,
3364+ line,
3365+ format!(
3366+ "{} goes on a line of its own: inline in text it marks and fills nothing",
3367+ what
3368+ ),
3369+ ));
3370+ }
3371+ if let Some(list) = &slot.head.attrs {
3372+ return Some(
3373+ Diagnostic::error(
3374+ code::UNEXPECTED_ARGUMENT,
3375+ line,
3376+ format!(
3377+ "{} takes no attributes: it is replaced by content, so style the \
3378+ element around it",
3379+ what
3380+ ),
3381+ )
3382+ .column(list.span.column),
3383+ );
3384+ }
3385+ if slot.is_slot() {
3386+ let name = slot.name();
3387+ if name.is_empty() {
3388+ return Some(Diagnostic::error(
3389+ code::MISSING_ARGUMENT,
3390+ line,
3391+ "@slot needs a name: `@slot footer` (the caller's content without a \
3392+ name goes where `@children` is)"
3393+ .to_string(),
3394+ ));
3395+ }
3396+ if !is_slot_name(name) {
3397+ let joined = name.split_whitespace().collect::<Vec<_>>().join("-");
3398+ let suggestion = is_slot_name(&joined).then_some(joined);
3399+ let column = slot.text.map(|t| t.span.column);
3400+ let d = Diagnostic::error(
3401+ code::INVALID_SLOT_NAME,
3402+ line,
3403+ format!(
3404+ "'{}' is not a slot name: a slot's name is one word of letters, digits, \
3405+ `-` and `_`, starting with a letter, and its content goes on the lines \
3406+ under it",
3407+ name
3408+ ),
3409+ )
3410+ .subject(name)
3411+ .suggest(suggestion);
3412+ return Some(match column {
3413+ Some(column) => d.column(column),
3414+ None => d,
3415+ });
3416+ }
3417+ if matches!(slot.at.place, Place::Call) || slot.at.body {
3418+ return None;
3419+ }
3420+ let message = match (slot.at.call, slot.at.place) {
3421+ (Some((call, call_line)), Place::Element(Some(element))) => format!(
3422+ "{} is inside @{}, so it fills nothing: a @slot block that fills a slot of \
3423+ @{} (line {}) goes directly under the call",
3424+ what, element, call, call_line
3425+ ),
3426+ _ => format!(
3427+ "{} is outside a function's body: `@slot NAME` marks a place in a body \
3428+ (`@let @name`), and directly under a call it fills one",
3429+ what
3430+ ),
3431+ };
3432+ return Some(Diagnostic::error(code::MISPLACED_SLOT, line, message));
3433+ }
3434+ if slot.at.body {
3435+ return None;
3436+ }
3437+ let message = match slot.at.call {
3438+ Some((call, _)) => format!(
3439+ "@children is outside a function's body, so it stands for nothing: the content \
3440+ for @{} is written directly under the call",
3441+ call
3442+ ),
3443+ None => "@children is outside a function's body: it marks where a body puts the \
3444+ content of each call"
3445+ .to_string(),
3446+ };
3447+ Some(Diagnostic::error(code::MISPLACED_SLOT, line, message))
3448+}
1193
3449
1194− let rest = rest.trim().to_string();
3450+/// A `@slot NAME` block that fills a slot of a call.
1195− track_var_refs(&rest, &mut ctx.used_variables);
3451+struct Filler<'a> {
3452+ node: &'a syntax::Node,
3453+ text: &'a syntax::Text,
3454+ /// The include chain of the file it is in, when that isn't the file
3455+ /// being compiled.
3456+ file: Option<&'a str>,
3457+}
1196
3458
1197− // For @link, first token of rest is URL, remainder is inline text
3459+/// Whether `@slot` is the built-in element here, and not a function
1198− let mut children = Vec::new();
3460+/// defined so far that took its name.
1199− let argument = if rest.is_empty() {
3461+fn slot_is_built_in(ctx: &ParseContext) -> bool {
1200− None
3462+ ctx.env.function("slot").is_none()
1201− } else if kind == ElementKind::Link {
3463+}
1202− let rest_sub = substitute_vars(&protect_escapes(&rest), &ctx.variables);
3464+
1203− if let Some((url, text)) = rest_sub.split_once(' ') {
3465+/// The `@slot NAME` blocks directly under a call, in `block` (under its
1204− let text = text.trim();
3466+/// `@if`, `@else` and `@each` too, and in the files its `@include` lines
1205− if !text.is_empty() {
3467+/// brought in).
1206− children.push(Node::Text(parse_text_segments(text, ctx)));
3468+fn fillers<'a>(
1207− }
3469+ block: &'a [syntax::Node],
1208− Some(restore_escapes(url))
3470+ file: Option<&'a str>,
1209− } else {
3471+ included: &'a IncludedBy,
1210− Some(restore_escapes(&rest_sub))
3472+ out: &mut Vec<Filler<'a>>,
3473+) {
3474+ for node in block {
3475+ match &node.kind {
3476+ NodeKind::Directive(directive)
3477+ if matches!(directive.name(), "if" | "else" | "each") =>
3478+ {
3479+ fillers(&node.children, file, included, out);
3480+ }
3481+ NodeKind::Directive(directive) if directive.name() == "include" => {
3482+ for (lines, file) in ran_includes(included, node) {
3483+ fillers(lines, file, included, out);
3484+ }
3485+ }
3486+ NodeKind::Element(line) if line.chain.len() == 1 && line.chain[0].name == "slot" => {
3487+ if let Some(text) = &line.text {
3488+ out.push(Filler { node, text, file });
3489+ }
3490+ }
3491+ _ => {}
1211 }
3492 }
1212− } else {
3493+ }
1213− Some(restore_escapes(&substitute_vars(
3494+}
1214− &protect_escapes(&rest),
3495+
1215− &ctx.variables,
3496+/// Whether `block`, the lines under a call, holds content other than
1216− )))
3497+/// `@slot` blocks (under `@if`, `@else` and `@each` too): text, an
3498+/// element, raw HTML, Markdown or an included file. For code that doesn't
3499+/// run.
3500+fn holds_content(block: &[syntax::Node], ctx: &ParseContext) -> bool {
3501+ block.iter().any(|node| match &node.kind {
3502+ NodeKind::Directive(directive) => match directive.name() {
3503+ "if" | "else" | "each" => holds_content(&node.children, ctx),
3504+ name => matches!(name, "raw" | "markdown" | "include"),
3505+ },
3506+ NodeKind::Element(line) => {
3507+ line.chain[0].name != "slot" || !slot_is_built_in(ctx) || line.chain.len() > 1
3508+ }
3509+ NodeKind::Text(_) => true,
3510+ _ => false,
3511+ })
3512+}
3513+
3514+/// Check the `@slot NAME` blocks passed to each call of a line against
3515+/// the slots its function declares. `calls` has the function of each head
3516+/// of the chain that is a call.
3517+fn check_fillers(
3518+ node: &syntax::Node,
3519+ element: &syntax::ElementLine,
3520+ calls: &[Option<(String, Rc<FnDef>)>],
3521+ ctx: &mut ParseContext,
3522+) {
3523+ if !slot_is_built_in(ctx) {
3524+ return;
3525+ }
3526+ let included = ctx.included_by.clone();
3527+ let last = element.chain.len() - 1;
3528+ for (i, call) in calls.iter().enumerate() {
3529+ let Some((name, function)) = call else {
3530+ continue;
3531+ };
3532+ let mut found = Vec::new();
3533+ if i == last {
3534+ fillers(&node.children, None, &included, &mut found);
3535+ } else if i + 1 == last
3536+ && element.chain[last].name == "slot"
3537+ && let Some(text) = &element.text
3538+ {
3539+ // `@card > @slot footer`
3540+ found.push(Filler {
3541+ node,
3542+ text,
3543+ file: None,
3544+ });
3545+ }
3546+ for filler in found {
3547+ let slot = filler.text.raw.trim();
3548+ if !is_slot_name(slot) || function.slots.iter().any(|s| s == slot) {
3549+ continue;
3550+ }
3551+ let mut d = unknown_slot(name, slot, &function.slots, filler.text.span.line)
3552+ .column(filler.text.span.column)
3553+ .source(written_line(filler.node));
3554+ if let Some(file) = filler.file {
3555+ d.message = format!("{}\n in {}", d.message, file);
3556+ }
3557+ ctx.push_once(d);
3558+ }
3559+ }
3560+}
3561+
3562+/// A `@slot NAME` block for a slot the function doesn't declare.
3563+fn unknown_slot(function: &str, slot: &str, slots: &[String], line: usize) -> Diagnostic {
3564+ let candidates: Vec<&str> = slots.iter().map(String::as_str).collect();
3565+ let suggestion = suggest_closest(slot, &candidates);
3566+ let message = match (slots.is_empty(), suggestion) {
3567+ (true, _) => format!(
3568+ "@{} has no slot '{}': its body declares no slots (`@slot NAME`)",
3569+ function, slot
3570+ ),
3571+ (false, Some(close)) => format!(
3572+ "@{} has no slot '{}', did you mean '{}'? (its slots: {})",
3573+ function,
3574+ slot,
3575+ close,
3576+ slots.join(", ")
3577+ ),
3578+ (false, None) => format!(
3579+ "@{} has no slot '{}' (its slots: {})",
3580+ function,
3581+ slot,
3582+ slots.join(", ")
3583+ ),
1217 };
3584 };
3585+ Diagnostic::error(code::UNKNOWN_SLOT, line, message)
3586+ .subject(slot)
3587+ .suggest(suggestion)
3588+}
3589+
3590+/// Content passed to a function whose body has no `@children`.
3591+fn no_children(function: &str, line: usize, column: Option<usize>) -> Diagnostic {
3592+ let d = Diagnostic::error(
3593+ code::UNEXPECTED_CONTENT,
3594+ line,
3595+ format!(
3596+ "@{0} takes no content: its body has no `@children`, so the text and lines \
3597+ passed to it would be dropped (pass text as a parameter, `@{0} [name value]`, \
3598+ or add `@children` to the body)",
3599+ function
3600+ ),
3601+ )
3602+ .subject(function);
3603+ match column {
3604+ Some(column) => d.column(column),
3605+ None => d,
3606+ }
3607+}
3608+
3609+// ---------------------------------------------------------------------------
3610+// Element parsing
3611+// ---------------------------------------------------------------------------
3612+
3613+/// Build the element for `@name [attrs] text` (on its own line, in a chain,
3614+/// or inline in text).
3615+fn parse_single_element(
3616+ head: &syntax::Head,
3617+ text: Option<&syntax::Text>,
3618+ line_num: usize,
3619+ ctx: &mut ParseContext,
3620+) -> Result<Element, ParseError> {
3621+ let kind = parse_element_kind(&head.name, line_num, ctx)?;
3622+ let mut attrs = head.attrs.as_ref().map_or_else(Vec::new, |list| {
3623+ parse_attr_list(&list.attrs, line_num, ctx, true, &[])
3624+ });
3625+
3626+ // The leading argument: for an element whose row names a leading
3627+ // attribute (`@link`'s href, `@image`'s src, `@form`'s action, ...),
3628+ // the first token of the text fills it, split before anything is
3629+ // filled in, and the rest is content (an error on an element without
3630+ // content). `@slot`'s whole text is its name. Any other element's text
3631+ // is content, parsed like any line of text, as in `@el [padding 8]
3632+ // Hello` or `@h2 Meet {@text htmlang}`; its layout decides how it
3633+ // combines with the lines under it.
3634+ let mut children = Vec::new();
3635+ let mut argument = None;
3636+ let leading = kind.arg().attributes();
3637+ match text {
3638+ Some(text) if !leading.is_empty() => {
3639+ let (token, rest) = text.split_leading();
3640+ let given = attrs
3641+ .iter()
3642+ .find(|a| a.html && leading.contains(&a.key.as_str()))
3643+ .map(|a| (a.key.clone(), a.value.clone().unwrap_or_default()));
3644+ if let Some((key, value)) = given {
3645+ // The attribute wins; the text is content, as if the
3646+ // element had no leading argument
3647+ ctx.push_once(leading_twice(&kind, &token, &key, &value, text, ctx));
3648+ if !takes_no_text(&kind) {
3649+ children.push(Node::Text(text_segments(text, ctx)));
3650+ }
3651+ } else if let Some(d) = bad_leading_token(&kind, &token, &text.raw, ctx) {
3652+ // Nothing is taken as the argument; the line is content
3653+ ctx.push_once(d);
3654+ if !takes_no_text(&kind) {
3655+ children.push(Node::Text(text_segments(text, ctx)));
3656+ }
3657+ } else {
3658+ let column = Some(token.span.column);
3659+ argument = Some(
3660+ ctx.fill_value(&token.raw, text.span.line, column, Sink::Text)
3661+ .0,
3662+ );
3663+ if let Some(rest) = rest {
3664+ if let Some(d) = chain_after_argument(&kind, &token.raw, &rest, ctx) {
3665+ ctx.push_once(d);
3666+ }
3667+ if takes_no_text(&kind) {
3668+ let d = after_the_leading_argument(&kind, &token.raw, &rest);
3669+ let d = at_attribute(d, Some(rest.span.column), ctx);
3670+ ctx.push_once(d);
3671+ } else {
3672+ children.push(Node::Text(text_segments(&rest, ctx)));
3673+ }
3674+ }
3675+ }
3676+ }
3677+ Some(text) if matches!(kind, ElementKind::Slot(_)) => {
3678+ argument =
3679+ Some(ctx.interpolate_text(&text.raw, text.span.line, Some(text.span.column)));
3680+ }
3681+ Some(text) => children.push(Node::Text(text_segments(text, ctx))),
3682+ // `@link [href=/about]`: the attribute is the leading argument, so
3683+ // both forms compile the same (`@image [src=a.svg, inline]`). Not
3684+ // for `@source`, whose attribute depends on where it is.
3685+ None if leading.len() == 1 => {
3686+ if let Some(i) = attrs.iter().position(|a| a.html && a.key == leading[0]) {
3687+ argument = Some(attrs.remove(i).value.unwrap_or_default());
3688+ }
3689+ }
3690+ None => {}
3691+ }
1218
3692
1219 // For @slot, the argument is the slot name
3693 // For @slot, the argument is the slot name
1220 let kind = if let ElementKind::Slot(_) = kind {
3694 let kind = if let ElementKind::Slot(_) = kind {
@@ -1223,62 +3697,323 @@ fn parse_single_element(
1223 kind
3697 kind
1224 };
3698 };
1225
3699
1226− // Every other element treats its argument as leading text content, as
1227− // in `@el [padding 8] Hello` or `@paragraph Read {@link /more more}`.
1228− let argument = match argument {
1229− Some(_) if !argument_is_special(&kind) && !renders_argument_as_text(&kind) => {
1230− children.insert(0, Node::Text(parse_text_segments(&rest, ctx)));
1231− None
1232− }
1233− other => other,
1234− };
1235−
1236 Ok(Element {
3700 Ok(Element {
1237 kind,
3701 kind,
1238 attrs,
3702 attrs,
1239 argument,
3703 argument,
1240 children,
3704 children,
1241 line_num,
3705 line_num,
3706+ function: None,
1242 })
3707 })
1243}
3708}
1244
3709
1245−/// Elements whose argument is not text content: a URL, a source, an action,
3710+/// Whether `node` or a line inside it calls `@name` outside any `@if`
1246−/// or a slot name.
3711+/// (in a chain, or inline in text).
1247−fn argument_is_special(kind: &ElementKind) -> bool {
3712+fn calls_unguarded(node: &syntax::Node, name: &str) -> bool {
1248− matches!(
3713+ if node.is_directive("if") || node.is_directive("else") {
1249− kind,
3714+ return false;
1250− ElementKind::Link | ElementKind::Image | ElementKind::Script | ElementKind::Slot(_)
3715+ }
1251− ) || kind
3716+ let text_calls = |text: &syntax::Text| {
1252− .spec()
3717+ text.segments
1253− .is_some_and(|spec| matches!(spec.arg, TagArg::Attr(_)))
3718+ .iter()
3719+ .any(|s| matches!(s, Segment::Inline(inline) if inline.head.name == name))
3720+ };
3721+ let here = match &node.kind {
3722+ NodeKind::Element(element) => {
3723+ element.chain.iter().any(|head| head.name == name)
3724+ || element.text.as_ref().is_some_and(text_calls)
3725+ }
3726+ NodeKind::Text(text) => text_calls(text),
3727+ _ => false,
3728+ };
3729+ here || node
3730+ .children
3731+ .iter()
3732+ .any(|child| calls_unguarded(child, name))
3733+}
3734+
3735+/// `@td [padding 8] @children`: `@children` and `@slot NAME` mark where a
3736+/// call's content goes only on a line of their own; after an element's
3737+/// attributes, or in a verbatim body, they are text.
3738+fn marker_in_text(node: &syntax::Node) -> Option<Diagnostic> {
3739+ let is_marker = |text: &str| {
3740+ let text = text.trim();
3741+ text == "@children" || text == "@slot" || text.starts_with("@slot ")
3742+ };
3743+ let mut line = node.span.line;
3744+ let mut source = written_line(node);
3745+ let (marker, message) = match &node.kind {
3746+ NodeKind::Element(element) => {
3747+ let text = element.text.as_ref()?;
3748+ if !is_marker(&text.raw) {
3749+ return None;
3750+ }
3751+ let name = &element.chain.last()?.name;
3752+ let marker = text.raw.trim().to_string();
3753+ let message = format!(
3754+ "`{marker}` after @{name}'s attributes is text: it marks where a call's content \
3755+ goes only on a line of its own, so write it on the next line, indented under \
3756+ @{name}"
3757+ );
3758+ (marker, message)
3759+ }
3760+ NodeKind::Verbatim(verbatim) if verbatim.escaped => {
3761+ let (i, written) = verbatim
3762+ .text
3763+ .lines()
3764+ .enumerate()
3765+ .find(|(_, l)| is_marker(l))?;
3766+ let marker = written.trim().to_string();
3767+ line += i;
3768+ source = written.to_string();
3769+ let message = format!(
3770+ "`{marker}` in a verbatim body is text: the body is written as it is, so it can't \
3771+ hold a call's content (pass the text as a parameter instead)"
3772+ );
3773+ (marker, message)
3774+ }
3775+ _ => return None,
3776+ };
3777+ Some(
3778+ Diagnostic::error(code::MISPLACED_SLOT, line, message)
3779+ .subject(marker)
3780+ .source(source),
3781+ )
3782+}
3783+
3784+/// A first word that can't be a leading argument: an inline element
3785+/// (`@link {@b x} y`), or a quote that isn't closed (`@link "a b`).
3786+fn bad_leading_token(
3787+ kind: &ElementKind,
3788+ token: &syntax::Arg,
3789+ text: &str,
3790+ ctx: &ParseContext,
3791+) -> Option<Diagnostic> {
3792+ let what = leading_name(kind);
3793+ let name = kind.name();
3794+ let message = if token.raw.starts_with("{@") {
3795+ format!(
3796+ "@{0} takes its {1} first, before any text: write `@{0} VALUE {2}`, or give it as \
3797+ `[{1}=VALUE]`",
3798+ name,
3799+ what,
3800+ text.trim()
3801+ )
3802+ } else if token.raw.starts_with('"') && syntax::quoted_string(&token.raw).is_none() {
3803+ format!(
3804+ "@{}'s {} starts with a `\"` that isn't closed: close it (`\"...\"`), or write \
3805+ `\\\"` for a quote in the value",
3806+ name, what
3807+ )
3808+ } else {
3809+ return None;
3810+ };
3811+ let d = Diagnostic::error(code::UNEXPECTED_ARGUMENT, token.span.line, message)
3812+ .subject(token.raw.clone());
3813+ Some(at_attribute(d, Some(token.span.column), ctx))
3814+}
3815+
3816+/// `@link /a > @image logo.png`: a `>` chains only after an element's
3817+/// name or its `[attributes]`, so after a leading argument it is text.
3818+/// Said when what follows names an element or a function.
3819+fn chain_after_argument(
3820+ kind: &ElementKind,
3821+ token: &str,
3822+ rest: &syntax::Text,
3823+ ctx: &ParseContext,
3824+) -> Option<Diagnostic> {
3825+ let after = rest.raw.strip_prefix('>')?;
3826+ let name_start = after.trim_start().strip_prefix('@')?;
3827+ if after.len() == after.trim_start().len() {
3828+ return None;
3829+ }
3830+ let name: String = name_start
3831+ .chars()
3832+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
3833+ .collect();
3834+ if ElementKind::from_name(&name).is_none() && ctx.env.function(&name).is_none() {
3835+ return None;
3836+ }
3837+ let attr = leading_name(kind);
3838+ let d = Diagnostic::warning(
3839+ code::UNEXPECTED_ARGUMENT,
3840+ rest.span.line,
3841+ format!(
3842+ "`{}` after @{}'s {} is text, not a chain: `>` chains after an element's name or \
3843+ its [attributes], so write `@{} [{}={}] {}`",
3844+ rest.raw.trim_end(),
3845+ kind.name(),
3846+ attr,
3847+ kind.name(),
3848+ attr,
3849+ token,
3850+ rest.raw.trim_end()
3851+ ),
3852+ )
3853+ .subject(rest.raw.trim_end());
3854+ Some(at_attribute(d, Some(rest.span.column), ctx))
3855+}
3856+
3857+/// A leading argument given twice, as the first token and as its
3858+/// attribute: `@link [href=/a] About`.
3859+fn leading_twice(
3860+ kind: &ElementKind,
3861+ token: &syntax::Arg,
3862+ key: &str,
3863+ value: &str,
3864+ text: &syntax::Text,
3865+ ctx: &ParseContext,
3866+) -> Diagnostic {
3867+ let name = kind.name();
3868+ let what = leading_name(kind);
3869+ // The value as one token: quoted when it is empty or has a space in it
3870+ let value = if !value.is_empty() && syntax::leading_token_len(value) == value.len() {
3871+ value.to_string()
3872+ } else {
3873+ format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
3874+ };
3875+ let written = if takes_no_text(kind) {
3876+ format!("@{} {}", name, value)
3877+ } else {
3878+ format!("@{} {} {}", name, value, text.raw)
3879+ };
3880+ let d = Diagnostic::error(
3881+ code::DUPLICATE_ATTRIBUTE,
3882+ text.span.line,
3883+ format!(
3884+ "@{} takes its first word '{}' as its {}, and it also has {}=: give it once, \
3885+ as `{}`",
3886+ name, token.raw, what, key, written
3887+ ),
3888+ )
3889+ .subject(token.raw.clone());
3890+ at_attribute(d, Some(token.span.column), ctx)
3891+}
3892+
3893+/// The attribute an element's leading argument fills, for messages.
3894+fn leading_name(kind: &ElementKind) -> &'static str {
3895+ match kind.arg() {
3896+ TagArg::Source => "srcset in @picture or src elsewhere",
3897+ arg => arg.attribute(false).unwrap_or("argument"),
3898+ }
1254}
3899}
1255
3900
1256−/// The directive names (without `@`) the parser recognizes, for tools such
3901+/// Whether text after an element's leading argument has nowhere to go:
1257−/// as the language server.
3902+/// an element without content (`@image`, `@source`, ...), or `@optgroup`,
1258−pub fn known_directives() -> &'static [&'static str] {
3903+/// which holds only `@option` lines, so `@optgroup Citrus fruits` would
1259− KNOWN_DIRECTIVES
3904+/// otherwise make `Citrus` the label and drop `fruits`.
3905+fn takes_no_text(kind: &ElementKind) -> bool {
3906+ kind.layout() == Layout::Void || kind.is_tag("optgroup")
1260}
3907}
1261
3908
1262−const KNOWN_DIRECTIVES: &[&str] = &[
3909+/// Text after the leading argument of an element without content:
1263− "page", "let", "include", "raw", "if", "else", "each", "meta", "head", "style", "markdown",
3910+/// `@image logo.png Our logo`.
1264− "data",
3911+fn after_the_leading_argument(kind: &ElementKind, token: &str, rest: &syntax::Text) -> Diagnostic {
1265−];
3912+ let name = kind.name();
3913+ let attr = leading_name(kind);
3914+ let alt = if matches!(kind, ElementKind::Image) || kind.is_tag("area") {
3915+ " For a text alternative, write `alt=...`."
3916+ } else {
3917+ ""
3918+ };
3919+ let holds = if kind.layout() == Layout::Void {
3920+ "it has no content"
3921+ } else {
3922+ "it holds only the elements on the lines under it"
3923+ };
3924+ let bare = syntax::quoted_string(token).unwrap_or(token);
3925+ Diagnostic::error(
3926+ code::UNEXPECTED_ARGUMENT,
3927+ rest.span.line,
3928+ format!(
3929+ "@{0} takes one word after its attributes, its {1} ('{2}'), and {5}, so '{3}' \
3930+ would go nowhere. A value with a space in it is quoted: `\"{6} {3}\"`.{4}",
3931+ name, attr, token, rest.raw, alt, holds, bare
3932+ ),
3933+ )
3934+ .subject(rest.raw.clone())
3935+}
1266
3936
1267−fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
3937+/// The element named `name`, or an "unknown element" error that suggests
1268− if let Some(kind) = ElementKind::from_name(s) {
3938+/// the closest element, directive or function.
3939+fn parse_element_kind(
3940+ name: &str,
3941+ line_num: usize,
3942+ ctx: &mut ParseContext,
3943+) -> Result<ElementKind, ParseError> {
3944+ if let Some(kind) = ElementKind::from_name(name) {
1269 return Ok(kind);
3945 return Ok(kind);
1270 }
3946 }
3947+ // A known name in a place it can't go: say why instead of suggesting
3948+ // the name itself.
3949+ let misplaced = if crate::ast::directive(name).is_some() {
3950+ Some(format!(
3951+ "unknown element @{}: @{} is a directive, which goes at the start of its own line",
3952+ name, name
3953+ ))
3954+ } else if ctx.env.bundle(name).is_some() {
3955+ Some(format!(
3956+ "unknown element @{}: '{}' is an attribute bundle, used as `[${}]` \
3957+ (a function is defined with `@let @{}`)",
3958+ name, name, name, name
3959+ ))
3960+ } else if ctx.env.value(name).is_some() {
3961+ Some(format!(
3962+ "unknown element @{}: '{}' is a value, used as `${}` \
3963+ (a function is defined with `@let @{}`)",
3964+ name, name, name, name
3965+ ))
3966+ } else if ctx.namespace.contains(name) {
3967+ ctx.used_functions.insert(name.to_string());
3968+ Some(format!(
3969+ "unknown element @{}: the function @{} isn't visible here. A definition is \
3970+ visible from its line to the end of its block, and a function's body sees what \
3971+ is defined above the function, so define @{} above this line, outside the block \
3972+ it is in",
3973+ name, name, name
3974+ ))
3975+ } else if matches!(name, "svg" | "math" | "template") {
3976+ Some(format!(
3977+ "unknown element @{}: htmlang doesn't write <{}>; put its markup in the page with \
3978+ @raw{}",
3979+ name,
3980+ name,
3981+ if name == "svg" {
3982+ " (or an SVG file with `@image [inline] file.svg`)"
3983+ } else {
3984+ ""
3985+ }
3986+ ))
3987+ } else {
3988+ None
3989+ };
3990+ if let Some(message) = misplaced {
3991+ return Err(Diagnostic::error(code::UNKNOWN_ELEMENT, line_num, message).subject(name));
3992+ }
1271 let all_known: Vec<&str> = ElementKind::all_names()
3993 let all_known: Vec<&str> = ElementKind::all_names()
1272− .chain(KNOWN_DIRECTIVES.iter().copied())
3994+ .chain(DIRECTIVES.iter().map(|d| d.name))
1273 .collect();
3995 .collect();
1274− let message = match suggest_closest(s, &all_known) {
3996+ // An HTML element htmlang writes under its own name (`@a` is `@link`).
1275− Some(closest) => format!("unknown element @{}, did you mean @{}?", s, closest),
3997+ let written_otherwise = crate::ast::HTML_NAMES_WRITTEN_OTHERWISE
1276− None => format!("unknown element @{}", s),
3998+ .iter()
3999+ .find(|(html, _)| *html == name)
4000+ .map(|(_, htmlang)| *htmlang);
4001+ let closest = written_otherwise.or_else(|| suggest_closest(name, &all_known));
4002+ let mut message = match (closest, written_otherwise) {
4003+ (Some(closest), Some(_)) => format!(
4004+ "unknown element @{}, did you mean @{}? (@{} writes <{}>)",
4005+ name, closest, closest, name
4006+ ),
4007+ (Some(closest), None) => format!("unknown element @{}, did you mean @{}?", name, closest),
4008+ (None, _) => format!("unknown element @{}", name),
1277 };
4009 };
1278− Err(ParseError {
4010+ let function = suggest_fn_name(name, ctx);
1279− line: line_num,
4011+ if let Some(function) = &function {
1280− message,
4012+ message = format!("{}, or did you mean @{}?", message, function);
1281− })
4013+ }
4014+ Err(Diagnostic::error(code::UNKNOWN_ELEMENT, line_num, message)
4015+ .subject(name)
4016+ .suggest(closest.map(str::to_string).or(function)))
1282}
4017}
1283
4018
1284/// Format include/import stack as a readable chain for error messages.
4019/// Format include/import stack as a readable chain for error messages.
@@ -1349,34 +4084,37 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
1349 best
4084 best
1350}
4085}
1351
4086
1352−/// Suggest the closest user-defined function name for typos.
4087+/// Suggest the closest user-defined function name for typos, from the
4088+/// functions defined so far and those defined anywhere in the file.
1353fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
4089fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
1354 let input_chars: Vec<char> = input.chars().collect();
4090 let input_chars: Vec<char> = input.chars().collect();
1355 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
4091 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
1356− let mut best: Option<String> = None;
4092+ let mut best: Option<(usize, &String)> = None;
1357− let mut best_dist = usize::MAX;
4093+ let visible: Vec<&String> = ctx.defined_functions.keys().collect();
1358− for name in ctx.functions.keys() {
4094+ for name in visible.into_iter().chain(ctx.namespace.iter()) {
1359 let nlen = name.chars().count();
4095 let nlen = name.chars().count();
1360 if nlen.abs_diff(input_chars.len()) > max_allowed {
4096 if nlen.abs_diff(input_chars.len()) > max_allowed {
1361 continue;
4097 continue;
1362 }
4098 }
1363 let name_chars: Vec<char> = name.chars().collect();
4099 let name_chars: Vec<char> = name.chars().collect();
1364 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
4100 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
1365− if dist < best_dist && dist <= max_allowed {
4101+ if dist <= max_allowed && best.is_none_or(|b| (dist, name) < b) {
1366− best_dist = dist;
4102+ best = Some((dist, name));
1367− best = Some(name.clone());
1368 }
4103 }
1369 }
4104 }
1370− best
4105+ best.map(|(_, name)| name.clone())
1371}
4106}
1372
4107
1373/// Suggest the closest variable name for undefined `$var` references.
4108/// Suggest the closest variable name for undefined `$var` references.
1374−fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<String> {
4109+fn suggest_var_name(input: &str, vars: &[&str]) -> Option<String> {
1375 let input_chars: Vec<char> = input.chars().collect();
4110 let input_chars: Vec<char> = input.chars().collect();
1376 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
4111 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
1377 let mut best: Option<String> = None;
4112 let mut best: Option<String> = None;
1378 let mut best_dist = usize::MAX;
4113 let mut best_dist = usize::MAX;
1379− for name in vars.keys().filter(|k| !k.ends_with('#')) {
4114+ let mut vars = vars.to_vec();
4115+ // The same answer every time, whatever the order of the names
4116+ vars.sort_unstable();
4117+ for name in vars {
1380 let nlen = name.chars().count();
4118 let nlen = name.chars().count();
1381 if nlen.abs_diff(input_chars.len()) > max_allowed {
4119 if nlen.abs_diff(input_chars.len()) > max_allowed {
1382 continue;
4120 continue;
@@ -1385,993 +4123,1950 @@ fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<Strin
1385 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
4123 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
1386 if dist < best_dist && dist <= max_allowed {
4124 if dist < best_dist && dist <= max_allowed {
1387 best_dist = dist;
4125 best_dist = dist;
1388− best = Some(name.clone());
4126+ best = Some(name.to_string());
1389 }
4127 }
1390 }
4128 }
1391 best
4129 best
1392}
4130}
1393
4131
1394−/// Check for undefined `$var` references and return "did you mean?" diagnostics.
1395−/// `indent` is the line's leading whitespace, which `input` has had trimmed;
1396−/// reported columns and source lines include it.
1397−fn check_undefined_vars(
1398− input: &str,
1399− vars: &HashMap<String, String>,
1400− line_num: usize,
1401− indent: usize,
1402−) -> Vec<Diagnostic> {
1403− let mut warnings = Vec::new();
1404− if !input.contains('$') {
1405− return warnings;
1406− }
1407− let chars: Vec<char> = input.chars().collect();
1408− let mut i = 0;
1409− while i < chars.len() {
1410− if chars[i] == '$'
1411− && i + 1 < chars.len()
1412− && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
1413− {
1414− let col = i;
1415− let start = i + 1;
1416− let mut end = start;
1417− while end < chars.len()
1418− && (chars[end].is_alphanumeric()
1419− || chars[end] == '-'
1420− || chars[end] == '_'
1421− || chars[end] == '.')
1422− {
1423− end += 1;
1424− }
1425− while end > start && chars[end - 1] == '.' {
1426− end -= 1;
1427− }
1428− let name: String = chars[start..end].iter().collect();
1429− if !name.is_empty()
1430− && !vars.contains_key(&name)
1431− && let Some(closest) = suggest_var_name(&name, vars)
1432− {
1433− warnings.push(Diagnostic {
1434− line: line_num,
1435− column: Some(indent + col),
1436− message: format!(
1437− "undefined variable '${}', did you mean '${}'?",
1438− name, closest
1439− ),
1440− severity: Severity::Warning,
1441− source_line: Some(format!("{}{}", " ".repeat(indent), input)),
1442− });
1443− }
1444− i = end;
1445− } else {
1446− i += 1;
1447− }
1448− }
1449− warnings
1450−}
1451−
1452// ---------------------------------------------------------------------------
4132// ---------------------------------------------------------------------------
1453// Attribute parsing
4133// Attribute parsing
1454// ---------------------------------------------------------------------------
4134// ---------------------------------------------------------------------------
1455
4135
1456−/// Attributes that expect purely numeric values (px-based) or values with CSS units.
4136+/// The quoted strings of a CSS value, without their quotes.
1457−const NUMERIC_ATTRS: &[&str] = &[
4137+fn css_strings(value: &str) -> Vec<&str> {
1458− "spacing",
4138+ let mut strings = Vec::new();
1459− "gap",
4139+ let mut start = None;
1460− "padding",
4140+ let mut escaped = false;
1461− "padding-top",
4141+ for (i, c) in value.char_indices() {
1462− "padding-bottom",
4142+ match (c, start) {
1463− "padding-left",
4143+ _ if escaped => escaped = false,
1464− "padding-right",
4144+ ('\\', Some(_)) => escaped = true,
1465− "min-width",
4145+ ('"', None) => start = Some(i + 1),
1466− "max-width",
4146+ ('"', Some(from)) => {
1467− "min-height",
4147+ strings.push(&value[from..i]);
1468− "max-height",
4148+ start = None;
1469− "border-radius",
1470− "font-size",
1471− "column-gap",
1472− "row-gap",
1473− "top",
1474− "right",
1475− "bottom",
1476− "left",
1477− "letter-spacing",
1478−];
1479−
1480−const CSS_UNIT_SUFFIXES: &[&str] = &[
1481− "px", "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm",
1482− "mm", "in", "pt", "pc", "fr",
1483−];
1484−
1485−fn has_css_unit(value: &str) -> bool {
1486− CSS_UNIT_SUFFIXES.iter().any(|u| value.ends_with(u))
1487− || value.starts_with("var(")
1488− || value.starts_with("calc(")
1489− || value.starts_with("clamp(")
1490− || value.starts_with("min(")
1491− || value.starts_with("max(")
1492−}
1493−
1494−/// Attributes that accept numeric OR keyword values.
1495−const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
1496−const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
1497−
1498−fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext) {
1499− let base_key = crate::vocab::base_attribute(attr.key.as_str());
1500−
1501− if let Some(val) = &attr.value {
1502− if NUMERIC_ATTRS.contains(&base_key) {
1503− // All space-separated parts must be numeric or have a CSS unit
1504− for part in val.split_whitespace() {
1505− if part.parse::<f64>().is_err() && !has_css_unit(part) {
1506− ctx.diagnostics.push(Diagnostic {
1507− line: line_num,
1508− column: None,
1509− message: format!(
1510− "'{}' expects a numeric value (with optional unit), got '{}'",
1511− attr.key, val
1512− ),
1513− severity: Severity::Warning,
1514− source_line: None,
1515− });
1516− return;
1517− }
1518 }
4149 }
1519− } else if NUMERIC_OR_KEYWORD_ATTRS.contains(&base_key) {
4150+ _ => {}
1520− let is_keyword = SIZE_KEYWORDS.contains(&val.as_str());
4151+ }
1521− let is_numeric = val.parse::<f64>().is_ok();
4152+ }
1522− let has_unit = has_css_unit(val);
4153+ strings
1523− if !is_keyword && !is_numeric && !has_unit {
4154+}
1524− ctx.diagnostics.push(Diagnostic {
4155+
1525− line: line_num,
4156+/// An `if(...)` in a CSS value that isn't CSS's if(): CSS writes each
1526− column: None,
4157+/// branch `CONDITION: VALUE`, so an if() without a `:` is something else.
1527− message: format!(
4158+/// Quoted CSS strings are text, not calls.
1528− "'{}' expects a number or one of [{}], got '{}'",
4159+fn not_css_if(value: &str) -> Option<&str> {
1529− attr.key,
4160+ let mut quote = None;
1530− SIZE_KEYWORDS.join(", "),
4161+ let mut prev = None;
1531− val
4162+ let mut chars = value.char_indices();
1532− ),
4163+ while let Some((at, c)) = chars.next() {
1533− severity: Severity::Warning,
4164+ match quote {
1534− source_line: None,
4165+ Some(_) if c == '\\' => {
1535− });
4166+ chars.next();
1536 }
4167 }
1537− } else if base_key == "opacity" {
4168+ Some(q) if c == q => quote = None,
1538− if let Ok(v) = val.parse::<f64>() {
4169+ Some(_) => {}
1539− if !(0.0..=1.0).contains(&v) {
4170+ None if matches!(c, '"' | '\'') => quote = Some(c),
1540− ctx.diagnostics.push(Diagnostic {
4171+ None => {
1541− line: line_num,
4172+ let word_before =
1542− column: None,
4173+ prev.is_some_and(|p: char| p.is_alphanumeric() || matches!(p, '-' | '_'));
1543− message: format!("'opacity' should be between 0 and 1, got '{}'", val),
4174+ if !word_before && value[at..].starts_with("if(") {
1544− severity: Severity::Warning,
4175+ let mut depth = 0;
1545− source_line: None,
4176+ let close = value[at + 2..].char_indices().find_map(|(i, c)| {
4177+ match c {
4178+ '(' => depth += 1,
4179+ ')' => depth -= 1,
4180+ _ => {}
4181+ }
4182+ (depth == 0).then_some(at + 2 + i)
1546 });
4183 });
4184+ let end = close.map_or(value.len(), |c| c + 1);
4185+ if !value[at + 3..end].contains(':') {
4186+ return Some(&value[at..end]);
4187+ }
1547 }
4188 }
1548− } else {
1549− ctx.diagnostics.push(Diagnostic {
1550− line: line_num,
1551− column: None,
1552− message: format!("'opacity' expects a numeric value, got '{}'", val),
1553− severity: Severity::Warning,
1554− source_line: None,
1555− });
1556 }
4189 }
1557− } else if base_key == "z-index" {
4190+ }
1558− if val.parse::<i32>().is_err() {
4191+ prev = Some(c);
1559− ctx.diagnostics.push(Diagnostic {
4192+ }
1560− line: line_num,
4193+ None
1561− column: None,
4194+}
1562− message: format!("'z-index' expects an integer, got '{}'", val),
4195+
1563− severity: Severity::Warning,
4196+/// Why a CSS value can't be written into a rule as it is, whatever it
1564− source_line: None,
4197+/// means: a `;` (outside parentheses, where CSS's own `if()` uses it), a
1565− });
4198+/// `{` or a `}` would end the declaration or the rule, and an unclosed
4199+/// quote or parenthesis would run on into the rules after it. Quoted
4200+/// strings are text, and a backslash escapes the character after it, as
4201+/// in CSS. A `</style` anywhere, even in a string, would end the page's
4202+/// `<style>` element and start HTML.
4203+fn css_breakout(value: &str) -> Option<&'static str> {
4204+ if value.to_ascii_lowercase().contains("</style") {
4205+ return Some("a `</style`, which would end the page's style element");
4206+ }
4207+ let mut quote = None;
4208+ let mut depth = 0usize;
4209+ let mut chars = value.chars();
4210+ while let Some(c) = chars.next() {
4211+ match (quote, c) {
4212+ (_, '\\') => {
4213+ chars.next();
1566 }
4214 }
1567− } else if base_key == "display" {
4215+ (Some(q), c) if c == q => quote = None,
1568− const DISPLAY_VALUES: &[&str] = &[
4216+ (Some(_), _) => {}
1569− "none",
4217+ (None, '"' | '\'') => quote = Some(c),
1570− "block",
4218+ (None, '(') => depth += 1,
1571− "inline",
4219+ (None, ')') if depth == 0 => return Some("a `)` that closes nothing"),
1572− "inline-block",
4220+ (None, ')') => depth -= 1,
1573− "flex",
4221+ (None, ';') if depth == 0 => return Some("a `;`, which would end the declaration"),
1574− "inline-flex",
4222+ (None, '{' | '}') => return Some("a `{` or `}`, which would end the CSS rule"),
1575− "grid",
4223+ _ => {}
1576− "inline-grid",
4224+ }
1577− "table",
4225+ }
1578− "table-row",
4226+ match (quote, depth) {
1579− "table-cell",
4227+ (Some('"'), _) => Some("a `\"` that isn't closed"),
1580− "contents",
4228+ (Some(_), _) => Some("a `'` that isn't closed"),
1581− "flow-root",
4229+ (None, 0) => None,
1582− "list-item",
4230+ (None, _) => Some("a `(` that isn't closed"),
1583− ];
4231+ }
1584− if !DISPLAY_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4232+}
1585− let suggestion = suggest_closest(val, DISPLAY_VALUES);
4233+
1586− let msg = match suggestion {
4234+/// The hex colors of a CSS value that don't have 3, 4, 6 or 8 hex
1587− Some(s) => format!("unknown display value '{}', did you mean '{}'?", val, s),
4235+/// digits. A `#` word in a CSS value is a color, except inside `url(...)`
1588− None => format!("unknown display value '{}'", val),
4236+/// and quoted strings.
1589− };
4237+fn bad_hex_colors(value: &str) -> Vec<&str> {
1590− ctx.diagnostics.push(Diagnostic {
4238+ let mut bad = Vec::new();
1591− line: line_num,
4239+ let mut quote = None;
1592− column: None,
4240+ let mut in_url = 0usize;
1593− message: msg,
4241+ let mut depth = 0usize;
1594− severity: Severity::Warning,
4242+ let mut chars = value.char_indices().peekable();
1595− source_line: None,
4243+ while let Some((at, c)) = chars.next() {
1596− });
4244+ match (quote, c) {
4245+ (_, '\\') => {
4246+ chars.next();
1597 }
4247 }
1598− } else if base_key == "position" {
4248+ (Some(q), c) if c == q => quote = None,
1599− const POSITION_VALUES: &[&str] = &["static", "relative", "absolute", "fixed", "sticky"];
4249+ (Some(_), _) => {}
1600− if !POSITION_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4250+ (None, '"' | '\'') => quote = Some(c),
1601− let suggestion = suggest_closest(val, POSITION_VALUES);
4251+ (None, '(') => {
1602− let msg = match suggestion {
4252+ depth += 1;
1603− Some(s) => format!("unknown position value '{}', did you mean '{}'?", val, s),
4253+ if value[..at].ends_with("url") && in_url == 0 {
1604− None => format!("unknown position value '{}'", val),
4254+ in_url = depth;
1605− };
4255+ }
1606− ctx.diagnostics.push(Diagnostic {
1607− line: line_num,
1608− column: None,
1609− message: msg,
1610− severity: Severity::Warning,
1611− source_line: None,
1612− });
1613 }
4256 }
1614− } else if base_key == "overflow" || base_key == "overflow-x" || base_key == "overflow-y" {
4257+ (None, ')') => {
1615− const OVERFLOW_VALUES: &[&str] = &["visible", "hidden", "scroll", "auto", "clip"];
4258+ if in_url == depth {
1616− if !OVERFLOW_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4259+ in_url = 0;
1617− let suggestion = suggest_closest(val, OVERFLOW_VALUES);
4260+ }
1618− let msg = match suggestion {
4261+ depth = depth.saturating_sub(1);
1619− Some(s) => format!("unknown overflow value '{}', did you mean '{}'?", val, s),
1620− None => format!("unknown overflow value '{}'", val),
1621− };
1622− ctx.diagnostics.push(Diagnostic {
1623− line: line_num,
1624− column: None,
1625− message: msg,
1626− severity: Severity::Warning,
1627− source_line: None,
1628− });
1629 }
4262 }
1630− } else if base_key == "text-align" {
4263+ (None, '#') if in_url == 0 => {
1631− const TEXT_ALIGN_VALUES: &[&str] =
4264+ let end = value[at + 1..]
1632− &["left", "right", "center", "justify", "start", "end"];
4265+ .find(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_'))
1633− if !TEXT_ALIGN_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4266+ .map_or(value.len(), |n| at + 1 + n);
1634− let suggestion = suggest_closest(val, TEXT_ALIGN_VALUES);
4267+ let hex = &value[at + 1..end];
1635− let msg = match suggestion {
4268+ if !(matches!(hex.len(), 3 | 4 | 6 | 8)
1636− Some(s) => format!("unknown text-align value '{}', did you mean '{}'?", val, s),
4269+ && hex.chars().all(|c| c.is_ascii_hexdigit()))
1637− None => format!("unknown text-align value '{}'", val),
4270+ {
1638− };
4271+ bad.push(&value[at..end]);
1639− ctx.diagnostics.push(Diagnostic {
4272+ }
1640− line: line_num,
1641− column: None,
1642− message: msg,
1643− severity: Severity::Warning,
1644− source_line: None,
1645− });
1646 }
4273 }
1647− } else if base_key == "cursor" {
4274+ _ => {}
1648− const CURSOR_VALUES: &[&str] = &[
4275+ }
1649− "auto",
4276+ }
1650− "default",
4277+ bad
1651− "none",
4278+}
1652− "pointer",
4279+
1653− "wait",
4280+/// A diagnostic about an attribute, with its column and source line when
1654− "text",
4281+/// it is on the line being evaluated.
1655− "move",
4282+fn at_attribute(diagnostic: Diagnostic, column: Option<usize>, ctx: &ParseContext) -> Diagnostic {
1656− "not-allowed",
4283+ match (&ctx.current_source, column) {
1657− "crosshair",
4284+ ((current, Some(text)), Some(column)) if *current == diagnostic.line => {
1658− "grab",
4285+ diagnostic.column(column).source(text.clone())
1659− "grabbing",
4286+ }
1660− "help",
4287+ _ => diagnostic,
1661− "progress",
4288+ }
1662− "col-resize",
4289+}
1663− "row-resize",
4290+
1664− "n-resize",
4291+/// Check a style's value. Values go to the CSS as written, so only what
1665− "s-resize",
4292+/// can't be wrong in any CSS is checked: a value that would break out of
1666− "e-resize",
4293+/// its rule is an error (and it is left out); a hex color without 3, 4, 6
1667− "w-resize",
4294+/// or 8 digits, an `if()` that isn't CSS's and a quoted font stack are
1668− "zoom-in",
4295+/// warnings. Returns whether the value can be written.
1669− "zoom-out",
4296+fn check_css_value(
1670− "context-menu",
4297+ attr: &Attribute,
1671− "cell",
4298+ base: &str,
1672− "copy",
4299+ line: usize,
1673− "alias",
4300+ column: Option<usize>,
1674− "no-drop",
4301+ ctx: &mut ParseContext,
1675− ];
4302+) -> bool {
1676− if !CURSOR_VALUES.contains(&val.as_str())
4303+ let Some(value) = &attr.value else {
1677− && !val.starts_with("url(")
4304+ return true;
1678− && !val.starts_with("var(")
4305+ };
4306+ if let Some(reason) = css_breakout(value) {
4307+ let diagnostic = Diagnostic::error(
4308+ code::INVALID_VALUE,
4309+ line,
4310+ format!(
4311+ "the value of '{}' has {}: `{}` can't be written into the page's CSS",
4312+ attr.key, reason, value
4313+ ),
4314+ )
4315+ .subject(value.as_str());
4316+ ctx.diagnostics.push(at_attribute(diagnostic, column, ctx));
4317+ return false;
4318+ }
4319+ if crate::vocab::is_custom_property(base) {
4320+ return true;
4321+ }
4322+
4323+ let warn = |ctx: &mut ParseContext, diagnostic: Diagnostic| {
4324+ let diagnostic = at_attribute(diagnostic, column, ctx);
4325+ ctx.diagnostics.push(diagnostic);
4326+ };
4327+ // In CSS, a quoted font-family is one family, commas and all
4328+ if base == "font-family"
4329+ && let Some(family) = css_strings(value).into_iter().find(|s| s.contains(','))
4330+ {
4331+ let stack = family
4332+ .split(',')
4333+ .map(str::trim)
4334+ .collect::<Vec<_>>()
4335+ .join("\\, ");
4336+ warn(
4337+ ctx,
4338+ Diagnostic::warning(
4339+ code::INVALID_VALUE,
4340+ line,
4341+ format!(
4342+ "font-family \"{}\" is one family, whose name has a comma in it: \
4343+ for a font stack, write `font-family {}`",
4344+ family, stack
4345+ ),
4346+ )
4347+ .subject(format!("\"{}\"", family))
4348+ .suggest(Some(stack)),
4349+ );
4350+ }
4351+ if let Some(call) = not_css_if(value) {
4352+ warn(
4353+ ctx,
4354+ Diagnostic::warning(
4355+ code::INVALID_VALUE,
4356+ line,
4357+ format!(
4358+ "'{}' is not CSS's if(), whose branches are written \
4359+ `if(CONDITION: VALUE; else: VALUE)`",
4360+ call
4361+ ),
4362+ )
4363+ .subject(call),
4364+ );
4365+ }
4366+ for color in bad_hex_colors(value) {
4367+ warn(
4368+ ctx,
4369+ Diagnostic::warning(
4370+ code::INVALID_COLOR,
4371+ line,
4372+ format!(
4373+ "invalid hex color '{}': a hex color has 3, 4, 6 or 8 hex digits",
4374+ color
4375+ ),
4376+ )
4377+ .subject(color),
4378+ );
4379+ }
4380+ true
4381+}
4382+
4383+/// An unknown prefix: the message, the text it is about, and what to write
4384+/// instead, when something is close enough.
4385+struct PrefixProblem {
4386+ message: String,
4387+ subject: String,
4388+ suggestion: Option<String>,
4389+}
4390+
4391+/// What is wrong with the prefix `rest` starts with, when the known
4392+/// prefixes are already split off it and what is left still starts with
4393+/// one: a word and a `:` (`hovr:`), or a word and a parenthesised argument
4394+/// and a `:` (`nth-chld(2):`). Selector prefixes are CSS's pseudo-classes
4395+/// and pseudo-elements under their CSS names, so an unknown one gets the
4396+/// closest name as a suggestion. Only names with the same first letter are
4397+/// suggested: `odd:` is two edits from `md:`, which it doesn't mean.
4398+fn unknown_prefix(rest: &str) -> Option<PrefixProblem> {
4399+ use crate::vocab::{self, Pseudo};
4400+ let name_len = rest.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))?;
4401+ let name = &rest[..name_len];
4402+ if name.is_empty() {
4403+ return None;
4404+ }
4405+ if rest[name_len..].starts_with('(') {
4406+ // `name(...):`: a pseudo-class with an argument
4407+ let close = vocab::closing_paren(rest, name_len)?;
4408+ if !rest[close + 1..].starts_with(':') {
4409+ return None;
4410+ }
4411+ let written = &rest[..close + 2];
4412+ if matches!(vocab::pseudo(name), Some(Pseudo::Class | Pseudo::Element)) {
4413+ let bare = format!("{}:", name);
4414+ return Some(PrefixProblem {
4415+ message: format!(
4416+ "'{}': `{}` takes no argument: write `{}`",
4417+ written,
4418+ vocab::pseudo_selector(&bare).unwrap_or_default(),
4419+ bare
4420+ ),
4421+ subject: written.to_string(),
4422+ suggestion: Some(bare),
4423+ });
4424+ }
4425+ let functional: Vec<&str> = vocab::functional_pseudos()
4426+ .into_iter()
4427+ .filter(|p| p.chars().next() == name.chars().next())
4428+ .collect();
4429+ let suggestion = suggest_closest(name, &functional)
4430+ .filter(|&closest| closest != name)
4431+ .map(|closest| format!("{}(", closest));
4432+ let written = format!("{}(", name);
4433+ let message = match &suggestion {
4434+ Some(closest) => format!("unknown prefix '{}', did you mean '{}'?", written, closest),
4435+ None => format!("unknown prefix '{}'", written),
4436+ };
4437+ return Some(PrefixProblem {
4438+ message,
4439+ subject: written,
4440+ suggestion,
4441+ });
4442+ }
4443+ if !rest[name_len..].starts_with(':') {
4444+ return None;
4445+ }
4446+ let written = &rest[..name_len + 1];
4447+ // `has:`: its argument is missing
4448+ if vocab::pseudo(name) == Some(Pseudo::Function) {
4449+ return Some(PrefixProblem {
4450+ message: format!(
4451+ "'{}': `:{}()` takes an argument, in parentheses, as in CSS: `{}(...):`",
4452+ written, name, name
4453+ ),
4454+ subject: written.to_string(),
4455+ suggestion: None,
4456+ });
4457+ }
4458+ let prefixes = vocab::all_prefixes();
4459+ let candidates: Vec<&str> = prefixes
4460+ .iter()
4461+ .map(String::as_str)
4462+ .filter(|p| p.chars().next() == written.chars().next())
4463+ .collect();
4464+ // The closest name that starts with the same letter (`hvoer:` →
4465+ // `hover:`), else the first that starts with the word written and a `-`
4466+ // (`first:` → `first-child:`)
4467+ let suggestion = suggest_closest(written, &candidates)
4468+ .or_else(|| {
4469+ let longer = format!("{}-", name);
4470+ candidates.iter().copied().find(|p| p.starts_with(&longer))
4471+ })
4472+ .filter(|&closest| closest != written)
4473+ .map(str::to_string);
4474+ let message = match &suggestion {
4475+ Some(closest) => format!("unknown prefix '{}', did you mean '{}'?", written, closest),
4476+ None => format!("unknown prefix '{}'", written),
4477+ };
4478+ Some(PrefixProblem {
4479+ message,
4480+ subject: written.to_string(),
4481+ suggestion,
4482+ })
4483+}
4484+
4485+/// Check an attribute's element prefix (`@td:padding 8`), when it has one:
4486+/// it names a built-in element that has an HTML tag of its own (not a
4487+/// function, and not `@el`, whose `<div>` other elements write too), there
4488+/// is one, not with `children:`, and what it styles is a CSS property, not
4489+/// one of htmlang's words, which compile against each element's layout and
4490+/// its parent's. Returns false, after reporting, when the attribute can't
4491+/// be written.
4492+fn check_element_prefixes(
4493+ attr: &Attribute,
4494+ prefixes: &[&str],
4495+ base: &str,
4496+ line: usize,
4497+ column: Option<usize>,
4498+ ctx: &mut ParseContext,
4499+) -> bool {
4500+ use crate::ast::ElementKind;
4501+ use crate::vocab;
4502+ let elements: Vec<&str> = prefixes
4503+ .iter()
4504+ .copied()
4505+ .filter(|p| vocab::element_prefix(p).is_some())
4506+ .collect();
4507+ let Some(&prefix) = elements.first() else {
4508+ return true;
4509+ };
4510+ let fail = |ctx: &mut ParseContext,
4511+ code: &'static str,
4512+ message: String,
4513+ subject: &str,
4514+ suggestion: Option<String>| {
4515+ let diagnostic = Diagnostic::error(code, line, message)
4516+ .subject(subject)
4517+ .suggest(suggestion);
4518+ let diagnostic = at_attribute(diagnostic, column, ctx);
4519+ ctx.diagnostics.push(diagnostic);
4520+ false
4521+ };
4522+ let name = vocab::element_prefix(prefix).unwrap_or_default();
4523+ if is_function(name, ctx) {
4524+ return fail(
4525+ ctx,
4526+ code::INVALID_PREFIX,
4527+ format!(
4528+ "'{}': @{} is a function, and `{}` would pick elements out by their HTML tag, \
4529+ which a function doesn't have: style what @{} writes in its body, or pass it \
4530+ attributes",
4531+ attr.key, name, prefix, name
4532+ ),
4533+ prefix,
4534+ None,
4535+ );
4536+ }
4537+ match ElementKind::from_name(name) {
4538+ None => {
4539+ let own: Vec<&str> = ElementKind::all_names()
4540+ .filter(|n| ElementKind::from_name(n).is_some_and(|k| k.own_tag().is_some()))
4541+ .collect();
4542+ // An HTML name htmlang writes under another (`@a:` is `@link:`)
4543+ let (written_otherwise, shared) = match crate::ast::HTML_NAMES_WRITTEN_OTHERWISE
4544+ .iter()
4545+ .find(|(html, _)| *html == name)
1679 {
4546 {
1680− let suggestion = suggest_closest(val, CURSOR_VALUES);
4547+ Some((_, htmlang)) if own.contains(htmlang) => (Some(*htmlang), false),
1681− let msg = match suggestion {
4548+ Some(_) => (None, true),
1682− Some(s) => format!("unknown cursor value '{}', did you mean '{}'?", val, s),
4549+ None => (None, false),
1683− None => format!("unknown cursor value '{}'", val),
4550+ };
1684− };
4551+ // `@each:`, `@markdown:`: a directive, which is no element
1685− ctx.diagnostics.push(Diagnostic {
4552+ if crate::ast::directive(name).is_some() {
1686− line: line_num,
4553+ return fail(
1687− column: None,
4554+ ctx,
1688− message: msg,
4555+ code::UNKNOWN_PREFIX,
1689− severity: Severity::Warning,
4556+ format!(
1690− source_line: None,
4557+ "unknown element @{} in '{}': @{} is a directive, and an element \
1691− });
4558+ prefix names an element with an HTML tag of its own, such as `@td:`",
4559+ name, prefix, name
4560+ ),
4561+ prefix,
4562+ None,
4563+ );
1692 }
4564 }
1693− } else if base_key == "font-weight" {
4565+ // `@div:`, `@span:`: several elements write it
1694− const WEIGHT_VALUES: &[&str] = &[
4566+ if shared {
1695− "normal", "bold", "bolder", "lighter", "100", "200", "300", "400", "500", "600",
4567+ return fail(
1696− "700", "800", "900",
4568+ ctx,
1697− ];
4569+ code::UNKNOWN_PREFIX,
1698− if !WEIGHT_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4570+ format!(
1699− ctx.diagnostics.push(Diagnostic {
4571+ "unknown element @{} in '{}': htmlang writes <{}> for several \
1700− line: line_num,
4572+ elements, so no element prefix picks it out",
1701− column: None,
4573+ name, prefix, name
1702− message: format!(
1703− "'font-weight' expects a weight keyword or number 100-900, got '{}'",
1704− val
1705 ),
4574 ),
1706− severity: Severity::Warning,
4575+ prefix,
1707− source_line: None,
4576+ None,
1708− });
4577+ );
1709 }
4578 }
1710− } else if base_key == "color" || base_key == "background" {
4579+ // A name with the same first letter first (`@tdd:` is `@td:`)
1711− // Validate named CSS colors (only if not hex, rgb, hsl, var, etc.)
4580+ let same_letter: Vec<&str> = own
1712− if !val.starts_with('#')
4581+ .iter()
1713− && !val.starts_with("rgb")
4582+ .copied()
1714− && !val.starts_with("hsl")
4583+ .filter(|n| n.chars().next() == name.chars().next())
1715− && !val.starts_with("var(")
4584+ .collect();
1716− && !val.starts_with("linear-gradient")
4585+ let closest = written_otherwise
1717− && !val.starts_with("radial-gradient")
4586+ .or_else(|| suggest_closest(name, &same_letter))
1718− && !val.starts_with("conic-gradient")
4587+ .or_else(|| suggest_closest(name, &own));
1719− && !val.starts_with("oklch")
4588+ let message = match (closest, written_otherwise) {
1720− && !val.starts_with("oklab")
4589+ (Some(closest), Some(_)) => format!(
1721− && !val.starts_with("color(")
4590+ "unknown element @{} in '{}', did you mean '@{}:'? (@{} writes <{}>)",
1722− && !val.starts_with("light-dark(")
4591+ name, prefix, closest, closest, name
1723− && !val.contains("url(")
4592+ ),
1724− && !val.contains(' ')
4593+ (Some(closest), None) => format!(
1725− // skip shorthand multi-value
4594+ "unknown element @{} in '{}', did you mean '@{}:'?",
1726− {
4595+ name, prefix, closest
1727− const NAMED_COLORS: &[&str] = &[
4596+ ),
1728− "transparent",
4597+ (None, _) => format!("unknown element @{} in '{}'", name, prefix),
1729− "currentcolor",
4598+ };
1730− "inherit",
4599+ let suggestion = closest.map(|c| format!("@{}:", c));
1731− "initial",
4600+ fail(ctx, code::UNKNOWN_PREFIX, message, prefix, suggestion)
1732− "unset",
4601+ }
1733− "black",
4602+ Some(kind) if kind.own_tag().is_none() => {
1734− "white",
4603+ let reason = match kind.tag() {
1735− "red",
4604+ None => format!(
1736− "green",
4605+ "@{} writes no element of its own, so there is nothing for `{}` to style",
1737− "blue",
4606+ name, prefix
1738− "yellow",
4607+ ),
1739− "orange",
4608+ Some(_) if kind.is_verbatim() => {
1740− "purple",
4609+ format!("@{} isn't shown, so there is nothing to style", name)
1741− "pink",
4610+ }
1742− "brown",
4611+ Some("span") => format!(
1743− "gray",
4612+ "@{} writes a <span>, as every line of text in a row or column does, so \
1744− "grey",
4613+ `{}` can't pick it out",
1745− "cyan",
4614+ name, prefix
1746− "magenta",
4615+ ),
1747− "lime",
4616+ Some(tag) => {
1748− "olive",
4617+ let others: Vec<String> = ElementKind::all_names()
1749− "navy",
4618+ .filter(|n| {
1750− "teal",
4619+ *n != name
1751− "aqua",
4620+ && ElementKind::from_name(n).and_then(|k| k.tag()) == Some(tag)
1752− "fuchsia",
4621+ })
1753− "maroon",
4622+ .map(|n| format!("@{}", n))
1754− "silver",
4623+ .collect();
1755− "coral",
4624+ let others = match others.split_last() {
1756− "salmon",
4625+ Some((last, [])) => last.clone(),
1757− "tomato",
4626+ Some((last, rest)) => format!("{} and {}", rest.join(", "), last),
1758− "crimson",
4627+ None => String::new(),
1759− "firebrick",
1760− "darkred",
1761− "indigo",
1762− "violet",
1763− "plum",
1764− "orchid",
1765− "thistle",
1766− "lavender",
1767− "gold",
1768− "khaki",
1769− "wheat",
1770− "tan",
1771− "sienna",
1772− "chocolate",
1773− "peru",
1774− "beige",
1775− "ivory",
1776− "linen",
1777− "snow",
1778− "seashell",
1779− "mintcream",
1780− "skyblue",
1781− "steelblue",
1782− "royalblue",
1783− "dodgerblue",
1784− "cornflowerblue",
1785− "slategray",
1786− "slategrey",
1787− "dimgray",
1788− "dimgrey",
1789− "lightgray",
1790− "lightgrey",
1791− "darkgray",
1792− "darkgrey",
1793− "gainsboro",
1794− "whitesmoke",
1795− ];
1796− if !NAMED_COLORS.contains(&val.to_lowercase().as_str()) {
1797− let lower = val.to_lowercase();
1798− let suggestion = suggest_closest(&lower, NAMED_COLORS);
1799− let msg = match suggestion {
1800− Some(s) => format!("unknown color '{}', did you mean '{}'?", val, s),
1801− None => format!("unknown color '{}'", val),
1802 };
4628 };
1803− ctx.diagnostics.push(Diagnostic {
4629+ format!(
1804− line: line_num,
4630+ "@{} writes a <{}>, as {} do, so `{}` can't pick it out",
1805− column: None,
4631+ name, tag, others, prefix
1806− message: msg,
4632+ )
1807− severity: Severity::Warning,
1808− source_line: None,
1809− });
1810 }
4633 }
4634+ };
4635+ fail(
4636+ ctx,
4637+ code::INVALID_PREFIX,
4638+ format!(
4639+ "'{}': {}. Give those elements their styles themselves, or a bundle \
4640+ (`@let name [...]`, used as `[$name]`)",
4641+ attr.key, reason
4642+ ),
4643+ prefix,
4644+ None,
4645+ )
4646+ }
4647+ Some(_) if elements.len() > 1 => fail(
4648+ ctx,
4649+ code::INVALID_PREFIX,
4650+ format!(
4651+ "'{}': a style takes one element prefix, which styles every such element \
4652+ inside this one, at any depth",
4653+ attr.key
4654+ ),
4655+ &attr.key,
4656+ None,
4657+ ),
4658+ Some(_) if prefixes.contains(&vocab::CHILDREN) => fail(
4659+ ctx,
4660+ code::INVALID_PREFIX,
4661+ format!(
4662+ "'{}': `children:` and `{}` each say which elements the style is for: write \
4663+ one of them",
4664+ attr.key, prefix
4665+ ),
4666+ &attr.key,
4667+ None,
4668+ ),
4669+ Some(_) if vocab::is_htmlang_word(base, attr.value.as_deref()) => {
4670+ let value = attr.value.as_deref().map(str::trim);
4671+ let css = match (base, value) {
4672+ ("spacing", _) => " (`gap`)",
4673+ ("wrap", _) => " (`flex-wrap wrap`)",
4674+ ("grid-cols", _) => " (`grid-template-columns`)",
4675+ ("grid-rows", _) => " (`grid-template-rows`)",
4676+ ("col-span", _) => " (`grid-column span N`)",
4677+ ("row-span", _) => " (`grid-row span N`)",
4678+ ("width", Some("fill")) => " (`flex 1` in a row, `width 100%` otherwise)",
4679+ ("height", Some("fill")) => " (`flex 1` in a column, `height 100%` otherwise)",
4680+ ("width", _) => " (`flex-shrink 0` in a row, `width fit-content` otherwise)",
4681+ ("height", _) => " (`flex-shrink 0` in a column, `height fit-content` otherwise)",
4682+ ("center-x", _) => " (`margin-inline auto`)",
4683+ ("center-y", _) => " (`margin-block auto`)",
4684+ _ => "",
4685+ };
4686+ let word = match value {
4687+ Some(v) if matches!(base, "width" | "height") => format!("{} {}", base, v),
4688+ _ => base.to_string(),
4689+ };
4690+ fail(
4691+ ctx,
4692+ code::INVALID_PREFIX,
4693+ format!(
4694+ "'{}': `{}` is one of htmlang's layout words, which compile against each \
4695+ element's layout and its parent's, and `{}` styles elements whose \
4696+ parents it doesn't know: write `{}` on the elements themselves, or the \
4697+ CSS you mean{}",
4698+ attr.key, word, prefix, word, css
4699+ ),
4700+ &attr.key,
4701+ None,
4702+ )
4703+ }
4704+ Some(_) => true,
4705+ }
4706+}
4707+
4708+/// Check an attribute's prefixes: each is known, their selector prefixes
4709+/// read left to right with a pseudo-element (`before:`) last, the argument
4710+/// of `has(...)` or `nth-child(...)` can be written into the CSS, and a word that
4711+/// places an element in its parent (`width fill`, `center-x`, `align-*`)
4712+/// isn't under `children:`, whose styles go on the children. Media,
4713+/// container and width prefixes go anywhere. Returns false, after
4714+/// reporting, when the attribute can't be written.
4715+fn check_prefixes(
4716+ attr: &Attribute,
4717+ line: usize,
4718+ column: Option<usize>,
4719+ ctx: &mut ParseContext,
4720+) -> bool {
4721+ use crate::vocab;
4722+ let (prefixes, base) = vocab::split_prefixes(&attr.key);
4723+ let fail =
4724+ |ctx: &mut ParseContext, message: String, subject: &str, suggestion: Option<String>| {
4725+ let diagnostic = Diagnostic::error(code::INVALID_PREFIX, line, message)
4726+ .subject(subject)
4727+ .suggest(suggestion);
4728+ let diagnostic = at_attribute(diagnostic, column, ctx);
4729+ ctx.diagnostics.push(diagnostic);
4730+ false
4731+ };
4732+
4733+ if let Some(problem) = unknown_prefix(base) {
4734+ let diagnostic = Diagnostic::error(code::UNKNOWN_PREFIX, line, problem.message)
4735+ .subject(problem.subject)
4736+ .suggest(problem.suggestion);
4737+ let diagnostic = at_attribute(diagnostic, column, ctx);
4738+ ctx.diagnostics.push(diagnostic);
4739+ return false;
4740+ }
4741+ if !check_element_prefixes(attr, &prefixes, base, line, column, ctx) {
4742+ return false;
4743+ }
4744+ // The argument of `has(...)` or `nth-child(...)` goes into the CSS as
4745+ // written
4746+ for prefix in &prefixes {
4747+ if let Some(reason) = css_breakout(prefix.trim_end_matches(':')) {
4748+ return fail(
4749+ ctx,
4750+ format!(
4751+ "the prefix '{}' has {}: it can't be written into the page's CSS",
4752+ prefix, reason
4753+ ),
4754+ prefix,
4755+ None,
4756+ );
4757+ }
4758+ }
4759+ // `nth-child():` or `has( ):`: CSS has no pseudo-class with an empty
4760+ // argument
4761+ let empty_argument = |prefix: &str| {
4762+ prefix
4763+ .strip_suffix("):")
4764+ .and_then(|p| p.split_once('('))
4765+ .is_some_and(|(_, argument)| argument.trim().is_empty())
4766+ };
4767+ if let Some(empty) = prefixes.iter().find(|p| empty_argument(p)) {
4768+ return fail(
4769+ ctx,
4770+ format!(
4771+ "'{}': `:{}()` takes an argument between its parentheses, as in CSS",
4772+ attr.key,
4773+ vocab::pseudo_name(empty)
4774+ ),
4775+ empty,
4776+ None,
4777+ );
4778+ }
4779+ // A pseudo-element is the last selector prefix: CSS has no
4780+ // `::before:hover`
4781+ let selectors: Vec<&str> = prefixes
4782+ .iter()
4783+ .copied()
4784+ .filter(|p| vocab::at_rule_rank(p).is_none())
4785+ .collect();
4786+ // Only one: an element has one `::before`, which has no `::after`
4787+ let elements: Vec<&str> = selectors
4788+ .iter()
4789+ .copied()
4790+ .filter(|p| vocab::is_pseudo_element(p))
4791+ .collect();
4792+ if elements.len() > 1 {
4793+ return fail(
4794+ ctx,
4795+ format!(
4796+ "'{}': `{}` and `{}` each select a pseudo-element, and a style applies to one \
4797+ of them",
4798+ attr.key, elements[0], elements[1]
4799+ ),
4800+ &attr.key,
4801+ None,
4802+ );
4803+ }
4804+ if let Some(at) = selectors.iter().position(|p| vocab::is_pseudo_element(p))
4805+ && at + 1 < selectors.len()
4806+ {
4807+ let element = selectors[at];
4808+ // The same prefixes, with the pseudo-element moved after the other
4809+ // selector prefixes
4810+ let mut order: Vec<&str> = prefixes.iter().copied().filter(|p| *p != element).collect();
4811+ let last_selector = order
4812+ .iter()
4813+ .rposition(|p| vocab::at_rule_rank(p).is_none())
4814+ .map_or(order.len(), |i| i + 1);
4815+ order.insert(last_selector, element);
4816+ let fixed = format!("{}{}", order.concat(), base);
4817+ return fail(
4818+ ctx,
4819+ format!(
4820+ "'{}': `{}` selects a pseudo-element, which comes after the other selector \
4821+ prefixes, as in CSS: write `{}`",
4822+ attr.key, element, fixed
4823+ ),
4824+ &attr.key,
4825+ Some(fixed),
4826+ );
4827+ }
4828+ // Under `children:` the styles go on the children, whose parent is this
4829+ // element: a word that places an element in its parent can't say where
4830+ if prefixes.contains(&"children:") && vocab::places_in_parent(base, attr.value.as_deref()) {
4831+ // The CSS of `fill` and `shrink` depends on the direction: along
4832+ // it, they are flex properties
4833+ let (word, css) = match (base, attr.value.as_deref().map(str::trim)) {
4834+ ("width", Some("fill")) => (
4835+ "width fill",
4836+ " (`children:flex 1` in a row, `children:width 100%` otherwise)",
4837+ ),
4838+ ("height", Some("fill")) => (
4839+ "height fill",
4840+ " (`children:flex 1` in a column, `children:height 100%` otherwise)",
4841+ ),
4842+ ("width", Some(_)) => (
4843+ "width shrink",
4844+ " (`children:flex-shrink 0` in a row, `children:width fit-content` otherwise)",
4845+ ),
4846+ ("height", Some(_)) => (
4847+ "height shrink",
4848+ " (`children:flex-shrink 0` in a column, `children:height fit-content` \
4849+ otherwise)",
4850+ ),
4851+ _ => (base, ""),
4852+ };
4853+ return fail(
4854+ ctx,
4855+ format!(
4856+ "'{}{}': `{}` places an element in its parent, and under `children:` that \
4857+ parent is this element: write `{}` on the children themselves, or the CSS \
4858+ you mean{}",
4859+ attr.key,
4860+ attr.value
4861+ .as_deref()
4862+ .map_or(String::new(), |v| format!(" {}", v.trim())),
4863+ word,
4864+ word,
4865+ css
4866+ ),
4867+ &attr.key,
4868+ None,
4869+ );
4870+ }
4871+ true
4872+}
4873+
4874+/// Check an attribute written as a style or a flag (not `key=value`): its
4875+/// prefix, its name, and, for a style, its value. A name htmlang doesn't
4876+/// know but CSS could have (`corner-shape`) is passed to the CSS as written,
4877+/// with a warning; custom properties (`--brand`) and vendor-prefixed ones
4878+/// (`-webkit-...`) without one. Anything that can't be written into the
4879+/// page is an error; returns false then, and the attribute is left out.
4880+fn check_attribute(
4881+ attr: &Attribute,
4882+ line: usize,
4883+ column: Option<usize>,
4884+ after_another: bool,
4885+ ctx: &mut ParseContext,
4886+) -> bool {
4887+ use crate::vocab;
4888+ let (prefixes, base) = vocab::split_prefixes(&attr.key);
4889+ let fail = |ctx: &mut ParseContext, code: &'static str, message: String, subject: &str| {
4890+ let diagnostic = Diagnostic::error(code, line, message).subject(subject);
4891+ let diagnostic = at_attribute(diagnostic, column, ctx);
4892+ ctx.diagnostics.push(diagnostic);
4893+ false
4894+ };
4895+
4896+ if !check_prefixes(attr, line, column, ctx) {
4897+ return false;
4898+ }
4899+ let prefixed = !prefixes.is_empty();
4900+ // `md:id=x`: `=` makes an HTML attribute, which has no states
4901+ if prefixed && let Some((name, _)) = base.split_once('=') {
4902+ return fail(
4903+ ctx,
4904+ code::INVALID_PREFIX,
4905+ format!(
4906+ "'{}': a prefix applies to a style, and `{}=` is an HTML attribute",
4907+ attr.key, name
4908+ ),
4909+ &attr.key,
4910+ );
4911+ }
4912+ let is_html_name = |name: &str| {
4913+ vocab::HTML_ATTRIBUTES.contains(&name)
4914+ || name.starts_with("aria-")
4915+ || name.starts_with("data-")
4916+ };
4917+
4918+ let Some(value) = &attr.value else {
4919+ // `inline` puts a file into the page as it compiles, which no
4920+ // state or media condition can undo
4921+ if base == "inline" && prefixed {
4922+ return fail(
4923+ ctx,
4924+ code::INVALID_PREFIX,
4925+ format!(
4926+ "'{}': `inline` puts the image's file into the page, which can't depend \
4927+ on a prefix",
4928+ attr.key
4929+ ),
4930+ &attr.key,
4931+ );
4932+ }
4933+ if vocab::HTMLANG_FLAGS.contains(&base) {
4934+ return true;
4935+ }
4936+ if vocab::BOOLEAN_HTML_ATTRS.contains(&base) {
4937+ if !prefixed {
4938+ return true;
1811 }
4939 }
4940+ return fail(
4941+ ctx,
4942+ code::INVALID_PREFIX,
4943+ format!(
4944+ "'{}': a prefix applies to a style, and '{}' is an HTML attribute",
4945+ attr.key, base
4946+ ),
4947+ &attr.key,
4948+ );
4949+ }
4950+ if vocab::is_style_attribute(base)
4951+ || vocab::is_custom_property(base)
4952+ || vocab::is_vendor_property(base)
4953+ {
4954+ return fail(
4955+ ctx,
4956+ code::MISSING_VALUE,
4957+ format!("'{}' needs a value: `{} VALUE`", attr.key, attr.key),
4958+ &attr.key,
4959+ );
1812 }
4960 }
4961+ if is_html_name(base) && !prefixed {
4962+ return fail(
4963+ ctx,
4964+ code::HTML_ATTRIBUTE_FORM,
4965+ format!(
4966+ "'{}' is an HTML attribute that isn't a flag: write `{}=VALUE`",
4967+ base, base
4968+ ),
4969+ base,
4970+ );
4971+ }
4972+ return unknown_attribute(attr, base, line, column, after_another, true, ctx);
4973+ };
4974+
4975+ if vocab::HTMLANG_FLAGS.contains(&base) {
4976+ return fail(
4977+ ctx,
4978+ code::INVALID_VALUE,
4979+ format!(
4980+ "'{}' is a flag and takes no value: write `{}` alone",
4981+ base, attr.key
4982+ ),
4983+ &attr.key,
4984+ );
4985+ }
4986+ if vocab::is_style_attribute(base)
4987+ || vocab::is_custom_property(base)
4988+ || vocab::is_vendor_property(base)
4989+ {
4990+ return check_css_value(attr, base, line, column, ctx);
4991+ }
4992+ if is_html_name(base) && !prefixed {
4993+ let diagnostic = Diagnostic::error(
4994+ code::HTML_ATTRIBUTE_FORM,
4995+ line,
4996+ format!(
4997+ "'{}' is an HTML attribute: write `{}={}`",
4998+ attr.key, attr.key, value
4999+ ),
5000+ )
5001+ .subject(attr.key.as_str());
5002+ let diagnostic = at_attribute(diagnostic, column, ctx);
5003+ ctx.diagnostics.push(diagnostic);
5004+ return false;
1813 }
5005 }
5006+ let passes = vocab::is_property_name(base);
5007+ unknown_attribute(attr, base, line, column, after_another, !passes, ctx)
5008+ && check_css_value(attr, base, line, column, ctx)
1814}
5009}
1815
5010
1816−fn parse_attr_brackets(
5011+/// Whether an attribute of a bundle could pass a parameter to a function
1817− input: &str,
5012+/// it is spliced into (`@let t [title Hello]`): a name without a prefix
1818− line_num: usize,
5013+/// that isn't a style or a flag. Such a name is checked where the bundle is
5014+/// used.
5015+fn could_be_parameter(attr: &Attribute) -> bool {
5016+ use crate::vocab;
5017+ let key = attr.key.as_str();
5018+ let flag = attr.value.is_none()
5019+ && (vocab::HTMLANG_FLAGS.contains(&key) || vocab::BOOLEAN_HTML_ATTRS.contains(&key));
5020+ !attr.html
5021+ && !flag
5022+ && !vocab::is_style_attribute(key)
5023+ && key.starts_with(|c: char| c.is_alphabetic())
5024+ && key
5025+ .chars()
5026+ .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_'))
5027+}
5028+
5029+/// At a call, a name close to one of the function's parameters is a
5030+/// misspelled parameter, not an attribute for its root: report it, and
5031+/// return true (it is left out).
5032+fn misspelled_parameter(
5033+ attr: &Attribute,
5034+ text_keys: &[String],
5035+ line: usize,
5036+ column: Option<usize>,
5037+ ctx: &mut ParseContext,
5038+) -> bool {
5039+ if text_keys.is_empty() || !could_be_parameter(attr) {
5040+ return false;
5041+ }
5042+ let params: Vec<&str> = text_keys.iter().map(String::as_str).collect();
5043+ let Some(closest) = suggest_closest(&attr.key, &params) else {
5044+ return false;
5045+ };
5046+ let what = if ctx.in_page {
5047+ "@page word"
5048+ } else {
5049+ "parameter"
5050+ };
5051+ let diagnostic = Diagnostic::error(
5052+ code::UNKNOWN_ATTRIBUTE,
5053+ line,
5054+ format!(
5055+ "unknown {} '{}', did you mean '{}'?",
5056+ what, attr.key, closest
5057+ ),
5058+ )
5059+ .subject(attr.key.as_str())
5060+ .suggest(Some(closest));
5061+ let diagnostic = at_attribute(diagnostic, column, ctx);
5062+ ctx.diagnostics.push(diagnostic);
5063+ true
5064+}
5065+
5066+/// An attribute name htmlang doesn't know. A style whose name CSS could
5067+/// have goes to the CSS as written, with a warning; anything else (a flag,
5068+/// a name that isn't a word) is an error. Returns whether it is kept.
5069+fn unknown_attribute(
5070+ attr: &Attribute,
5071+ base: &str,
5072+ line: usize,
5073+ column: Option<usize>,
5074+ after_another: bool,
5075+ error: bool,
1819 ctx: &mut ParseContext,
5076 ctx: &mut ParseContext,
1820−) -> Result<(Vec<Attribute>, String), ParseError> {
5077+) -> bool {
1821− parse_attr_brackets_inner(input, line_num, ctx, true)
5078+ // `@td padding 8`: an element prefix without its colon
5079+ if let Some(name) = base.strip_prefix('@')
5080+ && crate::ast::ElementKind::from_name(name).is_some()
5081+ {
5082+ let example = match attr.value.as_deref() {
5083+ Some(value) => format!("{}:{}", attr.key, value.trim()),
5084+ None => format!("{}:padding 8", attr.key),
5085+ };
5086+ let diagnostic = Diagnostic::error(
5087+ code::UNKNOWN_ATTRIBUTE,
5088+ line,
5089+ format!(
5090+ "unknown attribute '{}': an element prefix ends in a colon, as in `{}`",
5091+ attr.key, example
5092+ ),
5093+ )
5094+ .subject(base);
5095+ let diagnostic = at_attribute(diagnostic, column, ctx);
5096+ ctx.diagnostics.push(diagnostic);
5097+ return false;
5098+ }
5099+ // `[card]` where `card` is a bundle: it is used as `$card`
5100+ if attr.value.is_none() && ctx.env.bundle(base).is_some() && base == attr.key {
5101+ ctx.used_defines.insert(base.to_string());
5102+ let diagnostic = Diagnostic::error(
5103+ code::UNKNOWN_ATTRIBUTE,
5104+ line,
5105+ format!(
5106+ "unknown attribute '{}': '{}' is an attribute bundle, used as `${}`",
5107+ base, base, base
5108+ ),
5109+ )
5110+ .subject(base)
5111+ .suggest(Some(format!("${}", base)));
5112+ let diagnostic = at_attribute(diagnostic, column, ctx);
5113+ ctx.diagnostics.push(diagnostic);
5114+ return false;
5115+ }
5116+ // `hover:title x`: an HTML attribute's name is not a suggestion for
5117+ // itself
5118+ let suggestion =
5119+ suggest_closest(base, &crate::vocab::all_attributes()).filter(|&closest| closest != base);
5120+ let mut message = match (&suggestion, error) {
5121+ (Some(closest), true) => {
5122+ format!(
5123+ "unknown attribute '{}', did you mean '{}'?",
5124+ attr.key, closest
5125+ )
5126+ }
5127+ (None, true) => format!("unknown attribute '{}'", attr.key),
5128+ (Some(closest), false) => format!(
5129+ "unknown CSS property '{}', did you mean '{}'? It is written to the CSS as it is",
5130+ base, closest
5131+ ),
5132+ (None, false) => format!(
5133+ "unknown CSS property '{}': it is written to the CSS as it is",
5134+ base
5135+ ),
5136+ };
5137+ // `box-shadow 0 1px red, 0 2px blue` or `font-family Inter,
5138+ // sans-serif`: the comma ended the attribute, and the rest of the
5139+ // value became one
5140+ let rest_of_a_value = !base.starts_with(|c: char| c.is_ascii_alphabetic() || c == '-')
5141+ || (suggestion.is_none() && attr.value.is_none() && after_another);
5142+ if rest_of_a_value && error {
5143+ if !message.ends_with('?') {
5144+ message.push('.');
5145+ }
5146+ message.push_str(" A comma separates attributes: to keep one in a value, write `\\,`");
5147+ }
5148+ let severity = if error {
5149+ Severity::Error
5150+ } else {
5151+ Severity::Warning
5152+ };
5153+ let diagnostic = Diagnostic::new(code::UNKNOWN_ATTRIBUTE, severity, line, message)
5154+ .subject(base)
5155+ .suggest(suggestion);
5156+ let diagnostic = at_attribute(diagnostic, column, ctx);
5157+ ctx.diagnostics.push(diagnostic);
5158+ !error
1822}
5159}
1823
5160
1824−fn parse_attr_brackets_no_validate(
5161+/// Evaluate the attributes of a list: bundles are spliced in, `if()`
1825− input: &str,
5162+/// chooses, and variables fill values. A variable fills only the value it
5163+/// is written in: attributes come from bundles, never from text. With
5164+/// `validate`, unknown names and invalid values are reported. `text_keys`
5165+/// are a called function's parameters: their values are text rather than
5166+/// CSS, and they aren't checked as attributes.
5167+fn parse_attr_list(
5168+ tokens: &[syntax::Attr],
1826 line_num: usize,
5169 line_num: usize,
1827 ctx: &mut ParseContext,
5170 ctx: &mut ParseContext,
1828−) -> Result<(Vec<Attribute>, String), ParseError> {
5171+ validate: bool,
1829− parse_attr_brackets_inner(input, line_num, ctx, false)
5172+ text_keys: &[String],
5173+) -> Vec<Attribute> {
5174+ parse_attrs(tokens, line_num, ctx, validate, text_keys)
5175+ .into_iter()
5176+ .map(|(attr, _)| attr)
5177+ .collect()
1830}
5178}
1831
5179
1832−fn parse_attr_brackets_inner(
5180+/// [`parse_attr_list`], with the value of each attribute whose key is one
1833− input: &str,
5181+/// of `text_keys` (a call's parameters) as a value with its type (see
5182+/// [`ParseContext::slot_value`]), so a parameter can get a list or a
5183+/// record.
5184+fn parse_attrs(
5185+ tokens: &[syntax::Attr],
1834 line_num: usize,
5186 line_num: usize,
1835 ctx: &mut ParseContext,
5187 ctx: &mut ParseContext,
1836 validate: bool,
5188 validate: bool,
1837−) -> Result<(Vec<Attribute>, String), ParseError> {
5189+ text_keys: &[String],
1838− let mut depth = 0;
5190+) -> Vec<(Attribute, Option<Value>)> {
1839− let mut end_pos = 0;
5191+ let mut read = ReadAttrs::default();
1840−
5192+ read_attrs(tokens, "", line_num, ctx, validate, text_keys, &mut read);
1841− for (i, c) in input.char_indices() {
5193+ read.attrs
1842− match c {
1843− '[' => depth += 1,
1844− ']' => {
1845− depth -= 1;
1846− if depth == 0 {
1847− end_pos = i;
1848− break;
1849− }
1850− }
1851− _ => {}
1852− }
1853− }
1854−
1855− if depth != 0 {
1856− return Err(ParseError {
1857− line: line_num,
1858− message: "unclosed '[' in attribute list".to_string(),
1859− });
1860− }
1861−
1862− let attrs_inner = &input[1..end_pos];
1863− let remaining = input[end_pos + 1..].trim().to_string();
1864− let attrs = parse_attr_list(attrs_inner, line_num, ctx, validate);
1865−
1866− Ok((attrs, remaining))
1867}
5194}
1868
5195
1869−fn is_valid_hex_color(s: &str) -> bool {
5196+/// The attributes of a list read so far, and the keys written (for the
1870− if !s.starts_with('#') {
5197+/// duplicate check).
1871− return true; // Not a hex color, skip
5198+#[derive(Default)]
1872− }
5199+struct ReadAttrs {
1873− let hex = &s[1..];
5200+ attrs: Vec<(Attribute, Option<Value>)>,
1874− matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
5201+ seen_keys: Vec<(String, bool)>,
1875}
5202}
1876
5203
1877−fn parse_attr_list(
5204+/// [`parse_attrs`] for the attributes of `tokens`, each under the prefixes
1878− input: &str,
5205+/// `prefix` (`md:` for those of `md:[...]`), which distribute over a
5206+/// group, a bundle and the branch an `if()` takes. Only styles take a
5207+/// prefix, so an HTML attribute there is an error.
5208+fn read_attrs(
5209+ tokens: &[syntax::Attr],
5210+ prefix: &str,
1879 line_num: usize,
5211 line_num: usize,
1880 ctx: &mut ParseContext,
5212 ctx: &mut ParseContext,
1881 validate: bool,
5213 validate: bool,
1882−) -> Vec<Attribute> {
5214+ text_keys: &[String],
1883− let mut attrs = Vec::new();
5215+ out: &mut ReadAttrs,
1884− let mut seen_keys: Vec<String> = Vec::new();
5216+) {
1885−
5217+ let mut chosen = Vec::new();
1886− for part in split_commas(input) {
5218+ choose_attrs(
1887− let part = part.trim();
5219+ tokens,
1888− // A whole attribute `if(cond, a, b)` is the chosen branch's text.
5220+ prefix,
1889− let chosen;
5221+ line_num,
1890− let part = match choose_if(part, ctx, line_num) {
5222+ ctx,
1891− Some(branch) => {
5223+ validate,
1892− chosen = branch;
5224+ text_keys,
1893− chosen.as_str()
5225+ &mut chosen,
1894− }
5226+ );
1895− None => part,
5227+
5228+ for token in &chosen {
5229+ let line = if token.span.line == 0 {
5230+ line_num
5231+ } else {
5232+ token.span.line
1896 };
5233 };
1897− if part.is_empty() {
5234+ let (key, value, html) = (token.key.clone(), token.value.clone(), token.html);
5235+ let column = |at: usize| Some(token.span.column + at);
5236+
5237+ // `md:[...]`, `md:$card`, `md:if(...)`: the prefixes apply to each
5238+ // attribute there, and a prefix a parameter can't have
5239+ if let Some(prefixed) = &token.prefixed {
5240+ if let Some(inner) = group_prefix(token, prefixed, prefix, line, ctx) {
5241+ read_attrs(
5242+ prefixed.target.attrs(),
5243+ &inner,
5244+ line_num,
5245+ ctx,
5246+ validate,
5247+ text_keys,
5248+ out,
5249+ );
5250+ }
1898 continue;
5251 continue;
1899 }
5252 }
1900
5253
1901− // $define reference — expand attribute bundle
5254+ // `$name` (or `${name}`) alone: an attribute bundle, spliced in
1902− if let Some(name) = part.strip_prefix('$')
5255+ let bundle = key.strip_prefix('$').filter(|_| value.is_none() && !html);
1903− && let Some(define_attrs) = ctx.defines.get(name)
5256+ let bundle = bundle.and_then(|after| match interp::reference(after, &ctx.env)? {
1904− {
5257+ (interp::Reference::Var(name), len) if len == after.len() => Some(name),
1905− ctx.used_defines.insert(name.to_string());
5258+ _ => None,
1906− attrs.extend(define_attrs.clone());
5259+ });
5260+ if let Some(name) = bundle {
5261+ if let Some(define_attrs) = ctx.env.bundle(&name).cloned() {
5262+ ctx.used_defines.insert(name.clone());
5263+ for attr in define_attrs.iter() {
5264+ if !prefix.is_empty() {
5265+ let prefixed = Attribute {
5266+ key: format!("{}{}", prefix, attr.key),
5267+ ..attr.clone()
5268+ };
5269+ if prefixed_parameter(&prefixed, text_keys, line, column(0), validate, ctx)
5270+ {
5271+ continue;
5272+ }
5273+ if let Some(attr) =
5274+ prefixed_member(attr, prefix, &name, line, column(0), validate, ctx)
5275+ {
5276+ out.attrs.push((attr, None));
5277+ }
5278+ continue;
5279+ }
5280+ let attrs = &out.attrs;
5281+ // Checked here, where it is either a parameter or not
5282+ let checked_here = validate
5283+ && !ctx.in_bundle
5284+ && could_be_parameter(attr)
5285+ && !text_keys.contains(&attr.key);
5286+ if checked_here
5287+ && (misspelled_parameter(attr, text_keys, line, column(0), ctx)
5288+ || !check_attribute(attr, line, column(0), !attrs.is_empty(), ctx))
5289+ {
5290+ continue;
5291+ }
5292+ out.attrs.push((attr.clone(), None));
5293+ }
5294+ } else {
5295+ not_a_bundle(&name, line, column(0), ctx);
5296+ }
5297+ continue;
5298+ }
5299+ // A name never comes from a variable
5300+ if key.contains('$') {
5301+ track_var_refs(&key, &mut ctx.used_variables);
5302+ ctx.diagnostics.push(
5303+ Diagnostic::error(
5304+ code::ATTRIBUTE_FROM_VARIABLE,
5305+ line,
5306+ format!(
5307+ "'{}': an attribute's name can't come from a variable. A variable \
5308+ fills a value (`padding $size`), and whole attributes come from a \
5309+ bundle (`@let name [padding 8]`, used as `[$name]`)",
5310+ key
5311+ ),
5312+ )
5313+ .subject(key.as_str()),
5314+ );
1907 continue;
5315 continue;
1908 }
5316 }
1909
5317
1910− track_var_refs(part, &mut ctx.used_variables);
5318+ // Only a style has states: `md:[id=x]`, `md:id=x`
1911−
5319+ if html && (!prefix.is_empty() || !crate::vocab::split_prefixes(&key).0.is_empty()) {
1912− // A value `if(cond, a, b)` picks `a` or `b` (free text) by `cond`;
5320+ let written = format!("{}{}", prefix, key);
1913− // an empty choice leaves the attribute out.
5321+ let (name, _) = crate::vocab::split_prefixes(&written);
1914− let Some(part) = choose_if_value(part, ctx, line_num) else {
5322+ let diagnostic = Diagnostic::error(
5323+ code::INVALID_PREFIX,
5324+ line,
5325+ format!(
5326+ "'{}=': a prefix applies to a style, and `{}=` is an HTML attribute, \
5327+ which has no states",
5328+ written,
5329+ &written[name.concat().len()..]
5330+ ),
5331+ )
5332+ .subject(token.key.as_str());
5333+ let diagnostic = at_attribute(diagnostic, column(0), ctx);
5334+ ctx.diagnostics.push(diagnostic);
1915 continue;
5335 continue;
5336+ }
5337+ let key = if prefix.is_empty() {
5338+ key
5339+ } else {
5340+ format!("{}{}", prefix, key)
1916 };
5341 };
1917− let part = substitute_vars(&part, &ctx.variables);
1918
5342
1919− let attr = if let Some((key, value)) = split_html_attribute(&part) {
5343+ // A value's variables are filled in. A style's value is CSS, where
1920− Attribute {
5344+ // quoted text keeps its quotes; an HTML attribute's (and a
1921− key: key.to_string(),
5345+ // parameter's) is text, where it loses them.
1922− value: Some(value.to_string()),
5346+ let sink = if html || text_keys.contains(&key) {
1923− html: true,
5347+ Sink::Text
1924− }
1925− } else if let Some((key, value)) = part.split_once(' ') {
1926− let value = value.trim().to_string();
1927− Attribute {
1928− key: key.trim().to_string(),
1929− value: Some(value),
1930− html: false,
1931− }
1932 } else {
5348 } else {
1933− Attribute {
5349+ Sink::Css
1934− key: part.to_string(),
5350+ };
1935− value: None,
5351+ let mut filled = true;
1936− html: false,
5352+ let mut quoted = None;
5353+ let mut typed = None;
5354+ let value = match value {
5355+ None => None,
5356+ // A parameter's value keeps its type
5357+ Some(value) if !html && text_keys.contains(&key) => {
5358+ let at = column(token.raw.len() - value.len());
5359+ let (value, ok) = ctx.slot_value(&value, line, at);
5360+ filled = ok;
5361+ if let Value::Quoted(q) = &value {
5362+ quoted = Some(q.clone());
5363+ }
5364+ let text = value.to_string();
5365+ typed = Some(value);
5366+ Some(text)
5367+ }
5368+ Some(value) => {
5369+ let at = column(token.raw.len() - value.len());
5370+ let (text, is_quoted, ok) = ctx.fill_value(&value, line, at, sink);
5371+ filled = ok;
5372+ quoted = is_quoted;
5373+ Some(text)
1937 }
5374 }
1938 };
5375 };
5376+ let attr = Attribute {
5377+ key,
5378+ value,
5379+ html,
5380+ quoted,
5381+ };
5382+ // A value that couldn't be filled in is already reported, and a
5383+ // parameter's value is text for the function
5384+ let validate = validate && filled && !text_keys.contains(&attr.key);
1939
5385
1940− // Warn on duplicate attributes (compare full key so pseudo-class
5386+ // A duplicate is reported (compare the full key, so `border` and
1941− // variants like `border` and `hover:border` are not conflated)
5387+ // `hover:border` differ, and the form, so `width=800` and
5388+ // `width 200` do too), and the later one wins
1942 if validate {
5389 if validate {
1943− if seen_keys.contains(&attr.key) {
5390+ let seen = (normal_key(&attr.key), attr.html);
1944− ctx.diagnostics.push(Diagnostic {
5391+ if out.seen_keys.contains(&seen) {
1945− line: line_num,
5392+ let message = if attr.html {
1946− column: None,
5393+ format!("duplicate attribute '{}='", attr.key)
1947− message: format!("duplicate attribute '{}'", attr.key),
5394+ } else {
1948− severity: Severity::Warning,
5395+ format!("duplicate attribute '{}': the later one wins", attr.key)
1949− source_line: None,
5396+ };
1950− });
5397+ let diagnostic = Diagnostic::warning(code::DUPLICATE_ATTRIBUTE, line, message)
5398+ .subject(attr.key.as_str());
5399+ let diagnostic = at_attribute(diagnostic, column(0), ctx);
5400+ ctx.diagnostics.push(diagnostic);
1951 } else {
5401 } else {
1952− seen_keys.push(attr.key.clone());
5402+ out.seen_keys.push(seen);
1953 }
5403 }
5404+ }
1954
5405
1955− // Color validation for hex colors
5406+ if validate && misspelled_parameter(&attr, text_keys, line, column(0), ctx) {
1956− if !attr.html
5407+ continue;
1957− && matches!(
5408+ }
1958− crate::vocab::base_attribute(&attr.key),
5409+ if prefixed_parameter(&attr, text_keys, line, column(0), validate, ctx) {
1959− "background" | "color"
5410+ continue;
1960− )
5411+ }
1961− && let Some(ref val) = attr.value
5412+ if validate && attr.html && attr.key == "class" {
1962− && val.starts_with('#')
5413+ reserved_class(&attr, line, column(0), ctx);
1963− && !is_valid_hex_color(val)
5414+ }
1964− {
5415+ // `padding=4`: a style written as an HTML attribute, which the
1965− ctx.diagnostics.push(Diagnostic {
5416+ // browser ignores (`width=` and `height=` are real on some
1966− line: line_num,
5417+ // elements, see `validate_tree`)
1967− column: None,
5418+ if validate
1968− message: format!("invalid hex color '{}'", val),
5419+ && attr.html
1969− severity: Severity::Warning,
5420+ && crate::vocab::is_style_attribute(&attr.key)
1970− source_line: None,
5421+ && !crate::vocab::HTML_ATTRIBUTES.contains(&attr.key.as_str())
1971− });
5422+ {
1972− }
5423+ let value = token.value.as_deref().unwrap_or("").trim();
5424+ let diagnostic = Diagnostic::warning(
5425+ code::HTML_ATTRIBUTE_FORM,
5426+ line,
5427+ format!(
5428+ "'{0}={1}' is an HTML attribute, but {0} is a style: write `{0} {1}`",
5429+ attr.key, value
5430+ ),
5431+ )
5432+ .subject(token.raw.as_str())
5433+ .suggest(Some(format!("{} {}", attr.key, value)));
5434+ let diagnostic = at_attribute(diagnostic, column(0), ctx);
5435+ ctx.diagnostics.push(diagnostic);
1973 }
5436 }
1974
5437
1975− // Warn on unknown attributes. `key=value` HTML attributes may use any
5438+ // A style or a flag: its name is checked, and what can't be
1976− // name; everything else must be a known style.
5439+ // written into the page is an error and left out
1977− if validate && !attr.html {
5440+ let checked = validate && !attr.html && !(ctx.in_bundle && could_be_parameter(&attr));
1978− let base_key = crate::vocab::base_attribute(attr.key.as_str());
5441+ let after_another = !out.attrs.is_empty();
1979− let is_boolean_html =
5442+ if checked && !check_attribute(&attr, line, column(0), after_another, ctx) {
1980− attr.value.is_none() && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&base_key);
5443+ continue;
1981− if is_boolean_html || crate::vocab::is_style_attribute(base_key) {
1982− validate_attr_value(&attr, line_num, ctx);
1983− } else if crate::vocab::HTML_ATTRIBUTES.contains(&base_key)
1984− || base_key.starts_with("aria-")
1985− || base_key.starts_with("data-")
1986− {
1987− ctx.diagnostics.push(Diagnostic {
1988− line: line_num,
1989− column: None,
1990− message: format!(
1991− "'{}' is an HTML attribute: write `{}={}`",
1992− attr.key,
1993− attr.key,
1994− attr.value.as_deref().unwrap_or("")
1995− ),
1996− severity: Severity::Warning,
1997− source_line: None,
1998− });
1999− } else {
2000− let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
2001− let msg = match suggestion {
2002− Some(closest) => {
2003− format!(
2004− "unknown attribute '{}', did you mean '{}'?",
2005− attr.key, closest
2006− )
2007− }
2008− None => format!("unknown attribute '{}'", attr.key),
2009− };
2010− ctx.diagnostics.push(Diagnostic {
2011− line: line_num,
2012− column: None,
2013− message: msg,
2014− severity: Severity::Warning,
2015− source_line: None,
2016− });
2017− }
2018 }
5444 }
2019
5445
2020− attrs.push(attr);
5446+ out.attrs.push((attr, typed));
2021 }
5447 }
2022−
2023− attrs
2024}
5448}
2025
5449
2026−/// Split an HTML attribute written `key=value` (`alt=`, `type=email`,
5450+/// At a call, a parameter under prefixes (`md:title x`, `md:[title x]`):
2027−/// `aria-label=Close menu`). Returns `None` for style attributes.
5451+/// a parameter has one value, with no states, so it is reported (with
2028−fn split_html_attribute(part: &str) -> Option<(&str, &str)> {
5452+/// `validate`) and left out; returns true then. A parameter named like a
2029− let first_token = part.split(char::is_whitespace).next()?;
5453+/// style (`color`) is a style under a prefix, for the root element.
2030− let eq = first_token.find('=')?;
5454+fn prefixed_parameter(
2031− let key = &part[..eq];
5455+ attr: &Attribute,
2032− let valid_key = key.starts_with(|c: char| c.is_ascii_alphabetic())
5456+ text_keys: &[String],
2033− && key
5457+ line: usize,
2034− .chars()
5458+ column: Option<usize>,
2035− .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
5459+ validate: bool,
2036− valid_key.then(|| (key, part[eq + 1..].trim()))
5460+ ctx: &mut ParseContext,
5461+) -> bool {
5462+ let (prefixes, name) = crate::vocab::split_prefixes(&attr.key);
5463+ if attr.html
5464+ || prefixes.is_empty()
5465+ || !text_keys.iter().any(|k| k == name)
5466+ || crate::vocab::is_style_attribute(name)
5467+ {
5468+ return false;
5469+ }
5470+ if validate {
5471+ let what = if ctx.in_page {
5472+ "@page's own word"
5473+ } else {
5474+ "a parameter"
5475+ };
5476+ let diagnostic = Diagnostic::error(
5477+ code::INVALID_PREFIX,
5478+ line,
5479+ format!(
5480+ "'{}': `{}` is {}, which has one value, and a prefix applies to a style, \
5481+ which has states",
5482+ attr.key, name, what
5483+ ),
5484+ )
5485+ .subject(attr.key.as_str());
5486+ let diagnostic = at_attribute(diagnostic, column, ctx);
5487+ ctx.diagnostics.push(diagnostic);
5488+ }
5489+ true
2037}
5490}
2038
5491
2039−fn split_commas(input: &str) -> Vec<&str> {
5492+/// The prefixes of a prefixed group, bundle or `if()` inside `outer` (the
2040− let mut parts = Vec::new();
5493+/// prefixes of the groups it is in), when they are all known; else the
2041− let mut start = 0;
5494+/// unknown one is reported. Text after a group's `]` is an error too.
2042− let mut depth = 0;
5495+fn group_prefix(
2043− let mut in_quotes = false;
5496+ token: &syntax::Attr,
2044−
5497+ prefixed: &syntax::Prefixed,
2045− for (i, c) in input.char_indices() {
5498+ outer: &str,
2046− match c {
5499+ line: usize,
2047− '"' => in_quotes = !in_quotes,
5500+ ctx: &mut ParseContext,
2048− _ if in_quotes => {}
5501+) -> Option<String> {
2049− '[' | '(' => depth += 1,
5502+ let at = |ctx: &ParseContext, diagnostic: Diagnostic, span: syntax::Span| match &ctx
2050− ']' | ')' => depth -= 1,
5503+ .current_source
2051− ',' if depth == 0 => {
5504+ {
2052− parts.push(&input[start..i]);
5505+ (current, Some(text)) if span.line != 0 && *current == span.line => {
2053− start = i + 1;
5506+ diagnostic.column(span.column).source(text.clone())
2054− }
2055− _ => {}
2056 }
5507 }
5508+ _ => diagnostic,
5509+ };
5510+ if let syntax::Branch::Group {
5511+ trailing: Some(trailing),
5512+ ..
5513+ } = &prefixed.target
5514+ {
5515+ let diagnostic = Diagnostic::error(
5516+ code::UNEXPECTED_ARGUMENT,
5517+ line,
5518+ format!(
5519+ "'{}' after the `]` of `{}[...]`: the prefixes apply to the attributes in the \
5520+ group, so it goes inside it",
5521+ trailing.raw, prefixed.prefix
5522+ ),
5523+ )
5524+ .subject(trailing.raw.as_str());
5525+ let diagnostic = at(ctx, diagnostic, trailing.span);
5526+ ctx.push_once(diagnostic);
5527+ }
5528+ let (known, rest) = crate::vocab::split_prefixes(&prefixed.prefix);
5529+ if !rest.is_empty() {
5530+ let problem = unknown_prefix(rest).unwrap_or_else(|| {
5531+ let written = rest.split_inclusive(':').next().unwrap_or(rest);
5532+ PrefixProblem {
5533+ message: format!("unknown prefix '{}'", written),
5534+ subject: written.to_string(),
5535+ suggestion: None,
5536+ }
5537+ });
5538+ let diagnostic = Diagnostic::error(code::UNKNOWN_PREFIX, line, problem.message)
5539+ .subject(problem.subject)
5540+ .suggest(problem.suggestion);
5541+ let diagnostic = at(ctx, diagnostic, token.span);
5542+ ctx.push_once(diagnostic);
5543+ return None;
2057 }
5544 }
2058− parts.push(&input[start..]);
5545+ Some(format!("{}{}", outer, known.concat()))
2059− parts
2060}
5546}
2061
5547
2062−// ---------------------------------------------------------------------------
5548+/// An attribute of the bundle `bundle`, spliced in under `prefix`
2063−// Chain splitting (the > operator)
5549+/// (`md:$card`): the same attribute with the prefixes in front, when it can
2064−// ---------------------------------------------------------------------------
5550+/// take them. An HTML attribute can't; a style's prefixes are checked, as
2065−
5551+/// is a flag or a name that isn't a style (a bundle's parameter), which
2066−fn split_chain(content: &str) -> Vec<String> {
5552+/// under a prefix are attributes like any other.
2067− let mut segments = Vec::new();
5553+fn prefixed_member(
2068− let mut current = String::new();
5554+ attr: &Attribute,
2069− let chars: Vec<char> = content.chars().collect();
5555+ prefix: &str,
2070− let mut i = 0;
5556+ bundle: &str,
2071− let mut bracket_depth = 0;
5557+ line: usize,
2072−
5558+ column: Option<usize>,
2073− while i < chars.len() {
5559+ validate: bool,
2074− if chars[i] == '[' {
5560+ ctx: &mut ParseContext,
2075− bracket_depth += 1;
5561+) -> Option<Attribute> {
5562+ let key = format!("{}{}", prefix, attr.key);
5563+ if attr.html {
5564+ if validate {
5565+ let diagnostic = Diagnostic::error(
5566+ code::INVALID_PREFIX,
5567+ line,
5568+ format!(
5569+ "'{}${}': the bundle has `{}=`, an HTML attribute, and a prefix applies to \
5570+ styles, which have states",
5571+ prefix, bundle, attr.key
5572+ ),
5573+ )
5574+ .subject(format!("{}${}", prefix, bundle));
5575+ let diagnostic = at_attribute(diagnostic, column, ctx);
5576+ ctx.diagnostics.push(diagnostic);
2076 }
5577 }
2077− if chars[i] == ']' {
5578+ return None;
2078− bracket_depth -= 1;
5579+ }
5580+ let prefixed = Attribute {
5581+ key,
5582+ ..attr.clone()
5583+ };
5584+ if validate {
5585+ let fine = if attr.value.is_none() || could_be_parameter(attr) {
5586+ check_attribute(&prefixed, line, column, false, ctx)
5587+ } else {
5588+ check_prefixes(&prefixed, line, column, ctx)
5589+ };
5590+ if !fine {
5591+ return None;
2079 }
5592 }
5593+ }
5594+ Some(prefixed)
5595+}
2080
5596
2081− // Match " > @" outside brackets
5597+/// `class=hl-x`: the `hl-` prefix is htmlang's, for its generated classes,
2082− if bracket_depth == 0
5598+/// whose rules would then apply to the element too. It goes into the page
2083− && i + 3 < chars.len()
5599+/// as written, so this is a warning.
2084− && chars[i] == ' '
5600+fn reserved_class(attr: &Attribute, line: usize, column: Option<usize>, ctx: &mut ParseContext) {
2085− && chars[i + 1] == '>'
5601+ let classes = attr.value.as_deref().unwrap_or_default();
2086− && chars[i + 2] == ' '
5602+ for class in classes.split_whitespace() {
2087− && chars[i + 3] == '@'
5603+ if class.starts_with(crate::codegen::CLASS_PREFIX) {
2088− {
5604+ let diagnostic = Diagnostic::warning(
2089− segments.push(current.trim().to_string());
5605+ code::INVALID_VALUE,
2090− current = String::new();
5606+ line,
2091− i += 3; // skip " > ", keep the "@"
5607+ format!(
2092− continue;
5608+ "class '{}' starts with `{}`, which htmlang keeps for the classes it \
5609+ generates, so their styles could apply to this element: give it \
5610+ another name",
5611+ class,
5612+ crate::codegen::CLASS_PREFIX
5613+ ),
5614+ )
5615+ .subject(class);
5616+ let diagnostic = at_attribute(diagnostic, column, ctx);
5617+ ctx.diagnostics.push(diagnostic);
2093 }
5618 }
2094−
2095− current.push(chars[i]);
2096− i += 1;
2097 }
5619 }
5620+}
2098
5621
2099− if !current.trim().is_empty() {
5622+/// A style's key as the CSS reads it, for finding duplicates: its at-rule
2100− segments.push(current.trim().to_string());
5623+/// prefixes in one order (`dark:md:` is `md:dark:`), then its selector
5624+/// prefixes as written, whose order is the selector's.
5625+fn normal_key(key: &str) -> String {
5626+ let (prefixes, name) = crate::vocab::split_prefixes(key);
5627+ let mut at: Vec<(usize, &str)> = prefixes
5628+ .iter()
5629+ .filter_map(|p| crate::vocab::at_rule_rank(p).map(|rank| (rank, *p)))
5630+ .collect();
5631+ at.sort_unstable();
5632+ at.dedup();
5633+ let mut normal: String = at.iter().map(|(_, p)| *p).collect();
5634+ for prefix in prefixes
5635+ .iter()
5636+ .filter(|p| crate::vocab::at_rule_rank(p).is_none())
5637+ {
5638+ normal.push_str(prefix);
2101 }
5639 }
5640+ normal.push_str(name);
5641+ normal
5642+}
2102
5643
2103− segments
5644+/// `[$name]` where `$name` isn't an attribute bundle.
5645+fn not_a_bundle(name: &str, line: usize, column: Option<usize>, ctx: &mut ParseContext) {
5646+ track_var_refs(&format!("${}", name), &mut ctx.used_variables);
5647+ let is_value = interp::name_len(name) > 0 && interp::resolve(name, &ctx.env).is_some();
5648+ let diagnostic = if is_value {
5649+ Diagnostic::error(
5650+ code::ATTRIBUTE_FROM_VARIABLE,
5651+ line,
5652+ format!(
5653+ "'${}' is a value, not an attribute: attributes come from a bundle \
5654+ (`@let name [padding 8]`, used as `[$name]`), and a value fills an \
5655+ attribute's value (`padding ${}`)",
5656+ name, name
5657+ ),
5658+ )
5659+ .subject(name)
5660+ } else if ctx.env.function(name).is_some() {
5661+ ctx.used_functions.insert(name.to_string());
5662+ Diagnostic::error(
5663+ code::UNDEFINED_VARIABLE,
5664+ line,
5665+ format!(
5666+ "'${}' is a function, not an attribute bundle: it is called as an element, \
5667+ `@{}`",
5668+ name, name
5669+ ),
5670+ )
5671+ .subject(name)
5672+ } else {
5673+ let mut bundles = ctx.env.bundle_names();
5674+ bundles.sort_unstable();
5675+ let suggestion = suggest_closest(name, &bundles);
5676+ let message = match suggestion {
5677+ Some(closest) => format!(
5678+ "undefined attribute bundle '${}', did you mean '${}'?",
5679+ name, closest
5680+ ),
5681+ None => format!("undefined attribute bundle '${}'", name),
5682+ };
5683+ Diagnostic::error(code::UNDEFINED_VARIABLE, line, message)
5684+ .subject(name)
5685+ .suggest(suggestion)
5686+ };
5687+ let diagnostic = match (&ctx.current_source, column) {
5688+ ((current, Some(text)), Some(column)) if *current == line => {
5689+ diagnostic.column(column).source(text.clone())
5690+ }
5691+ _ => diagnostic,
5692+ };
5693+ ctx.diagnostics.push(diagnostic);
2104}
5694}
2105
5695
2106// ---------------------------------------------------------------------------
5696// ---------------------------------------------------------------------------
2107// Text segment parsing (inline {...} elements)
5697// Text segment parsing (inline {...} elements)
2108// ---------------------------------------------------------------------------
5698// ---------------------------------------------------------------------------
2109
5699
2110−/// Escapes in text: `\@`, `\$`, `\{`, `\--` and `\\` stand for the
5700+/// While a slot is filled in, each escape (see [`syntax::ESCAPES`]) is a
2111−/// character(s) themselves. While a text is processed, each is a private-use
5701+/// private-use placeholder, U+E000 plus its place in the table, so it
2112−/// placeholder, so it can't start a variable, an inline element or a line.
5702+/// can't start a variable, a quote or an inline element. Afterwards it
2113−const ESCAPES: &[(&str, char, &str)] = &[
5703+/// becomes what it stands for.
2114− ("\\\\", '\u{E000}', "\\"),
2115− ("\\$", '\u{E001}', "$"),
2116− ("\\{", '\u{E002}', "{"),
2117− ("\\@", '\u{E003}', "@"),
2118− ("\\--", '\u{E004}', "--"),
2119−];
2120−
2121fn protect_escapes(text: &str) -> String {
5704fn protect_escapes(text: &str) -> String {
2122 if !text.contains('\\') {
5705 if !text.contains('\\') {
2123 return text.to_string();
5706 return text.to_string();
2124 }
5707 }
2125− let mut out = text.to_string();
5708+ let mut out = String::with_capacity(text.len());
2126− for (escape, placeholder, _) in ESCAPES {
5709+ let mut i = 0;
2127− out = out.replace(escape, &placeholder.to_string());
5710+ while let Some(c) = text[i..].chars().next() {
5711+ let len = syntax::escape_len(&text[i..]);
5712+ if len == 0 {
5713+ out.push(c);
5714+ i += c.len_utf8();
5715+ continue;
5716+ }
5717+ let escape = &text[i..i + len];
5718+ let index = syntax::ESCAPES.iter().position(|e| *e == escape);
5719+ out.extend(index.and_then(|n| char::from_u32(0xE000 + n as u32)));
5720+ i += len;
2128 }
5721 }
2129 out
5722 out
2130}
5723}
2131
5724
5725+/// The escape a placeholder stands for, as written (`\,`).
5726+fn escape_of(c: char) -> Option<&'static str> {
5727+ let index = (c as u32).checked_sub(0xE000)?;
5728+ syntax::ESCAPES.get(index as usize).copied()
5729+}
5730+
2132fn restore_escapes(text: &str) -> String {
5731fn restore_escapes(text: &str) -> String {
2133− let mut out = text.to_string();
5732+ text.chars()
2134− for (_, placeholder, literal) in ESCAPES {
5733+ .fold(String::with_capacity(text.len()), |mut out, c| {
2135− out = out.replace(*placeholder, literal);
5734+ match escape_of(c) {
5735+ // What the escape stands for: what follows the backslash
5736+ Some(escape) => out.push_str(&escape[1..]),
5737+ None => out.push(c),
5738+ }
5739+ out
5740+ })
5741+}
5742+
5743+/// Inside the quoted strings of a CSS value, `\"` and `\\` mean in CSS what
5744+/// they mean in htmlang, so they stay as written there.
5745+fn keep_css_string_escapes(protected: &str) -> String {
5746+ let mut out = String::with_capacity(protected.len());
5747+ let mut inside = false;
5748+ for c in protected.chars() {
5749+ match escape_of(c) {
5750+ Some(escape @ ("\\\"" | "\\\\")) if inside => out.push_str(escape),
5751+ _ => {
5752+ if c == '"' {
5753+ inside = !inside;
5754+ }
5755+ out.push(c);
5756+ }
5757+ }
2136 }
5758 }
2137 out
5759 out
2138}
5760}
2139
5761
2140−fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment> {
5762+/// Evaluate text: fill in the variables of its plain runs and build its
5763+/// inline elements. Each run is one slot, so a value can't make markup.
5764+fn text_segments(text: &syntax::Text, ctx: &mut ParseContext) -> Vec<TextSegment> {
2141 let mut segments = Vec::new();
5765 let mut segments = Vec::new();
2142− let mut current_text = String::new();
5766+ // The plain text so far, and where it starts
2143− let chars: Vec<char> = protect_escapes(input).chars().collect();
5767+ let mut plain = String::new();
2144− let mut i = 0;
5768+ let mut start: Option<syntax::Span> = None;
2145−
5769+ let flush = |plain: &mut String,
2146− while i < chars.len() {
5770+ start: &mut Option<syntax::Span>,
2147− if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '@' {
5771+ segments: &mut Vec<TextSegment>,
2148− // Flush accumulated plain text
5772+ ctx: &mut ParseContext| {
2149− if !current_text.is_empty() {
5773+ if !plain.is_empty() {
2150− segments.push(TextSegment::Plain(restore_escapes(&substitute_vars(
5774+ let at = start.unwrap_or(text.span);
2151− &current_text,
5775+ let filled = ctx.interpolate_text(plain, at.line, Some(at.column));
2152− &ctx.variables,
5776+ segments.push(TextSegment::Plain(filled));
2153− ))));
5777+ plain.clear();
2154− current_text.clear();
5778+ }
2155− }
5779+ *start = None;
2156−
5780+ };
2157− // Find matching }
5781+ for segment in &text.segments {
2158− let mut depth = 0;
5782+ match segment {
2159− let start = i + 1; // after {
5783+ Segment::Plain { raw, span } => {
2160− i += 1;
5784+ start.get_or_insert(*span);
2161− loop {
5785+ plain.push_str(raw);
2162− if i >= chars.len() {
2163− break;
2164− }
2165− if chars[i] == '{' {
2166− depth += 1;
2167− } else if chars[i] == '}' {
2168− if depth == 0 {
2169− break;
2170− }
2171− depth -= 1;
2172− }
2173− i += 1;
2174 }
5786 }
2175−
5787+ Segment::Inline(inline) => {
2176− let inner: String = chars[start..i].iter().collect();
5788+ let line = ctx.current_line;
2177− i += 1; // skip }
5789+ match resolve(&inline.head, inline.text.as_ref(), line, &text.raw, ctx) {
2178−
5790+ Ok(resolved) => {
2179− match parse_single_element(&inner, ctx.current_line, ctx) {
5791+ if !inline.closed {
2180− Ok(elem) => segments.push(TextSegment::Inline(elem)),
5792+ let d = Diagnostic::warning(
2181− Err(e) => {
5793+ code::UNCLOSED_BRACKET,
2182− // Keep the braces as literal text, but flag what looks
5794+ inline.span.line,
2183− // like a mistyped inline element.
5795+ format!(
2184− if inner.trim_start().starts_with('@') {
5796+ "unclosed `{{`: the inline @{} runs to the end of the line. \
2185− ctx.diagnostics.push(Diagnostic {
5797+ An inline element ends with `}}` (a brace that is text is \
2186− line: ctx.current_line,
5798+ written `\\{{`)",
2187− column: None,
5799+ inline.head.name
2188− message: e.message,
5800+ ),
2189− severity: Severity::Warning,
5801+ );
2190− source_line: Some(input.to_string()),
5802+ let d = at_attribute(d, Some(inline.span.column), ctx);
2191− });
5803+ ctx.push_once(d);
5804+ }
5805+ flush(&mut plain, &mut start, &mut segments, ctx);
5806+ // A function called inline gets no children: its
5807+ // content is the text after its attributes
5808+ match Evaluator.complete(resolved, Vec::new(), ctx) {
5809+ Ok(nodes) => segments.extend(inline_segments(nodes, line)),
5810+ Err(mut e) => {
5811+ e.source_line
5812+ .get_or_insert_with(|| text.raw.as_str().into());
5813+ ctx.diagnostics.push(e);
5814+ }
5815+ }
5816+ }
5817+ Err(mut e) => {
5818+ // A misspelled element is an error inline too (a
5819+ // brace that is text is written `\{`); the braces
5820+ // stay as written
5821+ e.source_line = Some(text.raw.as_str().into());
5822+ ctx.diagnostics.push(e);
5823+ start.get_or_insert(inline.span);
5824+ plain.push('{');
5825+ plain.push_str(&inline.raw);
5826+ if inline.closed {
5827+ plain.push('}');
5828+ }
2192 }
5829 }
2193− current_text.push('{');
2194− current_text.push_str(&inner);
2195− current_text.push('}');
2196 }
5830 }
2197 }
5831 }
2198− } else {
2199− current_text.push(chars[i]);
2200− i += 1;
2201 }
5832 }
2202 }
5833 }
5834+ flush(&mut plain, &mut start, &mut segments, ctx);
5835+ segments
5836+}
2203
5837
2204− if !current_text.is_empty() {
5838+/// The variable `raw` is, when it is exactly one `$name` or `${name}`.
2205− segments.push(TextSegment::Plain(restore_escapes(&substitute_vars(
5839+fn whole_reference(raw: &str, env: &Env) -> Option<String> {
2206− &current_text,
5840+ let after = raw.trim().strip_prefix('$')?;
2207− &ctx.variables,
5841+ match interp::reference(after, env)? {
2208− ))));
5842+ (interp::Reference::Var(path), len) if len == after.len() => Some(path),
5843+ _ => None,
2209 }
5844 }
2210−
2211− segments
2212}
5845}
2213
5846
2214−/// The items of a text list: a range `A..B [step N]`, or comma-separated.
5847+/// The attributes of `tokens` after each whole-attribute `if()` has
2215−fn text_list_items(list: &str) -> Vec<String> {
5848+/// chosen (see [`syntax::Choice`]), in order, into `out`. The branch not
2216− if let Some((start, rest)) = list.split_once("..") {
5849+/// taken isn't evaluated; with `validate`, its attribute names are checked
2217− let (end, step) = match rest.split_once(" step ") {
5850+/// like code that doesn't run, under the prefixes `prefix` the `if()` is
2218− Some((end, step)) => (end.trim(), step.trim().parse::<i64>().unwrap_or(1).max(1)),
5851+/// under (`hover:if(...)`).
2219− None => (rest.trim(), 1),
5852+fn choose_attrs(
5853+ tokens: &[syntax::Attr],
5854+ prefix: &str,
5855+ line_num: usize,
5856+ ctx: &mut ParseContext,
5857+ validate: bool,
5858+ text_keys: &[String],
5859+ out: &mut Vec<syntax::Attr>,
5860+) {
5861+ for token in tokens {
5862+ let Some(choice) = &token.choice else {
5863+ out.push(token.clone());
5864+ continue;
5865+ };
5866+ let Some(condition) = choice_shape(token, choice, line_num, ctx) else {
5867+ continue;
2220 };
5868 };
2221− if let (Ok(start), Ok(end)) = (start.trim().parse::<i64>(), end.parse::<i64>()) {
5869+ let taken = match condition.span.line {
2222− return numeric_range(start, end, step);
5870+ 0 => ctx.condition(&condition.raw, line_num, None),
5871+ line => ctx.condition(&condition.raw, line, Some(condition.span.column)),
5872+ };
5873+ let (chosen, other) = match taken {
5874+ true => (choice.branches.first(), choice.branches.get(1)),
5875+ false => (choice.branches.get(1), choice.branches.first()),
5876+ };
5877+ if let Some(other) = other {
5878+ track_var_refs(&branch_text(other), &mut ctx.used_variables);
5879+ if validate {
5880+ check_branch(token, other, prefix, line_num, ctx, text_keys);
5881+ }
5882+ }
5883+ if let Some(branch) = chosen {
5884+ choose_attrs(
5885+ branch.attrs(),
5886+ prefix,
5887+ line_num,
5888+ ctx,
5889+ validate,
5890+ text_keys,
5891+ out,
5892+ );
2223 }
5893 }
2224 }
5894 }
2225− list.split(',')
2226− .map(|s| s.trim().to_string())
2227− .filter(|s| !s.is_empty())
2228− .collect()
2229}
5895}
2230
5896
2231−/// Make `$target` (and `$target.key`, and nested lists) a copy of the list
5897+/// Report what is wrong with the shape of `if()`: an empty condition, a
2232−/// item stored under `source`.
5898+/// number of branches other than one or two, text after a group's `]`.
2233−fn bind_item(vars: &mut HashMap<String, String>, source: &str, target: &str) {
5899+/// Returns the condition when the `if()` can be evaluated.
2234− let under = |key: &str, name: &str| {
5900+fn choice_shape<'a>(
2235− key.strip_prefix(name)
5901+ token: &syntax::Attr,
2236− .is_some_and(|rest| rest.is_empty() || rest.starts_with(['.', '#']))
5902+ choice: &'a syntax::Choice,
5903+ line_num: usize,
5904+ ctx: &mut ParseContext,
5905+) -> Option<&'a syntax::Arg> {
5906+ let line = if token.span.line == 0 {
5907+ line_num
5908+ } else {
5909+ token.span.line
2237 };
5910 };
2238− vars.retain(|key, _| !under(key, target));
5911+ // The line the column points into, when it is the one being evaluated
2239− let copies: Vec<(String, String)> = vars
5912+ let source = |ctx: &ParseContext, diagnostic: Diagnostic, at: usize| match &ctx.current_source {
2240− .iter()
5913+ (current, Some(text)) if *current == at => diagnostic.source(text.clone()),
2241− .filter(|(key, _)| under(key, source))
5914+ _ => diagnostic,
2242− .map(|(key, value)| (format!("{}{}", target, &key[source.len()..]), value.clone()))
5915+ };
2243− .collect();
5916+ for branch in &choice.branches {
2244− vars.extend(copies);
5917+ if let syntax::Branch::Group {
2245− // A record has no text of its own
5918+ trailing: Some(trailing),
2246− vars.entry(target.to_string()).or_default();
5919+ ..
2247−}
5920+ } = branch
2248−
5921+ {
2249−/// Inclusive integer range from `start` to `end` (counting down when
5922+ let diagnostic = source(
2250−/// `start > end`), stepping by `step`. Stops instead of overflowing.
5923+ ctx,
2251−fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
5924+ Diagnostic::error(
2252− let mut items = Vec::new();
5925+ code::UNEXPECTED_ARGUMENT,
2253− let mut n = start;
5926+ trailing.span.line,
2254− if start <= end {
5927+ format!(
2255− while n <= end {
5928+ "'{}' after the `]` of a group in if(): a branch is one attribute, \
2256− items.push(n.to_string());
5929+ one `[group]` of attributes or one `$bundle`",
2257− match n.checked_add(step) {
5930+ trailing.raw
2258− Some(next) => n = next,
5931+ ),
2259− None => break,
5932+ )
2260− }
5933+ .column(trailing.span.column)
5934+ .subject(trailing.raw.as_str()),
5935+ trailing.span.line,
5936+ );
5937+ ctx.push_once(diagnostic);
2261 }
5938 }
2262− } else {
5939+ }
2263− while n >= end {
5940+ match &choice.condition {
2264− items.push(n.to_string());
5941+ Some(condition) if (1..=2).contains(&choice.branches.len()) => Some(condition),
2265− match n.checked_sub(step) {
5942+ _ => {
2266− Some(next) => n = next,
5943+ let diagnostic = source(
2267− None => break,
5944+ ctx,
2268− }
5945+ Diagnostic::error(
5946+ code::INVALID_EXPRESSION,
5947+ line,
5948+ format!(
5949+ "`{}`: if() takes a condition and one or two branches, \
5950+ `if(CONDITION, A, B)`",
5951+ token.raw
5952+ ),
5953+ )
5954+ .column(token.span.column),
5955+ line,
5956+ );
5957+ ctx.push_once(diagnostic);
5958+ None
2269 }
5959 }
2270 }
5960 }
2271− items
2272}
5961}
2273
5962
2274−/// Evaluate `if(cond, a)` or `if(cond, a, b)` when it is all of `text`,
5963+/// A branch's text, for the names it uses.
2275−/// returning the chosen branch (empty when `cond` fails and there is no `b`).
5964+fn branch_text(branch: &syntax::Branch) -> String {
2276−fn choose_if(text: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
5965+ let raws: Vec<&str> = branch.attrs().iter().map(|a| a.raw.as_str()).collect();
2277− let inner = text.strip_prefix("if(")?.strip_suffix(')')?;
5966+ raws.join(", ")
2278− let args = split_if_args(inner);
2279− // `if(a) (b)` isn't one call
2280− if split_trailing_paren(inner) || !(2..=3).contains(&args.len()) {
2281− return None;
2282− }
2283− let branch = if ctx.condition(args[0].trim(), line) {
2284− args[1]
2285− } else {
2286− args.get(2).copied().unwrap_or("")
2287− };
2288− Some(branch.trim().to_string())
2289}
5967}
2290
5968
2291−/// Whether the parentheses in `inner` close before its end, as in the
5969+/// Check attributes that are never evaluated (in code that doesn't run,
2292−/// inside of `if(a)(b)`.
5970+/// or the branch an `if()` didn't take), as far as they can be checked:
2293−fn split_trailing_paren(inner: &str) -> bool {
5971+/// names are, and literal values; a value with a variable depends on what
2294− let mut depth = 0i32;
5972+/// the variable holds. Each branch of an `if()` is checked on its own.
2295− let mut quote = None;
5973+fn check_attrs(tokens: &[syntax::Attr], line: usize, ctx: &mut ParseContext, text_keys: &[String]) {
2296− for c in inner.chars() {
5974+ parse_attr_list(&literal_attrs(tokens), line, ctx, true, text_keys);
2297− match c {
5975+ check_choices(tokens, "", line, ctx, text_keys);
2298− '"' | '\'' if quote == Some(c) => quote = None,
2299− '"' | '\'' if quote.is_none() => quote = Some(c),
2300− _ if quote.is_some() => {}
2301− '(' => depth += 1,
2302− ')' => {
2303− depth -= 1;
2304− if depth < 0 {
2305− return true;
2306− }
2307− }
2308− _ => {}
2309− }
2310− }
2311− false
2312}
5976}
2313
5977
2314−/// Resolve a value written `if(cond, a, b)`: evaluate `cond` and keep the
5978+/// [`check_attrs`] for a branch of the `if()` `token`, under the prefixes
2315−/// chosen branch's text, or `None` when the choice is empty. Other
5979+/// `prefix` it is under: `hover:if($on, color red, [id=x])` checks
2316−/// attributes are returned unchanged.
5980+/// `hover:[id=x]`, an error whichever branch is taken.
2317−fn choose_if_value(part: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
5981+fn check_branch(
2318− let Some(pos) = part.find(['=', ' ']) else {
5982+ token: &syntax::Attr,
2319− return Some(part.to_string());
5983+ branch: &syntax::Branch,
2320− };
5984+ prefix: &str,
2321− let (head, value) = part.split_at(pos + 1);
5985+ line: usize,
2322− match choose_if(value.trim(), ctx, line) {
5986+ ctx: &mut ParseContext,
2323− Some(branch) if branch.is_empty() => None,
5987+ text_keys: &[String],
2324− Some(branch) => Some(format!("{}{}", head, branch)),
5988+) {
2325− None => Some(part.to_string()),
5989+ if prefix.is_empty() {
5990+ check_attrs(branch.attrs(), line, ctx, text_keys);
5991+ return;
2326 }
5992 }
5993+ let list = syntax::AttrList {
5994+ attrs: branch.attrs().to_vec(),
5995+ span: token.span,
5996+ closed: true,
5997+ };
5998+ let grouped = syntax::Attr {
5999+ key: prefix.to_string(),
6000+ value: None,
6001+ html: false,
6002+ raw: token.raw.clone(),
6003+ span: token.span,
6004+ choice: None,
6005+ prefixed: Some(Box::new(syntax::Prefixed {
6006+ prefix: prefix.to_string(),
6007+ prefix_span: token.span,
6008+ target: syntax::Branch::Group {
6009+ list,
6010+ trailing: None,
6011+ },
6012+ })),
6013+ };
6014+ check_attrs(std::slice::from_ref(&grouped), line, ctx, text_keys);
2327}
6015}
2328
6016
2329−fn split_if_args(input: &str) -> Vec<&str> {
6017+/// [`check_attrs`] for the branches of each `if()` of `tokens`, which are
2330− let mut parts = Vec::new();
6018+/// under the prefixes `prefix`, also inside a prefixed group or as a
2331− let mut start = 0;
6019+/// prefixed `if()`.
2332− let mut depth = 0;
6020+fn check_choices(
2333− let mut quote = None;
6021+ tokens: &[syntax::Attr],
2334− for (i, c) in input.char_indices() {
6022+ prefix: &str,
2335− match c {
6023+ line: usize,
2336− '"' | '\'' if quote == Some(c) => quote = None,
6024+ ctx: &mut ParseContext,
2337− '"' | '\'' if quote.is_none() => quote = Some(c),
6025+ text_keys: &[String],
2338− _ if quote.is_some() => {}
6026+) {
2339− '(' => depth += 1,
6027+ for token in tokens {
2340− ')' => depth -= 1,
6028+ if let Some(choice) = &token.choice
2341− ',' if depth == 0 => {
6029+ && choice_shape(token, choice, line, ctx).is_some()
2342− parts.push(&input[start..i]);
6030+ {
2343− start = i + 1;
6031+ for branch in &choice.branches {
6032+ check_branch(token, branch, prefix, line, ctx, text_keys);
2344 }
6033 }
2345− _ => {}
6034+ }
6035+ if let Some(prefixed) = &token.prefixed {
6036+ let inner = format!("{}{}", prefix, prefixed.prefix);
6037+ check_choices(prefixed.target.attrs(), &inner, line, ctx, text_keys);
2346 }
6038 }
2347 }
6039 }
2348− parts.push(&input[start..]);
2349− parts
2350}
6040}
2351
6041
2352// ---------------------------------------------------------------------------
6042// ---------------------------------------------------------------------------
2353// Post-parse validation (context-dependent warnings)
6043// Post-parse validation (context-dependent warnings)
2354// ---------------------------------------------------------------------------
6044// ---------------------------------------------------------------------------
2355
6045
2356−/// Attributes that only make sense on container elements (@row, @el, @el).
2357−const CONTAINER_ONLY_ATTRS: &[&str] = &[
2358− "spacing",
2359− "gap",
2360− "wrap",
2361− "grid-cols",
2362− "grid-rows",
2363− "container",
2364− "container-name",
2365− "container-type",
2366−];
2367−
2368fn element_kind_name(kind: &ElementKind) -> String {
6046fn element_kind_name(kind: &ElementKind) -> String {
2369 format!("@{}", kind.name())
6047 format!("@{}", kind.name())
2370}
6048}
2371
6049
2372−fn is_container(kind: &ElementKind) -> bool {
6050+/// HTML elements whose start tag ends an open `<p>`: written inside a
2373− matches!(kind, ElementKind::Row | ElementKind::El)
6051+/// `@paragraph`, the browser moves them out of it. (`div` isn't here:
2374− || kind.spec().is_some_and(|spec| spec.container)
6052+/// inside text, htmlang writes its `<div>`s as `<span>`s.)
6053+#[rustfmt::skip]
6054+const ENDS_A_PARAGRAPH: &[&str] = &[
6055+ "address", "article", "aside", "blockquote", "dd", "details", "dialog", "dl", "dt",
6056+ "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6",
6057+ "header", "hgroup", "hr", "li", "main", "menu", "nav", "ol", "p", "pre", "search",
6058+ "section", "summary", "table", "ul",
6059+];
6060+
6061+/// The HTML element `kind` is written as, if the browser would take it out
6062+/// of a `<p>`.
6063+fn ends_a_paragraph(kind: &ElementKind) -> Option<&'static str> {
6064+ let html = match kind {
6065+ ElementKind::Paragraph => "p",
6066+ ElementKind::Tag(spec) => spec.html,
6067+ _ => return None,
6068+ };
6069+ ENDS_A_PARAGRAPH.contains(&html).then_some(html)
2375}
6070}
2376
6071
2377/// Stricter checks for `htmlang lint`, on top of the diagnostics every
6072/// Stricter checks for `htmlang lint`, on top of the diagnostics every
@@ -2381,132 +6076,171 @@ pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
2381 fn walk(nodes: &[Node], depth: usize, out: &mut Vec<Diagnostic>) {
6076 fn walk(nodes: &[Node], depth: usize, out: &mut Vec<Diagnostic>) {
2382 for node in nodes {
6077 for node in nodes {
2383 let Node::Element(elem) = node else { continue };
6078 let Node::Element(elem) = node else { continue };
2384− let mut warn = |message: String| {
6079+ let start = out.len();
2385− out.push(Diagnostic {
6080+ let mut warn = |code: &'static str, message: String| {
2386− line: elem.line_num,
6081+ out.push(Diagnostic::warning(code, elem.line_num, message))
2387− column: None,
2388− message,
2389− severity: Severity::Warning,
2390− source_line: None,
2391− })
2392 };
6082 };
2393 if depth > 10 {
6083 if depth > 10 {
2394− warn(format!(
6084+ warn(
2395− "deeply nested element ({} levels): consider simplifying",
6085+ code::DEEP_NESTING,
2396− depth
6086+ format!(
2397− ));
6087+ "deeply nested element ({} levels): consider simplifying",
6088+ depth
6089+ ),
6090+ );
2398 }
6091 }
2399− if matches!(elem.kind, ElementKind::Row | ElementKind::El) && elem.children.is_empty() {
6092+ // A function's body may draw with an empty element (a spacer,
2400− warn(format!(
6093+ // a dot): only one written in the page is flagged
2401− "empty container (@{}) has no children",
6094+ if elem.kind.layout().is_container()
2402− elem.kind.name()
6095+ && elem.children.is_empty()
2403− ));
6096+ && elem.function.is_none()
6097+ {
6098+ warn(
6099+ code::EMPTY_CONTAINER,
6100+ format!("empty container (@{}) has no children", elem.kind.name()),
6101+ );
2404 }
6102 }
2405 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
6103 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
2406− warn("@button missing 'type' attribute (defaults to submit)".to_string());
6104+ warn(
6105+ code::MISSING_BUTTON_TYPE,
6106+ "@button missing 'type' attribute (defaults to submit)".to_string(),
6107+ );
2407 }
6108 }
6109+ in_function_body(elem, &mut out[start..]);
2408 walk(&elem.children, depth + 1, out);
6110 walk(&elem.children, depth + 1, out);
2409 }
6111 }
2410 }
6112 }
2411 let mut out = Vec::new();
6113 let mut out = Vec::new();
2412 walk(nodes, 0, &mut out);
6114 walk(nodes, 0, &mut out);
6115+ dedupe(&mut out);
2413 out
6116 out
2414}
6117}
2415
6118
6119+/// `in_paragraph`: the nodes are inside a `@paragraph` (and not inside a
6120+/// `@button` in it, which the browser keeps whole).
2416fn validate_tree(
6121fn validate_tree(
2417 nodes: &[Node],
6122 nodes: &[Node],
2418 parent_kind: Option<&ElementKind>,
6123 parent_kind: Option<&ElementKind>,
6124+ in_paragraph: bool,
2419 diagnostics: &mut Vec<Diagnostic>,
6125 diagnostics: &mut Vec<Diagnostic>,
2420) {
6126) {
2421 for node in nodes {
6127 for node in nodes {
6128+ // An element inside a line of text (`{@kbd ...}`, an inline call)
6129+ // is checked like one on a line of its own
6130+ if let Node::Text(segments) = node {
6131+ let inline: Vec<Node> = segments
6132+ .iter()
6133+ .filter_map(|segment| match segment {
6134+ TextSegment::Inline(elem) => Some(Node::Element(elem.clone())),
6135+ _ => None,
6136+ })
6137+ .collect();
6138+ validate_tree(&inline, parent_kind, in_paragraph, diagnostics);
6139+ }
2422 if let Node::Element(elem) = node {
6140 if let Node::Element(elem) = node {
6141+ let start = diagnostics.len();
6142+ dropped_by_the_element(elem, diagnostics);
6143+ if in_paragraph && let Some(html) = ends_a_paragraph(&elem.kind) {
6144+ diagnostics.push(
6145+ Diagnostic::warning(
6146+ code::BLOCK_IN_PARAGRAPH,
6147+ elem.line_num,
6148+ format!(
6149+ "{0} inside @paragraph: HTML ends a <p> before a <{1}>, so the \
6150+ browser moves the <{1}> out of the paragraph. Put {0} after the \
6151+ paragraph, or use @el, @row or @grid, which are laid out inline \
6152+ in text",
6153+ element_kind_name(&elem.kind),
6154+ html
6155+ ),
6156+ )
6157+ .subject(elem.kind.name()),
6158+ );
6159+ }
2423 for attr in &elem.attrs {
6160 for attr in &elem.attrs {
2424 let base = crate::vocab::base_attribute(&attr.key);
6161 let base = crate::vocab::base_attribute(&attr.key);
2425− if base == "width"
2426− && attr.value.as_deref() == Some("fill")
2427− && !matches!(parent_kind, Some(ElementKind::Row))
2428− {
2429− diagnostics.push(Diagnostic {
2430− line: elem.line_num,
2431− column: None,
2432− message: "'width fill' works best inside @row; using 100% as fallback"
2433− .to_string(),
2434− severity: Severity::Warning,
2435− source_line: None,
2436− });
2437− }
2438− if base == "height"
2439− && attr.value.as_deref() == Some("fill")
2440− && !parent_kind.is_some_and(ElementKind::is_column)
2441− {
2442− diagnostics.push(Diagnostic {
2443− line: elem.line_num,
2444− column: None,
2445− message: "'height fill' works best inside @el; using 100% as fallback"
2446− .to_string(),
2447− severity: Severity::Warning,
2448− source_line: None,
2449− });
2450− }
2451
6162
2452− // Container-only attributes on non-container elements
6163+ // htmlang's words for laying out children, on an element
2453− if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
6164+ // that doesn't lay out its children: left out (codegen)
2454− diagnostics.push(Diagnostic {
6165+ if !attr.html
2455− line: elem.line_num,
6166+ && crate::vocab::CONTAINER_ATTRIBUTES.contains(&base)
2456− column: None,
6167+ && !elem.kind.layout().is_container()
2457− message: format!(
6168+ && !has_no_element(&elem.kind)
2458− "'{}' has no effect on {} (only works on @row, @el, @el)",
6169+ && !crate::vocab::split_prefixes(&attr.key)
2459− base,
6170+ .0
2460− element_kind_name(&elem.kind)
6171+ .contains(&"children:")
2461− ),
6172+ {
2462− severity: Severity::Warning,
6173+ diagnostics.push(not_a_container(&elem.kind, elem.line_num, base));
2463− source_line: None,
2464− });
2465 }
6174 }
2466
6175
2467 // Form-specific: placeholder only on @input/@textarea
6176 // Form-specific: placeholder only on @input/@textarea
2468 if base == "placeholder"
6177 if base == "placeholder"
2469 && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
6178 && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
2470 {
6179 {
2471− diagnostics.push(Diagnostic {
6180+ diagnostics.push(Diagnostic::new(
2472− line: elem.line_num,
6181+ code::NO_EFFECT,
2473− column: None,
6182+ Severity::Warning,
2474− message: format!(
6183+ elem.line_num,
6184+ format!(
2475 "'placeholder' has no effect on {} (only works on @input, @textarea)",
6185 "'placeholder' has no effect on {} (only works on @input, @textarea)",
2476 element_kind_name(&elem.kind)
6186 element_kind_name(&elem.kind)
2477 ),
6187 ),
2478− severity: Severity::Warning,
6188+ ));
2479− source_line: None,
2480− });
2481 }
6189 }
2482
6190
2483− // 'for' only on @label
6191+ // 'for' only on @label and @output
2484− if base == "for" && !elem.kind.is_tag("label") {
6192+ if base == "for" && !(elem.kind.is_tag("label") || elem.kind.is_tag("output")) {
2485− diagnostics.push(Diagnostic {
6193+ diagnostics.push(Diagnostic::new(
2486− line: elem.line_num,
6194+ code::NO_EFFECT,
2487− column: None,
6195+ Severity::Warning,
2488− message: format!(
6196+ elem.line_num,
2489− "'for' has no effect on {} (only works on @label)",
6197+ format!(
6198+ "'for' has no effect on {} (only works on @label and @output)",
2490 element_kind_name(&elem.kind)
6199 element_kind_name(&elem.kind)
2491 ),
6200 ),
2492− severity: Severity::Warning,
6201+ ));
2493− source_line: None,
6202+ }
2494− });
6203+
6204+ // `width=` and `height=` are HTML attributes of media and
6205+ // embedded content only; elsewhere they are styles
6206+ if attr.html
6207+ && (base == "width" || base == "height")
6208+ && !matches!(elem.kind, ElementKind::Image)
6209+ && ![
6210+ "video", "canvas", "iframe", "embed", "object", "input", "source",
6211+ ]
6212+ .iter()
6213+ .any(|tag| elem.kind.is_tag(tag))
6214+ {
6215+ let value = attr.value.as_deref().unwrap_or("");
6216+ diagnostics.push(
6217+ Diagnostic::warning(
6218+ code::HTML_ATTRIBUTE_FORM,
6219+ elem.line_num,
6220+ format!(
6221+ "'{0}={1}' has no effect on {2}, which has no {0} attribute: \
6222+ the style is written `{0} {1}`",
6223+ base,
6224+ value,
6225+ element_kind_name(&elem.kind)
6226+ ),
6227+ )
6228+ .subject(format!("{}={}", base, value)),
6229+ );
2495 }
6230 }
2496
6231
2497 // 'rows'/'cols' only on @textarea
6232 // 'rows'/'cols' only on @textarea
2498 if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea") {
6233 if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea") {
2499− diagnostics.push(Diagnostic {
6234+ diagnostics.push(Diagnostic::new(
2500− line: elem.line_num,
6235+ code::NO_EFFECT,
2501− column: None,
6236+ Severity::Warning,
2502− message: format!(
6237+ elem.line_num,
6238+ format!(
2503 "'{}' has no effect on {} (only works on @textarea)",
6239 "'{}' has no effect on {} (only works on @textarea)",
2504 base,
6240 base,
2505 element_kind_name(&elem.kind)
6241 element_kind_name(&elem.kind)
2506 ),
6242 ),
2507− severity: Severity::Warning,
6243+ ));
2508− source_line: None,
2509− });
2510 }
6244 }
2511
6245
2512 // Media-specific attributes only on @video/@audio
6246 // Media-specific attributes only on @video/@audio
@@ -2521,38 +6255,35 @@ fn validate_tree(
2521 | "preload"
6255 | "preload"
2522 ) && !matches!(elem.kind.name(), "video" | "audio")
6256 ) && !matches!(elem.kind.name(), "video" | "audio")
2523 {
6257 {
2524− diagnostics.push(Diagnostic {
6258+ diagnostics.push(Diagnostic::new(
2525− line: elem.line_num,
6259+ code::NO_EFFECT,
2526− column: None,
6260+ Severity::Warning,
2527− message: format!(
6261+ elem.line_num,
6262+ format!(
2528 "'{}' has no effect on {} (only works on @video, @audio)",
6263 "'{}' has no effect on {} (only works on @video, @audio)",
2529 base,
6264 base,
2530 element_kind_name(&elem.kind)
6265 element_kind_name(&elem.kind)
2531 ),
6266 ),
2532− severity: Severity::Warning,
6267+ ));
2533− source_line: None,
2534− });
2535 }
6268 }
2536 }
6269 }
2537 // Missing alt text on @image
6270 // Missing alt text on @image
2538 if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt")
6271 if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt")
2539 {
6272 {
2540− diagnostics.push(Diagnostic {
6273+ diagnostics.push(Diagnostic::new(
2541− line: elem.line_num,
6274+ code::MISSING_ALT,
2542− column: None,
6275+ Severity::Warning,
2543− message: "@image missing 'alt' attribute (accessibility)".to_string(),
6276+ elem.line_num,
2544− severity: Severity::Warning,
6277+ "@image missing 'alt' attribute (accessibility)".to_string(),
2545− source_line: None,
6278+ ));
2546− });
2547 }
6279 }
2548 if elem.kind.is_tag("input") && !elem.attrs.iter().any(|a| a.key == "type") {
6280 if elem.kind.is_tag("input") && !elem.attrs.iter().any(|a| a.key == "type") {
2549− diagnostics.push(Diagnostic {
6281+ diagnostics.push(Diagnostic::new(
2550− line: elem.line_num,
6282+ code::MISSING_INPUT_TYPE,
2551− column: None,
6283+ Severity::Warning,
2552− message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
6284+ elem.line_num,
2553− severity: Severity::Warning,
6285+ "@input missing 'type' attribute (defaults to 'text')".to_string(),
2554− source_line: None,
6286+ ));
2555− });
2556 }
6287 }
2557 if matches!(elem.kind, ElementKind::Link) {
6288 if matches!(elem.kind, ElementKind::Link) {
2558 // For @link, argument is the URL, not text content
6289 // For @link, argument is the URL, not text content
@@ -2563,47 +6294,50 @@ fn validate_tree(
2563 .iter()
6294 .iter()
2564 .any(|a| a.key == "aria-label" || a.key == "title")
6295 .any(|a| a.key == "aria-label" || a.key == "title")
2565 {
6296 {
2566− diagnostics.push(Diagnostic {
6297+ diagnostics.push(Diagnostic::new(
2567− line: elem.line_num,
6298+ code::MISSING_LINK_TEXT,
2568− column: None,
6299+ Severity::Warning,
2569− message: "@link has no visible text or aria-label (accessibility)"
6300+ elem.line_num,
2570− .to_string(),
6301+ "@link has no visible text or aria-label (accessibility)".to_string(),
2571− severity: Severity::Warning,
6302+ ));
2572− source_line: None,
2573− });
2574 }
6303 }
2575 }
6304 }
2576
6305
2577− // Contrast ratio check for hex color pairs
6306+ // Contrast ratio check for hex color pairs, each pair on the
2578− {
6307+ // same elements: the element's own, or those an element prefix
2579− let bg_color = elem
6308+ // (`@td:`) picks out inside it
2580− .attrs
6309+ let inside = |key: &str| {
2581− .iter()
6310+ crate::vocab::split_prefixes(key)
2582− .find(|a| crate::vocab::base_attribute(&a.key) == "background")
6311+ .0
2583− .and_then(|a| a.value.as_deref());
6312+ .into_iter()
2584− let fg_color = elem
6313+ .find_map(crate::vocab::element_prefix)
2585− .attrs
6314+ .map(str::to_string)
2586− .iter()
6315+ };
2587− .find(|a| crate::vocab::base_attribute(&a.key) == "color")
6316+ let mut targets: Vec<Option<String>> = Vec::new();
2588− .and_then(|a| a.value.as_deref());
6317+ for attr in elem.attrs.iter().filter(|a| !a.html) {
6318+ let target = inside(&attr.key);
6319+ if !targets.contains(&target) {
6320+ targets.push(target);
6321+ }
6322+ }
6323+ for target in targets {
6324+ let color = |property: &str| {
6325+ elem.attrs
6326+ .iter()
6327+ .filter(|a| !a.html && inside(&a.key) == target)
6328+ .find(|a| crate::vocab::base_attribute(&a.key) == property)
6329+ .and_then(|a| a.value.as_deref())
6330+ };
6331+ let (bg_color, fg_color) = (color("background"), color("color"));
2589 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
6332 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
2590− && let (Some(bg_rgb), Some(fg_rgb)) = (
6333+ && let (Some(bg_rgb), Some(fg_rgb)) = (parse_hex_rgb(bg), parse_hex_rgb(fg))
2591− crate::expr::parse_hex_rgb(bg),
2592− crate::expr::parse_hex_rgb(fg),
2593− )
2594 {
6334 {
2595 let ratio = contrast_ratio(bg_rgb, fg_rgb);
6335 let ratio = contrast_ratio(bg_rgb, fg_rgb);
2596 if ratio < 4.5 {
6336 if ratio < 4.5 {
2597− diagnostics.push(Diagnostic {
6337+ diagnostics.push(Diagnostic::new(code::LOW_CONTRAST, Severity::Warning, elem.line_num, format!(
2598− line: elem.line_num,
2599− column: None,
2600− message: format!(
2601 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
6338 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
2602 ratio, fg, bg
6339 ratio, fg, bg
2603− ),
6340+ )));
2604− severity: Severity::Warning,
2605− source_line: None,
2606− });
2607 }
6341 }
2608 }
6342 }
2609 }
6343 }
@@ -2618,43 +6352,34 @@ fn validate_tree(
2618 let has_title = elem.attrs.iter().any(|a| a.key == "title");
6352 let has_title = elem.attrs.iter().any(|a| a.key == "title");
2619 let in_label = parent_kind.is_some_and(|k| k.is_tag("label"));
6353 let in_label = parent_kind.is_some_and(|k| k.is_tag("label"));
2620 if !has_id && !has_aria_label && !has_title && !in_label {
6354 if !has_id && !has_aria_label && !has_title && !in_label {
2621− diagnostics.push(Diagnostic {
6355+ diagnostics.push(Diagnostic::new(code::MISSING_LABEL, Severity::Warning, elem.line_num, format!(
2622− line: elem.line_num,
2623− column: None,
2624− message: format!(
2625 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
6356 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
2626 element_kind_name(&elem.kind)
6357 element_kind_name(&elem.kind)
2627− ),
6358+ )));
2628− severity: Severity::Warning,
2629− source_line: None,
2630− });
2631 }
6359 }
2632 }
6360 }
2633
6361
2634 // @iframe should have title attribute
6362 // @iframe should have title attribute
2635 if elem.kind.is_tag("iframe") && !elem.attrs.iter().any(|a| a.key == "title") {
6363 if elem.kind.is_tag("iframe") && !elem.attrs.iter().any(|a| a.key == "title") {
2636− diagnostics.push(Diagnostic {
6364+ diagnostics.push(Diagnostic::new(
2637− line: elem.line_num,
6365+ code::MISSING_TITLE,
2638− column: None,
6366+ Severity::Warning,
2639− message: "@iframe missing 'title' attribute (accessibility)".to_string(),
6367+ elem.line_num,
2640− severity: Severity::Warning,
6368+ "@iframe missing 'title' attribute (accessibility)".to_string(),
2641− source_line: None,
6369+ ));
2642− });
2643 }
6370 }
2644
6371
2645 // @button should have accessible text
6372 // @button should have accessible text
2646 if elem.kind.is_tag("button") {
6373 if elem.kind.is_tag("button") {
2647− let has_text = elem.argument.is_some() || !elem.children.is_empty();
6374+ let has_text = !elem.children.is_empty();
2648 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
6375 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
2649 if !has_text && !has_aria {
6376 if !has_text && !has_aria {
2650− diagnostics.push(Diagnostic {
6377+ diagnostics.push(Diagnostic::new(
2651− line: elem.line_num,
6378+ code::MISSING_BUTTON_TEXT,
2652− column: None,
6379+ Severity::Warning,
2653− message: "@button has no text content or aria-label (accessibility)"
6380+ elem.line_num,
2654− .to_string(),
6381+ "@button has no text content or aria-label (accessibility)".to_string(),
2655− severity: Severity::Warning,
6382+ ));
2656− source_line: None,
2657− });
2658 }
6383 }
2659 }
6384 }
2660
6385
@@ -2667,16 +6392,15 @@ fn validate_tree(
2667 let has_track = elem
6392 let has_track = elem
2668 .children
6393 .children
2669 .iter()
6394 .iter()
2670− .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("source")));
6395+ .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("track")));
2671 if !has_aria && !has_track {
6396 if !has_aria && !has_track {
2672− diagnostics.push(Diagnostic {
6397+ diagnostics.push(Diagnostic::new(
2673− line: elem.line_num,
6398+ code::MISSING_CAPTIONS,
2674− column: None,
6399+ Severity::Warning,
2675− message: "@video should have aria-label or captions for accessibility"
6400+ elem.line_num,
6401+ "@video should have aria-label or a captions @track for accessibility"
2676 .to_string(),
6402 .to_string(),
2677− severity: Severity::Warning,
6403+ ));
2678− source_line: None,
2679− });
2680 }
6404 }
2681 }
6405 }
2682
6406
@@ -2686,20 +6410,204 @@ fn validate_tree(
2686 && let Ok(n) = val.parse::<i32>()
6410 && let Ok(n) = val.parse::<i32>()
2687 && n > 0
6411 && n > 0
2688 {
6412 {
2689− diagnostics.push(Diagnostic {
6413+ 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)));
2690− line: elem.line_num,
2691− column: None,
2692− message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n),
2693− severity: Severity::Warning,
2694− source_line: None,
2695− });
2696 }
6414 }
6415+ in_function_body(elem, &mut diagnostics[start..]);
6416+
6417+ // An element that ends the <p> takes what is inside it out of
6418+ // the paragraph too, so only it is reported
6419+ let in_paragraph = elem.kind == ElementKind::Paragraph
6420+ || (in_paragraph
6421+ && ends_a_paragraph(&elem.kind).is_none()
6422+ && !elem.kind.is_tag("button"));
6423+ validate_tree(&elem.children, Some(&elem.kind), in_paragraph, diagnostics);
6424+ }
6425+ }
6426+}
6427+
6428+/// What an element has no place for in HTML, which would otherwise be
6429+/// left out of the page: attributes on `@fragment` (which has no element
6430+/// of its own), styles on `@script` (which isn't shown), a body under a
6431+/// `@script` that has a src (which the browser doesn't run), an element
6432+/// under a `@script` (whose body is code), and content in a void element
6433+/// such as `@input`.
6434+fn dropped_by_the_element(elem: &Element, diagnostics: &mut Vec<Diagnostic>) {
6435+ let name = elem.kind.name();
6436+ let keys = |attrs: Vec<&Attribute>| {
6437+ attrs
6438+ .iter()
6439+ .map(|a| format!("'{}'", a.key))
6440+ .collect::<Vec<_>>()
6441+ .join(", ")
6442+ };
6443+ let mut error = |code: &'static str, message: String| {
6444+ diagnostics.push(Diagnostic::error(code, elem.line_num, message).subject(name))
6445+ };
6446+ if elem.kind == ElementKind::Fragment && !elem.attrs.is_empty() {
6447+ error(
6448+ code::UNEXPECTED_ARGUMENT,
6449+ format!(
6450+ "@fragment has no element of its own to put attributes on, so {} would go \
6451+ nowhere: put them on its children, or use @el",
6452+ keys(elem.attrs.iter().collect())
6453+ ),
6454+ );
6455+ }
6456+ // The leading argument given twice: an attribute passed to the root
6457+ // of a function whose body gives the argument (written on one line,
6458+ // it is reported where it is read, and the attribute wins there)
6459+ if let Some(argument) = &elem.argument
6460+ && let Some(attr) = elem
6461+ .attrs
6462+ .iter()
6463+ .find(|a| a.html && elem.kind.arg().attributes().contains(&a.key.as_str()))
6464+ {
6465+ error(
6466+ code::DUPLICATE_ATTRIBUTE,
6467+ format!(
6468+ "@{} takes '{}' as its {}, and it also has {}={}: give it once",
6469+ name,
6470+ argument,
6471+ leading_name(&elem.kind),
6472+ attr.key,
6473+ attr.value.as_deref().unwrap_or("")
6474+ ),
6475+ );
6476+ }
6477+ if elem.kind.is_verbatim() {
6478+ let styles: Vec<&Attribute> = elem
6479+ .attrs
6480+ .iter()
6481+ .filter(|a| !a.html && !crate::vocab::BOOLEAN_HTML_ATTRS.contains(&a.key.as_str()))
6482+ .collect();
6483+ if !styles.is_empty() {
6484+ error(
6485+ code::UNEXPECTED_ARGUMENT,
6486+ format!(
6487+ "@{} isn't shown on the page, so its styles {} would go nowhere",
6488+ name,
6489+ keys(styles)
6490+ ),
6491+ );
6492+ }
6493+ let src = elem.argument.clone().or_else(|| {
6494+ elem.attrs
6495+ .iter()
6496+ .find(|a| a.html && a.key == "src")
6497+ .map(|a| a.value.clone().unwrap_or_default())
6498+ });
6499+ if let Some(src) = src
6500+ && !elem.children.is_empty()
6501+ {
6502+ error(
6503+ code::UNEXPECTED_CONTENT,
6504+ format!(
6505+ "@{0} has both a src ('{1}') and a body: the browser runs only the file, \
6506+ so the body would go nowhere. Put the code in {1}, or leave out the src",
6507+ name, src
6508+ ),
6509+ );
6510+ } else if let Some(Node::Element(child)) = elem
6511+ .children
6512+ .iter()
6513+ .find(|child| matches!(child, Node::Element(_)))
6514+ {
6515+ // `@script > @b x`: its body is code, not elements
6516+ error(
6517+ code::UNEXPECTED_CONTENT,
6518+ format!(
6519+ "@{} holds its code as the lines under it, kept as written, so @{} would \
6520+ go nowhere",
6521+ name,
6522+ child.kind.name()
6523+ ),
6524+ );
6525+ }
6526+ }
6527+ if elem.kind != ElementKind::Image && elem.attrs.iter().any(|a| !a.html && a.key == "inline") {
6528+ error(
6529+ code::UNEXPECTED_ARGUMENT,
6530+ format!(
6531+ "`inline` puts an image's file into the page, and @{} has none: it only \
6532+ works on @image",
6533+ name
6534+ ),
6535+ );
6536+ }
6537+ if elem.kind.layout() == Layout::Void && !elem.children.is_empty() {
6538+ error(
6539+ code::UNEXPECTED_CONTENT,
6540+ format!(
6541+ "@{} takes no content: it is an element without a closing tag, so its text \
6542+ and indented lines would go nowhere",
6543+ name
6544+ ),
6545+ );
6546+ }
6547+}
6548+
6549+/// `@fragment`, `@script`, `@children` and `@slot`, whose attributes are
6550+/// reported on their own (see `dropped_by_the_element`).
6551+fn has_no_element(kind: &ElementKind) -> bool {
6552+ matches!(
6553+ kind,
6554+ ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_)
6555+ ) || kind.is_verbatim()
6556+}
2697
6557
2698− validate_tree(&elem.children, Some(&elem.kind), diagnostics);
6558+/// htmlang's word `word` for laying out children (`spacing`, `wrap`,
6559+/// `grid-cols`, `grid-rows`) on an element that doesn't lay out its
6560+/// children: an error, and the word is left out.
6561+fn not_a_container(kind: &ElementKind, line: usize, word: &str) -> Diagnostic {
6562+ let name = element_kind_name(kind);
6563+ let (what, instead) = match kind.layout() {
6564+ Layout::Text => (
6565+ "is text, whose lines and children flow together with no gap between them",
6566+ "; to space the children out, put them in an @el or @row",
6567+ ),
6568+ Layout::Void => ("has no content to lay out", ""),
6569+ _ => (
6570+ "keeps HTML's own layout",
6571+ "; for a flex layout of your own, write CSS, such as `display flex, gap 8`",
6572+ ),
6573+ };
6574+ Diagnostic::error(
6575+ code::NO_EFFECT,
6576+ line,
6577+ format!(
6578+ "'{0}' can't go on {1}: {1} {2}. `{0}` works on a row, column or grid \
6579+ (@el, @row, @grid, @section, ...){3}",
6580+ word, name, what, instead
6581+ ),
6582+ )
6583+ .subject(word)
6584+}
6585+
6586+/// Diagnostics about an element a function's body wrote are reported at
6587+/// the call (see `belongs_to_call`); say which body the element is in.
6588+fn in_function_body(elem: &Element, diagnostics: &mut [Diagnostic]) {
6589+ if let Some(function) = &elem.function {
6590+ for d in diagnostics {
6591+ d.message = format!("{}\n in the body of @{}", d.message, function);
2699 }
6592 }
2700 }
6593 }
2701}
6594}
2702
6595
6596+/// Report each diagnostic once: a line that runs many times (in a loop, in
6597+/// a function called from one place many times) reports its problems once.
6598+fn dedupe(diagnostics: &mut Vec<Diagnostic>) {
6599+ let mut seen = HashSet::new();
6600+ diagnostics.retain(|d| {
6601+ seen.insert((
6602+ d.code,
6603+ d.line,
6604+ d.column,
6605+ d.message.clone(),
6606+ d.severity as u8,
6607+ ))
6608+ });
6609+}
6610+
2703fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
6611fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
2704 fn linearize(c: u8) -> f64 {
6612 fn linearize(c: u8) -> f64 {
2705 let s = c as f64 / 255.0;
6613 let s = c as f64 / 255.0;
@@ -2712,6 +6620,24 @@ fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
2712 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
6620 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
2713}
6621}
2714
6622
6623+/// The red, green and blue of a `#rgb` or `#rrggbb` colour.
6624+fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
6625+ let s = s.strip_prefix('#')?;
6626+ if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
6627+ return None;
6628+ }
6629+ let channel = |i: usize, len: usize| u8::from_str_radix(&s[i..i + len], 16).ok();
6630+ match s.len() {
6631+ 3 => Some((
6632+ channel(0, 1)? * 17,
6633+ channel(1, 1)? * 17,
6634+ channel(2, 1)? * 17,
6635+ )),
6636+ 6 => Some((channel(0, 2)?, channel(2, 2)?, channel(4, 2)?)),
6637+ _ => None,
6638+ }
6639+}
6640+
2715fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
6641fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
2716 let l1 = relative_luminance(c1.0, c1.1, c1.2);
6642 let l1 = relative_luminance(c1.0, c1.1, c1.2);
2717 let l2 = relative_luminance(c2.0, c2.1, c2.2);
6643 let l2 = relative_luminance(c2.0, c2.1, c2.2);
@@ -2719,70 +6645,48 @@ fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
2719 (lighter + 0.05) / (darker + 0.05)
6645 (lighter + 0.05) / (darker + 0.05)
2720}
6646}
2721
6647
2722−// ---------------------------------------------------------------------------
2723−// Variable substitution
2724−// ---------------------------------------------------------------------------
2725−
2726−/// Interpolate `$name` variables and `${expr}` expressions into text.
2727−/// Undefined variables, and expressions that don't evaluate, are left as
2728−/// written so they show up in the output.
2729−fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
2730− if !input.contains('$') {
2731− return input.to_string();
2732− }
2733− let is_name_char = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
2734− let mut result = String::with_capacity(input.len());
2735− let mut rest = input;
2736− while let Some(pos) = rest.find('$') {
2737− result.push_str(&rest[..pos]);
2738− let after = &rest[pos + 1..];
2739− if after.starts_with('{')
2740− && let Some(close) = matching_brace(after)
2741− {
2742− let source = &after[1..close];
2743− match crate::expr::eval(source, &|name: &str| lookup(vars, name)) {
2744− Ok(value) => result.push_str(&value.to_string()),
2745− Err(_) => result.push_str(&rest[pos..pos + 1 + close + 1]),
2746− }
2747− rest = &after[close + 1..];
2748− continue;
2749− }
2750− let mut end = after
2751− .find(|c: char| !is_name_char(c))
2752− .unwrap_or(after.len());
2753− while end > 0 && after[..end].ends_with('.') {
2754− end -= 1;
2755− }
2756− let name = &after[..end];
2757− match vars.get(name) {
2758− Some(value) if !name.is_empty() => result.push_str(value),
2759− _ => {
2760− result.push('$');
2761− result.push_str(name);
2762− }
2763− }
2764− rest = &after[end..];
2765− }
2766− result.push_str(rest);
2767− result
2768−}
2769−
2770/// `@image [inline] icon.svg` becomes the SVG's markup, with `width`,
6648/// `@image [inline] icon.svg` becomes the SVG's markup, with `width`,
2771−/// `height`, `color` / `fill`, `class=` and `id=` applied to the `<svg>`
6649+/// `height`, `color` / `fill` and the HTML attributes (`class=`, `id=`,
2772−/// tag. Other nodes are returned unchanged.
6650+/// `aria-label=`, ...) applied to the `<svg>` tag; any other attribute is
6651+/// an error. Other nodes are returned unchanged.
2773fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
6652fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
2774 let Node::Element(elem) = &node else {
6653 let Node::Element(elem) = &node else {
2775 return node;
6654 return node;
2776 };
6655 };
2777− let is_inline_svg = elem.kind == ElementKind::Image
6656+ let inline = elem.kind == ElementKind::Image
2778 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
6657 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
2779− && elem
6658+ && elem.argument.as_deref().is_some_and(|src| !src.is_empty());
2780− .argument
6659+ if !inline {
2781− .as_deref()
2782− .is_some_and(|src| src.ends_with(".svg"));
2783− if !is_inline_svg {
2784 return node;
6660 return node;
2785 }
6661 }
6662+ // A raster image: its file, read relative to this one, as a `data:` URI
6663+ if !elem
6664+ .argument
6665+ .as_deref()
6666+ .is_some_and(|src| src.ends_with(".svg"))
6667+ {
6668+ let Node::Element(mut elem) = node else {
6669+ return node;
6670+ };
6671+ let filename = elem.argument.clone().unwrap_or_default();
6672+ let resolved = ctx.resolve(&filename);
6673+ match std::fs::read(&resolved) {
6674+ Ok(data) => {
6675+ elem.argument = Some(crate::codegen::data_uri(&filename, &data));
6676+ ctx.included_files.push(resolved);
6677+ }
6678+ Err(e) => ctx.diagnostics.push(Diagnostic::new(
6679+ code::UNREADABLE_FILE,
6680+ Severity::Error,
6681+ line_num,
6682+ format!(
6683+ "cannot load image '{}' to put it into the page: {}",
6684+ filename, e
6685+ ),
6686+ )),
6687+ }
6688+ return Node::Element(elem);
6689+ }
2786 let filename = elem.argument.clone().unwrap_or_default();
6690 let filename = elem.argument.clone().unwrap_or_default();
2787 let resolved = match &ctx.base_path {
6691 let resolved = match &ctx.base_path {
2788 Some(base) => base.join(&filename),
6692 Some(base) => base.join(&filename),
@@ -2791,81 +6695,104 @@ fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
2791 let mut svg = match std::fs::read_to_string(&resolved) {
6695 let mut svg = match std::fs::read_to_string(&resolved) {
2792 Ok(text) => text.trim().to_string(),
6696 Ok(text) => text.trim().to_string(),
2793 Err(e) => {
6697 Err(e) => {
2794− ctx.diagnostics.push(Diagnostic {
6698+ ctx.diagnostics.push(Diagnostic::new(
2795− line: line_num,
6699+ code::UNREADABLE_FILE,
2796− column: None,
6700+ Severity::Error,
2797− message: format!("cannot load SVG '{}': {}", filename, e),
6701+ line_num,
2798− severity: Severity::Error,
6702+ format!("cannot load SVG '{}': {}", filename, e),
2799− source_line: None,
6703+ ));
2800− });
2801 return node;
6704 return node;
2802 }
6705 }
2803 };
6706 };
2804 ctx.included_files.push(resolved);
6707 ctx.included_files.push(resolved);
6708+ // The file's markup has no class of its own, so a style reaches it only
6709+ // as one of the root's attributes: anything else is reported, never
6710+ // left out silently
6711+ let mut left_out: Vec<String> = Vec::new();
6712+ // The styles go in the root's `style=`, where a length takes its unit
6713+ // and `var(--name)` works, as in any CSS
6714+ let mut style: Vec<String> = Vec::new();
2805 for attr in &elem.attrs {
6715 for attr in &elem.attrs {
2806− let Some(value) = &attr.value else { continue };
6716+ let target = match (attr.key.as_str(), attr.html, &attr.value) {
2807− let target = match (attr.key.as_str(), attr.html) {
6717+ ("inline", false, None) | ("src", true, _) => continue,
2808− ("width", false) | ("height", false) => attr.key.as_str(),
6718+ // `fill` and `shrink` are layout words, which need a class
2809− ("color", false) | ("fill", false) => "fill",
6719+ ("width" | "height", false, Some(value))
2810− ("class", true) | ("id", true) => attr.key.as_str(),
6720+ if matches!(value.as_str(), "fill" | "shrink") =>
2811− _ => continue,
6721+ {
6722+ left_out.push(format!("{} {}", attr.key, value));
6723+ continue;
6724+ }
6725+ ("width" | "height", false, Some(value)) => {
6726+ let value = crate::vocab::with_px(&attr.key, value);
6727+ style.push(format!("{}:{}", attr.key, value));
6728+ continue;
6729+ }
6730+ ("color" | "fill", false, Some(value)) => {
6731+ style.push(format!("fill:{}", value));
6732+ continue;
6733+ }
6734+ ("style", true, Some(value)) => {
6735+ style.insert(0, value.trim().trim_end_matches(';').to_string());
6736+ continue;
6737+ }
6738+ ("alt", true, _) => {
6739+ left_out.push("alt".to_string());
6740+ continue;
6741+ }
6742+ (key, true, Some(_)) => key,
6743+ (key, false, None) if crate::vocab::BOOLEAN_HTML_ATTRS.contains(&key) => key,
6744+ _ => {
6745+ left_out.push(attr.key.clone());
6746+ continue;
6747+ }
2812 };
6748 };
2813− svg = set_svg_attr(&svg, target, value);
6749+ let value = attr.value.as_deref().unwrap_or("");
6750+ svg = set_svg_attr(&svg, target, &html_escape_md(value));
2814 }
6751 }
2815− Node::Raw(svg)
6752+ if !style.is_empty() {
2816−}
6753+ // After the file's own `style=`, which stays: these come later, so
2817−
6754+ // they win where both set a property
2818−/// Index of the `}` matching the `{` that `s` starts with (skipping
6755+ let mut value = html_escape_md(&style.join(";"));
2819−/// braces inside string literals).
6756+ if let Some(own) = svg_attr_value(&svg, "style")
2820−fn matching_brace(s: &str) -> Option<usize> {
6757+ .map(|range| svg[range].trim().trim_end_matches(';').to_string())
2821− let mut depth = 0;
6758+ .filter(|own| !own.is_empty())
2822− let mut in_string = false;
6759+ {
2823− for (i, c) in s.char_indices() {
6760+ value = format!("{};{}", own, value);
2824− match c {
2825− '"' => in_string = !in_string,
2826− _ if in_string => {}
2827− '{' => depth += 1,
2828− '}' => {
2829− depth -= 1;
2830− if depth == 0 {
2831− return Some(i);
2832− }
2833− }
2834− _ => {}
2835 }
6761 }
6762+ svg = set_svg_attr(&svg, "style", &value);
2836 }
6763 }
2837− None
6764+ if !left_out.is_empty() {
6765+ let alt = left_out.iter().any(|name| name == "alt");
6766+ let names = left_out
6767+ .iter()
6768+ .map(|name| format!("'{}'", name))
6769+ .collect::<Vec<_>>()
6770+ .join(", ");
6771+ let mut message = format!(
6772+ "{} 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=`, ...)",
6773+ names
6774+ );
6775+ if alt {
6776+ message.push_str("; an SVG has no alt text: name it with `aria-label=` and `role=img`");
6777+ }
6778+ ctx.diagnostics.push(Diagnostic::new(
6779+ code::UNEXPECTED_ARGUMENT,
6780+ Severity::Error,
6781+ line_num,
6782+ message,
6783+ ));
6784+ }
6785+ Node::Raw(svg)
2838}
6786}
2839
6787
2840// ---------------------------------------------------------------------------
6788// ---------------------------------------------------------------------------
2841// Variable usage tracking
6789// Variable usage tracking
2842// ---------------------------------------------------------------------------
6790// ---------------------------------------------------------------------------
2843
6791
2844−/// Scan a string for $name references and record them in the used set.
6792+/// Record the names a string refers to (by syntax) as used.
2845fn track_var_refs(input: &str, used: &mut HashSet<String>) {
6793fn track_var_refs(input: &str, used: &mut HashSet<String>) {
2846− if !input.contains('$') {
6794+ if input.contains('$') {
2847− return;
6795+ used.extend(interp::names(input).into_iter().map(String::from));
2848− }
2849− let chars: Vec<char> = input.chars().collect();
2850− let mut i = 0;
2851− while i < chars.len() {
2852− if chars[i] == '$'
2853− && i + 1 < chars.len()
2854− && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
2855− {
2856− let start = i + 1;
2857− let mut end = start;
2858− while end < chars.len()
2859− && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
2860− {
2861− end += 1;
2862− }
2863− let name: String = chars[start..end].iter().collect();
2864− used.insert(name);
2865− i = end;
2866− } else {
2867− i += 1;
2868− }
2869 }
6796 }
2870}
6797}
2871
6798
@@ -2873,49 +6800,63 @@ fn track_var_refs(input: &str, used: &mut HashSet<String>) {
2873// Unused definition warnings
6800// Unused definition warnings
2874// ---------------------------------------------------------------------------
6801// ---------------------------------------------------------------------------
2875
6802
2876−fn check_unused(ctx: &mut ParseContext) {
6803+/// Warn about definitions nothing uses, except `exported` ones (the
6804+/// definitions of a library, for the files that include it).
6805+fn check_unused(ctx: &mut ParseContext, exported: &HashSet<String>) {
2877 // Check unused @let variables
6806 // Check unused @let variables
2878 for (name, &line) in &ctx.let_lines {
6807 for (name, &line) in &ctx.let_lines {
2879− if name.starts_with("--") {
6808+ if exported.contains(name) {
2880− continue; // CSS vars are always used
6809+ // A library's names are for other files (a custom property
6810+ // isn't a variable, so it is never listed here)
6811+ continue;
2881 }
6812 }
2882 if !ctx.used_variables.contains(name) {
6813 if !ctx.used_variables.contains(name) {
2883− ctx.diagnostics.push(Diagnostic {
6814+ ctx.diagnostics.push(
2884− line,
6815+ Diagnostic::new(
2885− column: None,
6816+ code::UNUSED_VARIABLE,
2886− message: format!("unused variable '${}' (defined but never referenced)", name),
6817+ Severity::Warning,
2887− severity: Severity::Warning,
6818+ line,
2888− source_line: None,
6819+ format!("unused variable '${}' (defined but never referenced)", name),
2889− });
6820+ )
6821+ .subject(name.as_str()),
6822+ );
2890 }
6823 }
2891 }
6824 }
2892
6825
2893− // Check unused attribute bundles (@let name [...])
6826+ // Check unused attribute bundles (@let name [...]). A `$name` in code
6827+ // that doesn't run (an `if()` branch or `@if` not taken) counts too.
2894 for (name, &line) in &ctx.define_lines {
6828 for (name, &line) in &ctx.define_lines {
2895− if !ctx.used_defines.contains(name) {
6829+ if !ctx.used_defines.contains(name)
2896− ctx.diagnostics.push(Diagnostic {
6830+ && !ctx.used_variables.contains(name)
2897− line,
6831+ && !exported.contains(name)
2898− column: None,
6832+ {
2899− message: format!(
6833+ ctx.diagnostics.push(
2900− "unused attribute bundle '${}' (defined but never referenced)",
6834+ Diagnostic::new(
2901− name
6835+ code::UNUSED_BUNDLE,
2902− ),
6836+ Severity::Warning,
2903− severity: Severity::Warning,
6837+ line,
2904− source_line: None,
6838+ format!(
2905− });
6839+ "unused attribute bundle '${}' (defined but never referenced)",
6840+ name
6841+ ),
6842+ )
6843+ .subject(name.as_str()),
6844+ );
2906 }
6845 }
2907 }
6846 }
2908
6847
2909 // Check unused functions (@let name ... with body)
6848 // Check unused functions (@let name ... with body)
2910 for (name, &line) in &ctx.fn_lines {
6849 for (name, &line) in &ctx.fn_lines {
2911− if !ctx.used_functions.contains(name) {
6850+ if !ctx.used_functions.contains(name) && !exported.contains(name) {
2912− ctx.diagnostics.push(Diagnostic {
6851+ ctx.diagnostics.push(
2913− line,
6852+ Diagnostic::new(
2914− column: None,
6853+ code::UNUSED_FUNCTION,
2915− message: format!("unused function '@{}' (defined but never called)", name),
6854+ Severity::Warning,
2916− severity: Severity::Warning,
6855+ line,
2917− source_line: None,
6856+ format!("unused function '@{}' (defined but never called)", name),
2918− });
6857+ )
6858+ .subject(name.as_str()),
6859+ );
2919 }
6860 }
2920 }
6861 }
2921}
6862}
@@ -3001,34 +6942,6 @@ fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
3001 }
6942 }
3002}
6943}
3003
6944
3004−/// Visit the lines of a block in source order, children after their line.
3005−fn for_each_line<'a>(block: &'a [Syntax], f: &mut impl FnMut(&'a Syntax)) {
3006− for node in block {
3007− f(node);
3008− if let Syntax::Line { children, .. } = node {
3009− for_each_line(children, f);
3010− }
3011− }
3012−}
3013−
3014−/// The text of a block whose lines are content, not htmlang (`@head`,
3015−/// `@style`, `@markdown`): each line trimmed, verbatim text as is.
3016−fn block_text(block: &[Syntax]) -> String {
3017− let mut text = String::new();
3018− for_each_line(block, &mut |node| match node {
3019− Syntax::Line { text: line, .. } => {
3020− text.push_str(line.trim());
3021− text.push('\n');
3022− }
3023− Syntax::Raw { text: raw, .. } => {
3024− text.push_str(raw);
3025− text.push('\n');
3026− }
3027− _ => {}
3028− });
3029− text
3030−}
3031−
3032fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
6945fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
3033 pos = skip_ws(chars, pos);
6946 pos = skip_ws(chars, pos);
3034 if pos >= chars.len() {
6947 if pos >= chars.len() {
@@ -3209,75 +7122,21 @@ fn skip_ws(chars: &[char], mut pos: usize) -> usize {
3209 pos
7122 pos
3210}
7123}
3211
7124
3212−/// Flatten a JSON value into variable assignments.
7125+/// A JSON value as an htmlang value: an object is a record, an array a
3213−/// - Top-level object: each key becomes `prefix.key`
7126+/// list, a string text (never quoted text), `null` empty. A number keeps
3214−/// - Top-level array: `prefix` becomes comma-separated, `prefix._count` set
7127+/// the text it is written with (`1.50`), and reads as a number.
3215−/// - Array of objects: each item becomes space-separated values for @each destructuring
7128+fn json_value(json: JsonValue) -> Value {
3216−fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, String>) {
7129+ match json {
3217− match value {
7130+ JsonValue::Null => Value::empty(),
3218− JsonValue::Str(s) => {
7131+ JsonValue::Bool(b) => Value::Bool(b),
3219− vars.insert(prefix.to_string(), s.clone());
7132+ JsonValue::Number(n) | JsonValue::Str(n) => Value::Str(n),
3220− }
7133+ JsonValue::Array(items) => Value::list(items.into_iter().map(json_value).collect()),
3221− JsonValue::Number(n) => {
7134+ JsonValue::Object(pairs) => Value::Record(Rc::new(
3222− vars.insert(prefix.to_string(), n.clone());
7135+ pairs
3223− }
7136+ .into_iter()
3224− JsonValue::Bool(b) => {
7137+ .map(|(key, value)| (key, json_value(value)))
3225− vars.insert(prefix.to_string(), b.to_string());
3226− }
3227− JsonValue::Null => {
3228− vars.insert(prefix.to_string(), String::new());
3229− }
3230− JsonValue::Object(pairs) => {
3231− for (key, val) in pairs {
3232− flatten_json(&format!("{}.{}", prefix, key), val, vars);
3233− }
3234− }
3235− JsonValue::Array(items) => {
3236− // A list: its length under `NAME#` (see `lookup`), its text
3237− // items joined as its value, and each item as `NAME.INDEX`
3238− vars.insert(format!("{}#", prefix), items.len().to_string());
3239− let text: Vec<String> = items
3240− .iter()
3241− .map(json_value_to_string)
3242− .filter(|s| !s.is_empty())
3243− .collect();
3244− vars.insert(prefix.to_string(), text.join(", "));
3245− // Also set indexed access: prefix.0, prefix.1, etc.
3246− for (i, item) in items.iter().enumerate() {
3247− flatten_json(&format!("{}.{}", prefix, i), item, vars);
3248− }
3249− }
3250− }
3251−}
3252−
3253−/// Resolve `$name` for an expression: a list if `name` was loaded from a
3254−/// JSON array, else its text.
3255−fn lookup(vars: &HashMap<String, String>, name: &str) -> Option<crate::expr::Value> {
3256− use crate::expr::Value;
3257− match vars
3258− .get(&format!("{}#", name))
3259− .and_then(|n| n.parse::<usize>().ok())
3260− {
3261− Some(len) => Some(Value::List(
3262− (0..len)
3263− .map(|i| {
3264− vars.get(&format!("{}.{}", name, i))
3265− .cloned()
3266− .unwrap_or_default()
3267− })
3268 .collect(),
7138 .collect(),
3269 )),
7139 )),
3270− None => vars.get(name).cloned().map(Value::Str),
3271− }
3272−}
3273−
3274−fn json_value_to_string(v: &JsonValue) -> String {
3275− match v {
3276− JsonValue::Str(s) => s.clone(),
3277− JsonValue::Number(n) => n.clone(),
3278− JsonValue::Bool(b) => b.to_string(),
3279− JsonValue::Null => String::new(),
3280− JsonValue::Array(_) | JsonValue::Object(_) => String::new(),
3281 }
7140 }
3282}
7141}
3283
7142
@@ -3285,6 +7144,29 @@ fn json_value_to_string(v: &JsonValue) -> String {
3285// SVG attribute injection helper
7144// SVG attribute injection helper
3286// ---------------------------------------------------------------------------
7145// ---------------------------------------------------------------------------
3287
7146
7147+/// Where the root `<svg ...>` tag's `attr_name="..."` value is, as a byte
7148+/// range of `svg`. Only the opening tag is read, so a child's attribute is
7149+/// never found, and the name must follow whitespace, so `width` doesn't
7150+/// match `stroke-width`.
7151+fn svg_attr_value(svg: &str, attr_name: &str) -> Option<std::ops::Range<usize>> {
7152+ let tag_start = svg.find("<svg")?;
7153+ let tag_end = svg[tag_start..]
7154+ .find('>')
7155+ .map(|p| tag_start + p)
7156+ .unwrap_or(svg.len());
7157+ let tag = &svg[tag_start..tag_end];
7158+ let pattern = format!("{}=\"", attr_name);
7159+ let (pos, _) = tag.match_indices(&pattern).find(|(pos, _)| {
7160+ tag[..*pos]
7161+ .chars()
7162+ .next_back()
7163+ .is_some_and(char::is_whitespace)
7164+ })?;
7165+ let value_start = tag_start + pos + pattern.len();
7166+ let end = svg[value_start..tag_end].find('"')?;
7167+ Some(value_start..value_start + end)
7168+}
7169+
3288fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
7170fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
3289 // Only the opening <svg ...> tag is touched, so child attributes such as
7171 // Only the opening <svg ...> tag is touched, so child attributes such as
3290 // `stroke-width` are left alone.
7172 // `stroke-width` are left alone.
@@ -3295,26 +7177,14 @@ fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
3295 .find('>')
7177 .find('>')
3296 .map(|p| tag_start + p)
7178 .map(|p| tag_start + p)
3297 .unwrap_or(svg.len());
7179 .unwrap_or(svg.len());
3298− let tag = &svg[tag_start..tag_end];
3299
7180
3300− // Replace an existing attribute (must be preceded by whitespace so that
7181+ // Replace an existing attribute
3301− // `width` doesn't match `stroke-width`).
7182+ if let Some(existing) = svg_attr_value(svg, attr_name) {
3302− let pattern = format!("{}=\"", attr_name);
7183+ let mut result = String::with_capacity(svg.len());
3303− let existing = tag.match_indices(&pattern).find(|(pos, _)| {
7184+ result.push_str(&svg[..existing.start]);
3304− tag[..*pos]
7185+ result.push_str(value);
3305− .chars()
7186+ result.push_str(&svg[existing.end..]);
3306− .next_back()
7187+ return result;
3307− .is_some_and(char::is_whitespace)
3308− });
3309− if let Some((pos, _)) = existing {
3310− let value_start = tag_start + pos + pattern.len();
3311− if let Some(end) = svg[value_start..tag_end].find('"') {
3312− let mut result = String::with_capacity(svg.len());
3313− result.push_str(&svg[..value_start]);
3314− result.push_str(value);
3315− result.push_str(&svg[value_start + end..]);
3316− return result;
3317− }
3318 }
7188 }
3319
7189
3320 // Otherwise inject it into the opening tag (before a self-closing `/`).
7190 // Otherwise inject it into the opening tag (before a self-closing `/`).
@@ -3637,7 +7507,10 @@ mod tests {
3637 #[test]
7507 #[test]
3638 fn parses_page_directive() {
7508 fn parses_page_directive() {
3639 let r = parse_ok("@page My Title\n");
7509 let r = parse_ok("@page My Title\n");
3640− assert_eq!(r.document.page_title.as_deref(), Some("My Title"));
7510+ assert_eq!(
7511+ r.document.page.map(|p| p.title).as_deref(),
7512+ Some("My Title")
7513+ );
3641 }
7514 }
3642
7515
3643 #[test]
7516 #[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+2809−335
@@ -1,438 +1,2912 @@
1−//! Parsing: source text to a syntax tree, before anything is evaluated.
1+//! The syntax tree: source text read into lines, element heads, attribute
2+//! lists and text, before anything is evaluated.
2//!
3//!
3−//! This step knows the shape of the language: indentation, continuation
4+//! The tree is lossless enough to print the file back: comments and blank
4−//! lines of attribute lists, verbatim bodies (of `@raw`, `@style`, `@head`,
5+//! lines are nodes, every piece keeps its source [`Span`], and verbatim
5−//! `@script` and `@markdown`), and the directives whose
6+//! bodies keep their text. It reads no files and substitutes no variables.
6−//! structure spans several lines (`@if` / `@else` chains, `@each` with an
7+//! The evaluator (`parser.rs`), the formatter and the language server all
7−//! `@else`, function definitions). It reads no files and substitutes no
8+//! work on this one tree, and what a directive accepts comes from the
8−//! variables; `parser.rs` evaluates the tree.
9+//! [`DIRECTIVES`](crate::ast::DIRECTIVES) table.
10+//!
11+//! The grammar, line by line:
12+//! - A line whose first word is `--` (a `--` followed by a space or the end
13+//! of the line) is a comment; an empty line is blank. `--gap 12px` is not
14+//! a comment: on a line of an attribute list it is a custom property.
15+//! - A line starting with `@NAME` is a directive when `NAME` is in the
16+//! directive table, and an element (or function call) otherwise. An
17+//! element line is a chain of heads `@name [attributes]` joined by `>`,
18+//! then text. A `[` opens an attribute list only right after a head's
19+//! name, or where a directive's grammar has one (`@page [..]`,
20+//! `@let name [..]`, `@let @name [..]`); anywhere else it is text.
21+//! - What a `@let` defines is decided here, from its line: `@let @name` is
22+//! a function (its body is the indented block), `@let name [..]` an
23+//! attribute bundle and any other `@let name ...` a value.
24+//! - Any other line is text, in which `{@name [attributes] text}` is an
25+//! inline element.
26+//! - An attribute list (or `@data`'s inline JSON) may continue onto the
27+//! following lines until it closes.
28+//! - Lines indented deeper than a line are its body. What the body may be
29+//! is the directive's or element's [`BodyKind`].
30+
31+use crate::ast::{self, ArgGrammar, BodyKind, DirectiveSpec};
32+use crate::diagnostic::{Diagnostic, Severity, code};
9
33
10−use std::collections::HashMap;
34+/// A range of the source.
35+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36+pub struct Span {
37+ /// Byte offsets into the source.
38+ pub start: usize,
39+ pub end: usize,
40+ /// 1-based line and 0-based byte column of `start`.
41+ pub line: usize,
42+ pub column: usize,
43+}
11
44
45+/// A parsed file.
12#[derive(Clone, Debug)]
46#[derive(Clone, Debug)]
13−pub(crate) enum LineContent {
47+pub struct Tree {
14− Normal(String),
48+ pub nodes: Vec<Node>,
15− Raw(String),
49+ /// Syntax errors: they are reported for every line, whether or not it
50+ /// is ever evaluated.
51+ pub diagnostics: Vec<Diagnostic>,
52+ /// One past the largest node id.
53+ pub(crate) end_id: usize,
16}
54}
17
55
18−/// One logical source line (continuations joined), without its indented
56+/// One line of the file (with its continuation lines), and the lines
19−/// children.
57+/// indented under it.
20#[derive(Clone, Debug)]
58#[derive(Clone, Debug)]
21−pub(crate) struct Line {
59+pub struct Node {
60+ /// Unique within a parse, in source order.
61+ pub id: usize,
62+ /// Width of the leading whitespace of its first line, in bytes.
22 pub indent: usize,
63 pub indent: usize,
23− pub content: LineContent,
64+ /// The node's own line(s), without its children.
24− pub line_num: usize,
65+ pub span: Span,
66+ /// How many source lines `span` covers.
67+ pub line_count: usize,
68+ /// The node's own lines joined into one (continuation lines joined with
69+ /// a space), without surrounding whitespace.
70+ pub source: String,
71+ pub kind: NodeKind,
72+ /// The lines indented under it, trivia included.
73+ pub children: Vec<Node>,
74+}
75+
76+#[derive(Clone, Debug)]
77+pub enum NodeKind {
78+ /// An empty line.
79+ Blank,
80+ /// A `--` comment line; its text is the node's `source`.
81+ Comment,
82+ /// A line of text.
83+ Text(Text),
84+ /// `@name [attrs] > @name [attrs] text`: an element or a function call.
85+ Element(ElementLine),
86+ Directive(Directive),
87+ /// The body of a verbatim directive or element (`@style`, `@script`).
88+ Verbatim(Verbatim),
89+}
90+
91+/// Text: plain runs and inline elements.
92+#[derive(Clone, Debug)]
93+pub struct Text {
94+ pub raw: String,
95+ pub span: Span,
96+ pub segments: Vec<Segment>,
97+}
98+
99+#[derive(Clone, Debug)]
100+pub enum Segment {
101+ /// Text as written: escapes are still in it and `$name`s are not
102+ /// substituted.
103+ Plain { raw: String, span: Span },
104+ /// `{@name [attrs] text}`.
105+ Inline(Box<Inline>),
106+}
107+
108+#[derive(Clone, Debug)]
109+pub struct Inline {
110+ /// What is between the braces.
111+ pub raw: String,
112+ /// Including the braces.
113+ pub span: Span,
114+ pub head: Head,
115+ pub text: Option<Text>,
116+ /// Whether the closing `}` was found.
117+ pub closed: bool,
25}
118}
26
119
120+/// `@name [attributes]`.
27#[derive(Clone, Debug)]
121#[derive(Clone, Debug)]
28−pub(crate) enum Syntax {
122+pub struct Head {
29− /// Verbatim text.
123+ /// Without the `@`.
30− Raw { line: usize, text: String },
124+ pub name: String,
31− /// An element, text, a function call or a single-line directive, with
125+ /// Including the `@`.
32− /// the lines indented under it.
126+ pub name_span: Span,
33− Line {
127+ pub attrs: Option<AttrList>,
34− line: usize,
128+ pub span: Span,
35− indent: usize,
129+}
36− text: String,
130+
37− children: Vec<Syntax>,
131+/// `[attr, attr]`.
132+#[derive(Clone, Debug)]
133+pub struct AttrList {
134+ pub attrs: Vec<Attr>,
135+ /// Including the brackets.
136+ pub span: Span,
137+ /// Whether the closing `]` was found.
138+ pub closed: bool,
139+}
140+
141+/// One attribute of a list, as written.
142+#[derive(Clone, Debug)]
143+pub struct Attr {
144+ /// The attribute's text, trimmed.
145+ pub raw: String,
146+ pub key: String,
147+ pub value: Option<String>,
148+ /// Written `key=value`.
149+ pub html: bool,
150+ pub span: Span,
151+ /// The attribute is `if(CONDITION, A, B)` as a whole.
152+ pub choice: Option<Box<Choice>>,
153+ /// The attribute is prefixes applied to a `[group]`, a `$bundle` or an
154+ /// `if()`: `md:[padding 32, font-size 20]`, `dark:$card`. Its `key` is
155+ /// then the prefixes, and it has no value.
156+ pub prefixed: Option<Box<Prefixed>>,
157+}
158+
159+/// Prefixes applied to several attributes at once, which they distribute
160+/// over: `md:[padding 32, font-size 20]` is `md:padding 32, md:font-size 20`.
161+#[derive(Clone, Debug)]
162+pub struct Prefixed {
163+ /// The prefixes as written, each with its `:` (`md:hover:`).
164+ pub prefix: String,
165+ pub prefix_span: Span,
166+ /// What they apply to: a group, or one attribute that is a `$bundle`
167+ /// or an `if()`.
168+ pub target: Branch,
169+}
170+
171+/// A whole attribute `if(CONDITION, A, B)`: `A` or `B`, by the condition.
172+#[derive(Clone, Debug)]
173+pub struct Choice {
174+ /// The condition, an expression; `None` when it is empty.
175+ pub condition: Option<Arg>,
176+ /// The branches after the condition, as written: `A`, and `B` when
177+ /// there is one. Any other number is an error the evaluator reports.
178+ pub branches: Vec<Branch>,
179+}
180+
181+/// One branch of an [`if()`](Choice).
182+#[derive(Clone, Debug)]
183+pub enum Branch {
184+ /// Nothing: the attribute is left out.
185+ Empty,
186+ /// One attribute (or `$bundle`, or another `if()`).
187+ Attr(Attr),
188+ /// `[attr, attr]`: several attributes at once. `trailing` is text
189+ /// written after the `]`, an error.
190+ Group {
191+ list: AttrList,
192+ trailing: Option<Arg>,
38 },
193 },
39− /// `@let NAME $param $param=default` with an indented body.
194+}
40− Function {
195+
41− line: usize,
196+impl Branch {
42− name: String,
197+ /// The branch's attributes as written.
43− params: Vec<String>,
198+ pub fn attrs(&self) -> &[Attr] {
44− defaults: HashMap<String, String>,
199+ match self {
45− body: Vec<Syntax>,
200+ Branch::Empty => &[],
201+ Branch::Attr(attr) => std::slice::from_ref(attr),
202+ Branch::Group { list, .. } => &list.attrs,
203+ }
204+ }
205+}
206+
207+/// A directive's argument text, as written.
208+#[derive(Clone, Debug)]
209+pub struct Arg {
210+ pub raw: String,
211+ pub span: Span,
212+}
213+
214+#[derive(Clone, Debug)]
215+pub struct ElementLine {
216+ /// The heads joined by `>`; the last one gets the children.
217+ pub chain: Vec<Head>,
218+ /// Text after the last head.
219+ pub text: Option<Text>,
220+}
221+
222+#[derive(Clone, Debug)]
223+pub struct Directive {
224+ pub spec: &'static DirectiveSpec,
225+ /// Including the `@`.
226+ pub name_span: Span,
227+ pub args: DirectiveArgs,
228+}
229+
230+impl Directive {
231+ pub fn name(&self) -> &'static str {
232+ self.spec.name
233+ }
234+}
235+
236+/// A directive's arguments, read by its [`ArgGrammar`].
237+#[derive(Clone, Debug)]
238+pub enum DirectiveArgs {
239+ Text(Option<Arg>),
240+ Page {
241+ attrs: Option<AttrList>,
242+ title: Option<Arg>,
46 },
243 },
47− /// `@if` with its `@else if` and `@else` branches.
244+ Let(LetDef),
48− If { branches: Vec<Branch> },
245+ Condition(Arg),
49− /// `@each $var in LIST` with its body and the `@else` for an empty list.
246+ Else {
50− Each {
247+ condition: Option<Arg>,
51− line: usize,
52− header: String,
53− body: Vec<Syntax>,
54− empty: Vec<Syntax>,
55 },
248 },
249+ Each(Loop),
250+ Data {
251+ name: String,
252+ name_span: Span,
253+ source: Arg,
254+ },
255+ /// The header has a syntax error, which is already reported.
256+ Invalid,
56}
257}
57
258
259+/// `@let NAME ...` or `@let @NAME [params]`. Its kind is decided here,
260+/// from the line alone: an `@` before the name makes a function, a `[`
261+/// after it a bundle, anything else a value.
58#[derive(Clone, Debug)]
262#[derive(Clone, Debug)]
59−pub(crate) struct Branch {
263+pub struct LetDef {
60− pub line: usize,
264+ /// Without the function's `@`.
61− /// `None` for `@else`.
265+ pub name: String,
62− pub condition: Option<String>,
266+ /// Just the name, without the function's `@`.
63− pub body: Vec<Syntax>,
267+ pub name_span: Span,
268+ pub form: LetForm,
64}
269}
65
270
66−impl Syntax {
271+#[derive(Clone, Debug)]
67− pub fn line(&self) -> usize {
272+pub enum LetForm {
68− match self {
273+ /// `@let name text`.
69− Syntax::Raw { line, .. }
274+ Value(Arg),
70− | Syntax::Line { line, .. }
275+ /// `@let name "text"`: the text between the quotes.
71− | Syntax::Function { line, .. }
276+ Quoted(Arg),
72− | Syntax::Each { line, .. } => *line,
277+ /// `@let name = EXPR`: the expression.
73− Syntax::If { branches } => branches.first().map_or(0, |b| b.line),
278+ Computed(Arg),
279+ /// `@let name [attrs]`.
280+ Bundle(AttrList),
281+ /// `@let @name [param, param default]` (or `@let @name`) with an
282+ /// indented body.
283+ Function(Function),
284+}
285+
286+/// A function's head: its parameter list as written, and the parameters
287+/// read from it.
288+#[derive(Clone, Debug, Default)]
289+pub struct Function {
290+ /// `[param, param default]`; `None` for `@let @name`.
291+ pub list: Option<AttrList>,
292+ pub params: Vec<Param>,
293+}
294+
295+/// One parameter: `title` (required) or `tone #f9fafb` (with a default).
296+/// The body uses it as `$title`.
297+#[derive(Clone, Debug)]
298+pub struct Param {
299+ pub name: String,
300+ /// The default as written, for `name value`.
301+ pub default: Option<String>,
302+ /// Just the name.
303+ pub name_span: Span,
304+ /// The whole parameter.
305+ pub span: Span,
306+}
307+
308+/// `@each $item, $index in LIST`.
309+#[derive(Clone, Debug)]
310+pub struct Loop {
311+ pub item: String,
312+ pub index: Option<String>,
313+ pub list: Arg,
314+}
315+
316+#[derive(Clone, Debug)]
317+pub struct Verbatim {
318+ /// The body's lines without their common indentation.
319+ pub text: String,
320+ /// Shown on the page as text, HTML-escaped: the body of `@code` and
321+ /// `@textarea`. Otherwise it goes into the page as it is (`@raw`,
322+ /// `@script`) or is read by its directive (`@style`, `@markdown`).
323+ pub escaped: bool,
324+}
325+
326+// ---------------------------------------------------------------------------
327+// Queries used by the evaluator and the tools
328+// ---------------------------------------------------------------------------
329+
330+impl Node {
331+ /// Blank lines and comments.
332+ pub fn is_trivia(&self) -> bool {
333+ matches!(self.kind, NodeKind::Blank | NodeKind::Comment)
334+ }
335+
336+ /// The directive on this line, if it is one.
337+ pub fn directive(&self) -> Option<&Directive> {
338+ match &self.kind {
339+ NodeKind::Directive(d) => Some(d),
340+ _ => None,
74 }
341 }
75 }
342 }
76
343
77− /// The node's first source line, for diagnostics.
344+ /// Whether this line is the directive `@name`.
78− pub fn source(&self) -> String {
345+ pub fn is_directive(&self, name: &str) -> bool {
79− match self {
346+ self.directive().is_some_and(|d| d.name() == name)
80− Syntax::Raw { text, .. } | Syntax::Line { text, .. } => text.clone(),
347+ }
81− Syntax::Function {
348+
82− name,
349+ /// Children other than blank lines and comments.
83− params,
350+ pub fn body(&self) -> impl Iterator<Item = &Node> {
84− defaults,
351+ self.children.iter().filter(|c| !c.is_trivia())
85− ..
352+ }
86− } => {
353+
87− let mut out = format!("@let {}", name);
354+ /// The last source line of the node and its children.
88− for param in params {
355+ pub fn end_line(&self) -> usize {
89− match defaults.get(param) {
356+ let own = self.span.line + self.source_lines() - 1;
90− Some(default) => out.push_str(&format!(" ${}={}", param, default)),
357+ self.children
91− None => out.push_str(&format!(" ${}", param)),
358+ .iter()
92− }
359+ .filter(|c| !matches!(c.kind, NodeKind::Blank))
360+ .map(Node::end_line)
361+ .fold(own, usize::max)
362+ }
363+
364+ fn source_lines(&self) -> usize {
365+ self.line_count.max(1)
366+ }
367+
368+ /// Visit this node and everything under it, in source order.
369+ pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) {
370+ f(self);
371+ for child in &self.children {
372+ child.walk(f);
373+ }
374+ }
375+
376+ /// The heads on this line: an element's chain, and the inline
377+ /// elements in its text.
378+ pub fn heads(&self) -> Vec<&Head> {
379+ let mut heads = Vec::new();
380+ match &self.kind {
381+ NodeKind::Element(line) => {
382+ heads.extend(line.chain.iter());
383+ if let Some(text) = &line.text {
384+ text.inline_heads(&mut heads);
93 }
385 }
94− out
95 }
386 }
96− Syntax::If { branches } => branches
387+ NodeKind::Text(text) => text.inline_heads(&mut heads),
97− .first()
388+ _ => {}
98− .map(|b| format!("@if {}", b.condition.as_deref().unwrap_or("")))
99− .unwrap_or_default(),
100− Syntax::Each { header, .. } => format!("@each {}", header),
101 }
389 }
390+ heads
102 }
391 }
103}
392}
104
393
105−/// Parse source text into a syntax tree.
394+impl Text {
106−pub(crate) fn parse(input: &str) -> Vec<Syntax> {
395+ fn inline_heads<'a>(&'a self, out: &mut Vec<&'a Head>) {
107− let lines = preprocess(input);
396+ for segment in &self.segments {
108− let mut pos = 0;
397+ if let Segment::Inline(inline) = segment {
109− build(&lines, &mut pos, None)
398+ out.push(&inline.head);
110−}
399+ if let Some(text) = &inline.text {
111−
400+ text.inline_heads(out);
112−/// Build the nodes for the lines from `pos` indented deeper than `parent`.
401+ }
113−fn build(lines: &[Line], pos: &mut usize, parent: Option<usize>) -> Vec<Syntax> {
114− let mut nodes = Vec::new();
115− let deeper = |line: &Line| parent.is_none_or(|p| line.indent > p);
116− while *pos < lines.len() && deeper(&lines[*pos]) {
117− let line = &lines[*pos];
118− *pos += 1;
119− let text = match &line.content {
120− LineContent::Raw(text) => {
121− nodes.push(Syntax::Raw {
122− line: line.line_num,
123− text: text.clone(),
124− });
125− continue;
126 }
402 }
127− LineContent::Normal(text) => text.clone(),
403+ }
404+ }
405+
406+ /// Split off the leading argument, the first token, with the rest as
407+ /// text: `/about` and `About us` in `@link /about About us`. The token
408+ /// ends at a space outside `"..."` and `${...}`, so neither is split,
409+ /// and it is taken before any `$name` is filled in.
410+ pub fn split_leading(&self) -> (Arg, Option<Text>) {
411+ let end = leading_token_len(&self.raw);
412+ let shifted = Shifted { base: self.span };
413+ let token = Arg {
414+ raw: self.raw[..end].to_string(),
415+ span: shifted.span(0, end),
128 };
416 };
129− // Text and one-line directives take no body: lines indented under
417+ let rest_start = self.raw.len() - self.raw[end..].trim_start().len();
130− // them are their siblings.
418+ if rest_start >= self.raw.len() {
131− if !takes_body(&text) {
419+ return (token, None);
132− nodes.push(Syntax::Line {
420+ }
133− line: line.line_num,
421+ let reader = Reader {
134− indent: line.indent,
422+ text: &self.raw,
135− text,
423+ spans: &shifted,
136− children: Vec::new(),
424+ };
137− });
425+ (token, Some(reader.text(rest_start, self.raw.len()).0))
426+ }
427+}
428+
429+/// The length of the first token of `s`: up to the first whitespace that
430+/// isn't inside `"..."` or `${...}` and isn't escaped.
431+pub fn leading_token_len(s: &str) -> usize {
432+ let mut i = 0;
433+ while i < s.len() {
434+ let rest = &s[i..];
435+ let escape = escape_len(rest);
436+ if escape > 0 {
437+ i += escape;
138 continue;
438 continue;
139 }
439 }
140− let children = build(lines, pos, Some(line.indent));
440+ if rest.starts_with('"') {
141−
441+ // To the closing quote, or the end of the text
142− if let Some(condition) = text.strip_prefix("@if ") {
442+ let mut j = 1;
143− let mut branches = vec![Branch {
443+ while j < rest.len() && !rest[j..].starts_with('"') {
144− line: line.line_num,
444+ j += match escape_len(&rest[j..]) {
145− condition: Some(condition.trim().to_string()),
445+ 0 => rest[j..].chars().next().map_or(1, char::len_utf8),
146− body: children,
446+ n => n,
147− }];
148− while let Some((next_line, next)) = sibling(lines, *pos, line.indent) {
149− let condition = if let Some(condition) = next.strip_prefix("@else if ") {
150− Some(condition.trim().to_string())
151− } else if next == "@else" {
152− None
153− } else {
154− break;
155 };
447 };
156− *pos += 1;
157− let last = condition.is_none();
158− branches.push(Branch {
159− line: next_line,
160− condition,
161− body: build(lines, pos, Some(line.indent)),
162− });
163− if last {
164− break;
165− }
166 }
448 }
167− nodes.push(Syntax::If { branches });
449+ i += (j + 1).min(rest.len());
450+ continue;
451+ }
452+ if rest.starts_with("${")
453+ && let Some(close) = crate::interp::matching_brace(&rest[1..])
454+ {
455+ i += close + 2;
168 continue;
456 continue;
169 }
457 }
458+ let c = rest.chars().next().unwrap_or(' ');
459+ if c.is_whitespace() {
460+ break;
461+ }
462+ i += c.len_utf8();
463+ }
464+ i
465+}
466+
467+impl Tree {
468+ /// Whether the file holds only definitions: at its top level, `@let`s
469+ /// (at least one), comments and blank lines. Such a file is a library,
470+ /// such as a layout, whose definitions are for the files that
471+ /// `@include` it: it isn't built into a page of its own, and its
472+ /// definitions aren't reported unused.
473+ pub fn is_library(&self) -> bool {
474+ self.is_library_with(&mut |_| false)
475+ }
170
476
171− if let Some(header) = text.strip_prefix("@each ") {
477+ /// Whether the file is a library (see [`Tree::is_library`]), which may
172− let mut empty = Vec::new();
478+ /// also read data (`@data`) and include other libraries: `library`
173− if let Some((_, "@else")) = sibling(lines, *pos, line.indent) {
479+ /// says whether the file an `@include` names, as written, is one.
174− *pos += 1;
480+ pub fn is_library_with(&self, library: &mut dyn FnMut(&str) -> bool) -> bool {
175− empty = build(lines, pos, Some(line.indent));
481+ let mut lets = 0;
482+ for node in &self.nodes {
483+ match &node.kind {
484+ NodeKind::Blank | NodeKind::Comment => {}
485+ _ if node.is_directive("let") => lets += 1,
486+ _ if node.is_directive("data") => {}
487+ NodeKind::Directive(Directive {
488+ args: DirectiveArgs::Text(Some(path)),
489+ ..
490+ }) if node.is_directive("include") && library(path.raw.trim_matches('"')) => {}
491+ _ => return false,
176 }
492 }
177− nodes.push(Syntax::Each {
178− line: line.line_num,
179− header: header.trim().to_string(),
180− body: children,
181− empty,
182− });
183− continue;
184 }
493 }
494+ lets > 0
495+ }
185
496
186− if let Some(rest) = text.strip_prefix("@let ")
497+ /// Visit every node, in source order.
187− && !children.is_empty()
498+ pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) {
188− && let Some((name, params)) = rest.split_whitespace().collect::<Vec<_>>().split_first()
499+ for node in &self.nodes {
189− {
500+ node.walk(f);
190− let mut names = Vec::new();
501+ }
191− let mut defaults = HashMap::new();
502+ }
192− for param in params {
503+
193− let param = param.strip_prefix('$').unwrap_or(param);
504+ /// Every `@let` in the file, wherever it is.
194− match param.split_once('=') {
505+ pub fn definitions(&self) -> Vec<Definition<'_>> {
195− Some((param, default)) => {
506+ let mut out = Vec::new();
196− names.push(param.to_string());
507+ self.walk(&mut |node| {
197− defaults.insert(param.to_string(), default.to_string());
508+ let Some(Directive {
198− }
509+ args: DirectiveArgs::Let(def),
199− None => names.push(param.to_string()),
510+ ..
511+ }) = node.directive()
512+ else {
513+ return;
514+ };
515+ let (kind, params, value): (_, &[Param], _) = match &def.form {
516+ LetForm::Value(value) => (DefinitionKind::Value, &[], Some(value.raw.clone())),
517+ LetForm::Quoted(value) => (
518+ DefinitionKind::Value,
519+ &[],
520+ Some(format!("\"{}\"", value.raw)),
521+ ),
522+ LetForm::Computed(expr) => {
523+ (DefinitionKind::Value, &[], Some(format!("= {}", expr.raw)))
200 }
524 }
201− }
525+ LetForm::Bundle(attrs) => (
202− nodes.push(Syntax::Function {
526+ DefinitionKind::Bundle,
203− line: line.line_num,
527+ &[],
204− name: name.to_string(),
528+ Some(
205− params: names,
529+ attrs
206− defaults,
530+ .attrs
207− body: children,
531+ .iter()
532+ .map(|a| a.raw.as_str())
533+ .collect::<Vec<_>>()
534+ .join(", "),
535+ ),
536+ ),
537+ LetForm::Function(function) => {
538+ (DefinitionKind::Function, function.params.as_slice(), None)
539+ }
540+ };
541+ out.push(Definition {
542+ name: &def.name,
543+ name_span: def.name_span,
544+ kind,
545+ params,
546+ value,
547+ node,
208 });
548 });
209− continue;
549+ });
210− }
550+ out
551+ }
211
552
212− nodes.push(Syntax::Line {
553+ /// The names visible at `line` (1-based), by the lexical rule the
213− line: line.line_num,
554+ /// compiler follows: a definition (`@let`, `@data`) is visible from the
214− indent: line.indent,
555+ /// line after it to the end of its block; an `@each` line's variables
215− text,
556+ /// in its body; a function's parameters (and the function itself) in
216− children,
557+ /// its body, which also sees what is visible where the function is
558+ /// defined. In the order they are defined, so a later one with the same
559+ /// name hides an earlier one.
560+ pub fn visible_at(&self, line: usize) -> Vec<Visible<'_>> {
561+ let mut out = Vec::new();
562+ visible_in(&self.nodes, line, &mut out);
563+ out
564+ }
565+
566+ /// The innermost node whose own lines include `line` (1-based).
567+ pub fn node_at_line(&self, line: usize) -> Option<&Node> {
568+ let mut found = None;
569+ self.walk(&mut |node| {
570+ let last = node.span.line + node.source_lines() - 1;
571+ if (node.span.line..=last).contains(&line) && !matches!(node.kind, NodeKind::Blank) {
572+ found = Some(node);
573+ }
217 });
574 });
575+ found
218 }
576 }
219− nodes
220}
577}
221
578
222−/// Whether the lines indented under `text` belong to it.
579+/// What a `@let` defines.
223−fn takes_body(text: &str) -> bool {
580+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
224− const ONE_LINE: &[&str] = &["@page", "@meta", "@data", "@include"];
581+pub enum DefinitionKind {
225− let name = text.split([' ', '[']).next().unwrap_or("");
582+ /// A value (`@let x 1`, `@let x = 1 + 1`, `@let x "text"`), used as `$x`.
226− text.starts_with('@') && !ONE_LINE.contains(&name)
583+ Value,
584+ /// An attribute bundle (`@let x [...]`), used as `[$x]`.
585+ Bundle,
586+ /// A function (`@let @x [a]` with a body), called as `@x`.
587+ Function,
588+}
589+
590+/// A `@let`, for tools.
591+#[derive(Clone, Debug)]
592+pub struct Definition<'a> {
593+ pub name: &'a str,
594+ pub name_span: Span,
595+ pub kind: DefinitionKind,
596+ pub params: &'a [Param],
597+ /// The value as written, for a value or a bundle.
598+ pub value: Option<String>,
599+ /// The `@let` line, with the function's body as its children.
600+ pub node: &'a Node,
601+}
602+
603+/// A name visible at a line (see [`Tree::visible_at`]).
604+#[derive(Clone, Debug)]
605+pub struct Visible<'a> {
606+ pub name: &'a str,
607+ pub kind: VisibleKind,
608+ /// Where the name is written in its definition (for a loop variable,
609+ /// the `@each` line).
610+ pub span: Span,
611+}
612+
613+/// What defines a visible name.
614+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
615+pub enum VisibleKind {
616+ /// A `@let`, of this kind.
617+ Let(DefinitionKind),
618+ /// A function's parameter, in its body.
619+ Parameter,
620+ /// An `@each` variable, in its body.
621+ Loop,
622+ /// `@data $name`.
623+ Data,
624+}
625+
626+fn visible_in<'a>(block: &'a [Node], line: usize, out: &mut Vec<Visible<'a>>) {
627+ for node in block {
628+ if node.span.line >= line {
629+ break;
630+ }
631+ let inside = line <= node.end_line();
632+ match node.directive().map(|d| &d.args) {
633+ Some(DirectiveArgs::Let(def)) => {
634+ let kind = match &def.form {
635+ LetForm::Function(_) => DefinitionKind::Function,
636+ LetForm::Bundle(_) => DefinitionKind::Bundle,
637+ _ => DefinitionKind::Value,
638+ };
639+ // `@let t.greeting` gives the record `t` a field; `@let
640+ // --brand` is a custom property, read with `var(--brand)`
641+ let name = def.name.split('.').next().unwrap_or(&def.name);
642+ if !name.starts_with("--") {
643+ out.push(Visible {
644+ name,
645+ kind: VisibleKind::Let(kind),
646+ span: def.name_span,
647+ });
648+ }
649+ if let (true, LetForm::Function(function)) = (inside, &def.form) {
650+ out.extend(function.params.iter().map(|p| Visible {
651+ name: &p.name,
652+ kind: VisibleKind::Parameter,
653+ span: p.name_span,
654+ }));
655+ }
656+ }
657+ Some(DirectiveArgs::Data {
658+ name, name_span, ..
659+ }) => out.push(Visible {
660+ name,
661+ kind: VisibleKind::Data,
662+ span: *name_span,
663+ }),
664+ Some(DirectiveArgs::Each(each)) if inside => {
665+ for name in std::iter::once(&each.item).chain(&each.index) {
666+ out.push(Visible {
667+ name,
668+ kind: VisibleKind::Loop,
669+ span: node.span,
670+ });
671+ }
672+ }
673+ _ => {}
674+ }
675+ if inside {
676+ visible_in(&node.children, line, out);
677+ return;
678+ }
679+ }
680+}
681+
682+// ---------------------------------------------------------------------------
683+// Parsing
684+// ---------------------------------------------------------------------------
685+
686+/// Whether a line (without its indentation) is a comment: its first word
687+/// is `--`, so `-- note` and a bare `--` are comments, while `--gap 12px`
688+/// (a custom property on a line of an attribute list) and `---` are not.
689+pub fn is_comment(trimmed: &str) -> bool {
690+ trimmed
691+ .strip_prefix("--")
692+ .is_some_and(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
693+}
694+
695+/// Parse source text into a syntax tree.
696+pub fn parse(source: &str) -> Tree {
697+ parse_from(source, 0)
227}
698}
228
699
229−/// The line at `pos` if it is a sibling at `indent`, with its text.
700+/// Parse, numbering the nodes from `first_id`.
230−fn sibling(lines: &[Line], pos: usize, indent: usize) -> Option<(usize, &str)> {
701+pub(crate) fn parse_from(source: &str, first_id: usize) -> Tree {
231− let line = lines.get(pos).filter(|l| l.indent == indent)?;
702+ let lines = physical_lines(source);
232− match &line.content {
703+ let index = LineIndex {
233− LineContent::Normal(text) => Some((line.line_num, text.trim())),
704+ starts: lines.iter().map(|l| l.0).collect(),
234− LineContent::Raw(_) => None,
705+ };
706+ let mut diagnostics = Vec::new();
707+ let mut indents = Vec::new();
708+ let entries = scan(&lines, &index, &mut diagnostics, &mut indents);
709+ mixed_indentation(&lines, &indents, &mut diagnostics);
710+ let mut next_id = first_id;
711+ let mut pos = 0;
712+ let mut nodes = build(entries, &mut pos, None, &mut next_id);
713+ literal_text(&mut nodes, false);
714+ check(&mut nodes, &mut diagnostics);
715+ diagnostics.sort_by_key(|d| d.line);
716+ Tree {
717+ nodes,
718+ diagnostics,
719+ end_id: next_id,
235 }
720 }
236}
721}
237
722
238−pub(crate) fn preprocess(input: &str) -> Vec<Line> {
723+/// The source's lines with their byte offsets, like `str::lines`.
239− let raw_lines: Vec<&str> = input.lines().collect();
724+fn physical_lines(source: &str) -> Vec<(usize, &str)> {
240 let mut lines = Vec::new();
725 let mut lines = Vec::new();
241− let mut i = 0;
726+ let mut start = 0;
727+ for piece in source.split('\n') {
728+ lines.push((start, piece.strip_suffix('\r').unwrap_or(piece)));
729+ start += piece.len() + 1;
730+ }
731+ if source.is_empty() || source.ends_with('\n') {
732+ lines.pop();
733+ }
734+ lines
735+}
242
736
243− while i < raw_lines.len() {
737+struct LineIndex {
244− let line = raw_lines[i];
738+ starts: Vec<usize>,
245− let trimmed = line.trim();
739+}
246
740
247− if trimmed.is_empty() || trimmed.starts_with("--") {
741+impl LineIndex {
248− i += 1;
742+ fn span(&self, start: usize, end: usize) -> Span {
743+ let line = self.starts.partition_point(|&s| s <= start).max(1);
744+ Span {
745+ start,
746+ end,
747+ line,
748+ column: start - self.starts.get(line - 1).copied().unwrap_or(0),
749+ }
750+ }
751+}
752+
753+/// A tab counts as one column, like a space, so indentation that mixes
754+/// them nests lines in ways the eye doesn't see: the first line whose
755+/// indentation mixes them, or uses the other one than the lines before
756+/// it, is reported. `indents` holds, for each line whose indentation
757+/// places it, its index and how many of its leading bytes do: a verbatim
758+/// body's own indentation after the body's is its content (a tab in a
759+/// `@code` sample), and so is a continuation line's.
760+fn mixed_indentation(
761+ lines: &[(usize, &str)],
762+ indents: &[(usize, usize)],
763+ diagnostics: &mut Vec<Diagnostic>,
764+) {
765+ let mut first: Option<char> = None;
766+ for &(number, width) in indents {
767+ let Some((_, line)) = lines.get(number) else {
768+ continue;
769+ };
770+ let indent = line.get(..width).unwrap_or("");
771+ let (tabs, spaces) = (indent.contains('\t'), indent.contains(' '));
772+ let used = match (tabs, spaces) {
773+ (false, false) => continue,
774+ (true, true) => None,
775+ (true, false) => Some('\t'),
776+ (false, true) => Some(' '),
777+ };
778+ if used.is_some() && (first.is_none() || first == used) {
779+ first = used;
249 continue;
780 continue;
250 }
781 }
782+ diagnostics.push(
783+ Diagnostic::warning(
784+ code::MIXED_INDENTATION,
785+ number + 1,
786+ "this line's indentation mixes tabs and spaces with the lines before it: a \
787+ tab counts as one space, so the nesting may not be what it looks like. \
788+ Indent with one of them"
789+ .to_string(),
790+ )
791+ .source(line.to_string()),
792+ );
793+ return;
794+ }
795+}
251
796
252− let indent = line.len() - line.trim_start().len();
797+fn leading_whitespace(line: &str) -> usize {
798+ line.len() - line.trim_start().len()
799+}
800+
801+/// A node before the tree is built: one logical line.
802+struct Entry {
803+ indent: usize,
804+ span: Span,
805+ span_lines: usize,
806+ source: String,
807+ kind: NodeKind,
808+}
253
809
254− // The bodies of `@markdown`, `@script`, `@style`, `@head` and `@raw`
810+/// Read the lines into entries: comments, blank lines, headers (with their
255− // are foreign text, kept verbatim: in Markdown, blank lines separate
811+/// continuation lines joined) and verbatim bodies.
256− // paragraphs and `---` is a rule (not a comment); in CSS, `--name` is
812+fn scan(
257− // a custom property; in JavaScript, newlines, `{...}` and `$` must
813+ lines: &[(usize, &str)],
258− // reach the output untouched.
814+ index: &LineIndex,
259− let verbatim_body = ["@markdown", "@script", "@style", "@head", "@raw"].contains(&trimmed)
815+ diagnostics: &mut Vec<Diagnostic>,
260− || trimmed.starts_with("@script ")
816+ indents: &mut Vec<(usize, usize)>,
261− || trimmed.starts_with("@script[")
817+) -> Vec<Entry> {
262− || trimmed.starts_with("@head ");
818+ let mut entries: Vec<Entry> = Vec::new();
263− if verbatim_body {
819+ let mut i = 0;
264− lines.push(Line {
820+ while i < lines.len() {
821+ let (start, line) = lines[i];
822+ let trimmed = line.trim();
823+ let indent = leading_whitespace(line);
824+ let begin = start + indent;
825+ if trimmed.is_empty() {
826+ entries.push(Entry {
827+ // Resolved below, from the next line
828+ indent: 0,
829+ span: index.span(start, start + line.len()),
830+ span_lines: 1,
831+ source: String::new(),
832+ kind: NodeKind::Blank,
833+ });
834+ i += 1;
835+ continue;
836+ }
837+ indents.push((i, indent));
838+ if is_comment(trimmed) {
839+ entries.push(Entry {
265 indent,
840 indent,
266− content: LineContent::Normal(trimmed.to_string()),
841+ span: index.span(begin, begin + trimmed.len()),
267− line_num: i + 1,
842+ span_lines: 1,
843+ source: trimmed.to_string(),
844+ kind: NodeKind::Comment,
268 });
845 });
269− let body_start = i + 1;
846+ i += 1;
270− let mut body_end = body_start;
847+ continue;
271− let mut j = body_start;
848+ }
272− while j < raw_lines.len() {
849+
273− let l = raw_lines[j];
850+ // A header, joined with its continuation lines while an attribute
851+ // list (or inline JSON) is open. Blank lines and comments inside it
852+ // are skipped.
853+ let mut logical = Logical::default();
854+ logical.push(trimmed, begin);
855+ let (mut kind, mut open, mut problems) = logical.header(index);
856+ let mut last = i;
857+ while open && last + 1 < lines.len() {
858+ last += 1;
859+ let (next_start, next) = lines[last];
860+ let t = next.trim();
861+ if t.is_empty() || is_comment(t) {
862+ continue;
863+ }
864+ logical.push(t, next_start + leading_whitespace(next));
865+ (kind, open, problems) = logical.header(index);
866+ }
867+ diagnostics.extend(problems);
868+ let end = lines[last].0 + lines[last].1.len();
869+ // What the lines under it are is decided by the directive, or by
870+ // the last element of a chain (`@pre > @code`)
871+ let (body, escaped) = match &kind {
872+ NodeKind::Directive(d) => (d.spec.body, false),
873+ NodeKind::Element(line) => line.chain.last().map_or((BodyKind::Htmlang, false), |h| {
874+ (ast::body_kind(&h.name), ast::has_literal_text(&h.name))
875+ }),
876+ _ => (BodyKind::Htmlang, false),
877+ };
878+ entries.push(Entry {
879+ indent,
880+ span: index.span(begin, end),
881+ span_lines: last - i + 1,
882+ source: logical.text.clone(),
883+ kind,
884+ });
885+ i = last + 1;
886+
887+ // A verbatim body: every following line indented deeper (or blank),
888+ // up to the last such non-blank line.
889+ if body == BodyKind::Verbatim {
890+ let mut body_end = i;
891+ let mut k = i;
892+ while k < lines.len() {
893+ let l = lines[k].1;
894+ k += 1;
274 if l.trim().is_empty() {
895 if l.trim().is_empty() {
275− j += 1;
276 continue;
896 continue;
277 }
897 }
278− if l.len() - l.trim_start().len() <= indent {
898+ if leading_whitespace(l) <= indent {
279 break;
899 break;
280 }
900 }
281− j += 1;
901+ body_end = k;
282− body_end = j;
283 }
902 }
284− let body = &raw_lines[body_start..body_end];
903+ if body_end > i {
285− let body_indent = body
904+ let body = &lines[i..body_end];
286− .iter()
905+ let common = body
287− .filter(|l| !l.trim().is_empty())
906+ .iter()
288− .map(|l| l.len() - l.trim_start().len())
907+ .filter(|(_, l)| !l.trim().is_empty())
289− .min()
908+ .map(|(_, l)| leading_whitespace(l))
290− .unwrap_or(0);
909+ .min()
291− if !body.is_empty() {
910+ .unwrap_or(0);
911+ indents.extend(
912+ (i..body_end)
913+ .filter(|&k| !lines[k].1.trim().is_empty())
914+ .map(|k| (k, common)),
915+ );
292 let text: Vec<&str> = body
916 let text: Vec<&str> = body
293 .iter()
917 .iter()
294− .map(|l| l.get(body_indent..).unwrap_or("").trim_end())
918+ .map(|(_, l)| l.get(common..).unwrap_or("").trim_end())
295 .collect();
919 .collect();
296− lines.push(Line {
920+ let (last_start, last_line) = lines[body_end - 1];
921+ entries.push(Entry {
297 indent: indent + 1,
922 indent: indent + 1,
298− content: LineContent::Raw(text.join("\n")),
923+ span: index.span(lines[i].0, last_start + last_line.len()),
299− line_num: body_start + 1,
924+ span_lines: body_end - i,
925+ source: String::new(),
926+ kind: NodeKind::Verbatim(Verbatim {
927+ text: text.join("\n"),
928+ escaped,
929+ }),
300 });
930 });
931+ i = body_end;
301 }
932 }
302− i = body_end.max(body_start);
303− continue;
304 }
933 }
934+ }
305
935
306− // Join continuation lines for multi-line attribute brackets
936+ // A blank line belongs where the next line does.
307− let first_line_num = i + 1;
937+ let mut next_indent = 0;
308− let mut full = trimmed.to_string();
938+ for entry in entries.iter_mut().rev() {
309− while open_attr_depth(&full) > 0 && i + 1 < raw_lines.len() {
939+ if matches!(entry.kind, NodeKind::Blank) {
310− i += 1;
940+ entry.indent = next_indent;
311− let next = raw_lines[i].trim();
941+ } else {
312− if next.is_empty() || next.starts_with("--") {
942+ next_indent = entry.indent;
313− continue;
314− }
315− full.push(' ');
316− full.push_str(next);
317 }
943 }
318−
319− lines.push(Line {
320− indent,
321− content: LineContent::Normal(full),
322− line_num: first_line_num,
323− });
324− i += 1;
325 }
944 }
945+ entries
946+}
326
947
327− lines
948+/// Build the nodes for the entries from `pos` indented deeper than `parent`.
949+fn build(
950+ entries: Vec<Entry>,
951+ pos: &mut usize,
952+ parent: Option<usize>,
953+ next_id: &mut usize,
954+) -> Vec<Node> {
955+ // Entries are taken out of the vector one by one, in order.
956+ let mut entries: Vec<Option<Entry>> = entries.into_iter().map(Some).collect();
957+ build_from(&mut entries, pos, parent, next_id)
328}
958}
329
959
330−/// Bracket depth left open at the end of `line`, counting only attribute
960+fn build_from(
331−/// lists: a `[` that starts the line or follows an `@name` token (optionally
961+ entries: &mut [Option<Entry>],
332−/// with one more word, as in `@let name [`). Brackets in text content, such
962+ pos: &mut usize,
333−/// as `@text [bold] Use [ to open`, are ignored.
963+ parent: Option<usize>,
334−pub(crate) fn open_attr_depth(line: &str) -> i32 {
964+ next_id: &mut usize,
335− if !line.starts_with('@') {
965+) -> Vec<Node> {
336− // Text: an inline `{@name [` whose list runs onto the next line
966+ let mut nodes = Vec::new();
337− let Some(start) = line.rfind("{@") else {
967+ while let Some(Some(entry)) = entries.get(*pos) {
338− return 0;
968+ if parent.is_some_and(|p| entry.indent <= p) {
969+ break;
970+ }
971+ let Some(entry) = entries[*pos].take() else {
972+ break;
339 };
973 };
340− let inline = &line[start + 1..];
974+ *pos += 1;
341− return if inline.contains('}') {
975+ let id = *next_id;
342− 0
976+ *next_id += 1;
977+ let takes_children = !matches!(
978+ entry.kind,
979+ NodeKind::Blank | NodeKind::Comment | NodeKind::Verbatim(_)
980+ );
981+ let children = if takes_children {
982+ build_from(entries, pos, Some(entry.indent), next_id)
343 } else {
983 } else {
344− open_attr_depth(inline)
984+ Vec::new()
345− };
985+ };
346− }
986+ nodes.push(Node {
347− let bytes = line.as_bytes();
987+ id,
348− let mut depth: i32 = 0;
988+ indent: entry.indent,
349− for (i, &b) in bytes.iter().enumerate() {
989+ span: entry.span,
350− match b {
990+ line_count: entry.span_lines,
351− b'[' if depth > 0 => depth += 1,
991+ source: entry.source,
352− b'[' => {
992+ kind: entry.kind,
353− // Is this the start of an attribute list?
993+ children,
354− let before = line[..i].trim_end();
994+ });
355− let last_directive = before.rsplit([']', '>']).next().unwrap_or("").trim();
995+ }
356− let tokens: Vec<&str> = last_directive.split_whitespace().collect();
996+ nodes
357− let starts_list = match tokens.as_slice() {
997+}
358− [] => before.is_empty() || before.ends_with('>'),
998+
359− [name] => name.starts_with('@'),
999+/// Make the text of `@code` and `@textarea` literal, on their line, inline
360− [name, _] => name.starts_with('@'),
1000+/// (`{@code {@link /x y}}`) and in the lines under a chain they are in the
361− _ => false,
1001+/// middle of (`@code > @b`): a `{@...}` in it is text. (The lines under
362− };
1002+/// `@code` itself are a verbatim body, see `scan`.) `inside` says that
363− if starts_list {
1003+/// `nodes` are in such an element.
364− depth = 1;
1004+fn literal_text(nodes: &mut [Node], inside: bool) {
365− } else if before.ends_with(']') {
1005+ for node in nodes {
366− // Text after a closed attribute list: stop scanning.
1006+ let mut literal = inside;
367− return 0;
1007+ match &mut node.kind {
1008+ NodeKind::Text(text) => text.literal_where_asked(inside),
1009+ NodeKind::Element(line) => {
1010+ literal |= line
1011+ .chain
1012+ .iter()
1013+ .any(|head| ast::has_literal_text(&head.name));
1014+ if let Some(text) = &mut line.text {
1015+ text.literal_where_asked(literal);
368 }
1016 }
369 }
1017 }
370− b']' if depth > 0 => depth -= 1,
371 _ => {}
1018 _ => {}
372 }
1019 }
1020+ literal_text(&mut node.children, literal);
373 }
1021 }
374− depth
375}
1022}
376
1023
377−#[cfg(test)]
1024+impl Text {
378−mod tests {
1025+ /// Make the whole text literal when `literal`, and otherwise the text
379− use super::*;
1026+ /// of the inline elements in it that show their text as written.
380−
1027+ fn literal_where_asked(&mut self, literal: bool) {
381− fn kinds(nodes: &[Syntax]) -> Vec<&'static str> {
1028+ if literal {
382− nodes
1029+ self.segments = match self.raw.is_empty() {
383− .iter()
1030+ true => Vec::new(),
384− .map(|n| match n {
1031+ false => vec![Segment::Plain {
385− Syntax::Raw { .. } => "raw",
1032+ raw: self.raw.clone(),
386− Syntax::Line { .. } => "line",
1033+ span: self.span,
387− Syntax::Function { .. } => "function",
1034+ }],
388− Syntax::If { .. } => "if",
1035+ };
389− Syntax::Each { .. } => "each",
1036+ return;
390− })
1037+ }
391− .collect()
1038+ for segment in &mut self.segments {
1039+ if let Segment::Inline(inline) = segment
1040+ && let Some(text) = &mut inline.text
1041+ {
1042+ text.literal_where_asked(ast::has_literal_text(&inline.head.name));
1043+ }
1044+ }
392 }
1045 }
1046+}
393
1047
394− #[test]
1048+/// Checks on the built tree: what each line's body may be, and `@else`
395− fn groups_control_flow_and_definitions() {
1049+/// placement.
396− let tree = parse(
1050+fn check(nodes: &mut [Node], diagnostics: &mut Vec<Diagnostic>) {
397− "@let card $title $icon=x\n @el\n @children\n@if $a\n A\n@else if $b\n B\n@else\n C\n@each $x in 1..3\n $x\n@else\n none\n@text done\n",
1051+ #[derive(PartialEq)]
398− );
1052+ enum Opener {
399− assert_eq!(kinds(&tree), ["function", "if", "each", "line"]);
1053+ If,
400− let Syntax::Function {
1054+ Each,
401− name,
1055+ }
402− params,
1056+ let mut opener = None;
403− defaults,
1057+ for node in nodes.iter_mut() {
404− body,
1058+ if node.is_trivia() {
405− ..
1059+ continue;
406− } = &tree[0]
1060+ }
407− else {
1061+ let line = node.span.line;
408− unreachable!()
1062+ match node.directive().map(|d| (d.name(), &d.args)) {
1063+ Some(("if", _)) => opener = Some(Opener::If),
1064+ Some(("each", _)) => opener = Some(Opener::Each),
1065+ Some(("else", args)) => {
1066+ let else_if = matches!(args, DirectiveArgs::Else { condition: Some(_) });
1067+ opener = match opener {
1068+ Some(Opener::If) if else_if => Some(Opener::If),
1069+ Some(Opener::If) | Some(Opener::Each) if !else_if => None,
1070+ _ => {
1071+ diagnostics.push(
1072+ Diagnostic::error(
1073+ code::STRAY_ELSE,
1074+ line,
1075+ "@else without matching @if".to_string(),
1076+ )
1077+ .source(node.source.clone()),
1078+ );
1079+ None
1080+ }
1081+ };
1082+ }
1083+ _ => opener = None,
1084+ }
1085+ check_body(node, diagnostics);
1086+ check(&mut node.children, diagnostics);
1087+ }
1088+}
1089+
1090+/// Whether the lines indented under `node` fit what it takes.
1091+fn check_body(node: &mut Node, diagnostics: &mut Vec<Diagnostic>) {
1092+ let first_child = node.body().next().map(|c| (c.span.line, c.source.clone()));
1093+ let has_verbatim = node
1094+ .children
1095+ .iter()
1096+ .any(|c| matches!(c.kind, NodeKind::Verbatim(_)));
1097+ let (line, source) = (node.span.line, node.source.clone());
1098+ // `@code` and `@textarea` (also at the end of a chain) take their text
1099+ // on their line or as a verbatim block, like a verbatim directive
1100+ if let NodeKind::Element(element) = &node.kind
1101+ && element.text.is_some()
1102+ && has_verbatim
1103+ && let Some(head) = element.chain.last()
1104+ && ast::has_literal_text(&head.name)
1105+ {
1106+ diagnostics.push(
1107+ Diagnostic::error(
1108+ code::UNEXPECTED_BODY,
1109+ line,
1110+ format!(
1111+ "@{} takes its text either on its own line or in an indented block, not both",
1112+ head.name
1113+ ),
1114+ )
1115+ .source(source.clone()),
1116+ );
1117+ }
1118+ let NodeKind::Directive(directive) = &mut node.kind else {
1119+ return;
1120+ };
1121+ let name = directive.spec.name;
1122+ let mut report = |code, (line, source): (usize, String), message: String| {
1123+ diagnostics.push(Diagnostic::error(code, line, message).source(source))
1124+ };
1125+ let mut problem = |at, message| report(code::UNEXPECTED_BODY, at, message);
1126+ match (&mut directive.args, directive.spec.body) {
1127+ // Only a function takes a body, and it needs one
1128+ (DirectiveArgs::Let(def), _) => {
1129+ let what = match &def.form {
1130+ LetForm::Function(_) => {
1131+ if first_child.is_none() {
1132+ report(
1133+ code::INVALID_DEFINITION,
1134+ (line, source),
1135+ format!(
1136+ "`@let @{}` needs an indented body: the lines the function \
1137+ produces",
1138+ def.name
1139+ ),
1140+ );
1141+ }
1142+ return;
1143+ }
1144+ LetForm::Bundle(_) => "an attribute bundle",
1145+ _ => "a value",
1146+ };
1147+ if let Some(child) = first_child {
1148+ let message = if def.name.starts_with("--") {
1149+ format!(
1150+ "`@let {}` declares a custom property, which takes no indented block",
1151+ def.name
1152+ )
1153+ } else {
1154+ format!(
1155+ "`@let {}` defines {}, which takes no indented block \
1156+ (a function is `@let @{} [param]` with a body)",
1157+ def.name, what, def.name
1158+ )
1159+ };
1160+ problem(child, message);
1161+ }
1162+ }
1163+ (_, BodyKind::None) => {
1164+ if let Some(child) = first_child {
1165+ problem(
1166+ child,
1167+ format!(
1168+ "@{} takes no indented block: this line isn't part of it, so unindent it",
1169+ name
1170+ ),
1171+ );
1172+ }
1173+ }
1174+ (DirectiveArgs::Text(Some(_)), BodyKind::Verbatim) if has_verbatim => problem(
1175+ (line, source),
1176+ match name {
1177+ "markdown" => "@markdown reads either a file named on its line or the Markdown \
1178+ in its indented block, not both"
1179+ .to_string(),
1180+ _ => format!(
1181+ "@{} takes its content either on its own line or in an indented block, not both",
1182+ name
1183+ ),
1184+ },
1185+ ),
1186+ _ => {}
1187+ }
1188+}
1189+
1190+/// The parameters in a function's list, `[title, tone #f9fafb]`: read
1191+/// like a call's attributes, a bare word is a required parameter and
1192+/// `name value` one with a default.
1193+fn params(list: &AttrList, source: &str, problems: &mut Vec<Diagnostic>) -> Vec<Param> {
1194+ let mut out: Vec<Param> = Vec::new();
1195+ for attr in &list.attrs {
1196+ let problem = |message: String| {
1197+ let diagnostic = Diagnostic::error(code::INVALID_DEFINITION, attr.span.line, message)
1198+ .source(source)
1199+ .subject(attr.raw.clone());
1200+ // The column, where the line shown is the parameter's own
1201+ match attr.span.line == list.span.line {
1202+ true => diagnostic.column(attr.span.column),
1203+ false => diagnostic,
1204+ }
1205+ };
1206+ let name = attr.key.strip_prefix('$').unwrap_or(&attr.key);
1207+ if attr.key.starts_with('$') {
1208+ problems.push(
1209+ problem(format!(
1210+ "a parameter is named without `$`: `{}`, used as `${}` in the body",
1211+ name, name
1212+ ))
1213+ .subject(attr.key.clone())
1214+ .suggest(Some(name)),
1215+ );
1216+ } else if attr.html {
1217+ problems.push(problem(format!(
1218+ "a parameter's default is written `{} VALUE`, without `=`",
1219+ name
1220+ )));
1221+ } else if crate::interp::name_len(name) != name.len() || name.starts_with("--") {
1222+ problems.push(problem(format!(
1223+ "'{}' is not a parameter name: a parameter is a name, or a name and its \
1224+ default (`tone #f9fafb`)",
1225+ attr.raw
1226+ )));
1227+ continue;
1228+ }
1229+ if out.iter().any(|p| p.name == name) {
1230+ problems.push(problem(format!("parameter '{}' is declared twice", name)));
1231+ continue;
1232+ }
1233+ let skip = attr.key.len() - name.len();
1234+ out.push(Param {
1235+ name: name.to_string(),
1236+ default: attr.value.clone(),
1237+ name_span: Span {
1238+ start: attr.span.start + skip,
1239+ end: attr.span.start + attr.key.len(),
1240+ line: attr.span.line,
1241+ column: attr.span.column + skip,
1242+ },
1243+ span: attr.span,
1244+ });
1245+ }
1246+ // A default is filled in when the function is called, with the
1247+ // parameters before it already bound: a later one isn't there yet.
1248+ for (i, param) in out.iter().enumerate() {
1249+ let Some(default) = &param.default else {
1250+ continue;
1251+ };
1252+ for later in &out[i + 1..] {
1253+ if crate::interp::names(default).contains(&later.name.as_str()) {
1254+ let diagnostic = Diagnostic::error(
1255+ code::INVALID_DEFINITION,
1256+ param.span.line,
1257+ format!(
1258+ "the default of '{}' uses `${}`, a parameter declared after it: \
1259+ a default can use only the parameters before it, so declare '{}' \
1260+ first",
1261+ param.name, later.name, later.name
1262+ ),
1263+ )
1264+ .source(source)
1265+ .subject(later.name.clone());
1266+ problems.push(match param.span.line == list.span.line {
1267+ true => diagnostic.column(param.span.column),
1268+ false => diagnostic,
1269+ });
1270+ }
1271+ }
1272+ }
1273+ out
1274+}
1275+
1276+// ---------------------------------------------------------------------------
1277+// Reading one logical line
1278+// ---------------------------------------------------------------------------
1279+
1280+/// Maps positions in a text to source spans.
1281+trait Spans {
1282+ fn span(&self, start: usize, end: usize) -> Span;
1283+}
1284+
1285+/// One logical line: physical lines joined with a space, and where each
1286+/// piece came from.
1287+#[derive(Default)]
1288+struct Logical {
1289+ text: String,
1290+ /// (offset in `text`, offset in the source, length)
1291+ pieces: Vec<(usize, usize, usize)>,
1292+}
1293+
1294+impl Logical {
1295+ fn push(&mut self, piece: &str, source_offset: usize) {
1296+ if !self.text.is_empty() {
1297+ self.text.push(' ');
1298+ }
1299+ self.pieces
1300+ .push((self.text.len(), source_offset, piece.len()));
1301+ self.text.push_str(piece);
1302+ }
1303+
1304+ fn source_offset(&self, pos: usize) -> usize {
1305+ let i = self.pieces.partition_point(|p| p.0 <= pos).max(1) - 1;
1306+ let (at, source, len) = self.pieces[i];
1307+ source + (pos - at).min(len)
1308+ }
1309+
1310+ /// Parse the line: its kind, whether an attribute list (or inline JSON)
1311+ /// is still open at its end, and its syntax errors.
1312+ fn header(&self, index: &LineIndex) -> (NodeKind, bool, Vec<Diagnostic>) {
1313+ let spans = LogicalSpans {
1314+ logical: self,
1315+ index,
1316+ };
1317+ let reader = Reader {
1318+ text: &self.text,
1319+ spans: &spans,
1320+ };
1321+ reader.line()
1322+ }
1323+}
1324+
1325+struct LogicalSpans<'a> {
1326+ logical: &'a Logical,
1327+ index: &'a LineIndex,
1328+}
1329+
1330+impl Spans for LogicalSpans<'_> {
1331+ fn span(&self, start: usize, end: usize) -> Span {
1332+ let start_src = self.logical.source_offset(start);
1333+ let end_src = self.logical.source_offset(end).max(start_src);
1334+ self.index.span(start_src, end_src)
1335+ }
1336+}
1337+
1338+/// Spans of a single-line text that starts at `base`.
1339+struct Shifted {
1340+ base: Span,
1341+}
1342+
1343+impl Spans for Shifted {
1344+ fn span(&self, start: usize, end: usize) -> Span {
1345+ Span {
1346+ start: self.base.start + start,
1347+ end: self.base.start + end,
1348+ line: self.base.line,
1349+ column: self.base.column + start,
1350+ }
1351+ }
1352+}
1353+
1354+/// The grammar of one logical line.
1355+struct Reader<'a> {
1356+ text: &'a str,
1357+ spans: &'a dyn Spans,
1358+}
1359+
1360+/// The escapes, the same in every htmlang string (text, arguments,
1361+/// attribute values, `@let`, `@page`, `@meta`): the character(s) after the
1362+/// backslash stand for themselves. A backslash before anything else is
1363+/// kept as written, so CSS's `\201C` and a pattern's `\d` pass through.
1364+pub const ESCAPES: &[&str] = &[
1365+ "\\\\", "\\$", "\\@", "\\{", "\\}", "\\[", "\\]", "\\,", "\\\"", "\\--",
1366+];
1367+
1368+/// The length of the escape `s` starts with, or 0.
1369+pub fn escape_len(s: &str) -> usize {
1370+ if !s.starts_with('\\') {
1371+ return 0;
1372+ }
1373+ ESCAPES
1374+ .iter()
1375+ .find(|escape| s.starts_with(**escape))
1376+ .map_or(0, |escape| escape.len())
1377+}
1378+
1379+/// `s` with its escapes replaced by what they stand for.
1380+pub fn unescape(s: &str) -> String {
1381+ let mut out = String::with_capacity(s.len());
1382+ let mut i = 0;
1383+ while let Some(found) = s[i..].find('\\') {
1384+ let at = i + found;
1385+ out.push_str(&s[i..at]);
1386+ let len = escape_len(&s[at..]);
1387+ if len == 0 {
1388+ out.push('\\');
1389+ i = at + 1;
1390+ } else {
1391+ out.push_str(&s[at + 1..at + len]);
1392+ i = at + len;
1393+ }
1394+ }
1395+ out.push_str(&s[i..]);
1396+ out
1397+}
1398+
1399+/// The parts of `text` between the commas that aren't escaped (`\,`) or
1400+/// inside `(...)`, `[...]`, `{...}` (so `${...}` too) or `"..."`: the items
1401+/// of a list written in the source, or the attributes of a list.
1402+pub fn split_list(text: &str) -> Vec<std::ops::Range<usize>> {
1403+ let mut parts = Vec::new();
1404+ let mut from = 0;
1405+ let mut depth = 0i32;
1406+ let mut quoted = false;
1407+ let mut i = 0;
1408+ while i < text.len() {
1409+ let rest = &text[i..];
1410+ let escape = escape_len(rest);
1411+ if escape > 0 {
1412+ i += escape;
1413+ continue;
1414+ }
1415+ let Some(c) = rest.chars().next() else { break };
1416+ match c {
1417+ '"' => quoted = !quoted,
1418+ _ if quoted => {}
1419+ '(' | '[' | '{' => depth += 1,
1420+ ')' | ']' | '}' => depth -= 1,
1421+ ',' if depth <= 0 => {
1422+ parts.push(from..i);
1423+ from = i + 1;
1424+ }
1425+ _ => {}
1426+ }
1427+ i += c.len_utf8();
1428+ }
1429+ parts.push(from..text.len());
1430+ parts
1431+}
1432+
1433+/// The text between the quotes when `s` is one quoted string, `"..."`, and
1434+/// nothing else: `\"` inside it is not its end.
1435+pub fn quoted_string(s: &str) -> Option<&str> {
1436+ let inner = s.strip_prefix('"')?;
1437+ let mut i = 0;
1438+ while i < inner.len() {
1439+ let rest = &inner[i..];
1440+ if rest.starts_with('"') {
1441+ return (i + 1 == inner.len()).then(|| &inner[..i]);
1442+ }
1443+ i += match escape_len(rest) {
1444+ 0 => rest.chars().next().map_or(1, char::len_utf8),
1445+ n => n,
409 };
1446 };
1447+ }
1448+ None
1449+}
1450+
1451+impl Reader<'_> {
1452+ fn span(&self, start: usize, end: usize) -> Span {
1453+ self.spans.span(start, end)
1454+ }
1455+
1456+ fn skip_ws(&self, mut pos: usize, limit: usize) -> usize {
1457+ while pos < limit {
1458+ match self.text[pos..].chars().next() {
1459+ Some(c) if c.is_whitespace() => pos += c.len_utf8(),
1460+ _ => break,
1461+ }
1462+ }
1463+ pos
1464+ }
1465+
1466+ /// The end of a name starting at `pos`: the first whitespace or `[`.
1467+ fn name_end(&self, pos: usize, limit: usize) -> usize {
1468+ self.text[pos..limit]
1469+ .find(|c: char| c.is_whitespace() || c == '[')
1470+ .map_or(limit, |i| pos + i)
1471+ }
1472+
1473+ fn arg(&self, start: usize, end: usize) -> Option<Arg> {
1474+ let piece = &self.text[start..end];
1475+ let trimmed = piece.trim();
1476+ if trimmed.is_empty() {
1477+ return None;
1478+ }
1479+ let s = start + (piece.len() - piece.trim_start().len());
1480+ Some(Arg {
1481+ raw: trimmed.to_string(),
1482+ span: self.span(s, s + trimmed.len()),
1483+ })
1484+ }
1485+
1486+ fn line(&self) -> (NodeKind, bool, Vec<Diagnostic>) {
1487+ let mut problems = Vec::new();
1488+ let (kind, open) = if self.text.starts_with('@') {
1489+ let end = self.name_end(1, self.text.len());
1490+ match ast::directive(&self.text[1..end]) {
1491+ Some(spec) => {
1492+ let (args, open) = self.directive_args(spec, end, &mut problems);
1493+ let directive = Directive {
1494+ spec,
1495+ name_span: self.span(0, end),
1496+ args,
1497+ };
1498+ (NodeKind::Directive(directive), open)
1499+ }
1500+ None => {
1501+ let (line, open) = self.element_line();
1502+ (NodeKind::Element(line), open)
1503+ }
1504+ }
1505+ } else {
1506+ let (text, open) = self.text(0, self.text.len());
1507+ (NodeKind::Text(text), open)
1508+ };
1509+ if open {
1510+ // An unclosed quote keeps the list open to the end of the file
1511+ let mut quotes = 0;
1512+ let mut chars = self.text.chars();
1513+ while let Some(c) = chars.next() {
1514+ match c {
1515+ '\\' => {
1516+ chars.next();
1517+ }
1518+ '"' => quotes += 1,
1519+ _ => {}
1520+ }
1521+ }
1522+ let message = if quotes % 2 == 1 {
1523+ "unclosed '[' in attribute list: a `\"` in it isn't closed (write `\\\"` for a \
1524+ quote character)"
1525+ } else {
1526+ "unclosed '[' in attribute list"
1527+ };
1528+ problems.push(self.error(code::UNCLOSED_BRACKET, message.to_string()));
1529+ }
1530+ (kind, open, problems)
1531+ }
1532+
1533+ fn error(&self, code: &'static str, message: String) -> Diagnostic {
1534+ Diagnostic::error(code, self.span(0, 0).line, message).source(self.text.to_string())
1535+ }
1536+
1537+ /// `@name [attrs] > @name [attrs] text`.
1538+ fn element_line(&self) -> (ElementLine, bool) {
1539+ let len = self.text.len();
1540+ let mut chain = Vec::new();
1541+ let mut pos = 0;
1542+ loop {
1543+ let (head, end) = self.head(pos, len);
1544+ let unclosed = head.attrs.as_ref().is_some_and(|a| !a.closed);
1545+ chain.push(head);
1546+ if unclosed {
1547+ return (ElementLine { chain, text: None }, true);
1548+ }
1549+ let after = self.skip_ws(end, len);
1550+ if let Some(rest) = self.text[after..].strip_prefix('>') {
1551+ let next = self.skip_ws(after + 1, len);
1552+ if next > after + 1 && rest.trim_start().starts_with('@') {
1553+ pos = next;
1554+ continue;
1555+ }
1556+ }
1557+ let (text, open) = if after < len {
1558+ let (text, open) = self.text(after, len);
1559+ (Some(text), open)
1560+ } else {
1561+ (None, false)
1562+ };
1563+ return (ElementLine { chain, text }, open);
1564+ }
1565+ }
1566+
1567+ /// `@name [attrs]` at `pos`, and where it ends.
1568+ fn head(&self, pos: usize, limit: usize) -> (Head, usize) {
1569+ let name_end = self.name_end(pos + 1, limit);
1570+ let name = self.text[pos + 1..name_end].to_string();
1571+ let bracket = self.skip_ws(name_end, limit);
1572+ let (attrs, end) = if self.text[bracket..limit].starts_with('[') {
1573+ let list = self.attr_list(bracket, limit);
1574+ let end = list.1;
1575+ (Some(list.0), end)
1576+ } else {
1577+ (None, name_end)
1578+ };
1579+ let head = Head {
1580+ name,
1581+ name_span: self.span(pos, name_end),
1582+ attrs,
1583+ span: self.span(pos, end),
1584+ };
1585+ (head, end)
1586+ }
1587+
1588+ /// `[attr, attr]` starting at `open`, and where it ends. Brackets
1589+ /// inside `"..."` and escaped ones (`\]`) don't count.
1590+ fn attr_list(&self, open: usize, limit: usize) -> (AttrList, usize) {
1591+ let mut depth = 0;
1592+ let mut quoted = false;
1593+ let mut close = None;
1594+ let mut i = open;
1595+ while i < limit {
1596+ let rest = &self.text[i..limit];
1597+ let escape = escape_len(rest);
1598+ if escape > 0 {
1599+ i += escape;
1600+ continue;
1601+ }
1602+ let Some(c) = rest.chars().next() else { break };
1603+ match c {
1604+ '"' => quoted = !quoted,
1605+ _ if quoted => {}
1606+ '[' => depth += 1,
1607+ ']' => {
1608+ depth -= 1;
1609+ if depth == 0 {
1610+ close = Some(i);
1611+ break;
1612+ }
1613+ }
1614+ _ => {}
1615+ }
1616+ i += c.len_utf8();
1617+ }
1618+ let inner_end = close.unwrap_or(limit);
1619+ let attrs = self
1620+ .split_commas(open + 1, inner_end)
1621+ .into_iter()
1622+ .filter_map(|(s, e)| self.attr(s, e))
1623+ .collect();
1624+ let end = close.map_or(limit, |c| c + 1);
1625+ let list = AttrList {
1626+ attrs,
1627+ span: self.span(open, end),
1628+ closed: close.is_some(),
1629+ };
1630+ (list, end)
1631+ }
1632+
1633+ /// Ranges between the commas that aren't escaped (`\,`) or inside
1634+ /// `(...)`, `[...]`, `{...}` or `"..."`.
1635+ fn split_commas(&self, start: usize, end: usize) -> Vec<(usize, usize)> {
1636+ split_list(&self.text[start..end])
1637+ .into_iter()
1638+ .map(|part| (start + part.start, start + part.end))
1639+ .collect()
1640+ }
1641+
1642+ fn attr(&self, start: usize, end: usize) -> Option<Attr> {
1643+ let arg = self.arg(start, end)?;
1644+ let (key, value, html) = split_attribute(&arg.raw);
1645+ let piece = &self.text[start..end];
1646+ let s = start + (piece.len() - piece.trim_start().len());
1647+ if let Some(prefixed) = self.prefixed(s, s + arg.raw.len()) {
1648+ return Some(Attr {
1649+ key: prefixed.prefix.clone(),
1650+ value: None,
1651+ html: false,
1652+ raw: arg.raw,
1653+ span: arg.span,
1654+ choice: None,
1655+ prefixed: Some(Box::new(prefixed)),
1656+ });
1657+ }
1658+ let choice = self.choice(s, s + arg.raw.len()).map(Box::new);
1659+ Some(Attr {
1660+ key,
1661+ value,
1662+ html,
1663+ raw: arg.raw,
1664+ span: arg.span,
1665+ choice,
1666+ prefixed: None,
1667+ })
1668+ }
1669+
1670+ /// Prefixes applied to a `[group]`, a `$bundle` or an `if()`, when that
1671+ /// is all of `start..end`: a key that ends in `:` directly followed by
1672+ /// `[`, `$` or `if(`. A value is always after a space, so the `[` of a
1673+ /// value (`grid-template-columns [a] 1fr`) is never a group, and one
1674+ /// inside a prefix's parentheses (`has([open]):`) isn't either.
1675+ fn prefixed(&self, start: usize, end: usize) -> Option<Prefixed> {
1676+ let text = &self.text[start..end];
1677+ let mut depth = 0;
1678+ for (i, c) in text.char_indices() {
1679+ match c {
1680+ '(' => depth += 1,
1681+ ')' => depth -= 1,
1682+ _ if depth > 0 => {}
1683+ '=' | '"' | '[' | '$' | '\\' => return None,
1684+ c if c.is_whitespace() => return None,
1685+ ':' => {
1686+ let rest = &text[i + 1..];
1687+ if !(rest.starts_with(['[', '$']) || rest.starts_with("if(")) || i == 0 {
1688+ continue;
1689+ }
1690+ let at = start + i + 1;
1691+ let target = if rest.starts_with('[') {
1692+ let (list, after) = self.attr_list(at, end);
1693+ let trailing = self.arg(after, end);
1694+ Branch::Group { list, trailing }
1695+ } else {
1696+ self.attr(at, end).map_or(Branch::Empty, Branch::Attr)
1697+ };
1698+ return Some(Prefixed {
1699+ prefix: text[..i + 1].to_string(),
1700+ prefix_span: self.span(start, at),
1701+ target,
1702+ });
1703+ }
1704+ _ => {}
1705+ }
1706+ }
1707+ None
1708+ }
1709+
1710+ /// `if(CONDITION, A, B)` when it is all of `start..end`: the condition
1711+ /// and the branches, each an attribute, a `[group]` (a branch that
1712+ /// starts with `[`) or nothing.
1713+ fn choice(&self, start: usize, end: usize) -> Option<Choice> {
1714+ let text = &self.text[start..end];
1715+ if !text.starts_with("if(") || self.closing_paren(start + 2, end)? + 1 != end {
1716+ return None;
1717+ }
1718+ let mut parts = self.split_commas(start + 3, end - 1).into_iter();
1719+ let (s, e) = parts.next()?;
1720+ let condition = self.arg(s, e);
1721+ let branches = parts
1722+ .map(|(s, e)| {
1723+ let at = self.skip_ws(s, e);
1724+ if self.text[at..e].starts_with('[') {
1725+ let (list, after) = self.attr_list(at, e);
1726+ let trailing = self.arg(after, e);
1727+ Branch::Group { list, trailing }
1728+ } else {
1729+ self.attr(s, e).map_or(Branch::Empty, Branch::Attr)
1730+ }
1731+ })
1732+ .collect();
1733+ Some(Choice {
1734+ condition,
1735+ branches,
1736+ })
1737+ }
1738+
1739+ /// The `)` that closes the `(` at `open`, before `limit`. Parentheses
1740+ /// inside `"..."` and escaped characters don't count.
1741+ fn closing_paren(&self, open: usize, limit: usize) -> Option<usize> {
1742+ let mut depth = 0;
1743+ let mut quoted = false;
1744+ let mut i = open;
1745+ while i < limit {
1746+ let rest = &self.text[i..limit];
1747+ let escape = escape_len(rest);
1748+ if escape > 0 {
1749+ i += escape;
1750+ continue;
1751+ }
1752+ let c = rest.chars().next()?;
1753+ match c {
1754+ '"' => quoted = !quoted,
1755+ _ if quoted => {}
1756+ '(' => depth += 1,
1757+ ')' => {
1758+ depth -= 1;
1759+ if depth == 0 {
1760+ return Some(i);
1761+ }
1762+ }
1763+ _ => {}
1764+ }
1765+ i += c.len_utf8();
1766+ }
1767+ None
1768+ }
1769+
1770+ /// Text from `start` to `end` (trimmed), and whether an inline
1771+ /// element's attribute list is still open at its end.
1772+ fn text(&self, start: usize, end: usize) -> (Text, bool) {
1773+ let piece = &self.text[start..end];
1774+ let s = start + (piece.len() - piece.trim_start().len());
1775+ let e = s + piece.trim().len();
1776+ let mut segments = Vec::new();
1777+ let mut open = false;
1778+ let mut plain = s;
1779+ let mut i = s;
1780+ let flush = |segments: &mut Vec<Segment>, from: usize, to: usize| {
1781+ if to > from {
1782+ segments.push(Segment::Plain {
1783+ raw: self.text[from..to].to_string(),
1784+ span: self.span(from, to),
1785+ });
1786+ }
1787+ };
1788+ while i < e {
1789+ let rest = &self.text[i..e];
1790+ if rest.starts_with('\\') {
1791+ i += escape_len(rest).max(1);
1792+ continue;
1793+ }
1794+ if rest.starts_with("{@") {
1795+ flush(&mut segments, plain, i);
1796+ let (inline, inline_open, next) = self.inline(i, e);
1797+ open |= inline_open;
1798+ segments.push(Segment::Inline(Box::new(inline)));
1799+ i = next;
1800+ plain = i;
1801+ continue;
1802+ }
1803+ i += rest.chars().next().map_or(1, char::len_utf8);
1804+ }
1805+ flush(&mut segments, plain, e);
1806+ let text = Text {
1807+ raw: self.text[s..e].to_string(),
1808+ span: self.span(s, e),
1809+ segments,
1810+ };
1811+ (text, open)
1812+ }
1813+
1814+ /// `{@name [attrs] text}` at `start`: the element, whether its
1815+ /// attribute list is open at `limit`, and where it ends.
1816+ fn inline(&self, start: usize, limit: usize) -> (Inline, bool, usize) {
1817+ let mut depth = 0;
1818+ let mut close = None;
1819+ let mut i = start + 1;
1820+ while i < limit {
1821+ let rest = &self.text[i..limit];
1822+ if rest.starts_with('\\') {
1823+ i += escape_len(rest).max(1);
1824+ continue;
1825+ }
1826+ match rest.chars().next() {
1827+ Some('{') => depth += 1,
1828+ Some('}') if depth == 0 => {
1829+ close = Some(i);
1830+ break;
1831+ }
1832+ Some('}') => depth -= 1,
1833+ _ => {}
1834+ }
1835+ i += rest.chars().next().map_or(1, char::len_utf8);
1836+ }
1837+ let inner_end = close.unwrap_or(limit);
1838+ let (head, head_end) = self.head(start + 1, inner_end);
1839+ let attrs_open = head.attrs.as_ref().is_some_and(|a| !a.closed);
1840+ let after = self.skip_ws(head_end, inner_end);
1841+ let (text, text_open) = if after < inner_end {
1842+ let (text, open) = self.text(after, inner_end);
1843+ (Some(text), open)
1844+ } else {
1845+ (None, false)
1846+ };
1847+ let end = close.map_or(limit, |c| c + 1);
1848+ let inline = Inline {
1849+ raw: self.text[start + 1..inner_end].to_string(),
1850+ span: self.span(start, end),
1851+ head,
1852+ text,
1853+ closed: close.is_some(),
1854+ };
1855+ (inline, close.is_none() && (attrs_open || text_open), end)
1856+ }
1857+
1858+ /// A directive's arguments, by its grammar, and whether they continue
1859+ /// onto the next line.
1860+ fn directive_args(
1861+ &self,
1862+ spec: &'static DirectiveSpec,
1863+ name_end: usize,
1864+ problems: &mut Vec<Diagnostic>,
1865+ ) -> (DirectiveArgs, bool) {
1866+ let len = self.text.len();
1867+ let name = spec.name;
1868+ // A verbatim directive takes no attributes: `[` right after its name
1869+ // opens a list, as after any name, and the list is an error
1870+ let mut name_end = name_end;
1871+ let bracket = self.skip_ws(name_end, len);
1872+ if spec.body == BodyKind::Verbatim && self.text[bracket..].starts_with('[') {
1873+ let (list, end) = self.attr_list(bracket, len);
1874+ let reason = match name {
1875+ "raw" => "its HTML goes into the page as written, so put them in the HTML",
1876+ "head" => "its HTML goes into the <head> as written, so put them in the HTML",
1877+ "markdown" => {
1878+ "to style the Markdown, put @markdown in an element that has them \
1879+ (`@el [padding 8]` with @markdown indented under it)"
1880+ }
1881+ _ => "its CSS goes into the page as written",
1882+ };
1883+ let hint = match name {
1884+ "markdown" => String::new(),
1885+ _ => format!(
1886+ ". Content that starts with `[` goes in an indented block under @{}",
1887+ name
1888+ ),
1889+ };
1890+ problems.push(
1891+ self.error(
1892+ code::UNEXPECTED_ARGUMENT,
1893+ format!("@{} takes no attributes: {}{}", name, reason, hint),
1894+ )
1895+ .subject(self.text[bracket..end].to_string())
1896+ .column(list.span.column),
1897+ );
1898+ if !list.closed {
1899+ return (DirectiveArgs::Invalid, true);
1900+ }
1901+ name_end = end;
1902+ }
1903+ let rest = self.text[name_end..].trim();
1904+ let mut missing = |what: &str| {
1905+ problems.push(self.error(code::MISSING_ARGUMENT, format!("@{} needs {}", name, what)));
1906+ (DirectiveArgs::Invalid, false)
1907+ };
1908+ match spec.args {
1909+ ArgGrammar::Text => match self.arg(name_end, len) {
1910+ // Without a body, the line is all the directive has
1911+ None if spec.body == BodyKind::None => missing(match name {
1912+ "include" => "a file: `@include header.hl`",
1913+ "meta" => "a name and a value: `@meta description A small site`",
1914+ _ => "an argument",
1915+ }),
1916+ arg => (DirectiveArgs::Text(arg), false),
1917+ },
1918+ ArgGrammar::AttrsText => {
1919+ let bracket = self.skip_ws(name_end, len);
1920+ let (attrs, after) = if self.text[bracket..].starts_with('[') {
1921+ let (list, end) = self.attr_list(bracket, len);
1922+ (Some(list), end)
1923+ } else {
1924+ (None, name_end)
1925+ };
1926+ let open = attrs.as_ref().is_some_and(|a| !a.closed);
1927+ let title = if open { None } else { self.arg(after, len) };
1928+ (DirectiveArgs::Page { attrs, title }, open)
1929+ }
1930+ ArgGrammar::Expression => match self.arg(name_end, len) {
1931+ Some(condition) => (DirectiveArgs::Condition(condition), false),
1932+ None => missing("a condition"),
1933+ },
1934+ ArgGrammar::Else => {
1935+ if rest.is_empty() {
1936+ return (DirectiveArgs::Else { condition: None }, false);
1937+ }
1938+ let at = self.skip_ws(name_end, len);
1939+ let after_if = at + 2;
1940+ let is_if = self.text[at..].starts_with("if")
1941+ && self.text[after_if..]
1942+ .chars()
1943+ .next()
1944+ .is_none_or(char::is_whitespace);
1945+ if !is_if {
1946+ problems.push(
1947+ self.error(
1948+ code::UNEXPECTED_ARGUMENT,
1949+ "@else takes nothing on its line (a condition is `@else if COND`)"
1950+ .to_string(),
1951+ ),
1952+ );
1953+ return (DirectiveArgs::Else { condition: None }, false);
1954+ }
1955+ match self.arg(after_if, len) {
1956+ Some(condition) => (
1957+ DirectiveArgs::Else {
1958+ condition: Some(condition),
1959+ },
1960+ false,
1961+ ),
1962+ None => {
1963+ problems.push(self.error(
1964+ code::MISSING_ARGUMENT,
1965+ "@else if needs a condition".to_string(),
1966+ ));
1967+ (
1968+ DirectiveArgs::Else {
1969+ condition: Some(Arg {
1970+ raw: String::new(),
1971+ span: self.span(after_if, after_if),
1972+ }),
1973+ },
1974+ false,
1975+ )
1976+ }
1977+ }
1978+ }
1979+ ArgGrammar::Loop => {
1980+ let Some((names, list)) = rest.split_once(" in ") else {
1981+ problems.push(self.error(
1982+ code::INVALID_LOOP,
1983+ "@each requires: @each $item in LIST".to_string(),
1984+ ));
1985+ return (DirectiveArgs::Invalid, false);
1986+ };
1987+ let written: Vec<&str> = names.split(',').map(str::trim).collect();
1988+ // Like `@data $name`, the variables are written with `$`
1989+ if let Some(bare) = written.iter().find(|n| !n.starts_with('$') || n.len() == 1) {
1990+ let fixed: Vec<String> = written
1991+ .iter()
1992+ .enumerate()
1993+ .map(|(i, n)| match n.trim_start_matches('$') {
1994+ "" if i == 0 => "$item".to_string(),
1995+ "" => "$index".to_string(),
1996+ name => format!("${}", name),
1997+ })
1998+ .collect();
1999+ let problem = self.error(
2000+ code::INVALID_LOOP,
2001+ format!(
2002+ "@each names its variables with `$`: `@each {} in LIST`",
2003+ fixed.join(", ")
2004+ ),
2005+ );
2006+ // A name without its `$` gets it; a missing name has
2007+ // nothing to replace
2008+ let problem = match bare.trim_start_matches('$').is_empty() {
2009+ true => problem,
2010+ false => problem.subject(*bare).suggest(Some(format!("${}", bare))),
2011+ };
2012+ problems.push(problem);
2013+ }
2014+ let names: Vec<&str> = written
2015+ .iter()
2016+ .map(|v| v.strip_prefix('$').unwrap_or(v))
2017+ .collect();
2018+ if names.len() > 2 {
2019+ problems.push(
2020+ self.error(
2021+ code::INVALID_LOOP,
2022+ "@each takes `$item` or `$item, $index`: to loop over records, \
2023+ load them with @data and use `$item.key`"
2024+ .to_string(),
2025+ ),
2026+ );
2027+ return (DirectiveArgs::Invalid, false);
2028+ }
2029+ let list_start = len - list.trim_start().len();
2030+ let list = Arg {
2031+ raw: list.trim().to_string(),
2032+ span: self.span(list_start, len),
2033+ };
2034+ let each = Loop {
2035+ item: names[0].to_string(),
2036+ index: names.get(1).map(|s| s.to_string()),
2037+ list,
2038+ };
2039+ (DirectiveArgs::Each(each), false)
2040+ }
2041+ ArgGrammar::Data => {
2042+ let at = self.skip_ws(name_end, len);
2043+ let named = self.text[at..].strip_prefix('$').and_then(|named| {
2044+ let (name, source) = named.split_once(char::is_whitespace)?;
2045+ Some((name, source.trim()))
2046+ });
2047+ let Some((data_name, source)) = named.filter(|(_, source)| !source.is_empty())
2048+ else {
2049+ problems.push(self.error(
2050+ code::MISSING_ARGUMENT,
2051+ format!(
2052+ "@data needs a name: write `@data $name {}` and use `$name.key`",
2053+ rest.trim_start_matches('$')
2054+ ),
2055+ ));
2056+ return (DirectiveArgs::Invalid, false);
2057+ };
2058+ let name_start = at + 1;
2059+ let source_start = len - source.len();
2060+ let open = source.starts_with(['[', '{']) && !json_balanced(source);
2061+ let args = DirectiveArgs::Data {
2062+ name: data_name.to_string(),
2063+ name_span: self.span(name_start, name_start + data_name.len()),
2064+ source: Arg {
2065+ raw: source.to_string(),
2066+ span: self.span(source_start, len),
2067+ },
2068+ };
2069+ (args, open)
2070+ }
2071+ ArgGrammar::Definition => {
2072+ let at = self.skip_ws(name_end, len);
2073+ if at >= len {
2074+ return missing("a name: `@let name value`");
2075+ }
2076+ // `@let @name`: a function
2077+ let function = self.text[at..].starts_with('@');
2078+ let name_at = at + usize::from(function);
2079+ let def_name_end = self.name_end(name_at, len);
2080+ let written = &self.text[name_at..def_name_end];
2081+ let def_name = written.strip_prefix('$').unwrap_or(written).to_string();
2082+ let name_span = self.span(name_at + written.len() - def_name.len(), def_name_end);
2083+ let invalid = |message: String| {
2084+ self.error(code::INVALID_DEFINITION, message)
2085+ .subject(written.to_string())
2086+ };
2087+ if function {
2088+ let valid = def_name.chars().next().is_some_and(char::is_alphabetic)
2089+ && def_name
2090+ .chars()
2091+ .all(|c| c.is_alphanumeric() || c == '-' || c == '_');
2092+ if def_name.is_empty() {
2093+ return missing("a function name: `@let @name [param]`");
2094+ }
2095+ if !valid || written.starts_with('$') {
2096+ problems.push(invalid(format!(
2097+ "'{}' is not a function name: a function is named with letters, \
2098+ digits, `-` and `_`, starting with a letter, as in `@let @card`",
2099+ written
2100+ )));
2101+ } else if let Some(message) = shadowed(&def_name) {
2102+ let mut warning = self
2103+ .error(code::SHADOWS_BUILT_IN, message)
2104+ .subject(def_name.clone())
2105+ .column(name_span.column);
2106+ warning.severity = Severity::Warning;
2107+ problems.push(warning);
2108+ }
2109+ } else if written.starts_with('$') {
2110+ problems.push(
2111+ invalid(format!(
2112+ "`@let` names are written without `$`: `@let {}`, used as `${}`",
2113+ def_name, def_name
2114+ ))
2115+ .suggest(Some(def_name.clone())),
2116+ );
2117+ } else if !is_definition_name(&def_name) {
2118+ problems.push(invalid(if def_name.starts_with("--") {
2119+ format!(
2120+ "'{}' is not a custom property's name: `--` and then letters, \
2121+ digits, `-` and `_` (`@let --brand #3b82f6`)",
2122+ written
2123+ )
2124+ } else {
2125+ format!(
2126+ "'{}' is not a name: a @let name is letters, digits, `-` and `_`, \
2127+ starting with a letter (`@let gap 8`)",
2128+ written
2129+ )
2130+ }));
2131+ }
2132+ let value_at = self.skip_ws(def_name_end, len);
2133+ let value = &self.text[value_at..];
2134+ let mut open = false;
2135+ // Text after a closed list's `]`
2136+ let mut after_list = |end: usize, what: &str| {
2137+ let rest = self.text[end..].trim();
2138+ if !rest.is_empty() {
2139+ problems.push(self.error(
2140+ code::UNEXPECTED_ARGUMENT,
2141+ format!("unexpected '{}' after the {}", rest, what),
2142+ ));
2143+ }
2144+ };
2145+ let form = if function {
2146+ let list = if value.starts_with('[') {
2147+ let (list, end) = self.attr_list(value_at, len);
2148+ open = !list.closed;
2149+ if list.closed {
2150+ after_list(end, "parameter list");
2151+ }
2152+ Some(list)
2153+ } else {
2154+ if !value.is_empty() {
2155+ problems.push(self.error(
2156+ code::INVALID_DEFINITION,
2157+ format!(
2158+ "a function's parameters go in brackets: \
2159+ `@let @{} [param, param default]`",
2160+ def_name
2161+ ),
2162+ ));
2163+ }
2164+ None
2165+ };
2166+ let params = match &list {
2167+ Some(list) => params(list, self.text, problems),
2168+ None => Vec::new(),
2169+ };
2170+ LetForm::Function(Function { list, params })
2171+ } else if value.is_empty() {
2172+ let message = if def_name.starts_with("--") {
2173+ format!(
2174+ "`@let {}` needs a value: a custom property is declared with one, \
2175+ `@let {} 8px`",
2176+ def_name, def_name
2177+ )
2178+ } else {
2179+ format!(
2180+ "`@let {}` needs a value: `@let {} VALUE` \
2181+ (a function is `@let @{}` with an indented body)",
2182+ def_name, def_name, def_name
2183+ )
2184+ };
2185+ problems.push(self.error(code::MISSING_ARGUMENT, message));
2186+ return (DirectiveArgs::Invalid, false);
2187+ } else if let Some(expr) = value.strip_prefix('=') {
2188+ let expr_at = len - expr.trim_start().len();
2189+ LetForm::Computed(Arg {
2190+ raw: expr.trim().to_string(),
2191+ span: self.span(expr_at, len),
2192+ })
2193+ } else if value.starts_with('[') {
2194+ let (list, end) = self.attr_list(value_at, len);
2195+ open = !list.closed;
2196+ if list.closed {
2197+ after_list(end, "attribute bundle");
2198+ }
2199+ LetForm::Bundle(list)
2200+ } else if let Some(inner) = quoted_string(value) {
2201+ LetForm::Quoted(Arg {
2202+ raw: inner.to_string(),
2203+ span: self.span(value_at + 1, len - 1),
2204+ })
2205+ } else {
2206+ match self.arg(value_at, len) {
2207+ Some(arg) => LetForm::Value(arg),
2208+ None => return (DirectiveArgs::Invalid, false),
2209+ }
2210+ };
2211+ let def = LetDef {
2212+ name: def_name,
2213+ name_span,
2214+ form,
2215+ };
2216+ (DirectiveArgs::Let(def), open)
2217+ }
2218+ }
2219+ }
2220+}
2221+
2222+/// Why a function can't be named `name`: it is the name of a built-in
2223+/// element, which the function would replace everywhere after it, or of a
2224+/// directive, which it could never replace.
2225+fn shadowed(name: &str) -> Option<String> {
2226+ if ast::ElementKind::from_name(name).is_some() {
2227+ Some(format!(
2228+ "@let @{0} replaces the built-in element @{0}: every @{0} after this line \
2229+ calls the function. Give the function another name",
2230+ name
2231+ ))
2232+ } else if ast::directive(name).is_some() {
2233+ Some(format!(
2234+ "@{0} is a directive, so a function named @{0} can never be called: give it \
2235+ another name",
2236+ name
2237+ ))
2238+ } else {
2239+ None
2240+ }
2241+}
2242+
2243+/// Whether `name` can be a value's or bundle's name, as `@let name`: a
2244+/// name `$name` reaches (`t.greeting` too, for a record's field), or a
2245+/// custom property `--name`, named as on an element (`[--name value]`).
2246+fn is_definition_name(name: &str) -> bool {
2247+ if name.starts_with("--") {
2248+ return crate::vocab::is_custom_property(name);
2249+ }
2250+ let n = crate::interp::name_len(name);
2251+ if n == 0 || matches!(&name[..n], "true" | "false" | "not" | "and" | "or") {
2252+ return false;
2253+ }
2254+ name[n..].split('.').skip(1).all(|field| {
2255+ !field.is_empty()
2256+ && field
2257+ .chars()
2258+ .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
2259+ }) && (n == name.len() || name[n..].starts_with('.'))
2260+}
2261+
2262+/// An attribute's key, value, and whether it is written `key=value`:
2263+/// `padding 20`, `type=email`, `required`.
2264+pub(crate) fn split_attribute(raw: &str) -> (String, Option<String>, bool) {
2265+ if let Some((key, value)) = split_html_attribute(raw) {
2266+ return (key.to_string(), Some(value.to_string()), true);
2267+ }
2268+ match key_end(raw) {
2269+ Some(end) => (
2270+ raw[..end].trim().to_string(),
2271+ Some(raw[end + 1..].trim().to_string()),
2272+ false,
2273+ ),
2274+ None => (raw.to_string(), None, false),
2275+ }
2276+}
2277+
2278+/// Where a style's key ends: at the first space outside parentheses, so a
2279+/// prefix's argument can hold spaces (`has(> img):padding 0`). Inside
2280+/// parentheses, a quoted string and a character after a backslash don't
2281+/// count, as in [`crate::vocab::closing_paren`]
2282+/// (`not([title=") x"]):color red`). When a `(` is never closed, at the
2283+/// first space.
2284+fn key_end(raw: &str) -> Option<usize> {
2285+ let mut depth = 0usize;
2286+ let mut quote = None;
2287+ let mut chars = raw.char_indices();
2288+ while let Some((i, c)) = chars.next() {
2289+ match (quote, c) {
2290+ (_, '\\') if depth > 0 => {
2291+ chars.next();
2292+ }
2293+ (Some(q), c) if c == q => quote = None,
2294+ (Some(_), _) => {}
2295+ (None, '"' | '\'') if depth > 0 => quote = Some(c),
2296+ (None, '(') => depth += 1,
2297+ (None, ')') => depth = depth.saturating_sub(1),
2298+ (None, ' ') if depth == 0 => return Some(i),
2299+ _ => {}
2300+ }
2301+ }
2302+ if depth > 0 || quote.is_some() {
2303+ raw.find(' ')
2304+ } else {
2305+ None
2306+ }
2307+}
2308+
2309+/// Split an HTML attribute written `key=value` (`alt=`, `type=email`,
2310+/// `aria-label=Close menu`, and names with a colon such as `xml:lang=en`
2311+/// or `x-on:click=open`). Returns `None` for style attributes.
2312+pub(crate) fn split_html_attribute(part: &str) -> Option<(&str, &str)> {
2313+ let first_token = part.split(char::is_whitespace).next()?;
2314+ let eq = first_token.find('=')?;
2315+ let key = &part[..eq];
2316+ let valid_key = key.starts_with(|c: char| c.is_ascii_alphabetic())
2317+ && key
2318+ .chars()
2319+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'));
2320+ valid_key.then(|| (key, part[eq + 1..].trim()))
2321+}
2322+
2323+/// Whether the brackets and braces of inline JSON are balanced.
2324+fn json_balanced(json: &str) -> bool {
2325+ let mut depth = 0i32;
2326+ let mut in_string = false;
2327+ let mut escaped = false;
2328+ for c in json.chars() {
2329+ if in_string {
2330+ match c {
2331+ _ if escaped => escaped = false,
2332+ '\\' => escaped = true,
2333+ '"' => in_string = false,
2334+ _ => {}
2335+ }
2336+ continue;
2337+ }
2338+ match c {
2339+ '"' => in_string = true,
2340+ '[' | '{' => depth += 1,
2341+ ']' | '}' => depth -= 1,
2342+ _ => {}
2343+ }
2344+ }
2345+ depth <= 0
2346+}
2347+
2348+#[cfg(test)]
2349+mod tests {
2350+ use super::*;
2351+
2352+ fn kinds(nodes: &[Node]) -> Vec<&'static str> {
2353+ nodes
2354+ .iter()
2355+ .map(|n| match &n.kind {
2356+ NodeKind::Blank => "blank",
2357+ NodeKind::Comment => "comment",
2358+ NodeKind::Text(_) => "text",
2359+ NodeKind::Element(_) => "element",
2360+ NodeKind::Directive(d) => d.name(),
2361+ NodeKind::Verbatim(_) => "verbatim",
2362+ })
2363+ .collect()
2364+ }
2365+
2366+ fn codes(tree: &Tree) -> Vec<&'static str> {
2367+ tree.diagnostics.iter().map(|d| d.code).collect()
2368+ }
2369+
2370+ #[test]
2371+ fn lines_become_nodes_with_trivia() {
2372+ let tree = parse("-- intro\n@el [padding 4]\n Hello\n\n @text x\n@text y\n");
2373+ assert_eq!(kinds(&tree.nodes), ["comment", "element", "element"]);
2374+ assert_eq!(kinds(&tree.nodes[1].children), ["text", "blank", "element"]);
2375+ assert!(tree.diagnostics.is_empty());
2376+ }
2377+
2378+ #[test]
2379+ fn a_comment_is_a_double_dash_followed_by_a_space_or_nothing() {
2380+ for line in ["-- note", "--", "--\tnote", "-- "] {
2381+ assert!(is_comment(line), "{line:?}");
2382+ }
2383+ for line in ["--gap 12px", "---", "--x", "- - x", "text -- x"] {
2384+ assert!(!is_comment(line), "{line:?}");
2385+ }
2386+ // `--gap 12px` on a line of a list is one of its attributes
2387+ let tree = parse("@el [\n padding 4,\n -- note\n --gap 12px,\n --\n color red\n]\n");
2388+ assert!(tree.diagnostics.is_empty(), "{:?}", tree.diagnostics);
2389+ let NodeKind::Element(line) = &tree.nodes[0].kind else {
2390+ panic!()
2391+ };
2392+ let keys: Vec<_> = line.chain[0]
2393+ .attrs
2394+ .as_ref()
2395+ .unwrap()
2396+ .attrs
2397+ .iter()
2398+ .map(|a| a.key.as_str())
2399+ .collect();
2400+ assert_eq!(keys, ["padding", "--gap", "color"]);
2401+ // At the start of a line outside a list, `--x` is text
2402+ assert_eq!(kinds(&parse("--x marks the spot\n").nodes), ["text"]);
2403+ }
2404+
2405+ #[test]
2406+ fn heads_attributes_and_spans() {
2407+ let src = "@row [spacing 8, id=main] > @link [color red] /home Home\n";
2408+ let tree = parse(src);
2409+ let NodeKind::Element(line) = &tree.nodes[0].kind else {
2410+ panic!()
2411+ };
2412+ let names: Vec<_> = line.chain.iter().map(|h| h.name.as_str()).collect();
2413+ assert_eq!(names, ["row", "link"]);
2414+ let attrs = &line.chain[0].attrs.as_ref().unwrap().attrs;
2415+ assert_eq!(attrs[0].key, "spacing");
2416+ assert_eq!(attrs[0].value.as_deref(), Some("8"));
2417+ assert!(attrs[1].html);
2418+ assert_eq!(&src[attrs[1].span.start..attrs[1].span.end], "id=main");
2419+ assert_eq!(attrs[1].span.column, 17);
2420+ assert_eq!(line.text.as_ref().unwrap().raw, "/home Home");
2421+ }
2422+
2423+ #[test]
2424+ fn a_bracket_opens_a_list_only_after_a_head() {
2425+ let tree = parse("@text Use [ to open\n@text next\n");
2426+ assert_eq!(kinds(&tree.nodes), ["element", "element"]);
2427+ assert!(tree.diagnostics.is_empty());
2428+ let tree = parse("@el [\n padding 4,\n -- note\n color red\n]\n child\n");
2429+ assert_eq!(kinds(&tree.nodes), ["element"]);
2430+ let NodeKind::Element(line) = &tree.nodes[0].kind else {
2431+ panic!()
2432+ };
2433+ let list = line.chain[0].attrs.as_ref().unwrap();
2434+ assert_eq!(list.attrs.len(), 2);
2435+ assert_eq!(list.attrs[1].span.line, 4);
2436+ assert_eq!(kinds(&tree.nodes[0].children), ["text"]);
2437+ }
2438+
2439+ #[test]
2440+ fn a_whole_attribute_if_is_read_into_its_branches() {
2441+ let src = "@el [if($a == \"x, y\", [padding 4, if($b, gap 1)], $card), width if(media(print): 1px), if(c, )]\n";
2442+ let tree = parse(src);
2443+ let NodeKind::Element(line) = &tree.nodes[0].kind else {
2444+ panic!()
2445+ };
2446+ let attrs = &line.chain[0].attrs.as_ref().unwrap().attrs;
2447+ assert_eq!(attrs.len(), 3);
2448+ let choice = attrs[0].choice.as_ref().expect("an if()");
2449+ let condition = choice.condition.as_ref().unwrap();
2450+ assert_eq!(condition.raw, "$a == \"x, y\"");
2451+ assert_eq!(
2452+ &src[condition.span.start..condition.span.end],
2453+ condition.raw
2454+ );
2455+ let Branch::Group { list, trailing } = &choice.branches[0] else {
2456+ panic!("{:?}", choice.branches[0])
2457+ };
2458+ assert!(trailing.is_none());
2459+ assert_eq!(list.attrs.len(), 2);
2460+ assert_eq!(list.attrs[0].key, "padding");
2461+ assert_eq!(
2462+ &src[list.attrs[0].span.start..list.attrs[0].span.end],
2463+ "padding 4"
2464+ );
2465+ assert!(list.attrs[1].choice.is_some(), "an if() inside a group");
2466+ assert!(matches!(&choice.branches[1], Branch::Attr(a) if a.key == "$card"));
2467+ // An if() inside a value is CSS's; an empty branch is empty
2468+ assert!(attrs[1].choice.is_none());
2469+ let empty = attrs[2].choice.as_ref().unwrap();
2470+ assert!(matches!(empty.branches[..], [Branch::Empty]));
2471+ // A group spanning lines keeps each attribute's line
2472+ let tree = parse("@el [if($on, [\n padding 4,\n margin 2\n])]\n");
2473+ let NodeKind::Element(line) = &tree.nodes[0].kind else {
2474+ panic!()
2475+ };
2476+ let choice = line.chain[0].attrs.as_ref().unwrap().attrs[0]
2477+ .choice
2478+ .clone()
2479+ .unwrap();
2480+ let group = choice.branches[0].attrs();
2481+ assert_eq!((group[0].span.line, group[0].span.column), (2, 2));
2482+ assert_eq!((group[1].span.line, group[1].span.column), (3, 2));
2483+ }
2484+
2485+ #[test]
2486+ fn prefixes_apply_to_a_group_a_bundle_or_an_if() {
2487+ let src = "@el [md:hover:[padding 32, dark:color red], dark:$card, hover:if($on, color red), grid-template-columns [a] 1fr, has([open]):color red, xml:lang=en, md:x]\n";
2488+ let tree = parse(src);
2489+ let NodeKind::Element(line) = &tree.nodes[0].kind else {
2490+ panic!()
2491+ };
2492+ let attrs = &line.chain[0].attrs.as_ref().unwrap().attrs;
2493+ assert_eq!(attrs.len(), 7);
2494+ let group = attrs[0].prefixed.as_ref().expect("a prefixed group");
2495+ assert_eq!(group.prefix, "md:hover:");
2496+ assert_eq!(attrs[0].key, "md:hover:");
2497+ assert_eq!(
2498+ &src[group.prefix_span.start..group.prefix_span.end],
2499+ "md:hover:"
2500+ );
2501+ let keys: Vec<&str> = group
2502+ .target
2503+ .attrs()
2504+ .iter()
2505+ .map(|a| a.key.as_str())
2506+ .collect();
2507+ assert_eq!(keys, ["padding", "dark:color"]);
2508+ let bundle = attrs[1].prefixed.as_ref().expect("a prefixed bundle");
2509+ assert!(matches!(&bundle.target, Branch::Attr(a) if a.key == "$card"));
2510+ let choice = attrs[2].prefixed.as_ref().expect("a prefixed if()");
2511+ assert!(matches!(&choice.target, Branch::Attr(a) if a.choice.is_some()));
2512+ // A value's `[` and a `[` inside a prefix's parentheses are not groups
2513+ assert!(attrs[3].prefixed.is_none());
2514+ assert_eq!(attrs[3].key, "grid-template-columns");
2515+ assert!(attrs[4].prefixed.is_none());
2516+ // A name with a colon and `=` is an HTML attribute
2517+ assert!(attrs[5].html && attrs[5].key == "xml:lang");
2518+ assert!(attrs[6].prefixed.is_none());
2519+ }
2520+
2521+ #[test]
2522+ fn a_chain_needs_complete_heads() {
2523+ let tree = parse("@text Ask me > @support\n");
2524+ let NodeKind::Element(line) = &tree.nodes[0].kind else {
2525+ panic!()
2526+ };
2527+ assert_eq!(line.chain.len(), 1);
2528+ assert_eq!(line.text.as_ref().unwrap().raw, "Ask me > @support");
2529+ }
2530+
2531+ #[test]
2532+ fn escapes_are_one_closed_table() {
410 assert_eq!(
2533 assert_eq!(
411− (name.as_str(), params.len(), defaults["icon"].as_str()),
2534+ unescape(r#"\\ \$ \@ \{ \} \[ \] \, \" \-- \201C \d \"#),
412− ("card", 2, "x")
2535+ r#"\ $ @ { } [ ] , " -- \201C \d \"#
413 );
2536 );
414− assert_eq!(kinds(body), ["line"]);
2537+ assert_eq!(quoted_string(r#""a\"b""#), Some(r#"a\"b"#));
415− let Syntax::If { branches } = &tree[1] else {
2538+ assert_eq!(quoted_string(r#""head head" "side main""#), None);
416− unreachable!()
2539+ assert_eq!(quoted_string(r#""a"#), None);
2540+ assert_eq!(quoted_string(r#""""#), Some(""));
2541+ }
2542+
2543+ #[test]
2544+ fn escaped_commas_and_brackets_stay_in_the_value() {
2545+ let tree = parse("@el [transition opacity 1s\\, color 1s, content \"]\", width 4\\]] x\n");
2546+ let NodeKind::Element(line) = &tree.nodes[0].kind else {
2547+ panic!()
2548+ };
2549+ let attrs = &line.chain[0].attrs.as_ref().unwrap().attrs;
2550+ let raw: Vec<&str> = attrs.iter().map(|a| a.raw.as_str()).collect();
2551+ assert_eq!(
2552+ raw,
2553+ [
2554+ r"transition opacity 1s\, color 1s",
2555+ r#"content "]""#,
2556+ r"width 4\]"
2557+ ]
2558+ );
2559+ assert_eq!(line.text.as_ref().unwrap().raw, "x");
2560+ }
2561+
2562+ #[test]
2563+ fn a_let_is_quoted_only_when_it_is_one_string() {
2564+ let tree = parse("@let a \"x \\\" y\"\n@let b \"h h\" \"s m\"\n");
2565+ let forms: Vec<&LetForm> = tree
2566+ .nodes
2567+ .iter()
2568+ .map(|n| match &n.directive().unwrap().args {
2569+ DirectiveArgs::Let(def) => &def.form,
2570+ _ => panic!(),
2571+ })
2572+ .collect();
2573+ assert!(matches!(forms[0], LetForm::Quoted(arg) if arg.raw == r#"x \" y"#));
2574+ assert!(matches!(forms[1], LetForm::Value(arg) if arg.raw == r#""h h" "s m""#));
2575+ }
2576+
2577+ #[test]
2578+ fn inline_elements_and_escapes() {
2579+ let tree = parse("Read {@link /docs the {@b docs}} \\{@not} now\n");
2580+ let NodeKind::Text(text) = &tree.nodes[0].kind else {
2581+ panic!()
417 };
2582 };
418− let conditions: Vec<_> = branches.iter().map(|b| b.condition.as_deref()).collect();
2583+ assert_eq!(text.segments.len(), 3);
419− assert_eq!(conditions, [Some("$a"), Some("$b"), None]);
2584+ let Segment::Inline(inline) = &text.segments[1] else {
420− let Syntax::Each { empty, .. } = &tree[2] else {
2585+ panic!()
421− unreachable!()
422 };
2586 };
423− assert_eq!(kinds(empty), ["line"]);
2587+ assert_eq!(inline.head.name, "link");
2588+ assert_eq!(inline.text.as_ref().unwrap().segments.len(), 2);
424 }
2589 }
425
2590
426 #[test]
2591 #[test]
427− fn text_bodies_are_verbatim() {
2592+ fn the_text_of_code_and_textarea_has_no_inline_elements() {
428− let tree = parse("@style\n :root {\n --brand: red;\n }\n@text x\n");
2593+ let plain = |text: &Text| {
429− let Syntax::Line { children, .. } = &tree[0] else {
2594+ text.segments.len() == 1 && matches!(text.segments[0], Segment::Plain { .. })
430− unreachable!()
431 };
2595 };
432− let [Syntax::Raw { text, .. }] = children.as_slice() else {
2596+ // On the element's line and in a chain
433− panic!("{:?}", children)
2597+ let tree = parse("@code {@link /x y}\n@pre > @code {@b x}\n");
2598+ for node in &tree.nodes[..2] {
2599+ let NodeKind::Element(line) = &node.kind else {
2600+ panic!()
2601+ };
2602+ assert!(plain(line.text.as_ref().unwrap()), "{:?}", line.text);
2603+ }
2604+ // The lines under it are a verbatim body, shown as text, also at
2605+ // the end of a chain; @pre's own lines are htmlang
2606+ let tree = parse("@textarea\n {@b x}\n@pre > @code\n @b y\n -- z\n@pre\n @b w\n");
2607+ for (node, text) in [(0, "{@b x}"), (1, "@b y\n -- z")] {
2608+ let NodeKind::Verbatim(body) = &tree.nodes[node].children[0].kind else {
2609+ panic!("{:?}", tree.nodes[node].children)
2610+ };
2611+ assert!(body.escaped);
2612+ assert_eq!(body.text, text);
2613+ }
2614+ assert!(matches!(
2615+ tree.nodes[2].children[0].kind,
2616+ NodeKind::Element(_)
2617+ ));
2618+ assert!(tree.diagnostics.is_empty(), "{:?}", tree.diagnostics);
2619+ // Its line and a body together are an error
2620+ assert_eq!(codes(&parse("@code x\n y\n")), [code::UNEXPECTED_BODY]);
2621+ assert_eq!(
2622+ codes(&parse("@pre > @code x\n y\n")),
2623+ [code::UNEXPECTED_BODY]
2624+ );
2625+ // Inline: `{@code ...}`'s text is literal, the line around it isn't
2626+ let tree = parse("@h2 Write {@code {@link /x y}} or {@b {@code {@i z}}}\n");
2627+ let NodeKind::Element(line) = &tree.nodes[0].kind else {
2628+ panic!()
2629+ };
2630+ let heads: Vec<&str> = tree.nodes[0]
2631+ .heads()
2632+ .iter()
2633+ .map(|h| h.name.as_str())
2634+ .collect();
2635+ assert_eq!(heads, ["h2", "code", "b", "code"]);
2636+ let Segment::Inline(code) = &line.text.as_ref().unwrap().segments[1] else {
2637+ panic!()
434 };
2638 };
435− assert_eq!(text, ":root {\n --brand: red;\n}");
2639+ assert!(plain(code.text.as_ref().unwrap()));
436− assert_eq!(kinds(&tree), ["line", "line"]);
2640+ assert_eq!(code.text.as_ref().unwrap().raw, "{@link /x y}");
2641+ // Any other element's text is parsed
2642+ let tree = parse("@h1 Hello {@text [color red] world}\n");
2643+ assert_eq!(tree.nodes[0].heads().len(), 2);
2644+ }
2645+
2646+ #[test]
2647+ fn inline_attribute_lists_continue() {
2648+ let tree = parse("Press {@kbd [font-weight bold,\n color red] K} to go\n@text x\n");
2649+ assert_eq!(kinds(&tree.nodes), ["text", "element"]);
2650+ assert!(tree.diagnostics.is_empty());
2651+ }
2652+
2653+ #[test]
2654+ fn directives_follow_the_table() {
2655+ let tree = parse(
2656+ "@let @card [title, icon x]\n @el\n @children\n@if $a\n A\n@else if $b\n B\n@else\n C\n@each $x in 1..3\n $x\n@else\n none\n",
2657+ );
2658+ assert_eq!(
2659+ kinds(&tree.nodes),
2660+ ["let", "if", "else", "else", "each", "else"]
2661+ );
2662+ let defs = tree.definitions();
2663+ assert_eq!(defs[0].kind, DefinitionKind::Function);
2664+ assert_eq!(defs[0].params[1].default.as_deref(), Some("x"));
2665+ assert!(tree.diagnostics.is_empty(), "{:?}", tree.diagnostics);
2666+ }
2667+
2668+ #[test]
2669+ fn names_are_visible_to_the_end_of_their_block() {
2670+ let tree = parse(
2671+ "@let a 1\n@let @card [title]\n @let inner 2\n @el $title\n@if $a\n @let b [padding 4]\n @el x\n@each $x, $i in 1, 2\n @text $x\n@data $d {}\n@let t.greeting hi\n@el end\n",
2672+ );
2673+ let names = |line| {
2674+ tree.visible_at(line)
2675+ .iter()
2676+ .map(|v| v.name.to_string())
2677+ .collect::<Vec<_>>()
2678+ };
2679+ // In a function's body: its parameters, itself, and what is above it
2680+ assert_eq!(names(4), ["a", "card", "title", "inner"]);
2681+ assert_eq!(names(7), ["a", "card", "b"]);
2682+ assert_eq!(names(9), ["a", "card", "x", "i"]);
2683+ assert_eq!(names(12), ["a", "card", "d", "t"]);
2684+ assert_eq!(names(1), Vec::<String>::new());
2685+ let kinds: Vec<VisibleKind> = tree.visible_at(9).iter().map(|v| v.kind).collect();
2686+ assert_eq!(kinds[2], VisibleKind::Loop);
2687+ }
2688+
2689+ #[test]
2690+ fn a_file_of_definitions_only_is_a_library() {
2691+ let library = |src: &str| parse(src).is_library();
2692+ assert!(library(
2693+ "-- layout\n@let @layout\n @page Home\n @children\n\n@let gap 8\n"
2694+ ));
2695+ assert!(library("@let card [\n padding 8,\n color red\n]\n"));
2696+ assert!(!library("@let gap 8\n@text $gap\n"));
2697+ assert!(!library("@page Home\n@let gap 8\n"));
2698+ assert!(!library("@include layout.hl\n"));
2699+ assert!(library("@data $site {\"a\": 1}\n@let gap 8\n"));
2700+ // An included file counts when it is a library too
2701+ let with = |src: &str, included: bool| parse(src).is_library_with(&mut |_| included);
2702+ assert!(with("@include base.hl\n@let accent red\n", true));
2703+ assert!(!with("@include header.hl\n@let accent red\n", false));
2704+ assert!(!library("-- nothing\n\n"));
2705+ assert!(!library(""));
2706+ }
2707+
2708+ #[test]
2709+ fn a_let_says_its_kind_on_its_line() {
2710+ let tree = parse(
2711+ "@let a 1\n@let b [padding 4]\n@let @c [p, q two words]\n @el\n@let @d\n @el\n@let @e [\n x,\n y 1\n]\n @el\n",
2712+ );
2713+ assert!(tree.diagnostics.is_empty(), "{:?}", tree.diagnostics);
2714+ let defs = tree.definitions();
2715+ let kinds: Vec<_> = defs.iter().map(|d| (d.name, d.kind)).collect();
2716+ assert_eq!(
2717+ kinds,
2718+ [
2719+ ("a", DefinitionKind::Value),
2720+ ("b", DefinitionKind::Bundle),
2721+ ("c", DefinitionKind::Function),
2722+ ("d", DefinitionKind::Function),
2723+ ("e", DefinitionKind::Function),
2724+ ]
2725+ );
2726+ // A bare word is required; `name value` has a default, spaces and all
2727+ let c = &defs[2];
2728+ assert_eq!(
2729+ (c.params[0].name.as_str(), c.params[0].default.as_deref()),
2730+ ("p", None)
2731+ );
2732+ assert_eq!(c.params[1].default.as_deref(), Some("two words"));
2733+ // The name's span leaves out the `@`
2734+ assert_eq!((c.name_span.column, c.params[1].name_span.column), (6, 12));
2735+ assert!(defs[3].params.is_empty());
2736+ let e: Vec<_> = defs[4]
2737+ .params
2738+ .iter()
2739+ .map(|p| (p.name.as_str(), p.name_span.line))
2740+ .collect();
2741+ assert_eq!(e, [("x", 8), ("y", 9)]);
2742+ }
2743+
2744+ #[test]
2745+ fn verbatim_bodies() {
2746+ let tree = parse("@style\n :root {\n --brand: red;\n }\n\n@raw <hr>\n@text x\n");
2747+ assert_eq!(kinds(&tree.nodes), ["style", "blank", "raw", "element"]);
2748+ let [child] = tree.nodes[0].children.as_slice() else {
2749+ panic!()
2750+ };
2751+ let NodeKind::Verbatim(body) = &child.kind else {
2752+ panic!()
2753+ };
2754+ assert_eq!(body.text, ":root {\n --brand: red;\n}");
2755+ }
2756+
2757+ #[test]
2758+ fn multi_line_inline_json() {
2759+ let tree = parse("@data $x {\n \"a\": [1, 2],\n \"b\": \"]\"\n}\n@text $x.a\n");
2760+ assert_eq!(kinds(&tree.nodes), ["data", "element"]);
2761+ let Some(Directive {
2762+ args: DirectiveArgs::Data { source, .. },
2763+ ..
2764+ }) = tree.nodes[0].directive()
2765+ else {
2766+ panic!()
2767+ };
2768+ assert!(source.raw.ends_with('}'));
2769+ }
2770+
2771+ #[test]
2772+ fn the_leading_token_keeps_quotes_and_expressions_whole() {
2773+ fn token(s: &str) -> &str {
2774+ &s[..leading_token_len(s)]
2775+ }
2776+ assert_eq!(token("/about About us"), "/about");
2777+ assert_eq!(token("$url More"), "$url");
2778+ assert_eq!(token("/page/${$n + 1} Next"), "/page/${$n + 1}");
2779+ assert_eq!(token("${ {\"a\": 1} } x"), "${ {\"a\": 1} }");
2780+ assert_eq!(token("\"my page.html\" Open"), "\"my page.html\"");
2781+ assert_eq!(token("\"a \\\" b\" c"), "\"a \\\" b\"");
2782+ assert_eq!(token("a\\,b c"), "a\\,b");
2783+ assert_eq!(token("${unclosed x"), "${unclosed");
2784+ assert_eq!(token("\"unclosed x"), "\"unclosed x");
2785+ assert_eq!(token("single"), "single");
2786+
2787+ let tree = parse("@link /page/${$n + 1} Next page\n");
2788+ let NodeKind::Element(line) = &tree.nodes[0].kind else {
2789+ panic!("an element")
2790+ };
2791+ let (token, rest) = line.text.as_ref().unwrap().split_leading();
2792+ assert_eq!(token.raw, "/page/${$n + 1}");
2793+ assert_eq!(token.span.column, 6);
2794+ let rest = rest.unwrap();
2795+ assert_eq!(rest.raw, "Next page");
2796+ assert_eq!(rest.span.column, 22);
2797+ }
2798+
2799+ #[test]
2800+ fn body_kinds_are_checked() {
2801+ let tree = parse("@page Home\n @text x\n@meta a b\n-- fine\n");
2802+ assert_eq!(codes(&tree), [code::UNEXPECTED_BODY]);
2803+ assert_eq!(tree.diagnostics[0].line, 2);
2804+ let tree = parse("@let x = 1\n @text y\n");
2805+ assert_eq!(codes(&tree), [code::UNEXPECTED_BODY]);
2806+ let tree = parse("@raw <hr>\n <br>\n");
2807+ assert_eq!(codes(&tree), [code::UNEXPECTED_BODY]);
2808+ let tree = parse("@style x\n a {}\n");
2809+ assert_eq!(codes(&tree), [code::UNEXPECTED_BODY]);
2810+ }
2811+
2812+ #[test]
2813+ fn verbatim_directives_take_their_line_and_no_attributes() {
2814+ // The rest of the line is the one-line body (or @markdown's file)
2815+ for src in [
2816+ "@style .a { color: red }\n",
2817+ "@head <meta name=x>\n",
2818+ "@raw <hr>\n",
2819+ "@markdown notes.md\n",
2820+ ] {
2821+ let tree = parse(src);
2822+ assert!(
2823+ tree.diagnostics.is_empty(),
2824+ "{}: {:?}",
2825+ src,
2826+ tree.diagnostics
2827+ );
2828+ let Some(DirectiveArgs::Text(Some(arg))) = tree.nodes[0].directive().map(|d| &d.args)
2829+ else {
2830+ panic!("{}", src)
2831+ };
2832+ assert_eq!(arg.raw, src.split_once(' ').unwrap().1.trim_end());
2833+ }
2834+ // `[` right after the name is an attribute list, which is an error;
2835+ // the text after it is still the line's content
2836+ for name in ["style", "head", "raw", "markdown"] {
2837+ let tree = parse(&format!("@{} [id=x] rest\n", name));
2838+ assert_eq!(codes(&tree), [code::UNEXPECTED_ARGUMENT], "@{}", name);
2839+ assert_eq!(tree.diagnostics[0].column, Some(name.len() + 2));
2840+ let Some(DirectiveArgs::Text(Some(arg))) = tree.nodes[0].directive().map(|d| &d.args)
2841+ else {
2842+ panic!("@{}", name)
2843+ };
2844+ assert_eq!(arg.raw, "rest");
2845+ }
2846+ // A line and a block together: @markdown's line is a file
2847+ let tree = parse("@markdown notes.md\n # Title\n");
2848+ assert_eq!(codes(&tree), [code::UNEXPECTED_BODY]);
2849+ assert!(
2850+ tree.diagnostics[0]
2851+ .message
2852+ .contains("a file named on its line"),
2853+ "{}",
2854+ tree.diagnostics[0].message
2855+ );
2856+ // In the indented block, `[` is content
2857+ assert!(
2858+ parse("@style\n [hidden] { display: none }\n")
2859+ .diagnostics
2860+ .is_empty()
2861+ );
2862+ }
2863+
2864+ #[test]
2865+ fn syntax_errors() {
2866+ assert_eq!(codes(&parse("@else\n x\n")), [code::STRAY_ELSE]);
2867+ assert_eq!(codes(&parse("@if\n x\n")), [code::MISSING_ARGUMENT]);
2868+ assert_eq!(codes(&parse("@each $x\n x\n")), [code::INVALID_LOOP]);
2869+ assert_eq!(
2870+ codes(&parse("@el [padding 4\n@text x\n")),
2871+ [code::UNCLOSED_BRACKET]
2872+ );
2873+ assert_eq!(codes(&parse("@data x.json\n")), [code::MISSING_ARGUMENT]);
2874+ }
2875+
2876+ #[test]
2877+ fn node_lookup_and_extent() {
2878+ let tree = parse("@el [\n padding 4\n]\n @text a\n\n@text b\n");
2879+ assert_eq!(tree.nodes[0].end_line(), 4);
2880+ assert_eq!(tree.node_at_line(2).map(|n| n.span.line), Some(1));
2881+ assert_eq!(tree.node_at_line(4).map(|n| n.span.line), Some(4));
2882+ }
2883+
2884+ #[test]
2885+ fn a_key_ends_at_the_first_space_outside_parentheses() {
2886+ let split = |raw: &str| {
2887+ let (key, value, _) = split_attribute(raw);
2888+ (key, value)
2889+ };
2890+ let pair = |k: &str, v: &str| (k.to_string(), Some(v.to_string()));
2891+ assert_eq!(
2892+ split("has(> img):padding 0"),
2893+ pair("has(> img):padding", "0")
2894+ );
2895+ assert_eq!(
2896+ split("is(:hover, .a b):color red"),
2897+ pair("is(:hover, .a b):color", "red")
2898+ );
2899+ assert_eq!(
2900+ split("grid-template-columns repeat(2, 1fr)"),
2901+ pair("grid-template-columns", "repeat(2, 1fr)")
2902+ );
2903+ // A quoted `)` inside the argument doesn't close it
2904+ assert_eq!(
2905+ split("not([title=\") x\"]):color red"),
2906+ pair("not([title=\") x\"]):color", "red")
2907+ );
2908+ // A `(` never closed: the first space, as before
2909+ assert_eq!(split("has(.a:color red"), pair("has(.a:color", "red"));
2910+ assert_eq!(split("hover:bold"), ("hover:bold".to_string(), None));
437 }
2911 }
438}
2912}
crates/htmlang-core/src/value.rsnew file+406

This diff is too big to show here.

crates/htmlang-core/src/vocab.rs+741−181

This diff is too big to show here.

crates/htmlang-wasm/playground/index.html+8−1

This diff is too big to show here.

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

This diff is too big to show here.

editors/vscode/README.md+69−18

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

examples/blog.hl+9−11

This diff is too big to show here.

examples/demo.hl+91−60

This diff is too big to show here.

examples/demo.html+594−509

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

This diff is too big to show here.

examples/landing.hl+87−78

This diff is too big to show here.

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

This diff is too big to show here.

src/bin/htmlang_lsp/completion.rs+1285−131

This diff is too big to show here.

src/bin/htmlang_lsp/docs.rs+327−81

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

src/bin/htmlang_lsp/tree.rsnew file+222

This diff is too big to show here.

src/cli.rs+3−2

This diff is too big to show here.

src/fmt.rs+391−374

This diff is too big to show here.

src/lib.rs+3

This diff is too big to show here.

src/main.rs+137−52

This diff is too big to show here.

tests/docs.rs+83−11

This diff is too big to show here.

tests/editor.rsnew file+157

This diff is too big to show here.

tests/fmt.rsnew file+55

This diff is too big to show here.

tests/regressions.rs+1826−18

This diff is too big to show here.

tests/snapshots.rs+4229−166

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

This diff is too big to show here.

tests/snapshots/code_samples.htmlnew file+5

This diff is too big to show here.

tests/snapshots/color_scheme.html+1−1

This diff is too big to show here.

tests/snapshots/colour_variants.hlnew file+12

This diff is too big to show here.

tests/snapshots/colour_variants.htmlnew file+1

This diff is too big to show here.

tests/snapshots/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.hlnew file+47

This diff is too big to show here.

tests/snapshots/custom_properties.htmlnew file+1

This diff is too big to show here.

tests/snapshots/dark_mode.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.hlnew file+42

This diff is too big to show here.

tests/snapshots/element_prefixes.htmlnew file+3

This diff is too big to show here.

tests/snapshots/else_if.html+1−1

This diff is too big to show here.

tests/snapshots/env_directive.html+1−1

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.hlnew file+19

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.htmlnew file+1

This diff is too big to show here.

tests/snapshots/extra_css.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.hlnew file+66

This diff is too big to show here.

tests/snapshots/function_calls.htmlnew file+1

This diff is too big to show here.

tests/snapshots/function_definitions.hlnew file+35

This diff is too big to show here.

tests/snapshots/function_definitions.htmlnew file+1

This diff is too big to show here.

tests/snapshots/functions.hl+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.hlnew file+42

This diff is too big to show here.

tests/snapshots/html_elements.htmlnew file+1

This diff is too big to show here.

tests/snapshots/if_expr.hl+21−3

This diff is too big to show here.

tests/snapshots/if_expr.html+1−1

This diff is too big to show here.

tests/snapshots/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.hlnew file+33

This diff is too big to show here.

tests/snapshots/layout_direction.htmlnew file+1

This diff is too big to show here.

tests/snapshots/layout_modes.hlnew file+44

This diff is too big to show here.

tests/snapshots/layout_modes.htmlnew file+2

This diff is too big to show here.

tests/snapshots/leading_argument.hlnew file+50

This diff is too big to show here.

tests/snapshots/leading_argument.htmlnew file+1

This diff is too big to show here.

tests/snapshots/list_elements.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.hlnew file+18

This diff is too big to show here.

tests/snapshots/names_and_values.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_css.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.hlnew file+14

This diff is too big to show here.

tests/snapshots/one_css_path.htmlnew file+4

This diff is too big to show here.

tests/snapshots/page_root.hlnew file+15

This diff is too big to show here.

tests/snapshots/page_root.htmlnew file+1

This diff is too big to show here.

tests/snapshots/parameters.hlnew file+31

This diff is too big to show here.

tests/snapshots/parameters.htmlnew file+1

This diff is too big to show here.

tests/snapshots/popover_api.html+1−1

This diff is too big to show here.

tests/snapshots/positional.html+1−1

This diff is too big to show here.

tests/snapshots/prefixes_compose.hlnew file+17

This diff is too big to show here.

tests/snapshots/prefixes_compose.htmlnew file+1

This diff is too big to show here.

tests/snapshots/pseudo_elements.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.hlnew file+20

This diff is too big to show here.

tests/snapshots/px_rule.htmlnew file+1

This diff is too big to show here.

tests/snapshots/raw_html.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.hlnew file+59

This diff is too big to show here.

tests/snapshots/slots_and_children.htmlnew file+1

This diff is too big to show here.

tests/snapshots/starting_style.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.hlnew file+41

This diff is too big to show here.

tests/snapshots/text_everywhere.htmlnew file+3

This diff is too big to show here.

tests/snapshots/translations_i18n.html+1−1

This diff is too big to show here.

tests/snapshots/unless_directive.html+1−1

This diff is too big to show here.

tests/snapshots/values_and_scope.hlnew file+62

This diff is too big to show here.

tests/snapshots/values_and_scope.htmlnew file+1

This diff is too big to show here.

tests/snapshots/variable_filters.html+1−1

This diff is too big to show here.

tests/snapshots/variable_slots.hlnew file+18

This diff is too big to show here.

tests/snapshots/variable_slots.htmlnew file+1

This diff is too big to show here.

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