on 964cd2d help sign in
.gitattributesdeleted−3
since splitsnapshots
unifiedsplit
@@ -1,3 +0,0 @@
1−# Check out text files with LF endings on every platform, so snapshot
2−# fixtures compare equal on Windows too.
3−* text=auto eol=lf
.github/workflows/playground.yml+5−4
@@ -40,12 +40,13 @@ jobs:
40 --out-dir target/wasm-pkg \
40 --out-dir target/wasm-pkg \
41 target/wasm32-unknown-unknown/release/htmlang_wasm.wasm
41 target/wasm32-unknown-unknown/release/htmlang_wasm.wasm
42
42
43− - name: Assemble the playground
43+ - name: Build htmlang CLI
44+ run: cargo build --release --bin htmlang
45+
46+ - name: Generate playground HTML
44 run: |
47 run: |
45 mkdir -p dist
48 mkdir -p dist
46− cp crates/htmlang-wasm/playground/index.html dist/
49+ ./target/release/htmlang playground dist/index.html
47− cp target/wasm-pkg/htmlang_wasm.js target/wasm-pkg/htmlang_wasm_bg.wasm dist/
48− cp examples/demo.hl dist/
49
50
50 - name: Upload Pages artifact
51 - name: Upload Pages artifact
51 uses: actions/upload-pages-artifact@v3
52 uses: actions/upload-pages-artifact@v3
.gitignore−1
@@ -1,4 +1,3 @@
1/target
1/target
2*.html
2*.html
3!tests/snapshots/*.html
3!tests/snapshots/*.html
4−!crates/htmlang-wasm/playground/*.html
CONTRIBUTING.md+12−102
@@ -19,100 +19,22 @@ Linux, macOS, and Windows. Your change should pass all three.
19## Repository layout
19## Repository layout
20
20
21- `crates/htmlang-core/` — parser, AST, code generator. No I/O lives here.
21- `crates/htmlang-core/` — parser, AST, code generator. No I/O lives here.
22− - `syntax.rs` — the syntax tree: every line as written, with spans. The
23− compiler evaluates it, the formatter prints it back, and the language
24− server takes definitions, directives and verbatim bodies from it. It
25− also holds `ESCAPES`, the one escape table of every htmlang string,
26− and decides where `{@...}` is an inline element: in every line of text
27− except the text of a `literal` element.
28− - `parser.rs` — evaluates the tree: definitions, data, loops and
29− conditions, and the checks over code that doesn't run. Names live in
30− `Env`, one frame per block, so a definition is visible to the end of
31− its block and a function keeps the frames of its definition.
32− - `diagnostic.rs` — `Diagnostic` and the stable diagnostic codes.
33− - `codegen.rs` — writes the HTML and the CSS. A parent's `Flow` (the
34− direction its CSS sets, with the changes under media and container
35− prefixes) is what its children's `fill` and `shrink` compile against;
36− the rules for a change are keyed on the parent's class in that block.
37− A page's `<body>` is written like an `@el` whose styles are `@page`'s
38− (the reset makes it the column), so the top level of a page is laid
39− out like any column; a fragment's top level has no layout of its own.
40− Every CSS property goes through one generic path (its name, its value
41− as written, with `vocab::with_px`'s pixels); only htmlang's layout
42− words and `line-clamp` have code of their own. Generated classes are
43− `hl-` + 7 base-36 digits of `stable_hash` of the style
44− (`CLASS_DIGITS`), so a style has one name in every file, and
45− `htmlang build` checks no name stands for two styles; an element's
46− defaults (its layout,
47− `ElementKind::css`) go with its class, as `:where(.hl-2k9xq0m)`, never a
48− rule on a global element selector, so `@markdown`/`@raw` HTML and a
49− page embedding a fragment are untouched. The rules are in cascade
50− layers (`LAYERS`): `hl-reset`, `hl-kind` (the defaults), `hl-inside`
51− (element prefixes, `@td:`, each an `@scope` on the class, and
52− functions' scoped `@style`, written when a function is called) and
53− `htmlang` (the elements' own styles and `children:`), so which one
54− wins is the layer's order, not specificity.
55− A style's prefixes are a `Condition`: its at-rule prefixes (sorted, so
56− `dark:md:` is `md:dark:`, and written as nested `@media`/`@container`
57− blocks) and its selector chain (read left to right; an element prefix
58− splits it into the scope's root and the elements it styles). Each
59− class keeps one list of (condition, declarations), and every block and
60− chain is written in one fixed order, never the order of the source.
61− - `ast.rs` — the element kinds; every plain HTML element is one row in
22+ - `ast.rs` — the element kinds; every plain HTML element is one row in `TAGS`.
62− `TAGS` with its one `Layout` (column, row, grid, text, native or void,
63− which decides its text lines, `spacing` and its children's layout
64− words), whether its text is shown as written (`literal`: `@code`,
65− `@textarea`, whose indented body is verbatim text, HTML-escaped), the
66− one HTML attribute its first word fills (`arg`: `src`, `action`, ...;
67− `ElementKind::arg` gives `@link`'s and `@image`'s too) and whether its
68− body is foreign text written into the page as it is (`verbatim`:
69− `@script`), and every directive one row in `DIRECTIVES` (its argument
70− and its kind of body; a verbatim directive's line is its one-line body
71− or file, and it takes no attributes). The list of
72− elements is fixed; `HTML_NAMES_WRITTEN_OTHERWISE` maps HTML's `a`, `img`,
73− `span`, `p` and `div` to htmlang's own names for the unknown-element
74− suggestion only.
75 - `vocab.rs` — the attribute vocabulary (htmlang attributes, CSS properties,
23 - `vocab.rs` — the attribute vocabulary (htmlang attributes, CSS properties,
76− HTML attributes, `@page`'s own `favicon`), the pixel rule
24+ HTML attributes) and the state/media prefixes.
77− (`LENGTH_PROPERTIES`, the one table of properties whose bare numbers
78− are px, and `with_px`, the one function that applies it) and the
79− prefixes: `PSEUDOS`, CSS's pseudo-classes and pseudo-elements under
80− their CSS names, each marked `:`, `::` or taking an argument, in the
81− order their rules are written; `children:`; element prefixes (`@td:`,
82− any name; the parser checks it is an element with a tag of its own,
83− `ElementKind::own_tag`); and the width, media and container prefixes,
84− with the rank that orders the at-rule blocks. A new pseudo-class is one
85− row in `PSEUDOS` and one word in DESIGN.md's list.
86− - `expr.rs` — the expression language for conditions, computed values and
25+ - `expr.rs` — the expression language for conditions and computed values.
87− `${...}`. It evaluates only what decides the result (the branch `if()`
88− takes, the side of `and`/`or` that decides) and only reads the rest.
89− - `value.rs` — the value types (text, number, true/false, list,
90− record) and the rules defined once on them: truthiness, comparison,
91− how numbers and lists print, ranges.
92− - `interp.rs` — `$name` and `${...}`: where a name ends and how one slot
93− of text (a text run, an attribute's value, an argument, a path) is
94− filled in, including what quoted text inserts in CSS and in text. The
95− one place variables are interpolated.
96 - `std.hl` — the standard library, written in htmlang and loaded before
26 - `std.hl` — the standard library, written in htmlang and loaded before
97 every file.
27 every file.
98- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
28- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
99−- `src/` — CLI, dev server and formatter (`fmt.rs`, which prints the syntax
29+- `src/` — CLI, dev server, formatter, and `upgrade` (syntax migrations).
100− tree back with its comments and blank lines).
101- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`). Its
30- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`). Its
102 completions come from the compiler's tables; hover and completion text live
31 completions come from the compiler's tables; hover and completion text live
103 in `docs.rs`.
32 in `docs.rs`.
104- `editors/vscode/` — VS Code extension.
33- `editors/vscode/` — VS Code extension.
105- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
34- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
106− These tests, and `tests/regressions.rs`, read the output with its
107− hashed classes numbered (`hl-a`, `hl-b`, ..., see `tests/common`), so a
108− snapshot changes when a style does, not when a hash does.
109- `tests/regressions.rs` — one test per fixed bug.
35- `tests/regressions.rs` — one test per fixed bug.
110−- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`,
36+- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`.
111− and every page in `examples/`: any diagnostic fails it.
37+- `examples/` — sample `.hl` files used as smoke tests and documentation.
112−- `tests/fmt.rs` — formats every example and snapshot input: the result is
113− stable, compiles to the same HTML and keeps every comment.
114−- `examples/` — complete pages: a landing page, a blog, a docs page, and a
115− tour of the whole language (`demo.hl`).
116
38
117## Adding a feature
39## Adding a feature
118
40
@@ -121,34 +43,22 @@ Linux, macOS, and Windows. Your change should pass all three.
121 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
43 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
122 tests alongside the code.
44 tests alongside the code.
1232. Prefer the smallest mechanism: a component belongs in `std.hl`, an HTML
452. Prefer the smallest mechanism: a component belongs in `std.hl`, an HTML
124− element is a row in `TAGS` (and a line in DESIGN.md's table of layouts
125− and a name in the VS Code grammar's element list, which tests check), and
126− a CSS property needs no code at all. Only
46+ element is a row in `TAGS`, and a CSS property needs no code at all. Only
127 thread a feature through the parser and codegen when it needs to be, and
47 thread a feature through the parser and codegen when it needs to be, and
128 describe it in the LSP's `docs.rs`.
48 describe it in the LSP's `docs.rs`.
1293. Document it in `DESIGN.md` (the examples there are compiled by the tests).
493. Document it in `DESIGN.md` (the examples there are compiled by the tests).
130− If it's user-facing, also update `README.md`, and show it in
50+ If it's user-facing, also update `README.md`.
131− `examples/demo.hl` if the tour has a section it belongs in.
51+4. If it removes or renames syntax, add a rewrite to `src/upgrade.rs` and a
52+ hint to `REMOVED_SYNTAX` in the parser.
132−4. If it changes the CLI surface, update the `--help` output.
53+5. If it changes the CLI surface, update the `--help` output.
133
54
134## Style
55## Style
135
56
136- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
57- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
137- Keep `@` prefixes and bracket-attribute syntax consistent with existing
58- Keep `@` prefixes and bracket-attribute syntax consistent with existing
138− directives. A new `@foo` is a row in `ast::DIRECTIVES`; the parser, the
59+ directives. Add `KNOWN_DIRECTIVES` entries when you add a new `@foo`.
139− formatter and the language server read that table, and `tests/editor.rs`
140− fails when the VS Code grammar's word lists drift from it.
141−- Every diagnostic has a code from `diagnostic::code`; add one there when no
142− existing code fits, and key tool behavior (quick fixes, tests) on the code
143− rather than on the message. Put the name the diagnostic is about in
144− `subject` and a replacement in `suggestion`.
145- Diagnostics should include `line` and, when practical, `column` and a
60- Diagnostics should include `line` and, when practical, `column` and a
146 `source_line` excerpt. Use `Severity::Help` for suggestions, not `Warning`.
61 `source_line` excerpt. Use `Severity::Help` for suggestions, not `Warning`.
147−- Check names, not values, and never drop input silently. When something
148− the author wrote can't go into the page, report an error; a warning means
149− it went into the page as written. Check a CSS value only for what is
150− wrong in any CSS (see `css_breakout` in `parser.rs`), not against a list
151− of values the browser may know better.
152- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
62- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
153 diagnostic so the compiler keeps going.
63 diagnostic so the compiler keeps going.
154
64
DESIGN.md+296−1422
@@ -1,1360 +1,339 @@
1−# htmlang language reference
1+# htmlang (.hl)
2
2
3−htmlang is a small layout language that compiles to static HTML. A page is a
3+A minimalist layout language inspired by elm-ui that compiles to static HTML.
4−tree of elements written one per line, with indentation for nesting:
5
4
6−```
5+## Principles
7−@page Hello
6+
8−@el [width fill, max-width 640, center-x, padding 40, spacing 16]
7+- `@` means structure, bare lines mean content.
9− @h1 Hello
8+- Layout is explicit and compositional: every element declares its own layout
10− @paragraph
9+ role, and there is no CSS cascade to reason about.
11− This page was written in {@b htmlang}.
10+- Output is one self-contained HTML file with embedded CSS (flexbox), and no
12−```
11+ JavaScript unless you write some.
13
12
14Every example block in this file is compiled by the test suite
13Every example block in this file is compiled by the test suite
15(`tests/docs.rs`), so the examples stay correct.
14(`tests/docs.rs`), so the examples stay correct.
16
15
17−## Principles
18−
19−- **`@` starts structure, and any other line is content.** An element is `@name`,
20− and a line without `@` is text.
21−- **htmlang's own vocabulary is about layout.** It comes from elm-ui. An
22− element is a row, a column, a grid or text (or keeps HTML's own layout),
23− `spacing` sets the gap between its children,
24− and each child says how it sits in its parent (`width fill`, `center-x`,
25− `align-right`). The layout attributes are the only styling words htmlang
26− adds.
27−- **Everything else is CSS or HTML, under its own name.** A style is any CSS
28− property with a CSS value. An element that isn't about layout or text has
29− its HTML name, and an HTML attribute is written `key=value`. Nothing is
30− renamed or abbreviated.
31−- **Each thing is done one way.** `@let` defines every reusable piece, `@data`
32− loads data, `@include` brings in a file, and `if()` makes an attribute
33− conditional. A layout is an ordinary function.
34−- **The output is what you wrote.** Each page compiles to one self-contained
35− HTML file. Its CSS is inside, and it has no JavaScript unless you write
36− some. The compiler doesn't add attributes, tags or rewritten values that
37− the source doesn't ask for, and it doesn't leave out anything you wrote:
38− what can't go into the page is an error. The one exception is a style
39− whose value comes out empty, such as an optional field a record doesn't
40− have: it is simply not written.
41−- **Everything runs at compile time.** Variables, expressions, loops and
42− conditions are resolved when the page is built. What CSS computes in the
43− browser (`var()`, `calc()`, `color-mix()`) stays CSS, written as it is.
44−
45## Syntax
16## Syntax
46
17
47−### Elements
18+### Structure
48
19
49```
20```
50@element [attributes] argument
21@element [attributes] argument
51 children
22 children
52```
23```
53
24
54−Children are indented under their parent, with spaces or with tabs (a
25+Children are indented under their parent. Attributes are comma-separated inside
55−tab counts as one space, so a file that mixes them is a warning).
56−Attributes go inside `[...]`,
57−separated by commas, and the list may span several lines:
26+`[...]` and may span several lines:
58
27
59```
28```
60@el [
29@el [
61 padding 20,
30 padding 20,
62 background white,
31 background white,
63− border-radius 8,
32+ rounded 8,
64− box-shadow 0 2 4 rgba(0,0,0,0.1)
33+ box-shadow 0 2px 4px rgba(0,0,0,0.1)
65]
34]
66 Content
35 Content
67```
36```
68
37
69−An attribute can take one of three forms:
38+An attribute is either a **style**, written `key value` (`padding 20`,
70−
71−- `key value` is a **style**: a layout attribute or a CSS property
72− (`spacing 20`, `color red`).
73−- `key=value` is an **HTML attribute** (`id=main`, `type=email`,
39+`color red`), or an **HTML attribute**, written `key=value` (`id=main`,
74− `aria-label=Close`).
40+`type=email`, `aria-label=Close`). Boolean HTML attributes are written bare
75−- A bare word is a **flag**: a layout attribute such as `center-x`, a
41+(`required`, `disabled`, `open`). A comma inside `(...)` or `"..."` does not
76− boolean HTML attribute such as `required`, `disabled`, `hidden` or `open`,
42+split attributes, so a font stack is written `font "Inter, sans-serif"`.
77− or a `data-` or `hx-` attribute that has no value (`data-open`,
78− `hx-preserve`).
79−
80−A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else,
81−`\,` keeps a comma in the value:
82−
83−```
84−@el [transition opacity 0.3s\, transform 0.3s, font-family "Open Sans"\, sans-serif]
85−@el [hx-get=/news, hx-trigger=load\, every 60s]
86−```
87−
88−Right after a `key=value`, a name htmlang doesn't know is most likely the
89−rest of that value, which a comma ended: `[hx-trigger=load, every 60s]` is
90−an error (`split-value`) that shows the value written with `\,`.
91−
92−An attribute given twice, by a bundle and after it, by a function and its
93−call, or written twice, takes the later value. An HTML attribute is
94−written once, with that value.
95−
96−A variable fills an attribute's value (`padding $gap`, `alt=$title`), never
97−its name or a whole attribute: attributes come from a
98−[bundle](#definitions), written `[$card]`, and a condition chooses whole
99−attributes with [`if()`](#conditional-attributes).
100−
101−The attribute list belongs to the element name right before it. Anywhere
102−else, `[` is an ordinary character, so a line of text can contain one:
103−
104−```
105−@text Use [ to open a list
106−@paragraph Arrays look like [1, 2, 3].
107−```
108
43
109### Text
44### Text
110
45
111−A line that doesn't start with `@` is text. Text after an element's
46+Any line that doesn't start with `@` or `[` is text. Text after an element's
112−attributes is the element's content: its first line of text, read like any
47+attributes is its content, and `{...}` puts elements inside a line of text:
113−other. Inside a line of text, `{...}` holds an inline element, and
114−[escapes](#escapes) and [`$names`](#variables) work:
115
48
116```
49```
117@paragraph
50@paragraph
118− This is {@strong important}, and this is {@link https://example.com a link}.
51+ This is {@text [bold] important} and this is a {@link https://example.com link}.
119−@text [font-weight bold, font-size 24, color #333] Hello world
52+@text [bold, size 24, color #333] Hello world
120−@h2 Welcome to {@text [color #3b82f6] htmlang}
121−@ul
122− @li Read {@link /docs the docs}
123@section [padding 8] Text after the attributes is content too.
53@section [padding 8] Text after the attributes is content too.
124```
54```
125
55
126−How an element's lines combine is its [layout](#rows-columns-and-text)'s
127−business: in a row or column each line is a child; in a text element lines
128−flow. Text that `@if`, `@each`, `@fragment` or a function writes counts as
129−lines written in its place, so in a text element it flows with the rest,
130−joined with a space, and in a native element it is on lines of its own.
131−
132−The text of `@code` and `@textarea` is shown as written. On its line and
133−inline, a `{@...}` in it is text, while escapes and `$names` still work.
134−The lines indented under it are a [verbatim](#verbatim-bodies) sample:
135−nothing in them is htmlang (`@`, `$`, `{`, `\` and `--` are text), and
136−they are HTML-escaped with their line breaks and indentation kept. This
137−is how a page shows code, htmlang included. `@pre` is an ordinary element,
138−so `@pre > @code` is HTML's `<pre><code>`, and a function can wrap `@pre`
139−around a sample. A sample is text on the line or lines in the block, not
140−both.
141−
142−```
143−@paragraph
144− Write {@code {@link /docs docs}} for a link.
145−@pre [padding 16] > @code
146− @el [padding 40]
147− <b>shown as text</b>
148−@textarea [aria-label=Notes]
149− First line
150− Second line, indented
151−```
152−
153−A sample in the block has no `$names`, so a version number in it can't
154−come from a variable; a one-line sample on `@code`'s own line can
155−(`@code cargo install htmlang@$version`).
156−
157−### Escapes
158−
159−A backslash before one of these characters stands for the character itself.
160−The table is the same in every htmlang string: text, arguments, attribute
161−values, `@let`, `@page` and `@meta`.
162−
163−| Escape | Writes | For |
164−|---|---|---|
165−| `\@`, `\--` | `@`, `--` | a line of text that starts with `@` or `--` |
166−| `\$` | `$` | a `$` before a name, which would start a variable |
167−| `\{`, `\}` | `{`, `}` | braces that don't start or end an inline element |
168−| `\[`, `\]` | `[`, `]` | brackets that don't open or close an attribute list; a `'...'` in a value (`alert(']')`) doesn't hide one, so a list that seems to end inside one is reported (`unbalanced-quote`) |
169−| `\,` | `,` | a comma in an attribute's value |
170−| `\"` | `"` | a quote that doesn't start or end quoted text |
171−| `\\` | `\` | a backslash before one of these characters |
172−
173−A backslash before any other character is kept as written, so CSS's own
174−escapes and patterns pass through: `before:content "\201C"`,
175−`pattern=\d{3}`. A `$` that isn't followed by a letter or `_` is text
176−anyway: `$5`, `$$`.
177−
178−```
179−\@htmlang on social media
180−@let price 5
181−@paragraph \$price is $price dollars: {@mark \{braces\}}, \[brackets\] and a comma\, too
182−@input [type=text, pattern=\d{3}-\d{4}, title=Costs \$5]
183−```
184−
185### Comments
56### Comments
186
57
187−A line whose first word is `--` is a comment: `--` followed by a space, or
58+`--` at the start of a line begins a comment. Comments must be on their own
188−alone on its line. A comment must be on its own line, because `--` later in
189−a line is ordinary text. A comment may sit between the lines of an attribute
59+line: `--` later in a line is ordinary text.
190−list, and `--name` with no space is not a comment but a
191−[custom property](#custom-properties), so it can start a line of a list
192−(anywhere else, a line starting `--name` or `---` is text).
193
60
194```
61```
195-- this is a comment
62-- this is a comment
196@row [spacing 10]
63@row [spacing 10]
197− -- todo: more nav items
64+ -- todo: add nav items
198− @link / Home
199−@el [
200− -- the gap, wider on larger screens
201− --gap 12px,
202− md:--gap 24px,
203− spacing var(--gap)
204−]
205− @text One
206− @text Two
207```
65```
208
66
209−### Chains
67+### Shorthands
210
68
211−`>` puts elements that have one child each on one line. The last element in
69+A line starting with `[` is an anonymous `@el`. `>` chains single-child
212−the chain gets the indented children. A `>` is a chain only between two
70+elements on one line; the last element in the chain gets the indented children.
213−elements (`@name [attributes]`); in text it is just a character. A
214−function call can be a link of a chain, like any element. After an
215−element's leading argument (`@link /about`) the rest of the line is text,
216−so a link around an image is `@link [href=/] > @image logo.png` (the
217−compiler warns when `> @name` follows a leading argument).
218
71
219```
72```
73+[padding 20, background white]
74+ Hello
75+
220−@el [padding 16, background blue, border-radius 8] > @link https://example.com
76+@el [padding 16, background blue, rounded 8] > @link https://example.com
221 @text [color white] Get Started
77 @text [color white] Get Started
222```
78```
223
79
224−### Verbatim bodies
80+### Raw content
225
81
226−The bodies of `@raw`, `@style`, `@head`, `@markdown`, `@script`, `@code`
82+`@raw """ ... """` is pasted into the output verbatim.
227−and `@textarea` are foreign text, kept exactly as written: nothing in them
228−is parsed as htmlang, and a `--` line in them is not a comment. `@raw`
229−writes its HTML into the page, `@style` its CSS and `@head` its HTML into
230−the `<head>`; `@code` and `@textarea` show theirs as text. Which lines are
231−verbatim is decided by the element or directive on the line, also at the
232−end of a chain (`@pre > @code`, `@el > @script`).
233−
234−The header's line follows one rule. For `@raw`, `@style` and `@head`, text
235−on the line is a one-line body; for `@markdown` it is the file and for
236−`@script` the `src`. Text on the line and an indented body together are an
237−error. Only `@script` takes attributes: an attribute list on `@raw`,
238−`@style`, `@head` or `@markdown` is an error, so content that starts with
239−`[` (a CSS attribute selector) goes in the indented block.
240
83
241```
84```
242−@raw <hr class="fancy">
85+@raw """
243−@raw
244− <div class="custom-widget">
86+<div class="custom-widget"></div>
245− <span>Hand-written HTML</span>
87+"""
246− </div>
247−@style .note { color: gray; }
248```
88```
249
89
250−### Directives
90+## Elements
251
91
252−A directive is a built-in word that isn't an element. Each one takes a
92+Layout elements set up flexbox; the rest map to the HTML element of the same
253−fixed kind of argument and a fixed kind of body. `@page` is the one
93+name.
254−directive that is also an element: it stands for the page's root, `<body>`,
255−whose content is the rest of the file (see [Page and head](#page-and-head)).
256
94
257−| Directive | Argument | Body |
95+| Element | Output | Purpose |
258−|---|---|---|
259−| `@page` | attributes (styles for `<body>`, `key=value` for `<html>`) and a title | none |
260−| `@let` | a name and a value or bundle, or `@name` and parameters | a function's body |
261−| `@include` | a file | none |
262−| `@data` | a variable and a source | none |
263−| `@meta` | a name and a value | none |
264−| `@if`, `@else`, `@each` | a condition or a loop | htmlang |
265−| `@raw`, `@style`, `@head` | the rest of the line: a one-line body | verbatim, instead of the line |
266−| `@markdown` | a file | verbatim Markdown, instead of the file |
267−
268−An indented line under a directive that takes no body is an error, because
269−it would otherwise silently become a sibling.
270−
271−### Diagnostics
272−
273−The compiler checks the whole file, including code that doesn't run: the
274−branch of an `@if` or an `if()` that isn't taken, the body of a function that is never
275−called and the body of a loop over an empty list. Unknown elements,
276−functions, attributes and prefixes are reported there too. In code that
277−doesn't run, a call is checked against every function in the file and the
278−files it includes; where the code runs, a name is looked up where it is
279−written, so a function's body sees only what is defined above the function
280−(a call to a function defined further down is reported when the body
281−runs, and two functions can't call each other). A `$name` is checked only
282−where the code runs, since any of them may come from data: an undefined
283−variable in a branch that isn't taken, or in a function that is never
284−called, isn't reported.
285−
286−**Names are checked, and values are CSS's.** A misspelled element, prefix,
287−parameter or slot is an error, and so is an undefined variable. A CSS
288−property htmlang doesn't know, but whose name CSS could have, is written to
289−the CSS as it is, with a warning that suggests the closest known name
290−(`text-grow`, or `colr`, which asks "did you mean `color`?"). Values go to
291−the CSS as written, so the browser decides what `z-index auto` or `color
292−rebeccapurple` means; only what is wrong in any CSS is reported (see
293−[CSS properties](#css-properties)).
294−
295−**Nothing you write is left out silently.** An error means something
296−couldn't go into the page: a flag htmlang doesn't know, an attribute
297−`@fragment` has no element for, content for a function without
298−`@children`. A warning means it went into the page as written, but may not
299−be what you meant. A warning is reported once, even from a line that runs
300−many times in a loop or a function.
301−
302−Every diagnostic has a stable code, such as `unknown-element` or
303−`unused-variable`. The command line prints it as `error[unknown-element]`,
304−`--format json` has it in a `code` field, and the editor's quick fixes are
305−keyed on it.
306−
307−## Layout
308−
309−### Rows, columns and text
310−
311−Every element has one layout, which decides what the lines written inside
312−it are. **In a row or column each line is a child; in a text element lines
313−flow.**
314−
315−| Layout | Elements | What is inside it |
316|---|---|---|
96|---|---|---|
317−| column | `@el`, and the containers: `@section`, `@nav`, `@form`, `@ul`, `@li`, ... | Each line of text is a child of its own, and the children are laid out top to bottom (side by side with `flex-direction row`) |
97+| `@row` | div, flex row | Horizontal layout |
318−| row | `@row` | The same, side by side |
98+| `@column` | div, flex column | Vertical layout |
319−| grid | `@grid` | Each line of text is a cell |
99+| `@el` | div, flex column | Generic container |
320−| text | `@paragraph`, `@text`, `@link`, `@h1` … `@h6`, `@button`, `@label`, `@td`, `@strong`, `@em`, ... | The argument, the lines and the children flow as one run of text, joined with spaces |
100+| `@grid` | div, grid | Grid container (use `grid-cols`) |
321−| native | `@table`, `@caption`, `@select`, `@pre`, `@textarea`, `@video`, ... | HTML's own layout, which htmlang leaves alone |
101+| `@stack` | div, position relative | Children layered on top of each other |
322−| void | `@input`, `@hr`, `@br`, `@image`, `@source` | Nothing: it takes no content |
102+| `@in-front` / `@behind` | div, absolute | Overlay layers filling the parent (see below) |
323−
103+| `@text` | span | Styled inline text |
324−The table under [HTML elements](#html-elements) gives every element's layout. The
104+| `@paragraph` | p | Flowing text with inline elements |
325−page itself is a column too: `@page` is the [root element](#page-and-head),
105+| `@link url` | a | Link; text after the URL becomes its content |
326−`<body>`, so what is written at the top of a page stacks like the children
106+| `@image src` | img | Image |
327−of an `@el`, `height fill` there takes the rest of the window and
107+| `@h1` … `@h6` | h1 … h6 | Headings |
328−`center-y` centres in it.
108+
329−
109+Semantic containers, all laid out as columns: `@nav`, `@header`, `@footer`,
110+`@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@form`,
111+`@details` / `@summary`, `@dialog`, `@figure` / `@figcaption`, `@blockquote` /
112+`@cite`, `@fieldset` / `@legend`, `@noscript`.
113+
114+Content: `@list` / `@item` (`@list [ordered]` for `<ol>`), `@dl` / `@dt` /
115+`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
116+`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
117+`@canvas`, `@iframe src`, `@video src`, `@audio src`, `@picture` / `@source`,
118+`@script`, `@fragment` (children without a wrapper).
119+
120+Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
121+`@label`, `@datalist`. `@form URL` sets the form's `action`.
122+
330−```
123+```
331−@el [spacing 4]
124+@list [ordered]
332− First line
125+ @item First
333− Second line
126+ @item Second
334−@button [type=button]
127+@form [method=post] /subscribe
335− Save
128+ @label [for=email] Email
336− changes
129+ @input [type=email, name=email, id=email, required]
337−```
130+ @button [type=submit] Send
338−
131+@details [open]
339−The column shows two lines 4px apart; the button says "Save changes".
132+ @summary Question
340−
133+ @text The answer.
341−These attributes are htmlang's own. They describe how an element lays out
342−its children, and how it sits in its parent:
343−
344−| Attribute | Effect |
345−|---|---|
346−| `spacing N` | Gap between children |
347−| `width fill` / `width shrink` / `width N` | In a row, take the remaining width or keep the content's width; in a column or anywhere else, take the full width or fit the content; or an exact size |
348−| `height fill` / `height shrink` / `height N` | In a column, take the remaining height or keep the content's height; in a row or anywhere else, take the full height or fit the content; or an exact size |
349−| `center-x`, `center-y` | Center the element in its parent (auto margins) |
350−| `align-left`, `align-right`, `align-top`, `align-bottom` | Align the element in its parent (an auto margin on the other side) |
351−| `wrap` | Let a row wrap onto more lines |
352−| `grid-cols N`, `grid-rows N` | Equal grid columns or rows (on `@grid`); any other value is a track list, as in `grid-template-columns` (`grid-cols 200 1fr`) |
353−| `col-span N`, `row-span N` | Cells a grid child spans |
354−
355−`spacing`, `wrap`, `grid-cols` and `grid-rows` lay out an element's
356−children, so they go on a row, column or grid. On a text, native or void
357−element they are an error: text has no gap between its lines. For a flex
358−layout of your own on such an element, write the CSS (`@label [display
359−flex, gap 8]`).
360−
361−A child's `fill` and `shrink` follow the direction its parent's CSS
362−actually sets: a row for `@row`, a column for `@el` and the other columns,
363−or whatever the parent's own `flex-direction` (or `flex-flow`) says. Along
364−that direction, `fill` takes the remaining space and `shrink` keeps the
365−content's size; across it, `fill` is the full size and `shrink` fits the
366−content. `center-x`, `center-y` and `align-*` are auto margins, which work
367−in either direction; a `margin` on the same element keeps its other sides
368−(`[center-x, margin 20]`). A layout word never overrides a CSS property
369−the element writes itself: `[width fill, min-width 200]` keeps its 200px,
370−and `[flex-shrink 0, width fill]` still grows but never shrinks.
371−
372−```
373−@row [spacing 8]
374− @el [width fill, padding 12, background #f3f4f6] Fills the row
375− @el [width 120, padding 12, background #e5e7eb] 120px
376−@el [height 200, spacing 8, background #f9fafb]
377− @text [center-x] Centered
378− @el [height fill, background #dbeafe] Fills the rest of the column
379− @text [align-right] Right-aligned
380−@nav [flex-direction row, spacing 20]
381− @link /features Features
382− @el [width fill]
383− @link /signup Sign up
384−@grid [grid-cols 3, spacing 8, md:grid-cols 4]
385− @el [col-span 2, background #fef3c7] Two columns wide
386− @el [background #fde68a] One
387− @el [background #fcd34d] One
388−```
389−
390−A direction set under media or container prefixes (`md:flex-direction
391−row`, `print:`, `cq-md:`, or a stack of them such as `md:dark:`) switches
392−the children's `fill` and `shrink` in that same condition. As in elm-ui, whose child rules are keyed on the
393−parent's class (`.r > .wf`), each child gets a rule keyed on its parent's
394−class in the parent's `@media` or `@container` block. A width prefix holds
395−from its width up, so `sm:flex-direction column` still applies at `lg:`
396−unless `lg:` sets a direction of its own. A direction under a state prefix
397−(`hover:`, `first-child:`, `children:`, or a stack with one in it) leaves the
398−children's layout words as they are.
399−
400−```
401−@header [spacing 16, md:flex-direction row, md:align-items center]
402− @text [font-weight 800] Launchpad
403− @el [width fill]
404− @nav [flex-direction row, spacing 20]
405− @link /features Features
406− @link /pricing Pricing
407−@section [padding 24]
408− @el [width fill, max-width 800, center-x, spacing 16]
409− The full width up to 800px, in the middle
410−```
411−
412−On a phone the header is a column and its `@el [width fill]` is the full
413−width; from md up it is a row and the `@el` takes the free space, pushing
414−the navigation to the right. The section's `@el` is the usual centred
415−column: the full width, at most 800px, with auto margins on both sides.
416−
417−Everything else about layout is plain CSS: `justify-content`, `align-items`,
418−`flex 1 1 240px`, `grid-template-areas`, `position`.
419−
420−### Layout inside text
421−
422−A row, column or grid written inside text, inline in a line (`{@el ...}`) or
423−as a child of a text element, is laid out inline (`display: inline-flex`, or
424−`inline-grid`), and keeps its attributes. Anywhere inside text, `@el`, `@row`
425−and `@grid` (and `@in-front` and `@behind`) are written as a `<span>` rather
426−than a `<div>`, since text can't hold a `<div>`.
427−
428−```
429−@paragraph
430− Price: {@el [padding 2 6, background #fef3c7, border-radius 4] $9} a month.
431−@h2
432− Plans
433− @row [spacing 4, font-size 14] > @text New
434−```
435−
436−Any other element keeps its HTML element. One whose HTML ends a paragraph,
437−such as `@section`, `@ul` or `@h2`, is a warning inside `@paragraph`
438−(`block-in-paragraph`): the browser would move it, and what follows it, out
439−of the `<p>`.
440−
441−`@link` is text too, so a card written inside a link is an inline `<span>`
442−that shrinks to its content. A clickable card is the link itself, laid out
443−as a column with CSS:
444−
445−```
446−@link [href=/post, display flex, flex-direction column, gap 8, padding 16, border 1 solid #e5e7eb]
447− @strong A post
448− @text Its summary.
449```
134```
450
135
451−### Overlays: `@in-front` and `@behind`
136+### `@in-front` / `@behind`
452
137
453−These layers work like elm-ui's `inFront` and `behind`. Their children fill
138+Overlay layers, like elm-ui's `inFront` and `behind`. Their children fill the
454−the parent's bounds, and the parent becomes a positioning context
139+parent's bounds; the parent automatically becomes a positioning context
455−(`position: relative; isolation: isolate`). An explicit `position` on the
140+(`position: relative; isolation: isolate`). An explicit `position` on the parent
456−parent takes precedence.
141+wins.
457
142
458```
143```
459@el [width 200, height 200, background blue]
144@el [width 200, height 200, background blue]
460 @text [color white] Main content
145 @text [color white] Main content
461 @in-front
146 @in-front
462− @text [color yellow, align-right, align-bottom] Painted on top
147+ @text [color yellow] Painted on top
463 @behind
148 @behind
464− @el [background red, height fill]
149+ @el [background red, width fill, height fill]
465```
150```
466
151
467### Standard library
152### Standard library
468
153
469−Two definitions are written in htmlang (`std.hl`) and are available in every
154+These components are written in htmlang (`std.hl`) and available in every file.
470−file. A `@let` of your own with the same name replaces them. `@spacer` takes
155+Use them like elements; your own `@let` with the same name takes precedence.
156+
157+| Component | Purpose |
158+|---|---|
159+| `@badge` | Small pill for counts and statuses |
160+| `@tag` | Label with slightly rounded corners |
161+| `@chip` | Outlined, fully rounded label |
162+| `@avatar` | Circular frame for an image or initials |
471−up the remaining space in a row or column. The `$truncate` bundle cuts text
163+| `@spacer` | Takes up the remaining space in a row or column |
472−off at one line with an ellipsis. You define any other components yourself
164+| `@tooltip [tip TEXT]` | Text that shows `TEXT` when hovered |
473−with `@let`.
165+| `@carousel` | Horizontally scrolling row that snaps to each child |
166+| `@breadcrumb` | Breadcrumb trail of `@item`s |
167+| `$skeleton`, `$truncate`, `$no-scrollbar` | Attribute bundles: loading placeholder, one-line ellipsis, hidden scrollbars |
474
168
475```
169```
476@row [spacing 8, align-items center]
170@row [spacing 8, align-items center]
477− @text Logo
171+ @badge [background #ef4444, color white] 3
172+ @chip Rust
478 @spacer
173 @spacer
479− @text [$truncate, max-width 200] A long title that gets cut off
174+ @tooltip [tip Opens in a new tab] Help
175+@breadcrumb
176+ @item > @link / Home
177+ @item Docs
178+@el [$skeleton, height 20, width fill]
480```
179```
481
180
482−## Style
181+## Attributes
483−
182+
484−### CSS properties
183+### Layout and sizing
485−
184+
486−Any attribute that isn't a layout attribute is a **CSS property**, with the
185+| Attribute | Effect |
487−same name and value as in CSS: `padding 20`, `font-weight bold`,
488−`border 1 solid #e5e7eb`, `border-radius 8`, `background red`,
489−`grid-template-areas "a b"`, `display none`.
490−
491−A quoted string in a value is CSS's own, so it keeps its quotes and its
492−commas: `font-family "Inter, sans-serif"` names one family called
493−"Inter, sans-serif", and the compiler warns about it. A font stack is
494−several values with commas between them, written `\,`:
495−`font-family Inter\, sans-serif`.
496−
497−Values mean what they mean in CSS, with one addition: **in a length
498−property, every bare number outside parentheses and quotes is pixels**. So
499−`padding 20` is `20px`, `border 1 solid red` is `1px solid red`,
500−`box-shadow 0 2 4 rgba(0,0,0,0.1)` is `0 2px 4px rgba(0,0,0,0.1)`, and
501−`margin 0 auto` is `0 auto` (a zero stays `0`). Numbers inside a function
502−belong to it (`rgb(255 128 0)`, `calc(100% - 20px)`), and values that have
503−a unit or are keywords pass through as written (`width 50%`, `max-width
504−min(100%, 800px)`). Every other property takes numbers as CSS does, so
505−`opacity 0.5`, `z-index 2`, `line-height 1.5`, `flex 1 1 240px`,
506−`grid-column 1 / 3`, `initial-letter 3` and `border-image-width 2` are
507−written as they are, and so are custom properties (`--cols 3`), which have
508−no type to go by.
509−
510−The length properties are these, and only these:
511−
512−| Group | Properties |
513|---|---|
186|---|---|
514−| Size | `width`, `height`, `min-*` and `max-*` of both, `block-size`, `inline-size` and their `min-`/`max-`, `flex-basis`, `column-width`, `contain-intrinsic-*` |
187+| `spacing N` | Gap between children |
515−| Space | `margin`, `padding`, `scroll-margin`, `scroll-padding` and every side of them, `gap`, `row-gap`, `column-gap`, `border-spacing` |
188+| `padding N` / `padding Y X` / `padding T R B L` | Padding (also `padding-x`, `padding-y`) |
516−| Position | `top`, `right`, `bottom`, `left`, `inset` and `inset-*`, `translate`, `transform-origin`, `perspective`, `perspective-origin`, `offset-position`, `offset-anchor`, `offset-distance` |
189+| `margin ...` | Margin, same forms as padding (also `margin-x`, `margin-y`) |
517−| Border | `border` and every `border-*` shorthand, width and radius (not `border-image-*`), `outline`, `outline-width`, `outline-offset`, `column-rule`, `column-rule-width` |
190+| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
518−| Shadow | `box-shadow`, `text-shadow` |
191+| `height fill` / `height N` / `height shrink` | Same for height |
519−| Grid | `grid-template-columns`, `grid-template-rows`, `grid-auto-columns`, `grid-auto-rows` |
192+| `center-x`, `center-y` | Center within the parent |
520−| Background | `background-position` (and `-x`, `-y`), `background-size`, `object-position`, `mask-position`, `mask-size` |
193+| `align-left`, `align-right`, `align-top`, `align-bottom` | Align within the parent |
521−| Text | `font-size`, `letter-spacing`, `word-spacing`, `text-indent`, `text-decoration-thickness`, `text-underline-offset`, `vertical-align`, `baseline-shift` |
194+| `wrap` | Let a row wrap |
522−| Other | `shape-margin`, `overflow-clip-margin`, `view-timeline-inset` |
195+| `grid-cols N`, `grid-rows N`, `col-span N`, `row-span N` | Grid layout |
523−
196+| `hidden` | `display: none` |
524−Shorthands that mix lengths with numbers of other kinds (`font`,
197+
525−`background`, `mask`, `grid`, `grid-template`, `offset`, `flex`) are not in
198+### Style
526−the table, so the lengths in them are written with their unit:
527−`font 700 16px/1.5 Inter`.
528−
529−Nothing else is added to a value, and a shorthand means what it means in
530−CSS: `outline 2 solid red` is `2px solid red`, while `outline 2 red` names
531−no line style, so CSS draws no outline. `container card / inline-size`
532−and `contain content` are CSS's own shorthands too, and a bare `contain` is
533−an error, like any style without a value. `line-clamp N` alone also adds
534−the `-webkit-box` declarations that browsers still need to cut text off
535−after N lines.
536−
537−A property htmlang doesn't know is written to the CSS as it is, so new CSS
538−(`text-grow per-line`) works before htmlang lists it; the
539−compiler warns and suggests the closest known name, in case it is a typo.
540−Vendor-prefixed properties (`-webkit-tap-highlight-color`) pass without a
541−warning. The value of a property htmlang doesn't know is written exactly as
542−it is, without pixels added. A style needs a value: `[padding]` is an error.
543
199
544−```
200+| Attribute | Effect |
545−@el [-webkit-tap-highlight-color transparent, -webkit-text-stroke 1px black]
201+|---|---|
546− Passed through as written
202+| `border N COLOR` (also `border-top` etc.) | Solid border |
547−```
203+| `rounded N` | Border radius |
204+| `bold`, `italic`, `underline` | Text style |
205+| `size N` | Font size |
206+| `font NAME` | Font family |
207+| `line-clamp N` | Cut text off after N lines |
548
208
549−A value is checked only for what is wrong in any CSS. A `;`, `{` or `}`
209+**Any standard CSS property** can also be used as an attribute, with the same
550−(outside a quoted string, and for `;` outside parentheses, as in CSS's own
210+name and value: `background red`, `opacity 0.5`, `cursor pointer`,
551−`if()`) or an unclosed quote or parenthesis would break out of the rule, and
211+`z-index 10`, `margin-top 16`, `grid-template-areas "a b"`, and so on.
552−a `</style` (even quoted) would end the page's style element, so each is an
553−error, including in a value that comes from a variable or from `@data`. A hex color that doesn't have 3, 4, 6 or 8 digits is a warning. A
554−style whose value comes out empty, such as a field that a record doesn't
555−have, is left out. Since a value is CSS's, a missing comma between two
556−styles makes one value: `padding 8 color red` is `padding: 8px color red`,
557−which the browser ignores. A style written as an HTML attribute
558−(`padding=8`) is a warning that shows its style form.
559−
560−#### Custom properties
561−
562−A custom property (`--name`) is a CSS property like any other: it goes on
563−any element, under any prefix, and is written into the element's class.
564−Its name is yours, so it is never checked against a list.
565−CSS passes it down, so it holds for the element and everything inside it,
566−where `var(--name)` reads it. Its value is written exactly as it is, since
567−a custom property has no type for pixels to go by: `--hue 200` can feed
568−`hsl(var(--hue) 80% 50%)` and `--cols 3` can feed `repeat(var(--cols),
569−1fr)`. A length therefore takes its unit: `--gap 16px`, not `--gap 16`.
570
212
571−```
213+**Units.** For lengths, a bare number is pixels (`padding 20` is `20px`).
572−@el [
214+Values with a unit, keywords and CSS functions are passed through: `width 50%`,
573− --gap 16px,
215+`margin 0 auto`, `max-width min(100%, 800px)`.
574− md:--gap 24px,
216+
575− --hue 200,
217+### HTML attributes
576− spacing var(--gap),
577− background hsl(var(--hue) 80% 95%)
578−]
579− @text One
580− @text Two
581−```
582
218
583−`@let --name value` declares a custom property for the whole page, on
219+Write HTML attributes as `key=value`: `id=main`, `class=note`, `href=/about`,
584−`:root` (see [CSS custom properties](#css-custom-properties));
220+`type=email`, `alt=Logo`, `target=_blank`, `aria-label=Close menu`,
585−`[--name value]` sets it on one element and everything inside it. So a
221+`data-id=42`. Any name works. Booleans are written bare: `required`,
586−design token is declared once with `@let`, and a theme redefines it where
222+`disabled`, `checked`, `open`, `sandbox`. A style and an HTML attribute may
587−it changes, once, rather than repeating a colour on every element that
223+share a name without clashing:
588−uses it:
589
224
590```
225```
591−@let --ink #0f172a
226+@image [width=800, width 200, alt=A photo] photo.jpg
592−@let --surface white
227+@select [size=4, size 18]
593−@let --subtle #f8fafc
228+ @option One
594−@page [color var(--ink), background var(--surface), dark:[--ink #e2e8f0, --surface #0b1220, --subtle #111a2e]] Tokens
595−@section [padding 96 24, background var(--subtle)]
596− @article [padding 24, background var(--surface)]
597− Both follow dark mode, from the one place that sets it.
598```
229```
599
230
600−### Prefixes
231+### State and media prefixes
601
232
602−A prefix applies a style only in some condition:
233+Prefix a style attribute to apply it conditionally:
603
234
604| Prefix | Applies |
235| Prefix | Applies |
605|---|---|
236|---|---|
606−| `hover:`, `focus:`, `first-child:`, `checked:`, `user-invalid:`, `open:`, ... | When that CSS pseudo-class matches (`:hover`, `:first-child`, ...) |
237+| `hover:`, `active:`, `focus:`, `focus-visible:`, `focus-within:`, `disabled:`, `checked:`, `visited:`, `target:`, `valid:`, `invalid:`, `empty:`, `placeholder:`, `selection:` | In that state |
607−| `nth-child(odd):`, `not(.featured):`, `has(> img):`, `is(...):`, ... | When that pseudo-class, with its argument, matches |
238+| `first:`, `last:`, `odd:`, `even:`, `nth:EXPR:` | By position among siblings |
608−| `before:`, `after:`, `marker:`, `placeholder:`, `backdrop:`, ... | To that pseudo-element (`::before`, `::marker`, ...) |
609| `children:` | To each direct child |
239| `children:` | To each direct child |
610−| `@td:`, `@h2:`, `@link:`, ... | To every such element inside it, at any depth (see [Styling the elements inside](#styling-the-elements-inside)) |
240+| `before:`, `after:` | On the `::before` / `::after` pseudo-element (with `content`) |
241+| `has(SELECTOR):` | When the element contains a match |
611−| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640, 768, 1024, 1280, 1536px) |
242+| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640–1536px) |
612−| `cq-sm:` … `cq-2xl:` | From that container width up (the same widths, for an ancestor with `container-type inline-size`) |
243+| `cq-sm:` … `cq-2xl:` | Container queries |
613−| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Under that media condition |
244+| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Media conditions |
614−
615−The selector prefixes are CSS's own pseudo-classes and pseudo-elements,
616−under their CSS names, without the colons: `link`, `visited`,
617−`first-child`, `last-child`, `only-child`, `first-of-type`,
618−`last-of-type`, `only-of-type`, `empty`, `target`, `open`,
619−`popover-open`, `default`, `checked`, `indeterminate`,
620−`placeholder-shown`, `autofill`, `required`, `optional`, `valid`,
621−`invalid`, `user-valid`, `user-invalid`, `read-only`, `read-write`,
622−`focus-within`, `hover`, `focus`, `focus-visible`, `active`, `enabled`
623−and `disabled`; with an argument, `nth-child()`, `nth-last-child()`,
624−`nth-of-type()`, `nth-last-of-type()`, `not()`, `is()`, `where()` and
625−`has()`; and the pseudo-elements `before`, `after`, `marker`,
626−`placeholder`, `selection`, `backdrop`, `first-line`, `first-letter` and
627−`file-selector-button`, which htmlang writes with CSS's `::`. The argument
628−is CSS, written as it is between balanced parentheses, and it may hold
629−spaces and colons: the key ends at the first space outside parentheses, so
630−`has(> img):padding 0` and `is(:hover, :focus-visible):color red` are one
631−key each. `children:` is htmlang's one combinator word, and an element's
632−name with `@` (`@td:`) picks out the elements of that kind inside. A name
633−that isn't one of these (`hvoer:`, or `first:` for `first-child:`) is an
634−unknown prefix, an error, not a selector that silently never matches.
635−
636−A prefix goes on a style or a layout flag, and prefixes stack: the key
637−is a chain of prefixes, then the property, and the style applies where
638−all of them hold. The selector prefixes (every row above the widths)
639−read left to right: `hover:children:` styles the children of a hovered
640−element, `children:hover:` a hovered child. A pseudo-element is not an
641−element with states of its own, so it comes last among them:
642−`hover:before:color` is the `::before` of a hovered element, and
643−`before:hover:color` is an error that says so. The width, media and
644−container prefixes go anywhere in the chain, in any order, and each is
645−an `@media` or `@container` rule around the ones inside it:
646−`md:dark:padding 32` and `dark:md:padding 32` are the same style, from
647−768px up in dark mode.
648−
649−A prefix written before a `[group]` applies to each style in it, and one
650−written before a `$bundle` to each style the bundle holds, so styles that
651−share a condition are written under it once. A prefix before an `if()`
652−applies to the branch it picks. The group or bundle holds styles only:
653−an HTML attribute (`md:[id=x]`) or a flag that is not a style has no
654−states, and it is an error there, as it is with a prefix of its own
655−(`md:id=x`).
656−
657−`children:` styles each direct child, as the parent sees it. They take
658−effect only where the child sets nothing itself, since what an element
659−says about itself wins over what its parent says about it. They do win
660−over the defaults of the child's kind, so `children:display list-item`
661−brings back a list's markers and `children:margin-bottom 8` spaces
662−headings. The words
663−that place an element in its parent (`width fill`, `width shrink`,
664−`center-x`, `align-*`) are errors under `children:`, because there the
665−parent they measure against is this element: they go on the children
666−themselves, or the CSS they stand for does (`children:flex 1`).
667−
668−#### Styling the elements inside
669−
670−An element prefix, `@NAME:`, styles every element of that kind inside
671−this one, at any depth. `@table [@td:padding 8]` pads every cell of the
672−table, and `@article [@h2:font-size 20]` sizes every heading in the
673−article. It is how content that has no attributes of its own is styled:
674−the HTML `@markdown` writes, and the rows a loop writes.
675−
676−```
677−@let team Ada, Grace, Linus
678−@table [border-collapse collapse, width 100%, @th:[padding 8, text-align left], @td:padding 8, @tr:border-bottom 1 solid #e5e7eb]
679− @tr
680− @th Name
681− @th Team
682− @each $name in $team
683− @tr
684− @td $name
685− @td [padding 2] Platform
686−@article [spacing 12, @h2:font-size 20, @code:[padding 1 4, background #f1f5f9], @link:hover:color #2563eb]
687− @markdown
688− ## Notes
689−
690− Run `htmlang check` before you [publish](/publish).
691−```
692−
693−- **The element has a tag of its own.** `@td:` is every `<td>`,
694− `@paragraph:` every `<p>`, `@link:` every `<a>` and `@image:` every
695− `<img>`, the ones `@markdown` and `@raw` write included. An element that
696− shares its tag can't be picked out, so its prefix is an error: `@el`,
697− `@row`, `@grid`, `@in-front` and `@behind` all write `<div>`, and `@text`
698− writes the `<span>` that every line of text in a row or column is too.
699− So is a function's name (`@card:`), since a function writes whatever its
700− body says and has no tag of its own, and a name that isn't an element
701− (`@a:` suggests `@link:`).
702−- **It takes CSS properties.** htmlang's layout words (`spacing`, `wrap`,
703− `grid-cols`, `col-span`, `width fill`, `center-x`, `align-*`) compile
704− against each element's own layout and its parent's, which the prefix
705− doesn't know, so they are errors under it: write them on the elements,
706− or the CSS they stand for (`@li:margin-inline auto`). HTML attributes
707− have no states: `@td:colspan=2` is an error, like `md:colspan=2`.
708−- **It composes with the other prefixes, read left to right.** Selector
709− prefixes after it choose among the elements:
710− `@tr:nth-child(even):background #f8fafc` shades every other row, and
711− `@link:hover:color red` colours a hovered link. Those before it are
712− about the element itself: `hover:@link:color red` recolours its links
713− while it is hovered. Width, media and container prefixes go anywhere
714− (`md:@td:padding 12`). A `[group]` or a `$bundle` takes it like any
715− prefix: `@th:[padding 8, text-align left]`. A style has one element
716− prefix, and not `children:` as well: each says which elements the style
717− is for.
718−- **What an element says about itself wins.** A cell's own `[padding 2]`
719− wins over the table's `@td:padding 8`, and a parent's `children:` wins
720− over it too. The prefix wins over the defaults of the element's kind, so
721− `@main [@h2:margin-block-start 16]` spaces headings, which start with
722− `margin: 0`. Where two elements around one both style it, the nearest
723− wins, whatever the order they are written in: in an `@article
724− [@td:padding 16]` around a `@table [@td:padding 8]`, the table's cells
725− get 8.
726−- **It styles what is inside, not the element itself.** `@ul
727− [@ul:padding-inline-start 16]` indents the lists inside the list, not
728− the list itself.
729−
730−`children:` and an element prefix answer different questions:
731−`children:` is for the direct children, whatever they are, as the parent
732−sees them; `@td:` is for every cell inside, however deep. An element's
733−own attributes are for it alone.
734−
735−Each style under an element prefix compiles to a rule in CSS's `@scope`,
736−on the element's class, in the layer `hl-inside` (see [CSS](#css)):
737−`@scope (.hl-2k9xq0m) { :scope td { padding: 8px } }`. `@scope`'s proximity
738−rule is what makes the nearest element win. `@scope` is Baseline 2025,
739−supported by Chrome and Edge 118, Safari 17.4 and Firefox 146; an older
740−browser ignores these rules, so the elements inside keep the styles they
741−have without them.
742
245
743```
246```
744−@let card [padding 16, border 1 solid #e5e7eb, border-radius 8]
745−@let active true
746−@el [padding 16, background #3b82f6, hover:background #2563eb, md:[padding 32, font-size 20], dark:[background #1e3a8a, hover:background #1e40af]]
247+@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
747 @text [color white] Click me
248 @text [color white] Click me
748−@row [spacing 4, children:flex 1, hover:children:opacity 0.8]
249+@row [spacing 4, children:flex 1, nth:2n:background #f3f4f6]
749− @el [nth-child(odd):background #f3f4f6] A
250+ @el A
750− @el [nth-child(odd):background #f3f4f6] B
251+ @el B
751− @el [nth-child(odd):background #f3f4f6] C
752−@el [before:content "→ ", before:color red, hover:before:color blue]
252+@el [before:content "→ ", before:color red]
753 Item with an arrow
253 Item with an arrow
754−@section [spacing 16, md:$card, hover:if($active, [background #eef2ff, color #3730a3])]
755− A card from md up
756```
254```
757
255
758−The styles of one element follow a fixed order, whatever the order they
759−are written in: those without a prefix, then those with selector
760−prefixes only, then each width, media and container condition in the
761−order of the table (a stack like `md:dark:` after `dark:` on its own),
762−with its selector chains inside it. So where two styles both hold, the
763−one later in the table wins (`dark:` over `md:`, `lg:` over `md:`), and a
764−stack wins over each of its prefixes on its own (`md:dark:` over `dark:`
765−and `md:`). Selector chains follow CSS's usual order: `link:` and
766−`visited:`, the position and form states, then `hover:`, `focus:`,
767−`active:`, and `enabled:` and `disabled:` last. So a hovered row wins over
768−`nth-child(odd):`, and a pressed button's `active:` over its `focus:`.
769−
770### Conditional attributes
256### Conditional attributes
771
257
772−`if(CONDITION, A, B)`, written as a whole attribute, is `A` when the
258+`key if CONDITION` includes an attribute only when the condition holds;
773−condition holds and `B` otherwise. Each branch is an attribute, a `[group]`
259+`if(CONDITION, A, B)` picks a value.
774−of attributes or a `$bundle`. An empty branch leaves the attribute out, and
775−`B` itself can be left out. Several attributes that depend on one condition
776−are one `if()` with a group:
777
260
778```
261```
779@let active true
262@let active true
780−@el [if($active, background blue, background gray), if($active, font-weight bold)]
263+@el [background if($active, blue, gray), bold if $active]
781 Conditionally styled
264 Conditionally styled
782−@link [if($active, [background #eef2ff, font-weight 600, aria-current=page])] /docs Docs
783```
265```
784
266
785−A value that depends on a condition is an [expression](#expressions),
267+## Variables and functions
786−`${if(CONDITION, A, B)}`. Its branches are expressions, so a number or a
787−single word is written as it is, and other CSS text is quoted:
788−`padding ${if($active, 24, 0)}`, `color ${if($active, #10b981, "var(--muted)")}`.
789−An `if()` written in a value without `${...}` is CSS's own `if()`, which the
790−browser decides, and it is passed through like any CSS function:
791−`width if(media(width > 40em): 50%; else: 100%)`.
792−
793−## HTML
794−
795−### HTML elements
796
268
797−The layout and text elements have elm-ui's names. Every other element has
798−its HTML name: `@strong`, `@em`, `@small`, `@br`, `@sub`, `@caption`,
799−`@tfoot`, `@optgroup`, `@hgroup` and the others in the table of layouts
800−below.
801−
802−| Element | Output | Purpose |
803−|---|---|---|
804−| `@el` | div, flex column | The container: children laid out top to bottom |
805−| `@row` | div, flex row | Children laid out side by side |
806−| `@grid` | div, grid | Grid container (with `grid-cols` or `grid-template-*`) |
807−| `@in-front` / `@behind` | div, absolute | Overlay layers that fill the parent |
808−| `@text` | span | Styled inline text |
809−| `@paragraph` | p | A paragraph of flowing text |
810−| `@link URL` | a | Link: the URL is its `href`, and the text after it is its content |
811−| `@image SRC` | img | Image: the one word after its attributes is its `src` |
812−| `@fragment` | (none) | Its children, without a wrapper element (so it takes no attributes) |
813−
814−Every element has one [layout](#rows-columns-and-text):
815−
816−| Layout | Elements |
817−|---|---|
818−| column | `@el`, `@in-front`, `@behind`, `@nav`, `@header`, `@footer`, `@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@noscript`, `@form`, `@details`, `@dialog`, `@figure`, `@blockquote`, `@fieldset`, `@hgroup`, `@ul`, `@ol`, `@menu`, `@li`, `@dl`, `@dd` |
819−| row | `@row` |
820−| grid | `@grid` |
821−| text | `@text`, `@paragraph`, `@link`, `@h1`, `@h2`, `@h3`, `@h4`, `@h5`, `@h6`, `@button`, `@label`, `@legend`, `@summary`, `@figcaption`, `@cite`, `@dt`, `@td`, `@th`, `@code`, `@kbd`, `@mark`, `@abbr`, `@time`, `@b`, `@i`, `@strong`, `@em`, `@small`, `@s`, `@u`, `@sub`, `@sup`, `@q`, `@var`, `@samp`, `@dfn`, `@bdi`, `@bdo`, `@ins`, `@del` |
822−| native | `@table`, `@caption`, `@colgroup`, `@thead`, `@tbody`, `@tfoot`, `@tr`, `@select`, `@optgroup`, `@option`, `@datalist`, `@textarea`, `@progress`, `@meter`, `@output`, `@pre`, `@ruby`, `@rt`, `@rp`, `@picture`, `@video`, `@audio`, `@iframe`, `@object`, `@map`, `@canvas`, `@script` |
823−| void | `@image`, `@input`, `@hr`, `@br`, `@wbr`, `@col`, `@source`, `@track`, `@embed`, `@area` |
824−
825−`@fragment`, `@children` and `@slot` have no element of their own: what
826−they hold takes the layout of the element they are in. The top of a page is
827−a column, `<body>` (see [Page and head](#page-and-head)). In a native
828−element, and at the top of a fragment, lines of text are separated by a line break,
829−which HTML shows as a space except where whitespace is kept, as in `@pre`
830−and `@textarea`.
831−
832−The text-level elements (`@strong`, `@em`, `@b`, `@i`, `@small`, `@sub`,
833−`@q`, `@del`, ...) are text with the browser's own style and no CSS of
834−htmlang's, so they work inside a line like any inline element. Table parts,
835−`@optgroup` and `@ruby` keep HTML's own layout:
836−
837−```
838−@paragraph
839− {@strong Note:} water is H{@sub 2}O,{@br}
840− and {@em this} is {@small fine print}.
841−@table
842− @caption Team
843− @thead
844− @tr
845− @th Name
846− @tbody
847− @tr
848− @td Ada
849− @tfoot
850− @tr
851− @td One person
852−@select [aria-label=Fruit]
853− @optgroup Citrus
854− @option Lemon
855−```
856−
857−The list of elements is fixed: a name that isn't in it is an unknown
858−element, not a new HTML tag, since a function is called with the same `@`
859−and a misspelled one would otherwise become a tag. HTML's `<div>`, `<span>`,
860−`<p>`, `<a>` and `<img>` are htmlang's own `@el`, `@text`, `@paragraph`,
861−`@link` and `@image`, so `@a` is an unknown element whose error suggests
862−`@link`. The head's elements (`<meta>`, `<style>`, `<title>`, `<link>`) are
863−written with [directives](#page-and-head). `<data>` and `<slot>` share their
864−names with `@data` and `@slot`, and `<template>` holds content that only a
865−script uses; these, and `<svg>` and `<math>`, are written with `@raw`.
866−
867−An element without a closing tag (`@input`, `@hr`, `@br`, `@image`,
868−`@source`, ...) takes no content.
869−
870−Browser default margins on headings, paragraphs, lists and figures are reset
871−to 0 (in the element's own class, so Markdown and raw HTML keep theirs; see
872−[CSS](#css)), so `spacing` controls the gaps. Lists (`@ul`, `@ol`, `@menu`) and list
873−items are columns like other containers, so `spacing` on a list is the gap between its items, and
874−a list shows no markers. To bring them back, write
875−`[list-style disc, padding-inline-start 20, children:display list-item]` on
876−the list, and `marker:` styles the markers themselves (`::marker`).
877−
878−```
879−@ol [spacing 4]
880− @li First
881− @li Second
882−@ul [list-style disc, padding-inline-start 20, children:display list-item, children:marker:color #2563eb]
883− @li [first-child:font-weight 700] Markers
884− @li Back
885−@form [method=post, spacing 8] /subscribe
886− @label [for=email] Email
887− @input [type=email, name=email, id=email, required]
888− @button [type=submit] Send
889−@details [open]
890− @summary Question
891− @text The answer.
892−```
893−
894−### The leading argument
895−
896−An element that points at a URL or a file takes it as the first word after
897−its attributes, its leading argument, which fills one HTML attribute:
898−
899−| Element | Leading argument |
900−|---|---|
901−| `@link`, `@area` | `href` |
902−| `@image`, `@script`, `@iframe`, `@video`, `@audio`, `@track`, `@embed` | `src` |
903−| `@source` | `srcset` directly inside `@picture`, `src` inside `@video` and `@audio` |
904−| `@form` | `action` |
905−| `@object` | `data` |
906−| `@optgroup` | `label` |
907−
908−The first word is taken before any `$name` in it is filled in, and it ends
909−at the first space that isn't inside `"..."` or `${...}`: `@link $url More`
910−and `@link /page/${$n + 1} Next` take the whole `$url` and
911−`/page/${$n + 1}`, and `@link "/my page" Open` takes `/my page`. After it,
912−the rest of the text is the element's content, read like any text; an
913−element without content (`@image`, `@source`, `@track`, `@embed`, `@area`)
914−takes nothing more, and a word after its argument is an error (write a
915−text alternative as `alt=...`). So is a word after an `@optgroup`'s label,
916−since it holds only the `@option` lines under it. A value with a space in
917−it is quoted, as such a label: `@optgroup "Citrus fruits"`. The first word is
918−always the argument, so on an element whose attribute is often left out
919−(`@form`, `@video`, `@audio`), text goes on the lines under it: `@form Sign
920−in` would make `Sign` the form's action. A first word that reads as text,
921−a capitalized word with nothing of a URL in it, is taken with a warning
922−(`text-as-url`): `@link [hx-get=/panel] Open panel` makes `Open` the link's
923−`href`. A relative URL like that one is written `./Open`.
924−
925−The attribute can be written as an attribute instead, and it means the same:
926−`@link [href=/about]` with its text on the lines under it, or
927−`@image [src=logo.svg, inline]`. Both at once, as in
928−`@link [href=/a] About`, is an error that says which word was taken as the
929−`href`.
930−
931−```
932−@nav [flex-direction row, spacing 16]
933− @link /docs The docs
934− @link [aria-current=page] /pricing Pricing
935−@image [alt=The team, width 320] "team photo.jpg"
936−@picture
937− @source [type=image/avif] hero.avif
938− @image [alt=The office] hero.jpg
939−@video [controls] intro.mp4
940− @track [kind=captions, srclang=en] intro.vtt
941− Your browser doesn't play this video.
942−```
943−
944−`@script` is an element like the others: its leading argument is its `src`,
945−any HTML attribute passes through (`@script [type=module, defer] app.js`),
946−and without a `src` its indented body is its JavaScript, kept exactly as
947−written. A `src` and a body together are an error, since the browser runs
948−only the file. It isn't shown, so a style on it is an error.
949−
950−```
951−@script [defer] analytics.js
952−@script
953− document.body.dataset.ready = "yes"
954−```
955−
956−### HTML attributes
957−
958−HTML attributes are written `key=value`: `id=main`, `class=note`,
959−`href=/about`, `type=email`, `alt=Logo`, `target=_blank`,
960−`aria-label=Close menu`, `data-id=42`, `hx-get=/items`. Any name works,
961−one with a colon in it included (`xml:lang=en`, `hx-on:click=open()`):
962−before an `=` a colon is part of the name, not a prefix. Boolean attributes
963−are written bare: `required`, `disabled`, `checked`, `hidden`, `open`,
964−`popover`, and so are `data-` and `hx-` attributes that have no value
965−(`hx-preserve`). An HTML attribute written like a style (`type email`,
966−`hx-get /items`) is an error that shows the `key=value` form. A style
967−and an HTML attribute can share a name, because the `=` tells them apart:
968−
969−```
970−@image [width=800, width 200, alt=A photo] photo.jpg
971−@label
972− Size
973− @select [size=4, font-size 18]
974− @option One
975−```
976−
977−## Definitions
978−
979−`@let` defines everything reusable. What it defines depends on its form:
269+`@let` defines everything reusable. What it defines depends on its shape:
980
270
981```
271```
982-- A value, used as $primary
272-- A value, used as $primary
983@let primary #3b82f6
273@let primary #3b82f6
984−-- A computed value: `=` makes the rest an expression
274+-- A computed value: `=` makes it an expression
985@let gap = 8 * 2
275@let gap = 8 * 2
986−-- Quoted text, with $variables filled in (see Variables for its quotes)
276+-- A quoted string, with $variables interpolated
987−@let greeting "Hello from $primary"
277+@let greeting "Hello $primary"
988-- An attribute bundle, used as [$card]
278-- An attribute bundle, used as [$card]
989−@let card [padding 20, background white, border-radius 8]
279+@let card [padding 20, background white, rounded 8]
990−-- A function: `@` before its name, its parameters in brackets, and a body
280+-- A function: a @let with an indented body
991−@let @panel [title]
281+@let panel $title $tone=neutral
992− @el [$card, spacing $gap]
282+ @el [$card]
993− @text [font-weight bold, color $primary] $title
283+ @text [bold] $title
994 @children
284 @children
995
285
996@panel [title Welcome]
286@panel [title Welcome]
997− $greeting
287+ Body text goes into @children.
998@el [$card, background #f9fafb]
288@el [$card, background #f9fafb]
999 Attributes after a bundle override it.
289 Attributes after a bundle override it.
1000```
290```
1001
291
1002−The line alone says what a `@let` defines: an `@` before the name makes a
1003−function, a `[` after the name an attribute bundle, and anything else a
1004−value. A value or a bundle is named without `$` and used with it; a function
1005−is named with `@` and called with it. Only a function has a body, so an
1006−indented block under a value or a bundle is an error, and so are `@let $x`
1007−(write `@let x`) and a `@let` without a value.
1008−
1009−Values, bundles and functions share one namespace, and one rule says where
1010−a name is visible: **from the line that defines it to the end of its
1011−block**. A block is the file, the lines indented under an element, a branch
1012−of `@if`, one repetition of `@each` or a function's body. A `@let` gives its
1013−name a new meaning in its block, whatever the name meant before, and hides
1014−what the name means around the block until the block ends:
1015−
1016−```
1017−@let gap 8
1018−@el [spacing $gap]
1019− @let gap 16
1020− @el [padding $gap] Sixteen
1021−@el [padding $gap] Eight
1022−```
1023−
1024−Parameters, `@each` variables and `@data` names follow the same rule, and
1025−each repetition of `@each` starts afresh, so a `@let` in one doesn't carry
1026−over to the next. A function's body sees its parameters and what is
1027−visible where the function is defined: not the names at a call, and not a
1028−definition further down, so a function means the same wherever it is
1029−called. `@let t.greeting Hello` gives the record `$t` the field `greeting`
1030−(making `$t` a record when it isn't defined), and `@let items.0 first`
1031−replaces the first item of the list `$items`; a field of any other value is
1032−an error.
1033−
1034−### Variables
1035−
1036−`$name` inserts a value wherever htmlang holds text: a line of text, an
1037−attribute's value, an element's argument, a `@let` value, a file path, the
1038−title of `@page` and the value of `@meta`. Each line is read first, and then
1039−`$name` fills the one place it is written in. What it inserts is never read
1040−again as htmlang, so a value can't become an attribute, an attribute's name,
1041−a comma between attributes, an inline element or another `$name`. Attributes
1042−come from a bundle:
1043−
1044−```
1045−@let link-style [color #2563eb, text-decoration underline]
1046−@let label Docs
1047−@link [$link-style, aria-label=$label] /docs Read the $label
1048−```
1049−
1050−A name starts with a letter or `_` and goes on with letters, digits, `_`
1051−and `-`. It ends at the first other character, so after `@let lang fr`,
1052−`$lang.json` is `fr.json`. A `.field` continues the name only when the value
1053−is a record or a list (`$post.title`, `$tags.0`). `${name}` is the same name
1054−with explicit ends, for text that follows it directly, and `${EXPR}` inserts
1055−the value of an [expression](#expressions). A `$` followed by anything else
1056−is text.
1057−
1058−```
1059−@let lang fr
1060−@let size 4
1061−@data $post {"title": "Hello"}
1062−@text locales/$lang.json, ${size}px, $post.title, $5
1063−```
1064−
1065−A name that isn't defined is an error. A field that a record doesn't have
1066−is empty, and so is any field of it, so optional fields of `@data` records
1067−work: `@if $post.draft` is false, `$post.author.name` is empty when there is
1068−no `author`, and `${default($post.tag, none)}` gives `none`.
1069−
1070−### Values
1071−
1072−A value is text, a number, `true` or `false`, a list or a record, and a
1073−name holds a whole value: a `@let`, a parameter, `@each` and `@data` bind
1074−one, and binding the name again replaces it. What a `@let` holds is what it
1075−says:
1076−
1077−```
1078−-- Text, as written
1079−@let size 16px
1080−-- Commas make a list, which prints as it was written
1081−@let fruits apple, banana, cherry
1082−-- Quoted, or with `\,`, text with commas in it is one text
1083−@let tagline "Fast, simple"
1084−@let motto small\, fast
1085−-- A range of whole numbers
1086−@let steps 1..9 step 4
1087−-- One `$name` or `${...}` keeps the type of what it holds
1088−@data $posts [{"title": "Hello"}, {"title": "Again"}]
1089−@let first $posts.0
1090−@let backwards ${reverse($fruits)}
1091−@text [font-size $size] $fruits: ${length($fruits)}. $tagline: ${length($tagline)}. $motto. $steps
1092−@text $first.title, $backwards
1093−```
1094−
1095−The last lines give `apple, banana, cherry: 3. Fast, simple: 12. small,
1096−fast. 1..9 step 4` and `Hello, cherry, banana, apple`. A value that is
1097−exactly one `$name` or `${...}` keeps its type wherever it goes: a `@let`, a
1098−function's parameter or default, the list of `@each`. So a record can be
1099−passed to a function whole (`@post-card [post $p]`), and a list stays a
1100−list.
1101−
1102−- Text written without quotes reads as a number when it is one (`3`,
1103− `0.5`), and as false when it is `false` or `0`: that is how htmlang writes
1104− numbers and flags (`@let gap 8`, `@card [dot false]`). Quoted text is
1105− always text.
1106−- **False** is `false`, empty text, `0` and an empty list (or record).
1107− Everything else is true, including `no` and quoted `"0"`.
1108−- `==` and `!=` compare numbers as numbers (`1.0 == 1`), lists and records
1109− item by item, and anything else as text, exactly (`#FFF` isn't `#fff`).
1110− `<`, `>`, `<=` and `>=` compare numbers as numbers and text by character
1111− order.
1112−- A computed number prints without float noise: whole numbers without
1113− decimals, others with at most four (`${100 / 3}` is `33.3333`).
1114−- A list prints as written when the source wrote it, and otherwise as its
1115− items joined with `, `. A record has no text of its own, so writing one
1116− (or a list of records) where text goes is an error: write one of its
1117− fields, or loop over the list with `@each`.
1118−- A `$list` in a comma list is one item, a list inside the list:
1119− `@let more $items, d` has two items. htmlang has no list concatenation.
1120−
1121−**Quoted text remembers that it was quoted.** A value written `"..."` (in a
1122−`@let`, an attribute or a function's argument) keeps its quotes in a CSS
1123−value, where CSS needs them, and loses them in text, in HTML attribute
1124−values and in expressions (`${length($x)}` counts no quotes). It stays
1125−quoted when it passes through another `@let` or a parameter. Inside a quoted string of a CSS value, it inserts
1126−what it says, without its own quotes:
1127−
1128−```
1129−@let arrow "→ "
1130−@let label "Next, please"
1131−@el [before:content $arrow, after:content " ($label)", aria-label=$label] $label
1132−```
1133−
1134−This gives `content:"→ "`, `content:" (Next, please)"`,
1135−`aria-label="Next, please"` and the text `Next, please`. A value written
1136−without quotes is inserted as written everywhere.
1137−
1138−### Functions
1139−
1140−A function's parameters are listed in brackets after its name and written
1141−the way a call passes them: a name alone (`title`) is a required parameter,
1142−and a name and a value (`tone #f9fafb`) is a parameter with that default.
1143−The body uses them as `$title` and `$tone`. `@let @name` without brackets
1144−takes no parameters.
1145−
1146−A default is filled in at each call that leaves its parameter out, like a
1147−value passed for it: it may hold spaces, quotes, escapes, `${...}` and
1148−`$variables`. It sees what the body sees: the names visible where the
1149−function is defined and the parameters declared before it
1150−(`[title, heading "About $title"]`). A default that uses a parameter
1151−declared after it is an error.
1152−
1153−A function is called like an element, wherever an element can be: on a
292+A function is called like an element:
1154−line of its own, in a [chain](#chains) (`@el > @card [title Hi]`) or
1155−inline in text (`{@key Ctrl+K}`):
1156−
1157−- Its parameters are passed by name, as attributes: `name value`, or the
1158− name alone for `true`. A parameter with a default can be left out;
1159− leaving out one without a default is an error that names it. `=` writes
1160− an HTML attribute, so `title=Hi` for a parameter is an error too.
1161−- Every other attribute goes to the function's root element: a style, a
1162− flag, or a `key=value` HTML attribute such as `id=intro`. It is checked
1163− like an attribute written on that element, so `@panel [title Hi,
1164− padding 40]` works the same as styling a built-in element, and
1165− `[paddin 40]` gets the same warning. A name close to one of the
1166− parameters (`titel Hi`) is a misspelled parameter, which is an error.
1167− This needs the body to have a single root element: attributes on a call
1168− whose body has several roots, or is text, are an error. A `class=` adds
1169− to the root's own classes.
1170−- A parameter passed twice (in the call's list, or after a bundle that
1171− passes it) takes the later value, as any attribute does.
1172−- Text after the attributes and the indented lines are the call's
1173− content, and replace `@children` in the body. A `@slot NAME` block
1174− directly under the call (or under an `@if`, `@else` or `@each` there)
1175− fills the body's `@slot NAME` instead.
1176−- The lines indented under `@children` or `@slot NAME` in the body are its
1177− fallback: they are shown when a call passes no content, or no block for
1178− that slot.
1179−
1180−```
1181−@let @card [title, tone #f9fafb]
1182− @article [padding 16, spacing 8, background $tone, border-radius 8]
1183− @row [align-items center]
1184− @h3 $title
1185− @spacer
1186− @slot actions
1187− @children
1188− @slot footer
1189− @text [font-size 12, color gray] No footer
1190−
1191−@card [title Plain]
1192− Just children.
1193−@card [title Full, tone #eff6ff, padding 24]
1194− @slot actions
1195− @link /edit Edit
1196− The children go here.
1197− @slot footer
1198− @text Updated today
1199−```
1200−
1201−`@children` takes a fallback the same way:
1202
293
1203−```
294+- Its parameters are passed as attributes; parameters with `=default` may be
1204−@let @notice
295+ omitted.
1205− @el [padding 12, background #fef3c7]
296+- Other attributes style its root element, so `@panel [title Hi, padding 40]`
1206− @children
297+ works like styling a built-in element.
1207− @text Nothing to report.
298+- Text after the attributes and indented children replace `@children`, and a
299+ caller's `@slot name` block replaces `@slot name` (the slot's own children are
300+ the default).
301+- An `@style` block at the top of the body is scoped to the function: its rules
302+ only apply inside the function's output.
1208
303
1209−@notice
1210−@notice Deploys are paused today.
1211```
304```
1212−
1213−Content that would go nowhere is an error, so a mistake can't drop it:
1214−
1215−- a `@slot NAME` block for a slot the function doesn't have (the message
1216− lists the slots it has);
1217−- text or lines passed to a function whose body has no `@children`;
1218−- a `@slot NAME` block inside an element at the call, instead of directly
1219− under the call;
1220−- `@slot` or `@children` outside a function's body, or inline in text;
1221−- `@children` or `@slot` after an element's attributes (`@td [padding 8]
1222− @children`), where it would be text: it goes on a line of its own.
1223−
1224−A slot's name is one word of letters, digits, `-` and `_`, starting with a
1225−letter, like a function's. Inside a body, a call can pass on what the
1226−function got: `@children` under the call passes the content, and a
1227−`@slot footer` block holding `@slot footer` passes that slot.
1228−
1229−A parameter that is off unless the call names it is a flag: give it the
1230−default `false`, and name it alone to turn it on.
1231−
1232−```
1233−@let @post-card [post, featured false, label "About $post"]
1234− @article [spacing 8, padding ${if($featured, 24, 0)}]
1235− @if $featured
1236− @text [font-weight bold] Featured
1237− @h3 $label
1238−
1239−@post-card [post htmlang, featured]
1240−@post-card [post CSS]
1241−```
1242−
1243−An `@style` block at the top of a function body is **scoped** to the
1244−function. Its rules apply inside the function's root element, and `&` is the
1245−root itself. This requires the body to have a single root element. The
1246−stylesheet goes into the page once, when the function is first called, so
1247−a function that is never called adds nothing. It is in the layer
1248−`hl-inside`, with the styles of element prefixes (see [CSS](#css)): what an
1249−element says about itself wins over it, so a call's `[padding 20]` wins
1250−over the function's `& { padding: 12px; }`, as it would over the root's own
1251−defaults. Where CSS properties are all it needs, an element prefix on the
1252−root says the same without a stylesheet (`@aside [@h2:font-size 18]`).
1253−Only an `@style` at the top of the body is scoped: one under an `@if` or an
1254−element is the page's, where `&` is the whole page, so the compiler warns
1255−about a `&` there.
1256−
1257−```
1258−@let @note [kind Note]
305+@let note $kind=info
1259 @style
306 @style
1260− & { border-left: 4px solid #3b82f6; }
1261 .title { font-weight: bold; }
307 .title { font-weight: bold; }
1262− @el [padding 12, border-radius 6, background #eff6ff]
308+ @el [padding 12, rounded 6, background #eff6ff]
1263 @text [class=title] $kind
309 @text [class=title] $kind
1264 @children
310 @children
1265
311
1266@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
312@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
1267```
313```
1268
314
1269−A call in text or in a chain whose body has several roots, or is text,
315+A multi-line string uses triple quotes; its indented lines are the value:
1270−puts them in its place, like `@fragment`. A function whose body is text
1271−holds multi-line content, and a small function works inside a sentence:
1272
316
1273```
317```
1274−@let @intro
318+@let intro """
1275− htmlang is a layout language.
319+ First line
1276− It compiles to {@strong static HTML}.
320+ Second line
1277−@let @key
321+ """
1278− @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
322+@text $intro
1279− @children
1280−
1281−@paragraph
1282− @intro
1283−@paragraph
1284− In short: {@intro} Press {@key Ctrl+K} to search.
1285```
323```
1286
324
1287−A function may call itself, under a condition that stops it, so it can
325+`@let --name value` also emits a CSS custom property, used as `$--name` and
1288−render nested data such as a menu. A parameter passed one `$name` that
326+as `var(--name)` in the generated CSS:
1289−holds a record or a list gets the whole value, fields and items
1290−included. Calls nest at most 64 deep: a function that calls itself with
1291−nothing to stop it is an error. Since a body sees only what is defined
1292−above its function, two functions can't call each other.
1293
327
1294```
328```
1295−@data $menu [
329+@let --brand #3b82f6
1296− {"label": "Guide", "children": [{"label": "Install"}, {"label": "Usage"}]},
330+@el [background $--brand] Themed
1297− {"label": "Reference"}
1298−]
1299−@let @tree [items]
1300− @ul [padding-left 16]
1301− @each $item in $items
1302− @li
1303− @text $item.label
1304− @if $item.children
1305− @tree [items $item.children]
1306−
1307−@tree [items $menu]
1308```
331```
1309
332
1310−The elements a function's body writes are the call's: a problem with
333+### Checks
1311−one of them, such as low contrast or `spacing` on an element that isn't a
1312−row, column or grid, is reported at the call, once, and names the function. A
1313−problem in the text of a body is reported on its own line, or at the call
1314−when the function comes from `std.hl` or another file.
1315−
1316−Naming a function after a built-in element or a directive is a warning:
1317−`@let @button` replaces `@button` in every call after it, and a function
1318−named `@if` could never be called. Inside its own body `@button` is the
1319−function too, so a function that wraps the element it is named after,
1320−outside an `@if`, is an error: give it another name.
1321−
1322−### CSS custom properties
1323−
1324−`@let --name value` declares the CSS custom property `--name` on `:root`,
1325−for the whole page. Its value is written as it is, with no pixels added,
1326−so a length takes its unit (`@let --radius 8px`). A token is read one way,
1327−`var(--name)`, in the browser, so an element (`[dark:--name value]`, see
1328−[Custom properties](#custom-properties)) or a stylesheet can still
1329−redefine it. It is not a variable: `$--name` is an error that says to
1330−write `var(--name)`. Since `:root` is one place for the whole page, a
1331−`@let --name` goes at the top level of the file, once: inside an element,
1332−an `@if`, a loop or a function's body it is an error (set the property on an
1333−element with `[--name value]` instead), and declaring it again warns.
1334−
1335−A colour variant of a token is CSS too, so it follows the token wherever
1336−it is redefined: `color-mix()` mixes two colours, and a relative colour
1337−(`rgb(from ...)`) keeps a colour's channels except those you write (`/ 0.5`
1338−for its alpha). `in srgb` mixes the red, green and blue channels, as most
1339−colour tools do; `in oklab` gives steps that look more even, a good choice
1340−for new tokens.
1341−
1342−| For | Write |
1343−|---|---|
1344−| A darker colour | `color-mix(in srgb, var(--brand), black 10%)` |
1345−| A lighter colour | `color-mix(in srgb, var(--brand), white 10%)` |
1346−| A transparent colour | `rgb(from var(--brand) r g b / 0.5)` |
1347−| Two colours mixed | `color-mix(in srgb, var(--brand), #f43f5e 50%)` |
1348
334
1349−```
335+`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
1350−@let --brand #3b82f6
336+warning.
1351−@let --radius 8px
1352−@page [dark:--brand #60a5fa] Themed
1353−@el [background var(--brand), border-radius var(--radius), border 1 solid color-mix(in srgb, var(--brand), black 10%)] Themed
1354−@row
1355− @each $shade in 0, 10, 20, 30
1356− @el [background color-mix(in srgb, var(--brand), black $shade%)] $shade%
1357−```
1358
337
1359## Expressions
338## Expressions
1360
339
@@ -1362,40 +341,25 @@ Conditions and computed values (`@let x = ...`) are expressions:
1362
341
1363| | |
342| | |
1364|---|---|
343|---|---|
1365−| Values | numbers, `"strings"` (with `$var` interpolation), `$variables` (with their type: a list or a record too), `$record.field`, `true`, `false`, ranges `A..B` and `A..B step N`, and bare words (`dark`, `#fff`) as text |
344+| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
1366| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
345| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
1367−| Comparison | `== != < > <= >=`: numbers as numbers, text exactly, lists and records item by item (see [Values](#values)) |
346+| Text | `~` joins two values: `$first ~ " " ~ $last` |
347+| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains`, `starts-with`, `ends-with` |
1368−| Logic | `and`, `or`, `not`. `false`, empty text, `0` and an empty list are false |
348+| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
1369−| Choice | `if(CONDITION, A, B)`, where `B` may be left out (empty) |
349+| Choice | `if(CONDITION, A, B)` |
1370−| Tests | `contains(s, x)` (in a text, or as an item of a list), `starts-with(s, x)`, `ends-with(s, x)` |
1371−| Text | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)` (the items of a list, or the characters of a text), `reverse(s)` (a list's items, or a text's characters), `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` (the fallback when `s` is empty text or an empty list) |
350+| Text functions | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)`, `reverse(s)`, `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` |
1372−
351+| Color functions | `lighten(c, pct)`, `darken(c, pct)`, `alpha(c, a)`, `mix(c1, c2, pct)` |
1373−Colour maths is CSS's, not an expression's: see
352+
1374−[CSS custom properties](#css-custom-properties). A CSS function such as
1375−`color-mix()` goes in an attribute's value as it is, or in a plain
1376−`@let` value (`@let hover color-mix(in srgb, var(--brand), black 8%)`),
1377−not in `@let x = ...` or `${...}`.
1378−
1379−Variables are looked up during evaluation, so a value that contains `==` or
353+Variables are looked up while evaluating, so a value containing `==` or spaces
1380−spaces is still one value. An invalid expression is a compile error, and so
354+is still one value. An invalid expression is a compile error.
1381−is an undefined variable. A text function given a list is an error too,
355+
1382−instead of a guess: `uppercase()` of `Fast, simple` (a list, because of its
356+In text and attribute values, `$name` inserts a variable and `${EXPR}` inserts
1383−comma) says so, and the text is written `"Fast, simple"` or `Fast\, simple`.
357+the value of any expression:
1384−A variable's name ends at `..`, so `${$from..$to}` is a range.
1385−
1386−Only what decides the result is evaluated: `if()` evaluates the branch it
1387−takes, and `and` and `or` stop at the side that decides. So
1388−`${if($n != 0, 10 / $n, 0)}` and `@if $n != 0 and 10 / $n > 1` work when
1389−`$n` is 0. The rest is still read, and its syntax and function names are
1390−checked.
1391−
1392−In text, values and file paths, `${EXPR}` inserts the value of any
1393−expression (see [Variables](#variables)):
1394
358
1395```
359```
1396@let name htmlang
360@let name htmlang
1397−@let size 12
361+@let base #3b82f6
1398−@text [font-size ${$size + 4}] ${uppercase($name)} has ${length($name)} letters
362+@text [color ${darken($base, 10)}] ${uppercase($name)} has ${length($name)} letters
1399```
363```
1400
364
1401## Control flow
365## Control flow
@@ -1403,8 +367,8 @@ expression (see [Variables](#variables)):
1403All control flow runs at compile time.
367All control flow runs at compile time.
1404
368
1405```
369```
370+@let items apple, banana, cherry
1406@let count 3
371@let count 3
1407−@let hidden false
1408
372
1409@if $count > 2 and not $hidden
373@if $count > 2 and not $hidden
1410 @text Many
374 @text Many
@@ -1412,208 +376,118 @@ All control flow runs at compile time.
1412 @text None
376 @text None
1413@else
377@else
1414 @text Few
378 @text Few
1415−```
1416
379
1417−`@each $item in LIST` repeats its body for each item. Its variables are
380+@each $item in $items
1418−written with `$`, like `@data $name`. LIST is a value, read like the value
381+ @text $_index: $item
1419−of a `@let`: items written with commas (a quoted item or one with `\,` is
1420−one text), a range `A..B` (which counts down when its start is greater than
1421−its end, and takes `step N`), or one `$name` or `${...}` that holds a list,
1422−such as one loaded with `@data` or `${reverse(1..5)}`. A text is one item,
1423−and empty text none. Each item is bound whole, so a record keeps its fields.
1424−An optional second variable is the index, starting from 0. `@else` gives
1425−the content to show when the list is empty.
1426−
1427−```
1428−@let fruits apple, banana, cherry
1429−@each $fruit, $i in $fruits
1430− @text ${$i + 1}. $fruit
1431@else
382@else
1432− @text No fruit.
383+ @text The list is empty.
384+
385+@each $i in 1..10 step 3
386+ @text $i
1433
387
1434−@row [spacing 8]
388+@each $label, $url in Home /, About /about
1435− @each $n in 10..0 step 5
389+ @link $url $label
1436− @text $n
1437```
390```
1438
391
1439−## Data and files
392+A range counts down when its start is greater than its end.
393+
394+## Files and data
1440
395
1441| Directive | Effect |
396| Directive | Effect |
1442|---|---|
397|---|---|
1443−| `@data $name file.json` | Load a JSON file. Objects are records (`$name.key`) and arrays are lists |
398+| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
1444−| `@data $name [...]` / `@data $name {...}` | Inline JSON, which may span several lines |
399+| `@include lib.hl as ui` | Take only its definitions, named `ui.name` |
400+| `@extends layout.hl` | Render this page inside a layout (below) |
401+| `@data file.json` / `@data $name file.json` | Load JSON values as variables (`$name.key`) |
1445−| `@data $name dir/*.json` | A list with one record per file, in name order. `$item.file` is the file's name |
402+| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
1446−| `@data $name env:NAME DEFAULT` | An environment variable, with an optional default |
403+| `@data $name env:NAME [default]` | Read an environment variable |
1447−| `@include file.hl` | Insert another file: its content and its definitions. A file that holds only definitions outputs nothing (a [library](#page-layouts)) |
1448−| `@markdown` / `@markdown file.md` | Markdown (an indented body or a file), converted to HTML |
404+| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
1449−| `@image [inline] file.svg` | Put the file inside the page: SVG as markup, other images as base64. The file is read next to the page, and one that can't be read is an error. The SVG's root gets `width` and `height` (lengths, not `fill` or `shrink`) and `color` or `fill` (as its `fill`) in its `style=`, after the file's own, where `var(--name)` works, and the HTML attributes (`class=`, `aria-label=`, ...); any other style, and `alt=`, is an error, since the markup has no generated class, and an element prefix (`@image:`) doesn't reach it. `inline` goes only on `@image`, without a prefix |
405+| `@image [inline] file.svg` | Inline an SVG file (`width`, `height`, `color`, `class=`, `id=` apply to it) |
1450−
1451−Records keep their values whole, even when a value contains spaces or
1452−commas:
1453−
1454−```
1455−@data $team [
1456− {"name": "Ada", "role": "Engineering, research"},
1457− {"name": "Grace", "role": "Design"}
1458−]
1459−@ul [spacing 4]
1460− @each $person in $team
1461− @li
1462− @text [font-weight bold] $person.name
1463− @text $person.role
1464−@text ${length($team)} people
1465−```
1466−
1467−### Page layouts
1468
406
1469−A layout is an ordinary function in a file of its own. It marks where content
407+With `@extends`, the layout marks where content goes with `@slot name` (named
1470−goes with `@slot NAME` (named blocks) and `@children` (everything in the call
408+blocks) and `@children` (everything in the page outside `@slot` blocks):
1471−that is outside a `@slot` block), each with fallback content indented under
1472−it.
1473
409
1474```
410```
1475-- layout.hl
411-- layout.hl
1476−@let @layout
1477− @page [lang=en, background #f8fafc] My Site
412+@page My Site
1478− @el [width fill, max-width 800, center-x, spacing 24]
413+@column [max-width 800, center-x]
1479− @header
1480− @slot header
414+ @slot header
1481− @text Default header
415+ @text Default header
1482− @main
1483− @children
416+ @children
1484```
417```
1485
418
1486```
419```
1487-- page.hl
420-- page.hl
1488−@include layout.hl
421+@extends layout.hl
1489−@layout
1490− @slot header
422+@slot header
1491− @h1 About us
423+ @text About us
1492− @paragraph This fills @children.
424+@paragraph This fills @children.
1493```
425```
1494
426
1495−The layout holds the page's `@page`, whose attributes can come from its
1496−parameters (`@page [background $bg] $title`). A page has one `@page`, so a
1497−second one is an error (`duplicate-page`): the page's own next to its
1498−layout's, or a layout called twice. The same `@meta` tag twice is written
1499−once. A file that holds only `@let`s, such as `layout.hl`, is a library
1500−(it may also read `@data` and `@include` other libraries): its definitions
1501−aren't reported unused, in it or in a page that includes it and uses only
1502−some of them, and `htmlang build`, `serve` and `watch` don't build it into
1503−a page of its own. An included file's unused definitions are never
1504−reported in the file that includes it.
1505−
1506## Page and head
427## Page and head
1507
428
1508−`@page TITLE` makes the output a full HTML document. Without it, the output
429+`@page TITLE` produces a full HTML document; without it the output is a fragment.
1509−is a fragment, which has no `<head>`: `@meta` and `@head` in it are a
430+Its attributes set `lang`, `favicon`, `canonical` and `base`. `@meta NAME VALUE`
1510−warning. `--partial` writes a fragment of a page too, and warns that
1511−`@page`, `@meta` and `@head` are left out. The page is the root element, and `@page`'s attributes are
1512−checked like any element's:
1513−
1514−- Styles style `<body>`, prefixes included, so a page's colour, font and
1515− dark background go on `@page` and cover the whole window.
1516−- `key=value` attributes go on `<html>`: `lang=en`, `dir=rtl`, `class=x`.
1517−- `favicon FILE` is its one word of htmlang's own: the file is put into
1518− the page as its icon (or linked, when it can't be read).
1519−
1520−`<body>` is a column that fills the window (`display: flex;
1521−flex-direction: column; min-height: 100dvh`, from the reset), so the
1522−page's top-level elements are laid out like the children of an `@el`:
1523−they stack, even a line of text or an inline element such as `@text` or
1524−`@link`, and each takes the full width unless it says otherwise. A
1525−centred column at the top writes `width fill` like anywhere else (see
1526−[Rows, columns and text](#rows-columns-and-text)).
1527−
1528−`@meta NAME VALUE` adds a meta tag, and names that start with `og:` become
431+adds a meta tag (`og:` names become Open Graph `property` tags), and
1529−Open Graph `property` tags; a `@meta viewport` replaces the usual one.
1530−`@head` holds any other raw HTML for the `<head>`, such as a font link, a
432+`@head` holds any other raw HTML for the `<head>` (fonts, JSON-LD, a manifest
1531−canonical URL or JSON-LD, on its line or in its indented block.
433+link). Translations are a JSON file per locale: `@data $t locales/$lang.json`.
1532
434
1533```
435```
1534−@page [lang=en, favicon favicon.png, color #1f2937, dark:background #0b1220, dark:color #e5e7eb] My Site
436+@page [lang en, favicon /favicon.png] My Site
1535@meta description A small site
437@meta description A small site
1536@meta og:title My Site
438@meta og:title My Site
1537−@head <link rel="canonical" href="https://example.com/">
1538−@head
1539− <script type="application/ld+json">{"@type": "WebSite"}</script>
1540−@header [padding 16] My Site
1541−@main [height fill, padding 16]
1542− The main part takes the rest of the window.
1543−@footer [padding 16] © 2026
1544```
439```
1545
440
1546−Translations are a JSON file per locale: `@data $t locales/$lang.json`
1547−loads `locales/fr.json` when `$lang` is `fr`.
1548−
1549## CSS
441## CSS
1550
442
1551−`@style` holds raw CSS, including at-rules such as `@keyframes`,
443+- `@style` holds raw CSS, including at-rules such as `@scope`, `@property` and
1552−`@font-face`, `@property` and `@starting-style`, in its indented block or,
444+ `@starting-style`.
1553−for a single rule, on its line:
445+- `@keyframes name` takes htmlang attribute syntax (`from [opacity 0]`) or raw
446+ CSS.
447+- Generated rules live in `@layer htmlang` (after a `hl-reset` layer), so any
448+ CSS in `@style` or `@raw` overrides them.
1554
449
1555```
450```
451+@keyframes fade-in
452+ from [opacity 0]
453+ to [opacity 1]
1556@style
454@style
1557− @keyframes fade-in {
1558− from { opacity: 0; }
1559− to { opacity: 1; }
1560− }
1561 .note { color: gray; }
455 .note { color: gray; }
1562−@style .quiet { opacity: 0.6; }
1563@el [animation fade-in 0.3s ease, class=note] Fades in
456@el [animation fade-in 0.3s ease, class=note] Fades in
1564```
457```
1565
458
1566−Each element with styles gets a generated class, and elements with the
459+## Upgrading older files
1567−same styles share one. A class is named by its style: `hl-` and seven
460+
1568−characters of a hash of what it sets (`hl-2k9xq0m`), so one style has one
461+`htmlang upgrade [dir|file]` rewrites removed syntax; the compiler reports each
1569−name in every file. A fragment put into a page (`--partial` output that a
462+removed form with its replacement.
1570−server returns, or any HTML of one file put into another's page) means
463+
1571−the same there, and its rules restyle nothing of the page's: a class of
464+| Removed | Use instead |
1572−both is the same style. Two different styles of one file never share a
1573−name (the later one takes more characters), and `htmlang build` checks
1574−the whole site: two files whose different styles hash to one name are an
1575−error (`class-collision`), which one more attribute on either style
1576−fixes. A function's scoped `@style` is named by the function and a hash
1577−of the stylesheet, `hl-fn-card-1x0a9zq`. The `hl-` prefix is htmlang's:
1578−a class of your own that starts with it gets a warning, since the generated
1579−rules would apply to it too. Every style goes into the class under its own
1580−name, with its value as written (plus pixels, see
1581−[CSS properties](#css-properties)); only the layout words, and
1582−`line-clamp`'s fallback, write more than one declaration.
1583−
1584−An element's defaults go with its own class: its layout (a column is
1585−`display: flex`), the margins browsers give headings, paragraphs, lists,
1586−figures and blockquotes are 0, lists have no markers, `@fieldset` has a
1587−thin border and padding, `@code`, `@kbd` and `@pre` are monospace, `@link`
1588−has no underline and takes its parent's text colour, and `@image` is a
1589−block. They apply only to htmlang's own elements, so HTML that comes from
1590−`@markdown` or `@raw` keeps the browser's defaults: a Markdown list keeps
1591−its bullets and a Markdown link its underline.
1592−
1593−The generated rules live in three cascade layers, after a small reset in
1594−the layer `hl-reset`, and a later layer wins over an earlier one whatever
1595−the specificity:
1596−
1597−| Layer | Holds |
1598|---|---|
465|---|---|
1599−| `hl-reset` | The reset |
466+| `@fn`, `@define`, `@mixin`, `@component` | `@let` (function, bundle; `@style` in a function body is scoped) |
1600−| `hl-kind` | The defaults of each element's kind, as `:where(.hl-2k9xq0m)` |
467+| `@unless COND` | `@if not COND` |
1601−| `hl-inside` | The styles an element gets from an element prefix on an element around it (`@td:padding 8`, see [Styling the elements inside](#styling-the-elements-inside)), and functions' scoped `@style` |
468+| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
1602−| `htmlang` | Each element's own styles, and its parent's `children:` styles |
469+| `grid` (attribute) | `@grid`, or `display grid` |
1603−
470+| `@switch`, `@match` | `@if $x == a` / `@else if` / `@else` |
1604−So an element's own styles win over everything else htmlang writes for
471+| `@use "file" names`, `@import file` | `@include file` |
1605−it; its parent's `children:` styles and the element prefixes of the
472+| `@collection`, `@env`, `@translations`, `@fetch` | `@data $name SOURCE` (glob, `env:NAME`, or a JSON file per locale) |
1606−elements around it win over its defaults.
473+| `@theme` | `@let --name value` lines |
1607−
474+| `@json-ld`, `@font-face`, `@manifest` | Raw HTML in `@head`, CSS in `@style` |
1608−A page's reset sets `box-sizing: border-box` everywhere and makes
475+| `@breakpoint`, `@deprecated` | A media query in `@style`; nothing |
1609−`<body>` a column that fills the window. A fragment (a file
476+| `@svg [attrs] file.svg` | `@image [inline, attrs] file.svg` |
1610−without `@page`, or `--partial` output) goes into a page it doesn't own, so
477+| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
1611−its reset touches only htmlang's own elements (those with an `hl-` class),
478+| `@with $x as y` | `@let y $x` |
1612−which get `box-sizing: border-box`; no other element on the page is
479+| `@layout file` | `@extends file` |
1613−touched. In both, an element
480+| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
1614−htmlang lays out stays hidden while it has `hidden`, while an `@dialog` is
481+| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
1615−closed, and while a popover isn't showing, even though its generated
482+| `@og KEY VALUE` | `@meta og:KEY VALUE` |
1616−`display` would otherwise beat the browser's `display: none`. A page with
483+| `@debug`, `@log` | `@warn` |
1617−links, buttons or form fields also gives htmlang's ones a focus outline.
484+| `@defer` | Its content, directly |
1618−CSS outside any layer takes precedence over every layer, so rules in
485+| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@column`, `@paragraph`, `@image`, `@item`, `@button`, `@list`, `@hr`, `@option` |
1619−`@style` or `@raw` override the generated ones.
486+| `type email`, `id main` (HTML attributes written as styles) | `type=email`, `id=main` |
487+| `skeleton`, `no-scrollbar`, `gradient A B` | `$skeleton`, `$no-scrollbar`, `background linear-gradient(A, B)` |
488+| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
489+| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
490+| `COND ? A : B` | `if(COND, A, B)` |
491+| `...$bundle` | `$bundle` |
492+| `animate`, `inset-area` | `animation`, `position-area` |
493+| `$x\|uppercase`, `$c\|darken:10` (filters) | `${uppercase($x)}`, `${darken($c, 10)}` |
README.md+69−190
@@ -1,123 +1,35 @@
1# htmlang
1# htmlang
2
2
3−A small layout language that compiles to static HTML. The layout model comes
4−from [elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest/):
3+A minimalist layout language inspired by [elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest/) that compiles to static HTML.
5−rows and columns, spacing between children, and elements that fill or center
4+
6−themselves. Styling uses CSS properties under their CSS names, and every other
5+`@` means structure. Bare lines mean content. No CSS required.
7−element uses its HTML name.
6+
7+## Example
8
8
9```
9```
10−@page [lang=en, background #f8fafc] My Site
10+@page My Site
11−@let --brand #3b82f6
11+@let primary #3b82f6
12
12
13−@let @card [title]
13+@let card $title
14− @article [padding 20, spacing 8, border 1 solid #e5e7eb, border-radius 8, hover:border-color var(--brand)]
14+ @el [padding 20, background white, rounded 8, border 1 #e5e7eb, hover:border 1 $primary, transition all 0.15s ease]
15− @h3 $title
15+ @text [bold] $title
16 @children
16 @children
17
17
18−@el [width fill, max-width 800, center-x, padding 40, spacing 24]
18+@column [max-width 800, center-x, padding 40, spacing 20]
19− @h1 [font-size 32] Hello
19+ @text [bold, size 32] Hello
20+
20 @paragraph
21 @paragraph
21− Built with {@text [font-weight bold, color var(--brand)] htmlang}.
22+ Built with {@text [bold, color $primary] htmlang}.
23+
22− @row [spacing 16, wrap]
24+ @row [wrap, spacing 10]
23 @card [title Simple]
25 @card [title Simple]
24− Layout without writing a stylesheet.
26+ Write layouts without CSS
25− @card [title Static]
27+ @card [title Fast]
26− One HTML file, with its CSS inside.
28+ Compiles to a single HTML file
27```
29```
28
30
29−Each page compiles to one self-contained `.html` file. It has no JavaScript
31+This compiles to one self-contained `.html` file with flexbox layout and
30−unless you write some, and nothing is added that you didn't ask for.
32+generated CSS classes. No JavaScript, no external dependencies.
31−
32−## The language in brief
33−
34−- **`@` starts structure, and any other line is content.** Indentation nests
35− elements. Text after an element's attributes is its content, read like
36− any line of text, and `{...}` puts an element inside a line of text
37− (`@h1 Hello {@text [color red] world}`). The text of `@code` and
38− `@textarea` is shown as written, and the lines indented under them are
39− verbatim, HTML-escaped with their line breaks kept, so `@pre > @code`
40− over an indented block shows a code sample. `@raw`, `@style` and `@head`
41− take the rest of their line or an indented block, kept as written
42− (`@style .note { color: gray; }`). An element that points at a URL or a
43− file takes it as its first word, and the rest is its content: `@link
44− /about About us`, `@image logo.png`, `@form /subscribe`, `@script app.js`.
45−- **Layout comes from elm-ui.** Every element has one layout. `@el` and the
46− containers (`@section`, `@nav`, `@ul`, `@li`, ...) are columns, `@row` is a
47− row and `@grid` a grid: in them each line of text is a child, and
48− `spacing` is the gap between the children. Text elements (`@paragraph`,
49− `@h1`, `@button`, `@td`) let their lines flow, joined with spaces, and an
50− `@el` inside text is laid out inline. Layout attributes (`width fill`,
51− `center-x`, `align-right`, `wrap`) say how an element sits inside its
52− parent, and follow the direction the parent's CSS sets: `@nav
53− [flex-direction row]` is a row, and `md:flex-direction row` makes a
54− column a row from that width up, children included. The page is the root
55− element: `@page`'s styles go on `<body>`, a column that fills the window,
56− and its `key=value` attributes on `<html>` (`@page [lang=en, background
57− #f8fafc] Home`).
58−- **Styling is CSS.** Any other attribute is a CSS property with its CSS
59− name and value: `padding 20`, `border 1 solid #eee`,
60− `grid-template-columns 1fr 2fr`. In a length property, every bare
61− number is pixels (`box-shadow 0 2 4 black` is `0 2px 4px black`); numbers
62− in any other property (`flex 1`, `z-index 2`) and in custom properties
63− stay numbers.
64− A custom property is a style like any other: `@let --surface white`
65− declares it for the whole page, and `[--gap 16px, md:--gap 24px]` sets
66− it on an element and everything inside it, so dark mode redefines a
67− token once (`@page [dark:--surface #0b1220]`) instead of every colour
68− that uses it. A token is read one way, `var(--surface)`, and a colour
69− variant is CSS's too (`color-mix(in srgb, var(--brand), black 8%)`,
70− `rgb(from var(--brand) r g b / 0.5)`), so it follows the token.
71− Prefixes make a style conditional: `hover:`, `md:`, `dark:`, and CSS's
72− common pseudo-classes and pseudo-elements under their CSS names
73− (`first-child:`, `nth-child(odd):`, `has(> img):`, `marker:`,
74− `backdrop:`; the list is in DESIGN.md).
75− They stack (`md:dark:background`, `hover:children:opacity`), and one
76− prefix covers a group or a bundle (`md:[padding 32, font-size 20]`,
77− `md:$card`).
78− An element prefix styles every such element inside, at any depth:
79− `@table [@td:padding 8]` pads every cell, and
80− `@article [@h2:font-size 20, @code:background #f1f5f9]` styles what
81− `@markdown` writes. An element's own attributes still win, and the
82− nearest element that styles it wins over one further out.
83− A comma separates attributes, so a comma inside a value is written `\,`
84− (`transition opacity 0.3s\, transform 0.3s`), and quoted text keeps its
85− quotes only in CSS (`before:content "→ "`).
86−- **Names are checked, values are CSS's.** A misspelled element, prefix,
87− parameter or slot is an error. A CSS property htmlang doesn't know is
88− passed through with a "did you mean" warning, and values go to the CSS as
89− written (`outline 2 solid red` is `outline: 2px solid red`, nothing
90− added). Nothing you write is left out of the page without an error.
91−- **The generated CSS stays on htmlang's elements.** Its classes are
92− named by their styles (`hl-2k9xq0m`), in cascade layers (`hl-reset`,
93− `hl-kind`, `hl-inside`, `htmlang`), so your own CSS wins. An element's
94− defaults (no heading margins, no list markers, links in the text colour)
95− go with its own class, so `@markdown` and `@raw` HTML, and a page that
96− embeds `--partial` output, keep theirs. One style has one name in every
97− file, so a fragment a server swaps into a page (with htmx, say) means
98− the same there and restyles nothing of the page's.
99−- **HTML stays HTML.** An element that isn't about layout or text has its
100− HTML name (`@nav`, `@ul`, `@form`, `@details`, `@strong`, `@em`, `@br`,
101− `@caption`), from a fixed list, so a typo is an error rather than a new
102− tag. HTML attributes are written `key=value` (`id=main`, `type=email`) or
103− bare (`required`).
104−- **`@let` defines everything.** It defines values, computed values,
105− attribute bundles and functions. A function's body puts a call's content
106− where `@children` is and fills `@slot NAME` from the call's `@slot NAME`
107− blocks, and content that would go nowhere (a misspelled slot, content for
108− a function without `@children`) is an error. A layout is just a function
109− with slots.
110− `$name` fills in a value exactly where it is written (a line of text, an
111− attribute's value, a file path), whole attributes come from a bundle or
112− from `if()` (`if($active, [font-weight bold, aria-current=page])`), and
113− an undefined name is an error where the code runs.
114−- **Values are typed: text, numbers, `true`/`false`, lists and records.**
115− Commas make a list (`@let fruits apple, banana`), `1..5` is a range,
116− `@data` loads JSON as records and lists, and a value passed as one `$name`
117− keeps its type, so a record can go to a function (`@post-card [post $p]`).
118− `@each` and `@if` run at compile time. A definition is visible from its
119− line to the end of its block, and a function sees what is visible where
120− it is defined.
121
33
122## Install
34## Install
123
35
@@ -128,118 +40,85 @@ cargo install --path .
128## Usage
40## Usage
129
41
130```
42```
131−htmlang page.hl # compile page.hl to page.html
43+htmlang page.hl # compile page.hl -> page.html
132htmlang page.hl -o out.html # choose the output file
44htmlang page.hl -o out.html # choose the output file
45+htmlang -w page.hl # recompile on change
133htmlang serve . # dev server with live reload
46htmlang serve . # dev server with live reload
134−htmlang build src -o dist # compile a whole site
135```
47```
136
48
137| Command | Purpose |
49| Command | Purpose |
138|---|---|
50|---|---|
139−| `build <dir> [-o out] [--minify] [--strict]` | Compile every `.hl` file under a directory (into `out/` by default), except libraries: files that hold only `@let`s. Two files whose different styles got one class name are an error (`class-collision`) |
51+| `build <dir> [-o out] [--minify] [--strict]` | Compile every `.hl` file under a directory |
140−| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload. It serves files, so it answers GET and HEAD (other methods get 405), and reloads pages, not fragments |
52+| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload |
141| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
53| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
142| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
54| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
143| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
55| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
144−| `fmt <file.hl>...` | Format files in place |
56+| `fmt <file.hl>` | Format a file in place |
57+| `upgrade [dir\|file]` | Rewrite syntax from older versions of the language |
145| `lsp` | Run the language server over stdio |
58| `lsp` | Run the language server over stdio |
146
59
147−Compiling a file directly also takes `-w` / `--watch`, `--dev` (readable
60+Compiling directly also takes `--dev`, `--strict`, `--partial`,
148−output and source maps), `--strict`, `--partial` (a fragment, without the
149−document wrapper), `--format json`, `-s` / `--serve`, `-p` / `--port` and
61+`--format json`, `-s` / `--serve`, `-p` / `--port` and `--open`.
150−`--open`.
151
62
152−## A short tour
63+## Editor support
153
64
154−Layout attributes place an element. Everything else is CSS, and `key=value`
65+A VS Code extension with syntax highlighting and LSP integration is in
155−is an HTML attribute:
66+[`editors/vscode`](editors/vscode). The language server (`htmlang-lsp`) provides
67+diagnostics, completions, hover documentation, go to definition, rename and
68+formatting.
156
69
157−```
70+## Language tour
158−@row [spacing 12, align-items center, padding 12 20, border-bottom 1 solid #e5e7eb, id=toolbar]
71+
159− @text [font-weight 700] Acme
72+Elements are laid out with `@row`, `@column` and `@el`; `@text`, `@paragraph`,
160− @spacer
73+`@link` and `@image` hold content, and every other HTML element is available by
161− @input [type=search, placeholder=Search, aria-label=Search, width 240, padding 6 10]
74+name (`@nav`, `@section`, `@table`, `@input`, ...).
162− @button [type=submit, padding 6 14, hover:background #f3f4f6] Go
163−```
164
75
165−A prefix applies a style only in a state, from a screen width up, or under a
76+Attributes are styles, written `key value`, or HTML attributes, written
166−media condition. Prefixes stack, and a prefix before a `[group]` applies to
77+`key=value`:
167−each style in it:
168
78
169```
79```
170−@el [padding 16, md:[padding 32, font-size 20], background #3b82f6, hover:background #2563eb, dark:[background #1e3a8a, hover:background #1e40af]]
80+-- gap between children, padding, and an HTML id
171− @text [color white] Click me
81+@row [spacing 20, padding 16, id=toolbar]
82+ -- take the remaining space; center the text
83+ @el [width fill, text-align center] Search
84+ @input [type=search, placeholder=Find..., width 240]
172```
85```
173
86
174−An element prefix, `@td:`, styles every such element inside, so rows a
87+Any standard CSS property works as a style (`opacity 0.5`, `margin-top 16`).
175−loop writes and HTML from `@markdown` need no attributes of their own:
88+Prefixes apply a style conditionally:
176
89
177```
90```
178−@table [border-collapse collapse, @th:[padding 8, text-align left], @td:padding 8, @tr:nth-child(even):background #f8fafc]
91+@el [background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
179− @tr
92+ @text [color white] Click me
180− @th Name
181− @tr
182− @td Ada
183```
93```
184
94
185−`@let` defines values, bundles and functions, in one namespace. A function
95+`@let` defines values, computed values, attribute bundles and functions:
186−has `@` before its name and its parameters in brackets (`label`, or
187−`href #` with a default). It is called like an element, on its own line,
188−in a chain or inline in text: its parameters are attributes, `name value`
189−(a name alone is `true`), a parameter without a default must be passed,
190−and any other attributes style its root element and are checked like
191−attributes written there.
192
96
193```
97```
194−@let --primary #3b82f6
98+@let primary #3b82f6
195@let gap = 8 * 2
99@let gap = 8 * 2
196−@let rounded [border-radius 8, overflow hidden]
100+@let card [padding 20, rounded 8]
197−
198−@let @cta [label, href #]
101+@let button $label
199− @link [$rounded, padding 10 16, background var(--primary), color white] $href
102+ @el [$card, background $primary]
200− $label
103+ @text [color white, bold] $label
201−@let @key
202− @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
203 @children
104 @children
204
105
205−@row [spacing $gap]
106+@button [label Click me, padding 12]
206− @cta [label Sign up]
207− @cta [label Learn more, href /about, background #64748b]
208−@paragraph
209− Press {@key Ctrl+K} to search.
210```
107```
211
108
212−Data, loops and conditions run at compile time:
109+Functions are used like elements: extra attributes (here `padding 12`) style
110+their root element. A small standard library (`@badge`, `@chip`, `@tooltip`,
111+`@carousel`, `@breadcrumb`, ...) is written the same way.
112+
113+Control flow runs at compile time:
213
114
214```
115```
215−@data $links [
116+@let items Home, About, Contact
216− {"label": "Home", "url": "/"},
217− {"label": "Blog", "url": "/blog"},
218− {"label": "About us", "url": "/about"}
219−]
220−@let current /blog
221−
222−@nav
223− @row [spacing 16]
117+@row [spacing 8]
224− @each $link in $links
118+ @each $item in $items
225− @link [if($link.url == $current, [font-weight bold, aria-current=page])] $link.url $link.label
119+ @if $item != About
120+ @link /${lowercase($item)} $item
226```
121```
227
122
228−The [`examples/`](examples) directory has complete pages (a landing page, a
123+See [DESIGN.md](DESIGN.md) for the full language: files and data (`@include`,
229−blog, a docs page, and a tour of the whole language). [DESIGN.md](DESIGN.md)
124+`@extends`, `@data`), page metadata, expressions and CSS.
230−is the language reference.
231−
232−## Editor support
233−
234−The VS Code extension in [`editors/vscode`](editors/vscode) provides syntax
235−highlighting, snippets, and the language server (`htmlang-lsp`). The server
236−gives diagnostics, completions, hover documentation, go to definition, rename
237−and formatting.
238−
239−The compiler, the formatter and the language server read a file with the
240−same parser, so the editor sees exactly what the compiler sees, relative
241−`@include` and `@data` paths included. Every diagnostic has a stable code
242−(`error[unknown-element]` on the command line, a `code` field in
243−`--format json`), and the whole file is checked, including branches and
244−functions that don't run (a `$name` there is checked only where the code
245−runs, since it may come from data).
crates/htmlang-core/src/ast.rs+106−624
@@ -3,33 +3,28 @@ use std::collections::HashMap;
3#[derive(Debug)]
3#[derive(Debug)]
4#[allow(dead_code)]
4#[allow(dead_code)]
5pub struct Document {
5pub struct Document {
6− /// The page `@page` makes: without one, the output is a fragment.
6+ pub page_title: Option<String>,
7+ pub lang: Option<String>,
7− pub page: Option<Page>,
8+ pub favicon: Option<String>,
8 pub meta_tags: Vec<(String, String)>,
9 pub meta_tags: Vec<(String, String)>,
9 pub head_blocks: Vec<String>,
10 pub head_blocks: Vec<String>,
10 pub variables: HashMap<String, String>,
11 pub variables: HashMap<String, String>,
11 pub defines: HashMap<String, Vec<Attribute>>,
12 pub defines: HashMap<String, Vec<Attribute>>,
13+ pub keyframes: Vec<(String, String)>,
12 pub css_vars: Vec<(String, String)>,
14 pub css_vars: Vec<(String, String)>,
13 pub custom_css: Vec<String>,
15 pub custom_css: Vec<String>,
14− /// The scoped `@style` of each function called, nested under its
15− /// `.hl-fn-NAME-HASH` class, for `@layer hl-inside`.
16− pub scoped_css: Vec<String>,
17 pub og_tags: Vec<(String, String)>,
16 pub og_tags: Vec<(String, String)>,
17+ pub canonical: Option<String>,
18+ pub base_url: Option<String>,
19+ pub preload_hints: Vec<PreloadHint>,
18 pub nodes: Vec<Node>,
20 pub nodes: Vec<Node>,
19}
21}
20
22
21−/// What `@page` says: the page is the root element. Its styles style
22−/// `<body>`, which lays out the page's top-level elements as a column; its
23−/// HTML attributes (`lang=en`, `dir=rtl`, `class=x`) go on `<html>`.
24−#[derive(Debug, Clone, Default)]
23+#[derive(Debug, Clone)]
25−pub struct Page {
24+pub struct PreloadHint {
26− pub title: String,
25+ pub href: String,
27− /// `key=value` attributes, and bare boolean HTML attributes, for `<html>`
26+ pub as_type: String, // "font", "image", "style", "script"
28− pub html_attrs: Vec<Attribute>,
27+ pub crossorigin: bool,
29− /// Styles, prefixed ones included, for `<body>`
30− pub styles: Vec<Attribute>,
31− /// `favicon FILE`, `@page`'s one word of htmlang's own
32− pub favicon: Option<String>,
33}
28}
34
29
35#[derive(Debug, Clone)]
30#[derive(Debug, Clone)]
@@ -49,52 +44,32 @@ pub enum TextSegment {
49pub struct Element {
44pub struct Element {
50 pub kind: ElementKind,
45 pub kind: ElementKind,
51 pub attrs: Vec<Attribute>,
46 pub attrs: Vec<Attribute>,
52− /// The value of the element's leading attribute (see
53− /// [`ElementKind::arg`]): the first token of `@link /about About`, or
54− /// its `href=` when it has no argument. For `@slot`, the slot's name.
55− /// Any other text after the attributes is content, a first child.
56 pub argument: Option<String>,
47 pub argument: Option<String>,
57 pub children: Vec<Node>,
48 pub children: Vec<Node>,
58− /// The line it is written on; for an element a function's body wrote,
59− /// the line of the call, since a function is an element at its call.
60 pub line_num: usize,
49 pub line_num: usize,
61− /// The function whose body wrote it, when it comes from a call.
62− pub function: Option<String>,
63}
50}
64
51
52+
65#[derive(Debug, Clone)]
53#[derive(Debug, Clone)]
66pub struct Attribute {
54pub struct Attribute {
67 pub key: String,
55 pub key: String,
68− /// The value as it goes into the output: a style's with the quotes of
69− /// quoted text, an HTML attribute's without them.
70 pub value: Option<String>,
56 pub value: Option<String>,
71 /// Written `key=value`: an HTML attribute rather than a style.
57 /// Written `key=value`: an HTML attribute rather than a style.
72 pub html: bool,
58 pub html: bool,
73− /// When the value is quoted text (`"..."`, or a variable holding
74− /// quoted text), both of its forms, for a parameter it binds.
75− pub quoted: Option<Quoted>,
76−}
77−
78−/// Quoted text, which remembers that it was quoted: a CSS value writes it
79−/// with its quotes, text and HTML attribute values without them.
80−#[derive(Debug, Clone, PartialEq)]
81−pub struct Quoted {
82− /// What it says: `a"b` for `"a\"b"`.
83− pub text: String,
84− /// As CSS writes it, quotes included: `"a\"b"`.
85− pub css: String,
86}
59}
87
60
88#[derive(Debug, Clone, PartialEq)]
61#[derive(Debug, Clone, PartialEq)]
89pub enum ElementKind {
62pub enum ElementKind {
90 // Layout and text: the core of the language
63 // Layout and text: the core of the language
91 Row,
64 Row,
65+ Column,
92 El,
66 El,
93 Text,
67 Text,
94 Paragraph,
68 Paragraph,
95 Link,
69 Link,
96 Image,
70 Image,
97 // Elements generated in a special way
71 // Elements generated in a special way
72+ Script,
98 Fragment,
73 Fragment,
99 // Function bodies: placeholders for the caller's content
74 // Function bodies: placeholders for the caller's content
100 Children,
75 Children,
@@ -108,11 +83,13 @@ impl ElementKind {
108 pub fn name(&self) -> &str {
83 pub fn name(&self) -> &str {
109 match self {
84 match self {
110 ElementKind::Row => "row",
85 ElementKind::Row => "row",
86+ ElementKind::Column => "column",
111 ElementKind::El => "el",
87 ElementKind::El => "el",
112 ElementKind::Text => "text",
88 ElementKind::Text => "text",
113 ElementKind::Paragraph => "paragraph",
89 ElementKind::Paragraph => "paragraph",
114 ElementKind::Link => "link",
90 ElementKind::Link => "link",
115 ElementKind::Image => "image",
91 ElementKind::Image => "image",
92+ ElementKind::Script => "script",
116 ElementKind::Fragment => "fragment",
93 ElementKind::Fragment => "fragment",
117 ElementKind::Children => "children",
94 ElementKind::Children => "children",
118 ElementKind::Slot(_) => "slot",
95 ElementKind::Slot(_) => "slot",
@@ -127,86 +104,6 @@ impl ElementKind {
127 }
104 }
128 }
105 }
129
106
130− /// How the element lays out what is inside it. `@fragment`, `@children`
131− /// and `@slot` have no element of their own: their content lands in the
132− /// element around them and takes its layout.
133− pub fn layout(&self) -> Layout {
134− match self {
135− ElementKind::Row => Layout::Row,
136− ElementKind::El => Layout::Column,
137− ElementKind::Text | ElementKind::Paragraph | ElementKind::Link => Layout::Text,
138− ElementKind::Image => Layout::Void,
139− ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_) => Layout::Native,
140− ElementKind::Tag(spec) => spec.layout,
141− }
142− }
143−
144− /// The CSS every such element starts with, besides its layout's
145− /// `display` (browser margins reset, the list markers of a column, a
146− /// link's colour). It goes into the element's own generated class,
147− /// never onto a global element selector, so HTML from `@markdown` and
148− /// `@raw` keeps the browser's defaults.
149− pub fn css(&self) -> &'static str {
150− match self {
151− ElementKind::Paragraph => "margin:0;",
152− ElementKind::Link => "text-decoration:none;color:inherit;",
153− ElementKind::Image => "display:block;",
154− ElementKind::Tag(spec) => spec.css,
155− _ => "",
156− }
157− }
158−
159− /// What the first token after its attributes is: `@link`'s `href`,
160− /// `@image`'s `src`, or its row's [`TagArg`].
161− pub fn arg(&self) -> TagArg {
162− match self {
163− ElementKind::Link => TagArg::Attr("href"),
164− ElementKind::Image => TagArg::Attr("src"),
165− ElementKind::Tag(spec) => spec.arg,
166− _ => TagArg::Child,
167− }
168− }
169−
170− /// The HTML attribute its leading argument fills, when it has one;
171− /// `in_picture`: the element is directly inside `@picture`.
172− pub fn leading_attribute(&self, in_picture: bool) -> Option<&'static str> {
173− self.arg().attribute(in_picture)
174− }
175−
176− /// Whether its indented body is foreign text written into the page as
177− /// it is (`@script`). (`@code` and `@textarea` also have a verbatim
178− /// body, shown as text: see [`TagSpec::literal`].)
179− pub fn is_verbatim(&self) -> bool {
180− self.spec().is_some_and(|spec| spec.verbatim)
181− }
182−
183− /// The HTML element it writes (outside text, where htmlang's own
184− /// `<div>`s are `<span>`s): `None` for `@fragment`, `@children` and
185− /// `@slot`, which write none of their own.
186− pub fn tag(&self) -> Option<&'static str> {
187− match self {
188− ElementKind::Row | ElementKind::El => Some("div"),
189− ElementKind::Text => Some("span"),
190− ElementKind::Paragraph => Some("p"),
191− ElementKind::Link => Some("a"),
192− ElementKind::Image => Some("img"),
193− ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_) => None,
194− ElementKind::Tag(spec) => Some(spec.html),
195− }
196− }
197−
198− /// The HTML element only this element writes, which a selector can
199− /// pick out: `@td` is every `<td>`, `@paragraph` every `<p>`. `None`
200− /// for an element that shares its tag: `@el`, `@row`, `@grid`,
201− /// `@in-front` and `@behind` are all `<div>`, and `@text` is a
202− /// `<span>`, as is every line of text in a row or column. `@script`
203− /// isn't shown, so there is nothing to style.
204− pub fn own_tag(&self) -> Option<&'static str> {
205− let tag = self.tag()?;
206− let shared = matches!(tag, "div" | "span") || self.is_verbatim();
207− (!shared).then_some(tag)
208− }
209−
210 /// Is this the table element named `name` (e.g. `"main"`)?
107 /// Is this the table element named `name` (e.g. `"main"`)?
211 pub fn is_tag(&self, name: &str) -> bool {
108 pub fn is_tag(&self, name: &str) -> bool {
212 self.spec().is_some_and(|spec| spec.name == name)
109 self.spec().is_some_and(|spec| spec.name == name)
@@ -216,11 +113,13 @@ impl ElementKind {
216 pub fn from_name(name: &str) -> Option<ElementKind> {
113 pub fn from_name(name: &str) -> Option<ElementKind> {
217 Some(match name {
114 Some(match name {
218 "row" => ElementKind::Row,
115 "row" => ElementKind::Row,
116+ "column" => ElementKind::Column,
219 "el" => ElementKind::El,
117 "el" => ElementKind::El,
220 "text" => ElementKind::Text,
118 "text" => ElementKind::Text,
221 "paragraph" => ElementKind::Paragraph,
119 "paragraph" => ElementKind::Paragraph,
222 "link" => ElementKind::Link,
120 "link" => ElementKind::Link,
223 "image" => ElementKind::Image,
121 "image" => ElementKind::Image,
122+ "script" => ElementKind::Script,
224 "fragment" => ElementKind::Fragment,
123 "fragment" => ElementKind::Fragment,
225 "children" => ElementKind::Children,
124 "children" => ElementKind::Children,
226 "slot" => ElementKind::Slot(String::new()),
125 "slot" => ElementKind::Slot(String::new()),
@@ -231,15 +130,8 @@ impl ElementKind {
231 /// Every element name, for suggestions and completions.
130 /// Every element name, for suggestions and completions.
232 pub fn all_names() -> impl Iterator<Item = &'static str> {
131 pub fn all_names() -> impl Iterator<Item = &'static str> {
233 [
132 [
234− "row",
133+ "row", "column", "el", "text", "paragraph", "link", "image", "script", "fragment",
235− "el",
236− "text",
237− "paragraph",
238− "link",
239− "image",
240− "fragment",
241− "children",
134+ "children", "slot",
242− "slot",
243 ]
135 ]
244 .into_iter()
136 .into_iter()
245 .chain(TAGS.iter().map(|spec| spec.name))
137 .chain(TAGS.iter().map(|spec| spec.name))
@@ -249,90 +141,12 @@ impl ElementKind {
249/// What an element does with the text after its name and attributes.
141/// What an element does with the text after its name and attributes.
250#[derive(Debug, Clone, Copy, PartialEq)]
142#[derive(Debug, Clone, Copy, PartialEq)]
251pub enum TagArg {
143pub enum TagArg {
252− /// Its content: the first line of its text, parsed like any text
144+ /// Leading text content (`@section Hello`).
253− /// (`@h1 Hello {@text [color red] world}`). The element's layout
254− /// decides how it combines with the lines under it.
255 Child,
145 Child,
256− /// Its leading attribute: the first token fills this HTML attribute
146+ /// Printed as the element's text (`@item First`).
257− /// (`@iframe URL` sets `src`), and the rest of the text is content, or
258− /// an error on an element without content. The token ends at a space
259− /// outside `"..."` and `${...}`.
260− Attr(&'static str),
261− /// `@source`'s leading attribute, which depends on where it is:
262− /// `srcset` directly inside `@picture`, `src` elsewhere (`@video`,
263− /// `@audio`).
264− Source,
265−}
266−
267−impl TagArg {
268− /// The HTML attribute the leading argument fills; `in_picture`: the
269− /// element is directly inside `@picture`.
270− pub fn attribute(self, in_picture: bool) -> Option<&'static str> {
271− match self {
272− TagArg::Child => None,
273− TagArg::Attr(attr) => Some(attr),
274− TagArg::Source if in_picture => Some("srcset"),
275− TagArg::Source => Some("src"),
276− }
277− }
278−
279− /// The attributes the leading argument may fill, wherever the element
280− /// is: one, or `src` and `srcset` for `@source`.
281− pub fn attributes(self) -> Vec<&'static str> {
282− let mut out: Vec<&'static str> = [self.attribute(false), self.attribute(true)]
283− .into_iter()
284− .flatten()
285− .collect();
286− out.dedup();
287− out
288− }
289−}
290−
291−/// How an element lays out what is inside it: one value per element,
292−/// after elm-ui's layouts. It decides what its text lines are, whether
293−/// `spacing` works on it, and how its children's `width fill`, `center-x`
294−/// and `align-*` compile.
295−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296−pub enum Layout {
297− /// A flex column (`@el`, `@section`, `@ul`, `@li`): each text line is a
298− /// child of its own, and `spacing` is the gap between the children.
299− Column,
300− /// A flex row (`@row`): like a column, side by side.
301− Row,
302− /// A CSS grid (`@grid`): each text line is a cell.
303− Grid,
304− /// Flowing text (`@paragraph`, `@h1`, `@button`, `@td`): the argument,
305− /// the text lines and the children are one run, joined with spaces. It
306− /// has no gap for `spacing`, and a row, column or grid inside it is laid
307− /// out inline (`@el` becomes an inline-flex `<span>`).
308 Text,
147 Text,
309− /// HTML's own layout, which htmlang leaves alone: tables, `@select`,
148+ /// Emitted as this HTML attribute (`@iframe URL` sets `src`).
310− /// `@picture`, media and form controls. Text lines are separated by a
311− /// line break, which HTML shows as a space except in `@pre` and
312− /// `@textarea`.
313− Native,
314− /// No content at all (`@input`, `@hr`, `@image`).
315− Void,
316−}
317−
318−impl Layout {
319− /// A row, column or grid: an element that lays out its children, so
320− /// `spacing`, `wrap` and `grid-cols` work on it.
321− pub fn is_container(self) -> bool {
322− matches!(self, Layout::Column | Layout::Row | Layout::Grid)
323− }
324−
325− /// The name used in messages and the reference: `column`, `text`, ...
326− pub fn name(self) -> &'static str {
149+ Attr(&'static str),
327− match self {
328− Layout::Column => "column",
329− Layout::Row => "row",
330− Layout::Grid => "grid",
331− Layout::Text => "text",
332− Layout::Native => "native",
333− Layout::Void => "void",
334− }
335− }
336}
150}
337
151
338/// How an element compiles: the data behind [`ElementKind::Tag`].
152/// How an element compiles: the data behind [`ElementKind::Tag`].
@@ -342,19 +156,17 @@ pub struct TagSpec {
342 pub name: &'static str,
156 pub name: &'static str,
343 /// HTML element emitted.
157 /// HTML element emitted.
344 pub html: &'static str,
158 pub html: &'static str,
345− /// Default CSS besides the layout's `display` (margin reset, ...).
159+ /// Default CSS (flex column, margin reset, ...).
346 pub css: &'static str,
160 pub css: &'static str,
347 pub arg: TagArg,
161 pub arg: TagArg,
348− /// How it lays out its content.
162+ /// Bare text children are wrapped in `<span>` (flex containers).
349− pub layout: Layout,
163+ pub wraps_text: bool,
350− /// Its text is shown as written (`@code`, `@textarea`). On its line and
164+ /// Accepts layout attributes for its children (`spacing`, ...).
351− /// inline, a `{@...}` is text, not an inline element (escapes and
165+ pub container: bool,
352− /// `$names` still work). Its indented body is verbatim: nothing in it is
166+ /// Void element: no children, no closing tag (`<hr>`, `<input>`).
353− /// parsed, and it is shown HTML-escaped, its lines and indentation kept.
354− pub literal: bool,
167+ pub void: bool,
355− /// Its indented body is foreign text written into the page exactly as
168+ /// Children are joined with spaces, like a paragraph (headings).
356− /// it is (`@script`'s JavaScript): nothing in it is parsed as htmlang.
357− pub verbatim: bool,
169+ pub inline: bool,
358}
170}
359
171
360impl TagSpec {
172impl TagSpec {
@@ -363,413 +175,83 @@ impl TagSpec {
363 html: "",
175 html: "",
364 css: "",
176 css: "",
365 arg: TagArg::Child,
177 arg: TagArg::Child,
366− layout: Layout::Native,
178+ wraps_text: false,
367− literal: false,
179+ container: false,
180+ void: false,
368− verbatim: false,
181+ inline: false,
369 };
182 };
370}
183}
371
184
372/// Every element besides the core ones in [`ElementKind`].
185/// Every element besides the core ones in [`ElementKind`].
373−#[rustfmt::skip]
374pub static TAGS: &[TagSpec] = &[
186pub static TAGS: &[TagSpec] = &[
375− TagSpec { name: "nav", html: "nav", layout: Layout::Column, ..TagSpec::DEFAULT },
187+ TagSpec { name: "nav", html: "nav", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
376− TagSpec { name: "header", html: "header", layout: Layout::Column, ..TagSpec::DEFAULT },
188+ TagSpec { name: "header", html: "header", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
377− TagSpec { name: "footer", html: "footer", layout: Layout::Column, ..TagSpec::DEFAULT },
189+ TagSpec { name: "footer", html: "footer", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
378− TagSpec { name: "main", html: "main", layout: Layout::Column, ..TagSpec::DEFAULT },
190+ TagSpec { name: "main", html: "main", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
379− TagSpec { name: "section", html: "section", layout: Layout::Column, ..TagSpec::DEFAULT },
191+ TagSpec { name: "section", html: "section", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
380− TagSpec { name: "article", html: "article", layout: Layout::Column, ..TagSpec::DEFAULT },
192+ TagSpec { name: "article", html: "article", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
381− TagSpec { name: "aside", html: "aside", layout: Layout::Column, ..TagSpec::DEFAULT },
193+ TagSpec { name: "aside", html: "aside", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
382− TagSpec { name: "details", html: "details", layout: Layout::Column, ..TagSpec::DEFAULT },
194+ TagSpec { name: "details", html: "details", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
383− TagSpec { name: "dialog", html: "dialog", layout: Layout::Column, ..TagSpec::DEFAULT },
195+ TagSpec { name: "dialog", html: "dialog", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
384− TagSpec { name: "search", html: "search", layout: Layout::Column, ..TagSpec::DEFAULT },
196+ TagSpec { name: "search", html: "search", wraps_text: true, container: true, ..TagSpec::DEFAULT },
385− TagSpec { name: "address", html: "address", layout: Layout::Column, ..TagSpec::DEFAULT },
197+ TagSpec { name: "address", html: "address", wraps_text: true, container: true, ..TagSpec::DEFAULT },
386− TagSpec { name: "noscript", html: "noscript", layout: Layout::Column, ..TagSpec::DEFAULT },
198+ TagSpec { name: "noscript", html: "noscript", wraps_text: true, container: true, ..TagSpec::DEFAULT },
387− TagSpec { name: "form", html: "form", arg: TagArg::Attr("action"), layout: Layout::Column, ..TagSpec::DEFAULT },
199+ TagSpec { name: "form", html: "form", css: "display:flex;flex-direction:column;", arg: TagArg::Attr("action"), wraps_text: true, container: true, ..TagSpec::DEFAULT },
388− TagSpec { name: "figure", html: "figure", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
200+ TagSpec { name: "figure", html: "figure", css: "display:flex;flex-direction:column;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
389− TagSpec { name: "blockquote", html: "blockquote", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
201+ TagSpec { name: "blockquote", html: "blockquote", css: "display:flex;flex-direction:column;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
390− TagSpec { name: "fieldset", html: "fieldset", css: "border:1px solid currentColor;padding:8px;margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
202+ TagSpec { name: "fieldset", html: "fieldset", css: "display:flex;flex-direction:column;border:1px solid currentColor;padding:8px;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
391− TagSpec { name: "datalist", html: "datalist", layout: Layout::Native, ..TagSpec::DEFAULT },
203+ TagSpec { name: "datalist", html: "datalist", wraps_text: true, container: true, ..TagSpec::DEFAULT },
392− TagSpec { name: "ul", html: "ul", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
204+ TagSpec { name: "list", html: "ul", css: "margin:0;padding-left:0;list-style:none;", container: true, ..TagSpec::DEFAULT },
393− TagSpec { name: "ol", html: "ol", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
205+ TagSpec { name: "item", html: "li", css: "display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
394− TagSpec { name: "li", html: "li", layout: Layout::Column, ..TagSpec::DEFAULT },
206+ TagSpec { name: "dl", html: "dl", css: "margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
395− TagSpec { name: "dl", html: "dl", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
207+ TagSpec { name: "dt", html: "dt", arg: TagArg::Text, container: true, ..TagSpec::DEFAULT },
396− TagSpec { name: "dt", html: "dt", layout: Layout::Text, ..TagSpec::DEFAULT },
208+ TagSpec { name: "dd", html: "dd", css: "margin:0;display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
397− TagSpec { name: "dd", html: "dd", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
209+ TagSpec { name: "table", html: "table", ..TagSpec::DEFAULT },
398− TagSpec { name: "table", html: "table", layout: Layout::Native, ..TagSpec::DEFAULT },
210+ TagSpec { name: "thead", html: "thead", ..TagSpec::DEFAULT },
399− TagSpec { name: "thead", html: "thead", layout: Layout::Native, ..TagSpec::DEFAULT },
211+ TagSpec { name: "tbody", html: "tbody", ..TagSpec::DEFAULT },
400− TagSpec { name: "tbody", html: "tbody", layout: Layout::Native, ..TagSpec::DEFAULT },
212+ TagSpec { name: "tr", html: "tr", ..TagSpec::DEFAULT },
401− TagSpec { name: "tr", html: "tr", layout: Layout::Native, ..TagSpec::DEFAULT },
213+ TagSpec { name: "select", html: "select", ..TagSpec::DEFAULT },
402− TagSpec { name: "select", html: "select", layout: Layout::Native, ..TagSpec::DEFAULT },
214+ TagSpec { name: "picture", html: "picture", ..TagSpec::DEFAULT },
403− TagSpec { name: "picture", html: "picture", layout: Layout::Native, ..TagSpec::DEFAULT },
215+ TagSpec { name: "progress", html: "progress", ..TagSpec::DEFAULT },
404− TagSpec { name: "progress", html: "progress", layout: Layout::Native, ..TagSpec::DEFAULT },
216+ TagSpec { name: "meter", html: "meter", ..TagSpec::DEFAULT },
405− TagSpec { name: "meter", html: "meter", layout: Layout::Native, ..TagSpec::DEFAULT },
217+ TagSpec { name: "output", html: "output", ..TagSpec::DEFAULT },
406− TagSpec { name: "output", html: "output", layout: Layout::Native, ..TagSpec::DEFAULT },
218+ TagSpec { name: "canvas", html: "canvas", container: true, ..TagSpec::DEFAULT },
407− TagSpec { name: "canvas", html: "canvas", layout: Layout::Native, ..TagSpec::DEFAULT },
219+ TagSpec { name: "td", html: "td", arg: TagArg::Text, ..TagSpec::DEFAULT },
408− TagSpec { name: "td", html: "td", layout: Layout::Text, ..TagSpec::DEFAULT },
220+ TagSpec { name: "th", html: "th", arg: TagArg::Text, ..TagSpec::DEFAULT },
409− TagSpec { name: "th", html: "th", layout: Layout::Text, ..TagSpec::DEFAULT },
221+ TagSpec { name: "button", html: "button", arg: TagArg::Text, ..TagSpec::DEFAULT },
410− TagSpec { name: "button", html: "button", layout: Layout::Text, ..TagSpec::DEFAULT },
222+ TagSpec { name: "label", html: "label", arg: TagArg::Text, ..TagSpec::DEFAULT },
411− TagSpec { name: "label", html: "label", layout: Layout::Text, ..TagSpec::DEFAULT },
223+ TagSpec { name: "option", html: "option", arg: TagArg::Text, ..TagSpec::DEFAULT },
412− TagSpec { name: "option", html: "option", layout: Layout::Native, ..TagSpec::DEFAULT },
224+ TagSpec { name: "textarea", html: "textarea", arg: TagArg::Text, ..TagSpec::DEFAULT },
413− TagSpec { name: "textarea", html: "textarea", layout: Layout::Native, literal: true, ..TagSpec::DEFAULT },
225+ TagSpec { name: "summary", html: "summary", arg: TagArg::Text, ..TagSpec::DEFAULT },
414− TagSpec { name: "summary", html: "summary", layout: Layout::Text, ..TagSpec::DEFAULT },
226+ TagSpec { name: "cite", html: "cite", arg: TagArg::Text, ..TagSpec::DEFAULT },
415− TagSpec { name: "cite", html: "cite", layout: Layout::Text, ..TagSpec::DEFAULT },
227+ TagSpec { name: "figcaption", html: "figcaption", arg: TagArg::Text, ..TagSpec::DEFAULT },
416− TagSpec { name: "figcaption", html: "figcaption", layout: Layout::Text, ..TagSpec::DEFAULT },
228+ TagSpec { name: "legend", html: "legend", arg: TagArg::Text, ..TagSpec::DEFAULT },
417− TagSpec { name: "legend", html: "legend", layout: Layout::Text, ..TagSpec::DEFAULT },
229+ TagSpec { name: "time", html: "time", arg: TagArg::Text, ..TagSpec::DEFAULT },
418− TagSpec { name: "time", html: "time", layout: Layout::Text, ..TagSpec::DEFAULT },
230+ TagSpec { name: "mark", html: "mark", arg: TagArg::Text, ..TagSpec::DEFAULT },
419− TagSpec { name: "mark", html: "mark", layout: Layout::Text, ..TagSpec::DEFAULT },
231+ TagSpec { name: "abbr", html: "abbr", arg: TagArg::Text, ..TagSpec::DEFAULT },
420− TagSpec { name: "abbr", html: "abbr", layout: Layout::Text, ..TagSpec::DEFAULT },
232+ TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
421− TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, literal: true, ..TagSpec::DEFAULT },
233+ TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
422− TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, ..TagSpec::DEFAULT },
234+ TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", ..TagSpec::DEFAULT },
423− TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", layout: Layout::Native, ..TagSpec::DEFAULT },
235+ TagSpec { name: "h1", html: "h1", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
424− TagSpec { name: "h1", html: "h1", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
236+ TagSpec { name: "h2", html: "h2", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
425− TagSpec { name: "h2", html: "h2", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
237+ TagSpec { name: "h3", html: "h3", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
426− TagSpec { name: "h3", html: "h3", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
238+ TagSpec { name: "h4", html: "h4", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
427− TagSpec { name: "h4", html: "h4", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
239+ TagSpec { name: "h5", html: "h5", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
428− TagSpec { name: "h5", html: "h5", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
240+ TagSpec { name: "h6", html: "h6", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
429− TagSpec { name: "h6", html: "h6", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
241+ TagSpec { name: "input", html: "input", void: true, ..TagSpec::DEFAULT },
430− TagSpec { name: "input", html: "input", layout: Layout::Void, ..TagSpec::DEFAULT },
242+ TagSpec { name: "hr", html: "hr", void: true, ..TagSpec::DEFAULT },
431− TagSpec { name: "hr", html: "hr", layout: Layout::Void, ..TagSpec::DEFAULT },
243+ TagSpec { name: "source", html: "source", arg: TagArg::Attr("src"), void: true, ..TagSpec::DEFAULT },
432− TagSpec { name: "source", html: "source", arg: TagArg::Source, layout: Layout::Void, ..TagSpec::DEFAULT },
244+ TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
433− TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
245+ TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
434− TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
246+ TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), container: true, ..TagSpec::DEFAULT },
435− TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
247+ TagSpec { name: "grid", html: "div", css: "display:grid;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
436− TagSpec { name: "script", html: "script", arg: TagArg::Attr("src"), layout: Layout::Native, verbatim: true, ..TagSpec::DEFAULT },
248+ TagSpec { name: "stack", html: "div", css: "position:relative;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
437− // Flow containers
249+ TagSpec { name: "in-front", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;", container: true, ..TagSpec::DEFAULT },
438− TagSpec { name: "hgroup", html: "hgroup", layout: Layout::Column, ..TagSpec::DEFAULT },
250+ TagSpec { name: "behind", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;z-index:-1;", container: true, ..TagSpec::DEFAULT },
439− TagSpec { name: "menu", html: "menu", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
440− // Phrasing: text with the browser's own style, and no CSS of htmlang's
441− TagSpec { name: "b", html: "b", layout: Layout::Text, ..TagSpec::DEFAULT },
442− TagSpec { name: "i", html: "i", layout: Layout::Text, ..TagSpec::DEFAULT },
443− TagSpec { name: "strong", html: "strong", layout: Layout::Text, ..TagSpec::DEFAULT },
444− TagSpec { name: "em", html: "em", layout: Layout::Text, ..TagSpec::DEFAULT },
445− TagSpec { name: "small", html: "small", layout: Layout::Text, ..TagSpec::DEFAULT },
446− TagSpec { name: "s", html: "s", layout: Layout::Text, ..TagSpec::DEFAULT },
447− TagSpec { name: "u", html: "u", layout: Layout::Text, ..TagSpec::DEFAULT },
448− TagSpec { name: "sub", html: "sub", layout: Layout::Text, ..TagSpec::DEFAULT },
449− TagSpec { name: "sup", html: "sup", layout: Layout::Text, ..TagSpec::DEFAULT },
450− TagSpec { name: "q", html: "q", layout: Layout::Text, ..TagSpec::DEFAULT },
451− TagSpec { name: "var", html: "var", layout: Layout::Text, ..TagSpec::DEFAULT },
452− TagSpec { name: "samp", html: "samp", layout: Layout::Text, ..TagSpec::DEFAULT },
453− TagSpec { name: "dfn", html: "dfn", layout: Layout::Text, ..TagSpec::DEFAULT },
454− TagSpec { name: "bdi", html: "bdi", layout: Layout::Text, ..TagSpec::DEFAULT },
455− TagSpec { name: "bdo", html: "bdo", layout: Layout::Text, ..TagSpec::DEFAULT },
456− TagSpec { name: "ins", html: "ins", layout: Layout::Text, ..TagSpec::DEFAULT },
457− TagSpec { name: "del", html: "del", layout: Layout::Text, ..TagSpec::DEFAULT },
458− TagSpec { name: "br", html: "br", layout: Layout::Void, ..TagSpec::DEFAULT },
459− TagSpec { name: "wbr", html: "wbr", layout: Layout::Void, ..TagSpec::DEFAULT },
460− // HTML's own display: table parts, option groups, ruby, embedded content
461− TagSpec { name: "caption", html: "caption", layout: Layout::Native, ..TagSpec::DEFAULT },
462− TagSpec { name: "colgroup", html: "colgroup", layout: Layout::Native, ..TagSpec::DEFAULT },
463− TagSpec { name: "col", html: "col", layout: Layout::Void, ..TagSpec::DEFAULT },
464− TagSpec { name: "tfoot", html: "tfoot", layout: Layout::Native, ..TagSpec::DEFAULT },
465− TagSpec { name: "optgroup", html: "optgroup", arg: TagArg::Attr("label"), layout: Layout::Native, ..TagSpec::DEFAULT },
466− TagSpec { name: "ruby", html: "ruby", layout: Layout::Native, ..TagSpec::DEFAULT },
467− TagSpec { name: "rt", html: "rt", layout: Layout::Native, ..TagSpec::DEFAULT },
468− TagSpec { name: "rp", html: "rp", layout: Layout::Native, ..TagSpec::DEFAULT },
469− TagSpec { name: "track", html: "track", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
470− TagSpec { name: "embed", html: "embed", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
471− TagSpec { name: "object", html: "object", arg: TagArg::Attr("data"), layout: Layout::Native, ..TagSpec::DEFAULT },
472− TagSpec { name: "map", html: "map", layout: Layout::Native, ..TagSpec::DEFAULT },
473− TagSpec { name: "area", html: "area", arg: TagArg::Attr("href"), layout: Layout::Void, ..TagSpec::DEFAULT },
474− // htmlang's own
475− TagSpec { name: "grid", html: "div", layout: Layout::Grid, ..TagSpec::DEFAULT },
476− TagSpec { name: "in-front", html: "div", css: "position:absolute;inset:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
477− TagSpec { name: "behind", html: "div", css: "position:absolute;inset:0;z-index:-1;", layout: Layout::Column, ..TagSpec::DEFAULT },
478−];
479−
480−/// HTML elements htmlang writes under another name. The HTML name isn't an
481−/// element: the unknown-element error for `@a` suggests `@link`. (Not a
482−/// second spelling: only the suggestion reads this.)
483−pub const HTML_NAMES_WRITTEN_OTHERWISE: &[(&str, &str)] = &[
484− ("a", "link"),
485− ("img", "image"),
486− ("span", "text"),
487− ("p", "paragraph"),
488− ("div", "el"),
489−];
490−
491−/// Whether the element named `name` (without the `@`) shows its text as
492−/// written: `{@code {@link /x y}}` prints the braces, and its indented body
493−/// is verbatim text, HTML-escaped. The syntax tree reads this, so every
494−/// tool agrees on it.
495−pub fn has_literal_text(name: &str) -> bool {
496− ElementKind::from_name(name)
497− .and_then(|kind| kind.spec())
498− .is_some_and(|spec| spec.literal)
499−}
500−
501−/// How a directive reads the rest of its line.
502−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503−pub enum ArgGrammar {
504− /// Free text, taken as written (`@include file.hl`, `@meta name value`).
505− /// For a directive with a verbatim body it is the one-line form of the
506− /// body (`@raw <hr>`, `@style .a { color: red }`) or, for `@markdown`,
507− /// the file.
508− Text,
509− /// An optional `[attributes]` list, then text (`@page [lang=en] Title`).
510− AttrsText,
511− /// An expression (`@if $count > 2`).
512− Expression,
513− /// A name, then a value, `= EXPR`, `"text"`, `[attributes]` or the
514− /// parameters of a function (`@let`).
515− Definition,
516− /// Nothing, or `if EXPR` (`@else`, `@else if`).
517− Else,
518− /// `$item in LIST` or `$item, $index in LIST` (`@each`).
519− Loop,
520− /// `$name SOURCE`; inline JSON may span several lines (`@data`).
521− Data,
522−}
523−
524−/// What the lines indented under a directive or element are.
525−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526−pub enum BodyKind {
527− /// Nothing: indented lines are an error.
528− None,
529− /// htmlang: elements, text and directives.
530− Htmlang,
531− /// Foreign text (CSS, HTML, JavaScript, Markdown, a code sample), kept
532− /// exactly as written: nothing in it is parsed, and `--` is not a
533− /// comment. The text on the header's line is its one-line form (or a
534− /// file or URL: `@markdown notes.md`, `@script app.js`); the line and a
535− /// body together are an error. A verbatim directive takes no
536− /// attributes.
537− Verbatim,
538−}
539−
540−/// A directive: a `@name` that isn't an element. The syntax tree
541−/// (`syntax.rs`), the evaluator, the formatter and the editor tools all
542−/// read the directives' shapes from this table.
543−#[derive(Debug, PartialEq)]
544−pub struct DirectiveSpec {
545− /// Name in htmlang source, without the `@`.
546− pub name: &'static str,
547− pub args: ArgGrammar,
548− pub body: BodyKind,
549−}
550−
551−/// Every directive.
552−#[rustfmt::skip]
553−pub static DIRECTIVES: &[DirectiveSpec] = &[
554− DirectiveSpec { name: "page", args: ArgGrammar::AttrsText, body: BodyKind::None },
555− DirectiveSpec { name: "let", args: ArgGrammar::Definition, body: BodyKind::Htmlang },
556− DirectiveSpec { name: "include", args: ArgGrammar::Text, body: BodyKind::None },
557− DirectiveSpec { name: "data", args: ArgGrammar::Data, body: BodyKind::None },
558− DirectiveSpec { name: "meta", args: ArgGrammar::Text, body: BodyKind::None },
559− DirectiveSpec { name: "if", args: ArgGrammar::Expression, body: BodyKind::Htmlang },
560− DirectiveSpec { name: "else", args: ArgGrammar::Else, body: BodyKind::Htmlang },
561− DirectiveSpec { name: "each", args: ArgGrammar::Loop, body: BodyKind::Htmlang },
562− DirectiveSpec { name: "style", args: ArgGrammar::Text, body: BodyKind::Verbatim },
563− DirectiveSpec { name: "head", args: ArgGrammar::Text, body: BodyKind::Verbatim },
564− DirectiveSpec { name: "raw", args: ArgGrammar::Text, body: BodyKind::Verbatim },
565− DirectiveSpec { name: "markdown", args: ArgGrammar::Text, body: BodyKind::Verbatim },
566];
251];
567
252
568−/// The directive named `name` (without the `@`).
253+/// Elements that print their argument as their text (`@text Hello`,
569−pub fn directive(name: &str) -> Option<&'static DirectiveSpec> {
254+/// `@item First`).
570− DIRECTIVES.iter().find(|d| d.name == name)
255+pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
571−}
256+ *kind == ElementKind::Text || kind.spec().is_some_and(|spec| spec.arg == TagArg::Text)
572−
573−/// What the lines indented under `@name` are: the directive's body kind,
574−/// or an element's (verbatim for a row that says so: `@script`, and
575−/// `@code` and `@textarea`, whose text is literal; htmlang for every other
576−/// element).
577−pub fn body_kind(name: &str) -> BodyKind {
578− let verbatim = |spec: &TagSpec| spec.verbatim || spec.literal;
579− match directive(name) {
580− Some(spec) => spec.body,
581− None if ElementKind::from_name(name)
582− .and_then(|kind| kind.spec())
583− .is_some_and(verbatim) =>
584− {
585− BodyKind::Verbatim
586− }
587− None => BodyKind::Htmlang,
588− }
589−}
590−
591−#[cfg(test)]
592−mod tests {
593− use super::{ElementKind, Layout};
594−
595− fn layout(name: &str) -> Layout {
596− ElementKind::from_name(name).unwrap().layout()
597− }
598−
599− #[test]
600− fn every_element_has_the_layout_it_is_documented_with() {
601− let text = [
602− "text",
603− "paragraph",
604− "link",
605− "h1",
606− "h2",
607− "h3",
608− "h4",
609− "h5",
610− "h6",
611− "button",
612− "td",
613− "th",
614− "label",
615− "summary",
616− "legend",
617− "figcaption",
618− "dt",
619− "cite",
620− "code",
621− "kbd",
622− "mark",
623− "abbr",
624− "time",
625− "b",
626− "i",
627− "strong",
628− "em",
629− "small",
630− "sub",
631− "sup",
632− "q",
633− "del",
634− ];
635− for name in text {
636− assert_eq!(layout(name), Layout::Text, "@{}", name);
637− }
638− let columns = [
639− "el", "li", "dd", "ul", "ol", "dl", "search", "address", "noscript", "section", "form",
640− "in-front", "behind", "hgroup", "menu",
641− ];
642− for name in columns {
643− assert_eq!(layout(name), Layout::Column, "@{}", name);
644− }
645− assert_eq!(layout("row"), Layout::Row);
646− assert_eq!(layout("grid"), Layout::Grid);
647− for name in [
648− "table", "tr", "select", "option", "picture", "video", "pre", "canvas", "caption",
649− "colgroup", "tfoot", "optgroup", "ruby", "rt", "object",
650− ] {
651− assert_eq!(layout(name), Layout::Native, "@{}", name);
652− }
653− for name in [
654− "image", "input", "hr", "source", "br", "wbr", "col", "track", "embed", "area",
655− ] {
656− assert_eq!(layout(name), Layout::Void, "@{}", name);
657− }
658− }
659−
660− #[test]
661− fn html_names_written_otherwise_are_not_elements() {
662− for (html, htmlang) in super::HTML_NAMES_WRITTEN_OTHERWISE {
663− assert!(ElementKind::from_name(html).is_none(), "@{}", html);
664− let kind = ElementKind::from_name(htmlang).unwrap();
665− let written = match kind {
666− ElementKind::El => "div",
667− ElementKind::Text => "span",
668− ElementKind::Paragraph => "p",
669− ElementKind::Link => "a",
670− ElementKind::Image => "img",
671− _ => panic!("@{} is not one of htmlang's own", htmlang),
672− };
673− assert_eq!(written, *html);
674− }
675− // No two rows share a name, and none is a directive's
676− let mut names: Vec<&str> = ElementKind::all_names().collect();
677− let count = names.len();
678− names.sort_unstable();
679− names.dedup();
680− assert_eq!(names.len(), count, "an element name is listed twice");
681− for name in names {
682− assert!(super::directive(name).is_none(), "@{}", name);
683− }
684− }
685−
686− #[test]
687− fn each_row_names_at_most_one_leading_attribute() {
688− let leading = |name: &str, in_picture: bool| {
689− ElementKind::from_name(name)
690− .unwrap()
691− .leading_attribute(in_picture)
692− };
693− for (name, attr) in [
694− ("link", "href"),
695− ("area", "href"),
696− ("image", "src"),
697− ("script", "src"),
698− ("iframe", "src"),
699− ("video", "src"),
700− ("audio", "src"),
701− ("track", "src"),
702− ("embed", "src"),
703− ("form", "action"),
704− ("object", "data"),
705− ("optgroup", "label"),
706− ] {
707− assert_eq!(leading(name, false), Some(attr), "@{}", name);
708− assert_eq!(leading(name, true), Some(attr), "@{}", name);
709− }
710− assert_eq!(leading("source", false), Some("src"));
711− assert_eq!(leading("source", true), Some("srcset"));
712− for name in ["el", "text", "h1", "button", "slot", "fragment", "picture"] {
713− assert_eq!(leading(name, false), None, "@{}", name);
714− }
715− // @script is an ordinary row whose body is kept as written
716− let script = ElementKind::from_name("script").unwrap();
717− assert!(script.is_verbatim() && script.spec().is_some());
718− assert_eq!(super::body_kind("script"), super::BodyKind::Verbatim);
719− assert_eq!(super::body_kind("el"), super::BodyKind::Htmlang);
720− // @code and @textarea: a verbatim body, shown as text; @pre is an
721− // ordinary element, so `@pre > @code` and wrapper functions work
722− for name in ["code", "textarea"] {
723− assert_eq!(
724− super::body_kind(name),
725− super::BodyKind::Verbatim,
726− "@{}",
727− name
728− );
729− assert!(super::has_literal_text(name), "@{}", name);
730− }
731− assert_eq!(super::body_kind("pre"), super::BodyKind::Htmlang);
732− // Every verbatim directive takes its line as its one-line body or file
733− for spec in super::DIRECTIVES {
734− if spec.body == super::BodyKind::Verbatim {
735− assert_eq!(spec.args, super::ArgGrammar::Text, "@{}", spec.name);
736− }
737− }
738− }
739−
740− #[test]
741− fn an_element_has_a_tag_of_its_own_when_no_other_element_writes_it() {
742− let own = |name: &str| ElementKind::from_name(name).unwrap().own_tag();
743− for shared in [
744− "el", "row", "grid", "in-front", "behind", "text", "script", "fragment",
745− ] {
746− assert_eq!(own(shared), None, "@{}", shared);
747− }
748− assert_eq!(own("td"), Some("td"));
749− assert_eq!(own("link"), Some("a"));
750− assert_eq!(own("paragraph"), Some("p"));
751− assert_eq!(own("image"), Some("img"));
752− // Every tag of its own is written by that one element
753− let names: Vec<&str> = ElementKind::all_names().collect();
754− for name in &names {
755− let Some(tag) = own(name) else { continue };
756− let writers: Vec<&&str> = names
757− .iter()
758− .filter(|other| ElementKind::from_name(other).unwrap().tag() == Some(tag))
759− .collect();
760− assert_eq!(writers, vec![name], "<{}>", tag);
761− }
762− }
763−
764− #[test]
765− fn a_layout_s_display_is_not_repeated_in_the_row_s_css() {
766− for spec in super::TAGS {
767− assert!(
768− !spec.css.contains("display"),
769− "@{}: {}",
770− spec.name,
771− spec.css
772− );
773− }
774− }
775}
257}
crates/htmlang-core/src/codegen.rs+1804−1571
@@ -1,8 +1,7 @@
1use std::collections::HashMap;
1use std::collections::HashMap;
2−use std::hash::Hash;
2+use std::hash::{Hash, Hasher};
3
3
4use crate::ast::*;
4use crate::ast::*;
5−use crate::vocab::with_px;
6
5
7// ---------------------------------------------------------------------------
6// ---------------------------------------------------------------------------
8// Style collector: deduplicates CSS and assigns class names
7// Style collector: deduplicates CSS and assigns class names
@@ -17,315 +16,56 @@ const BREAKPOINTS: &[(&str, &str)] = &[
17 ("2xl", "1536px"),
16 ("2xl", "1536px"),
18];
17];
19
18
20−/// The prefix of every generated class (`hl-2k9xq0m`), which keeps them
19+/// Generate short CSS class names: a..z, then aa..a9, ba..b9, ..., z9, then
21−/// out of the author's own class names.
20+/// aaa, ... The first character is always a letter; later ones are drawn from
22−pub const CLASS_PREFIX: &str = "hl-";
21+/// [a-z0-9]. The mapping is a bijection, so distinct indices never collide.
23−
24−/// How many base-36 digits a class's name has after `hl-`: 7, about 36
25−/// bits of its style's hash, so a site with 10,000 different styles has
26−/// odds of about 6 in 10,000 that two of them share a name, which
27−/// `htmlang build` reports (`class-collision`). Within one file, a style
28−/// whose name is taken takes more digits.
29−pub const CLASS_DIGITS: usize = 7;
30−
31−/// A stable hash of `text`: FNV-1a, then SplitMix64's finalizer, so every
32−/// digit of a name depends on all of the text. It is the same on every
33−/// platform and in every build, unlike `std`'s hasher.
34−pub fn stable_hash(text: &str) -> u64 {
35− let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
36− for byte in text.bytes() {
37− hash ^= u64::from(byte);
38− hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
39− }
40− hash = (hash ^ (hash >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
41− hash = (hash ^ (hash >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
42− hash ^ (hash >> 31)
43−}
44−
45−/// `digits` base-36 digits of `hash`: `2k9xq0m`. With 13, all of it.
46−pub fn hash_digits(hash: u64, digits: usize) -> String {
22+pub(crate) fn short_class_name(idx: usize) -> String {
47− const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
23+ const REST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
24+ // Find the name length: 26 names of length 1, 26*36 of length 2, ...
48− let mut n = hash;
25+ let mut n = idx;
49− (0..digits)
26+ let mut len = 1;
50− .map(|_| {
27+ let mut count = 26usize;
51− let digit = DIGITS[(n % 36) as usize] as char;
28+ while n >= count {
29+ n -= count;
30+ len += 1;
31+ count = count.saturating_mul(36);
32+ }
33+ let mut tail = Vec::with_capacity(len - 1);
34+ for _ in 1..len {
35+ tail.push(REST[n % 36]);
52− n /= 36;
36+ n /= 36;
53− digit
54− })
55− .collect()
56−}
57−
58−/// A class htmlang generated: its name, and the hash of the style it
59−/// stands for. One style has one name in every file, so a fragment's
60−/// classes mean the same in any page it goes into.
61−#[derive(Clone, Debug, PartialEq, Eq)]
62−pub struct Class {
63− pub name: String,
64− pub style: u64,
65−}
37+ }
66−
38+ let mut name = String::with_capacity(len);
67−/// When a style applies: the at-rule prefixes it is under (their ranks,
39+ name.push((b'a' + n as u8) as char);
68−/// see [`crate::vocab::at_rule_rank`], sorted, so `dark:md:` and `md:dark:`
40+ name.extend(tail.iter().rev().map(|&b| b as char));
69−/// are one condition) and its selector prefixes, left to right as the
41+ name
70−/// selector reads (`hover:children:` is `.a:hover > *`).
71−#[derive(Clone, Default, PartialEq, Eq, Hash, Debug)]
72−struct Condition {
73− at: Vec<usize>,
74− selector: Vec<String>,
75−}
76−
77−impl Condition {
78− /// The condition of an attribute's key, and the name after its
79− /// prefixes.
80− fn of(key: &str) -> (Condition, &str) {
81− let (prefixes, name) = crate::vocab::split_prefixes(key);
82− let mut condition = Condition::default();
83− for prefix in prefixes {
84− match crate::vocab::at_rule_rank(prefix) {
85− Some(rank) => condition.at.push(rank),
86− None => condition.selector.push(prefix.to_string()),
87− }
88− }
89− condition.at.sort_unstable();
90− condition.at.dedup();
91− (condition, name)
92− }
93−
94− /// Only the at-rule part: a direction under `hover:` is the one without
95− /// it.
96− fn at_only(at: &[usize]) -> Condition {
97− Condition {
98− at: at.to_vec(),
99− selector: Vec::new(),
100− }
101− }
102−
103− /// The styles go on the element's children (`children:`).
104− fn on_children(&self) -> bool {
105− self.selector.iter().any(|p| p == crate::vocab::CHILDREN)
106− }
107−
108− /// The styles go on the elements of one kind inside the element
109− /// (`@td:`): where the element prefix is in the selector chain.
110− fn inside(&self) -> Option<usize> {
111− self.selector
112− .iter()
113− .position(|p| crate::vocab::element_prefix(p).is_some())
114− }
115−
116− /// Whether this condition holds wherever `other` does: its at-rules
117− /// are implied by `other`'s, and it has no selector or `other`'s.
118− fn holds_at(&self, other: &Condition) -> bool {
119− (self.selector.is_empty() || self.selector == other.selector)
120− && self.at.iter().all(|&a| {
121− other
122− .at
123− .iter()
124− .any(|&b| crate::vocab::at_rule_implied(a, b))
125− })
126− }
127−
128− /// The order the rules of each condition are written in, which the
129− /// cascade follows: no prefix, then selectors, then each at-rule
130− /// block (see [`at_order`]), with its selectors inside it.
131− fn order(&self) -> (AtOrder, SelectorOrder) {
132− (at_order(&self.at), selector_order(&self.selector))
133− }
134−}
135−
136−/// Where a block of at-rules is written: after the rules without one; by
137−/// its last (innermost) prefix in the order of
138−/// [`crate::vocab::at_rule_prefixes`] (widths, media, container widths), so
139−/// `md:dark:` comes with `dark:`, after it; then by length.
140−type AtOrder = (bool, usize, usize, Vec<usize>);
141−
142−fn at_order(at: &[usize]) -> AtOrder {
143− (
144− !at.is_empty(),
145− at.last().copied().unwrap_or(0),
146− at.len(),
147− at.to_vec(),
148− )
149−}
150−
151−/// Selector chains in the order of [`crate::vocab::PSEUDOS`] (position,
152−/// then form state, then `hover:`, `focus:`, `active:`, `disabled:`), and
153−/// by their argument's text for the same pseudo-class; a chain before the
154−/// longer ones it starts. A chain through `children:` comes after the
155−/// others, ordered by what follows its last `children:` and then by what
156−/// comes before it: only that tail counts toward its specificity (see
157−/// [`selector`]), so `hover:children:` follows `children:` and wins over it
158−/// on hover.
159−type SelectorOrder = (bool, Vec<(usize, String)>, Vec<(usize, String)>);
160−
161−fn selector_order(selector: &[String]) -> SelectorOrder {
162− let table = crate::vocab::PSEUDOS;
163− let ranked = |chain: &[String]| -> Vec<(usize, String)> {
164− chain
165− .iter()
166− .map(|p| {
167− let name = crate::vocab::pseudo_name(p);
168− let rank = table
169− .iter()
170− .position(|(q, _)| *q == name)
171− .unwrap_or(table.len());
172− (rank, p.clone())
173− })
174− .collect()
175− };
176− match selector.iter().rposition(|p| p == crate::vocab::CHILDREN) {
177− None => (false, ranked(selector), Vec::new()),
178− Some(i) => (true, ranked(&selector[i + 1..]), ranked(&selector[..i])),
179− }
180−}
181−
182−/// The order of the chains through an element prefix (`@td:`, see
183−/// [`scoped_selector`]): by the prefixes before it, which select the root
184−/// of the scope and add nothing to the specificity, so `hover:@td:` comes
185−/// after `@td:` and wins on hover; then by the element; then by the
186−/// prefixes after it.
187−fn inside_order(chain: &[String]) -> (SelectorOrder, String, SelectorOrder) {
188− let at = chain
189− .iter()
190− .position(|p| crate::vocab::element_prefix(p).is_some())
191− .unwrap_or(chain.len());
192− (
193− selector_order(&chain[..at]),
194− chain.get(at).cloned().unwrap_or_default(),
195− selector_order(chain.get(at + 1..).unwrap_or_default()),
196− )
197−}
198−
199−/// The selector for `class` under a chain of selector prefixes, read left
200−/// to right. Everything before the last `children:` goes in `:where()`, so
201−/// a parent's `children:` styles have no specificity, and a child's own
202−/// attributes win over them: `children:hover:` is `:where(.a)>*:hover`,
203−/// `hover:children:` is `:where(.a:hover)>*`.
204−fn selector(class: &str, chain: &[String]) -> String {
205− let last_children = chain.iter().rposition(|p| p == crate::vocab::CHILDREN);
206− let mut out = format!(".{}", class);
207− for (i, prefix) in chain.iter().enumerate() {
208− if prefix == crate::vocab::CHILDREN {
209− if Some(i) == last_children {
210− out = format!(":where({})>*", out);
211− } else {
212− out.push_str(">*");
213− }
214− } else if let Some(pseudo) = crate::vocab::pseudo_selector(prefix) {
215− out.push_str(&pseudo);
216− }
217− }
218− out
219−}
220−
221−/// The rule for the elements an element prefix picks out inside the
222−/// element with the class `class` (see [`Condition::inside`]): the scope's
223−/// root, from the selector prefixes before it (`hover:@td:` is inside a
224−/// hovered element), and the selector for the elements, from those after
225−/// it (`@td:hover:` is a hovered cell). `:scope td` leaves out the root
226−/// itself, so `@ul [@ul:...]` styles the lists inside, not its own. `None`
227−/// when the name has no tag of its own (the parser reports it).
228−fn scoped_selector(class: &str, chain: &[String], at: usize) -> Option<(String, String)> {
229− let name = crate::vocab::element_prefix(&chain[at])?;
230− let tag = ElementKind::from_name(name)?.own_tag()?;
231− let root = selector(class, &chain[..at]);
232− let mut target = format!(":scope {}", tag);
233− for prefix in &chain[at + 1..] {
234− if let Some(pseudo) = crate::vocab::pseudo_selector(prefix) {
235− target.push_str(&pseudo);
236− }
237− }
238− Some((root, target))
239−}
240−
241−/// The `@media` or `@container` rule an at-rule prefix stands for, as
242−/// written in readable and in compact output.
243−fn at_rule(rank: usize, dev: bool) -> String {
244− let prefix = crate::vocab::at_rule_prefixes()
245− .nth(rank)
246− .unwrap_or_default();
247− let name = prefix.trim_end_matches(':');
248− let width = |name: &str| {
249− BREAKPOINTS
250− .iter()
251− .find(|(n, _)| *n == name)
252− .map_or("0", |(_, w)| *w)
253− };
254− let (kind, feature, value) = if let Some(size) = name.strip_prefix("cq-") {
255− ("@container", "min-width", width(size))
256− } else {
257− match name {
258− "print" => {
259− return "@media print".to_string();
260− }
261− "dark" => ("@media", "prefers-color-scheme", "dark"),
262− "motion-safe" => ("@media", "prefers-reduced-motion", "no-preference"),
263− "motion-reduce" => ("@media", "prefers-reduced-motion", "reduce"),
264− "landscape" | "portrait" => ("@media", "orientation", name),
265− _ => ("@media", "min-width", width(name)),
266− }
267− };
268− if dev {
269− format!("{} ({}: {})", kind, feature, value)
270− } else {
271− format!("{}({}:{})", kind, feature, value)
272− }
273−}
274−
275−/// A style as text, for its hash: the defaults of its element's kind, its
276−/// rules (the at-rules' ranks, the selector prefixes and the declarations
277−/// of each) and the rules keyed on it, each part ended by a character CSS
278−/// doesn't have.
279−fn style_text(defaults: &str, rules: &[(Condition, String)], distinct: &str) -> String {
280− let mut text = format!("{defaults}\u{1}");
281− for (condition, body) in rules {
282− for rank in &condition.at {
283− text.push_str(&format!("{rank}\u{2}"));
284− }
285− text.push('\u{3}');
286− for prefix in &condition.selector {
287− text.push_str(prefix);
288− text.push('\u{2}');
289− }
290− text.push('\u{3}');
291− text.push_str(body);
292− text.push('\u{1}');
293− }
294− text.push_str(distinct);
295− text
296}
42}
297
43
298struct StyleEntry {
44struct StyleEntry {
299 class_name: String,
45 class_name: String,
300− /// The hash of the style, which names the class
301− style: u64,
302− /// The defaults of the element's kind (its layout, a heading's
303− /// `margin:0`, ...), less what its own attributes set. They are written
304− /// as `:where(.hl-a)` in the layer `hl-kind`, so an element prefix
305− /// around it (`@h2:`), its parent's `children:` styles and its own
306− /// attributes, all in later layers, win over them.
307− defaults: String,
46+ base: String,
308− /// The element's rules: under each condition, its declarations, in the
47+ /// (CSS selector suffix, css_rules) — e.g. (":hover", "color:red;")
309− /// order they are written
48+ pseudo: Vec<(String, String)>,
310− rules: Vec<(Condition, String)>,
49+ /// Responsive overrides: (breakpoint_prefix, css)
311− /// What else tells two elements with the same CSS apart: the rules
50+ responsive: Vec<(String, String)>,
312− /// keyed on this class (see [`StyleCollector::keyed`]). Not written.
51+ /// Dark mode overrides
52+ dark: String,
53+ /// Print overrides
313− distinct: String,
54+ print: String,
55+ motion_safe: String,
56+ motion_reduce: String,
57+ landscape: String,
58+ portrait: String,
59+ /// Container query overrides: (breakpoint_prefix, css)
60+ container: Vec<(String, String)>,
314}
61}
315
62
316struct StyleCollector {
63struct StyleCollector {
317 entries: Vec<StyleEntry>,
64 entries: Vec<StyleEntry>,
318− /// Maps a style's hash to its entries (more than one only when two
65+ /// Maps a pre-hashed style signature to an index into `entries`.
319− /// styles hash alike, which the full comparison tells apart).
66+ /// Using u64 as the key keeps lookups allocation-free; on the rare case of
67+ /// a hash collision we fall back to a full equality check against the entry.
320 index: HashMap<u64, Vec<usize>>,
68 index: HashMap<u64, Vec<usize>>,
321− /// The names given, which a style whose name is taken doesn't get
322− names: std::collections::HashSet<String>,
323− /// Rules for an element's children, keyed on its class and written in
324− /// the block of an at-rule condition: `(at-rules, selector, body)`, e.g.
325− /// `([md], ":where(.a)>.b", "flex:1;")` (see [`Flow`]).
326− keyed: Vec<(Vec<usize>, String, String)>,
327− /// The keyed rules already added
328− keyed_seen: std::collections::HashSet<(Vec<usize>, String, String)>,
329}
69}
330
70
331impl StyleCollector {
71impl StyleCollector {
@@ -333,282 +73,313 @@ impl StyleCollector {
333 StyleCollector {
73 StyleCollector {
334 entries: Vec::new(),
74 entries: Vec::new(),
335 index: HashMap::new(),
75 index: HashMap::new(),
336− names: std::collections::HashSet::new(),
337− keyed: Vec::new(),
338− keyed_seen: std::collections::HashSet::new(),
339− }
340− }
341−
342− /// Add a rule for `selector` under the at-rules `at`, once.
343− fn add_keyed(&mut self, at: Vec<usize>, selector: String, body: String) {
344− let rule = (at, selector, body);
345− if self.keyed_seen.insert(rule.clone()) {
346− self.keyed.push(rule);
347 }
76 }
348 }
77 }
349
78
350 /// Returns a class name for this style combination, or None if all empty.
79 /// Returns a class name for this style combination, or None if all empty.
80+ #[allow(clippy::too_many_arguments)]
351 fn get_class(
81 fn get_class(
352 &mut self,
82 &mut self,
353− defaults: String,
83+ base: String,
354− mut rules: Vec<(Condition, String)>,
84+ pseudo: Vec<(String, String)>,
355− distinct: String,
85+ responsive: Vec<(String, String)>,
86+ dark: String,
87+ print: String,
88+ motion_safe: String,
89+ motion_reduce: String,
90+ landscape: String,
91+ portrait: String,
92+ container: Vec<(String, String)>,
356 ) -> Option<String> {
93 ) -> Option<String> {
357− rules.retain(|(_, body)| !body.is_empty());
94+ if base.is_empty()
358− if defaults.is_empty() && rules.is_empty() {
95+ && pseudo.is_empty()
96+ && responsive.is_empty()
97+ && dark.is_empty()
98+ && print.is_empty()
99+ && motion_safe.is_empty()
100+ && motion_reduce.is_empty()
101+ && landscape.is_empty()
102+ && portrait.is_empty()
103+ && container.is_empty()
104+ {
359 return None;
105 return None;
360 }
106 }
361− rules.sort_by_cached_key(|(condition, _)| condition.order());
107+ use std::collections::hash_map::DefaultHasher;
362− let style = stable_hash(&style_text(&defaults, &rules, &distinct));
108+ let mut h = DefaultHasher::new();
363−
109+ base.hash(&mut h);
110+ pseudo.hash(&mut h);
111+ responsive.hash(&mut h);
112+ dark.hash(&mut h);
113+ print.hash(&mut h);
114+ motion_safe.hash(&mut h);
115+ motion_reduce.hash(&mut h);
116+ landscape.hash(&mut h);
117+ portrait.hash(&mut h);
118+ container.hash(&mut h);
119+ let sig = h.finish();
120+
364− if let Some(indices) = self.index.get(&style) {
121+ if let Some(indices) = self.index.get(&sig) {
365 for &idx in indices {
122 for &idx in indices {
366 let e = &self.entries[idx];
123 let e = &self.entries[idx];
367− if e.defaults == defaults && e.rules == rules && e.distinct == distinct {
124+ if e.base == base
125+ && e.pseudo == pseudo
126+ && e.responsive == responsive
127+ && e.dark == dark
128+ && e.print == print
129+ && e.motion_safe == motion_safe
130+ && e.motion_reduce == motion_reduce
131+ && e.landscape == landscape
132+ && e.portrait == portrait
133+ && e.container == container
134+ {
368 return Some(e.class_name.clone());
135 return Some(e.class_name.clone());
369 }
136 }
370 }
137 }
371 }
138 }
372− // The name is the hash's first digits, and more of them when
373− // another style of this file has those; after all 13, the entry's
374− // number tells the two apart
375 let idx = self.entries.len();
139 let idx = self.entries.len();
376− let name = (CLASS_DIGITS..=13)
140+ let name = short_class_name(idx);
377− .map(|digits| format!("{}{}", CLASS_PREFIX, hash_digits(style, digits)))
378− .find(|name| !self.names.contains(name))
379− .unwrap_or_else(|| format!("{}{}{}", CLASS_PREFIX, hash_digits(style, 13), idx));
380− self.names.insert(name.clone());
381 self.entries.push(StyleEntry {
141 self.entries.push(StyleEntry {
382 class_name: name.clone(),
142 class_name: name.clone(),
383− style,
143+ base,
384− defaults,
144+ pseudo,
385− rules,
145+ responsive,
386− distinct,
146+ dark,
147+ print,
148+ motion_safe,
149+ motion_reduce,
150+ landscape,
151+ portrait,
152+ container,
387 });
153 });
388− self.index.entry(style).or_default().push(idx);
154+ self.index.entry(sig).or_default().push(idx);
389 Some(name)
155 Some(name)
390 }
156 }
391
157
392− /// The classes given, in the order they were
158+ /// All generated rules, wrapped in `@layer htmlang`.
393− fn classes(&self) -> impl Iterator<Item = Class> + '_ {
394− self.entries.iter().map(|e| Class {
395− name: e.class_name.clone(),
396− style: e.style,
397− })
398− }
399−
400− /// All generated rules, in three layers (see [`LAYERS`]): the element
401− /// kinds' defaults in `hl-kind` (`:where(.hl-a)`), the rules for the
402− /// elements an element prefix picks out inside an element (`@td:`), and
403− /// a function's scoped `@style`, in `hl-inside`, and the elements' own
404− /// rules in `htmlang`. They are written in a fixed order, so which rule
405− /// wins never depends on where in the page an element is: the rules
406− /// without a prefix, then those under selector prefixes, then each block
407− /// of at-rules (see [`at_order`]), in which the rules keyed on a parent
408− /// come first, then the element's own, then its selectors (see
409− /// [`selector_order`]).
410− fn to_css_formatted(&self, dev: bool, scoped: &[String]) -> String {
159+ fn to_css_formatted(&self, dev: bool) -> String {
411− // The defaults: `:where(.hl-a,.hl-b){...}`
412− let mut kind = String::new();
160+ let mut css = String::new();
413− let mut defaults: Vec<(&str, Vec<&str>)> = Vec::new();
161+ let inner_indent = if dev { " " } else { "" };
414− for e in self.entries.iter().filter(|e| !e.defaults.is_empty()) {
162+ css.push_str(if dev { "@layer htmlang {\n" } else { "@layer htmlang{" });
415− match defaults.iter_mut().find(|(body, _)| *body == e.defaults) {
163+
416− Some((_, classes)) => classes.push(&e.class_name),
164+ // Non-responsive rules. Base and pseudo rules are merged separately by
417− None => defaults.push((&e.defaults, vec![&e.class_name])),
165+ // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
418− }
166+ // replaces two identical rules. Pseudo variants are grouped per selector
419− }
167+ // suffix (`:hover` with `:hover`, etc.) to keep each pseudo's cascade
420− for (body, classes) in defaults {
168+ // position independent of others.
421− let classes: Vec<String> = classes.iter().map(|c| format!(".{}", c)).collect();
169+ let mut base_pairs: Vec<(&str, &str)> = Vec::with_capacity(self.entries.len());
422− let selector = format!(":where({})", classes.join(","));
170+ for e in &self.entries {
423− if dev {
171+ if !e.base.is_empty() {
424− kind.push_str(&format!(" {} {{{}}}\n", selector, body));
172+ base_pairs.push((e.class_name.as_str(), e.base.as_str()));
425− } else {
426− kind.push_str(&format!("{}{{{}}}", selector, body));
427 }
173 }
428 }
174 }
175+ emit_grouped_rules(&mut css, &base_pairs, "", inner_indent, dev);
429
176
430− let mut inside = self.blocks(true, dev);
177+ // Collect pseudo rules grouped by selector suffix, preserving the order
431− // A function's scoped `@style`, nested under its class
178+ // in which suffixes first appear across entries.
432− for block in scoped {
179+ let mut pseudo_order: Vec<&str> = Vec::new();
433− if dev {
180+ let mut pseudo_buckets: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
434− inside.push_str(block);
181+ for e in &self.entries {
435− inside.push('\n');
182+ for (selector, body) in &e.pseudo {
436− } else {
183+ if body.is_empty() {
437− inside.push_str(&compact_css(block));
184+ continue;
185+ }
186+ let key = selector.as_str();
187+ if !pseudo_buckets.contains_key(key) {
188+ pseudo_order.push(key);
189+ }
190+ pseudo_buckets
191+ .entry(key)
192+ .or_default()
193+ .push((e.class_name.as_str(), body.as_str()));
438 }
194 }
439 }
195 }
440−
196+ for selector in &pseudo_order {
441− let mut css = String::new();
197+ if let Some(pairs) = pseudo_buckets.get(selector) {
442− for (name, rules) in [
198+ emit_grouped_rules(&mut css, pairs, selector, inner_indent, dev);
443− ("hl-kind", kind),
444− ("hl-inside", inside),
445− ("htmlang", self.blocks(false, dev)),
446− ] {
447− if rules.is_empty() {
448− continue;
449− }
450− if dev {
451− css.push_str(&format!("@layer {} {{\n{}}}\n", name, rules));
452− } else {
453− css.push_str(&format!("@layer {}{{{}}}", name, rules));
454 }
199 }
455 }
200 }
456− css
457− }
458
201
459− /// The rules of every block of at-rules, in order: those for the
202+ // Responsive rules grouped by breakpoint
460− /// elements inside (`inside`: rules under an element prefix, each in
203+ for &(bp_name, bp_width) in BREAKPOINTS {
461− /// an `@scope` on its element's class), or the elements' own.
204+ let mut bp_pairs: Vec<(&str, &str)> = Vec::new();
462− fn blocks(&self, inside: bool, dev: bool) -> String {
463− let mut css = String::new();
464− let rules = || {
465− self.entries.iter().flat_map(move |e| {
205+ for e in &self.entries {
466− e.rules
206+ for (bp, rule_css) in &e.responsive {
467− .iter()
207+ if bp == bp_name && !rule_css.is_empty() {
468− .filter(move |(c, _)| c.inside().is_some() == inside)
208+ bp_pairs.push((e.class_name.as_str(), rule_css.as_str()));
469− .map(move |(c, body)| (e, c, body))
470− })
471− };
472− let mut blocks: Vec<&[usize]> = vec![&[]];
473− let keyed = self.keyed.iter().filter(|_| !inside);
474− let every = rules()
475− .map(|(_, c, _)| c.at.as_slice())
476− .chain(keyed.map(|(at, _, _)| at.as_slice()));
477− for at in every {
478− if !blocks.contains(&at) {
479− blocks.push(at);
480− }
481− }
482− blocks.sort_by_key(|at| at_order(at));
483−
484− for at in blocks {
485− let depth = at.len();
486− // Readable output indents a rule inside `@layer` and inside
487− // each at-rule but the first
488− let indent = if dev {
489− " ".repeat(depth.max(1))
490− } else {
491− String::new()
492− };
493− let mut inner = String::new();
494− // Rules keyed on a parent's class come first: they have the
495− // specificity of one class, like a class rule, so the child's
496− // own rules in the block still win
497− if !inside {
498− let keyed: Vec<(&str, &str)> = self
499− .keyed
500− .iter()
501− .filter(|(a, _, _)| a.as_slice() == at)
502− .map(|(_, selector, body)| (selector.as_str(), body.as_str()))
503− .collect();
504− emit_selector_rules(&mut inner, &keyed, &indent, dev);
505− }
506− // The selector chains of this block, in order
507− let mut chains: Vec<&[String]> = Vec::new();
508− for (_, condition, _) in rules() {
509− if condition.at == at && !chains.contains(&condition.selector.as_slice()) {
510− chains.push(&condition.selector);
511− }
512− }
513− if inside {
514− chains.sort_by_cached_key(|chain| inside_order(chain));
515− // One `@scope` for each root, its rules in the order of
516− // the chains: rules for one element under two roots (`.a`
517− // and `.a:hover`) tie on specificity and proximity, so the
518− // later one wins
519− let mut scopes: Vec<(String, Vec<(String, &str)>)> = Vec::new();
520− for chain in chains {
521− let Some(element) = chain
522− .iter()
523− .position(|p| crate::vocab::element_prefix(p).is_some())
524− else {
525− continue;
526− };
527− for (e, _, body) in
528− rules().filter(|(_, c, _)| c.at == at && c.selector == chain)
529− {
530− let Some((root, target)) = scoped_selector(&e.class_name, chain, element)
531− else {
532− continue;
533− };
534− match scopes.iter_mut().find(|(r, _)| *r == root) {
535− Some((_, targets)) => targets.push((target, body)),
536− None => scopes.push((root, vec![(target, body)])),
537− }
538 }
209 }
539 }
210 }
540− let inner_indent = if dev {
541− format!("{} ", indent)
542− } else {
543− String::new()
544− };
545− for (root, targets) in scopes {
546− let targets: Vec<(&str, &str)> =
547− targets.iter().map(|(t, b)| (t.as_str(), *b)).collect();
548− inner.push_str(&indent);
549− inner.push_str(&format!("@scope ({})", root));
550− inner.push_str(if dev { " {\n" } else { "{" });
551− emit_selector_rules(&mut inner, &targets, &inner_indent, dev);
552− inner.push_str(&indent);
553− inner.push_str(if dev { "}\n" } else { "}" });
554− }
555− } else {
556− chains.sort_by_cached_key(|chain| selector_order(chain));
557− for chain in chains {
558− let pairs: Vec<(String, &str)> = rules()
559− .filter(|(_, c, _)| c.at == at && c.selector == chain)
560− .map(|(e, _, body)| (selector(&e.class_name, chain), body.as_str()))
561− .collect();
562− emit_grouped_rules(&mut inner, &pairs, &indent, dev);
563− }
564− }
565− if inner.is_empty() {
566− continue;
567 }
211 }
568− // Each at-rule wraps the next, outermost first
212+ emit_media_block(
569− for (level, &rank) in at.iter().enumerate().rev() {
213+ &mut css,
570− let pad = if dev {
214+ &format!("@media (min-width: {})", bp_width),
571− " ".repeat(level)
215+ &format!("@media(min-width:{})", bp_width),
572− } else {
216+ &bp_pairs,
573− String::new()
217+ "",
574− };
218+ dev,
575− inner = if dev {
219+ );
576− format!("{pad}{} {{\n{inner}{pad}}}\n", at_rule(rank, true))
220+ }
577− } else {
221+
578− format!("{}{{{inner}}}", at_rule(rank, false))
222+ // Dark mode rules
223+ let dark_pairs: Vec<(&str, &str)> = self
224+ .entries
225+ .iter()
226+ .filter(|e| !e.dark.is_empty())
227+ .map(|e| (e.class_name.as_str(), e.dark.as_str()))
228+ .collect();
229+ emit_media_block(
230+ &mut css,
231+ "@media (prefers-color-scheme: dark)",
232+ "@media(prefers-color-scheme:dark)",
233+ &dark_pairs,
234+ "",
235+ dev,
236+ );
237+
238+ // Print rules
239+ let print_pairs: Vec<(&str, &str)> = self
240+ .entries
241+ .iter()
242+ .filter(|e| !e.print.is_empty())
243+ .map(|e| (e.class_name.as_str(), e.print.as_str()))
244+ .collect();
245+ emit_media_block(
246+ &mut css,
247+ "@media print",
248+ "@media print",
249+ &print_pairs,
250+ "",
251+ dev,
252+ );
253+
254+ // Motion safe rules
255+ let motion_safe_pairs: Vec<(&str, &str)> = self
256+ .entries
257+ .iter()
258+ .filter(|e| !e.motion_safe.is_empty())
259+ .map(|e| (e.class_name.as_str(), e.motion_safe.as_str()))
260+ .collect();
261+ emit_media_block(
262+ &mut css,
263+ "@media (prefers-reduced-motion: no-preference)",
264+ "@media(prefers-reduced-motion:no-preference)",
265+ &motion_safe_pairs,
266+ "",
267+ dev,
268+ );
269+
270+ // Motion reduce rules
271+ let motion_reduce_pairs: Vec<(&str, &str)> = self
272+ .entries
273+ .iter()
274+ .filter(|e| !e.motion_reduce.is_empty())
275+ .map(|e| (e.class_name.as_str(), e.motion_reduce.as_str()))
276+ .collect();
277+ emit_media_block(
278+ &mut css,
279+ "@media (prefers-reduced-motion: reduce)",
280+ "@media(prefers-reduced-motion:reduce)",
281+ &motion_reduce_pairs,
282+ "",
283+ dev,
284+ );
285+
286+ // Landscape rules
287+ let landscape_pairs: Vec<(&str, &str)> = self
288+ .entries
289+ .iter()
290+ .filter(|e| !e.landscape.is_empty())
291+ .map(|e| (e.class_name.as_str(), e.landscape.as_str()))
292+ .collect();
293+ emit_media_block(
294+ &mut css,
295+ "@media (orientation: landscape)",
296+ "@media(orientation:landscape)",
297+ &landscape_pairs,
298+ "",
299+ dev,
300+ );
301+
302+ // Portrait rules
303+ let portrait_pairs: Vec<(&str, &str)> = self
304+ .entries
305+ .iter()
306+ .filter(|e| !e.portrait.is_empty())
307+ .map(|e| (e.class_name.as_str(), e.portrait.as_str()))
308+ .collect();
309+ emit_media_block(
310+ &mut css,
311+ "@media (orientation: portrait)",
312+ "@media(orientation:portrait)",
313+ &portrait_pairs,
314+ "",
315+ dev,
316+ );
317+
318+ // Container query rules grouped by breakpoint
319+ for &(bp_name, bp_width) in BREAKPOINTS {
320+ let mut cq_pairs: Vec<(&str, &str)> = Vec::new();
321+ for e in &self.entries {
322+ for (bp, rule_css) in &e.container {
323+ if bp == bp_name && !rule_css.is_empty() {
324+ cq_pairs.push((e.class_name.as_str(), rule_css.as_str()));
325+ }
579− };
326+ }
580 }
327 }
581− css.push_str(&inner);
328+ emit_media_block(
329+ &mut css,
330+ &format!("@container (min-width: {})", bp_width),
331+ &format!("@container(min-width:{})", bp_width),
332+ &cq_pairs,
333+ "",
334+ dev,
335+ );
582 }
336 }
337+
338+ css.push_str(if dev { "}\n" } else { "}" });
583 css
339 css
584 }
340 }
585}
341}
586
342
587−/// Emit CSS rules from `(selector, body)` pairs, merging identical bodies
343+/// Emit CSS rules from `(class_name, body)` pairs, merging identical bodies
588/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
344/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
589/// of each distinct body determines ordering, so output stays deterministic
345/// of each distinct body determines ordering, so output stays deterministic
346+/// across runs. `selector_suffix` is appended to each class (e.g. `":hover"`,
590−/// across runs. `indent` is prepended to each rule line.
347+/// or `""` for plain class rules). `indent` is prepended to each rule line.
591−fn emit_grouped_rules(out: &mut String, pairs: &[(String, &str)], indent: &str, dev: bool) {
348+fn emit_grouped_rules(
349+ out: &mut String,
350+ pairs: &[(&str, &str)],
351+ selector_suffix: &str,
352+ indent: &str,
353+ dev: bool,
354+) {
592 if pairs.is_empty() {
355 if pairs.is_empty() {
593 return;
356 return;
594 }
357 }
595 // Group in first-occurrence order.
358 // Group in first-occurrence order.
596 let mut order: Vec<&str> = Vec::new();
359 let mut order: Vec<&str> = Vec::new();
597 let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
360 let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
598− for (selector, body) in pairs {
361+ for &(name, body) in pairs {
599 if body.is_empty() {
362 if body.is_empty() {
600 continue;
363 continue;
601 }
364 }
602 if !groups.contains_key(body) {
365 if !groups.contains_key(body) {
603 order.push(body);
366 order.push(body);
604 }
367 }
605− groups.entry(body).or_default().push(selector);
368+ groups.entry(body).or_default().push(name);
606 }
369 }
607 let sp = if dev { " " } else { "" };
370 let sp = if dev { " " } else { "" };
608 let nl = if dev { "\n" } else { "" };
371 let nl = if dev { "\n" } else { "" };
609 for body in &order {
372 for body in &order {
373+ let names = &groups[body];
610 out.push_str(indent);
374 out.push_str(indent);
611− out.push_str(&groups[body].join(","));
375+ for (i, n) in names.iter().enumerate() {
376+ if i > 0 {
377+ out.push(',');
378+ }
379+ out.push('.');
380+ out.push_str(n);
381+ out.push_str(selector_suffix);
382+ }
612 out.push_str(sp);
383 out.push_str(sp);
613 out.push('{');
384 out.push('{');
614 out.push_str(body);
385 out.push_str(body);
@@ -617,44 +388,37 @@ fn emit_grouped_rules(out: &mut String, pairs: &[(String, &str)], indent: &str,
617 }
388 }
618}
389}
619
390
620−/// Emit `(selector, body)` rules in their order, merging a run of rules
391+/// Emit an `@media` / `@container` block containing grouped class rules.
621−/// with the same body into one rule with a selector list (the order
392+/// Skips the block entirely if no non-empty bodies are present.
622−/// matters: two rules may apply to one element).
393+fn emit_media_block(
623−fn emit_selector_rules(out: &mut String, rules: &[(&str, &str)], indent: &str, dev: bool) {
394+ out: &mut String,
624− let mut i = 0;
395+ header_dev: &str,
625− while i < rules.len() {
396+ header_min: &str,
626− let body = rules[i].1;
397+ pairs: &[(&str, &str)],
627− let run = rules[i..].iter().take_while(|(_, b)| *b == body).count();
398+ selector_suffix: &str,
628− if !body.is_empty() {
399+ dev: bool,
629− let selectors: Vec<&str> = rules[i..i + run].iter().map(|(s, _)| *s).collect();
400+) {
630− out.push_str(indent);
401+ if pairs.iter().all(|(_, body)| body.is_empty()) {
631− out.push_str(&selectors.join(","));
402+ return;
403+ }
404+ let mut inner = String::new();
405+ let inner_indent = if dev { " " } else { "" };
406+ emit_grouped_rules(&mut inner, pairs, selector_suffix, inner_indent, dev);
407+ if inner.is_empty() {
408+ return;
409+ }
632− if dev {
410+ if dev {
633− out.push(' ');
411+ out.push_str(&format!("{} {{\n{}}}\n", header_dev, inner));
634− }
412+ } else {
635− out.push('{');
636− out.push_str(body);
413+ out.push_str(&format!("{}{{{}}}", header_min, inner));
637− out.push('}');
638− if dev {
639− out.push('\n');
640− }
641− }
642− i += run;
643 }
414 }
644}
415}
645
416
646struct GenContext {
417struct GenContext {
647 dev: bool,
418 dev: bool,
648 depth: usize,
419 depth: usize,
420+ image_count: usize,
649 has_interactive: bool,
421 has_interactive: bool,
650− /// Writing inside a text element, at any depth: htmlang's own `<div>`s
651− /// are `<span>`s there, which text can hold.
652− in_text: bool,
653− /// How the element whose children are being written lays them out
654− flow: Flow,
655− /// Writing the children of a `@picture`, where `@source`'s leading
656− /// argument is its `srcset`
657− in_picture: bool,
658}
422}
659
423
660impl GenContext {
424impl GenContext {
@@ -684,22 +448,15 @@ pub struct CodegenOptions {
684
448
685/// Generate HTML from a parsed document using the given options.
449/// Generate HTML from a parsed document using the given options.
686pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String {
450pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String {
687− generate_with_classes(doc, opts).0
688−}
689−
690−/// [`generate_with`], and the classes the document's styles were given,
691−/// for a check across files that no name stands for two styles.
692−pub fn generate_with_classes(doc: &Document, opts: &CodegenOptions) -> (String, Vec<Class>) {
693− let mut classes = Vec::new();
694 let mut html = if opts.partial {
451 let mut html = if opts.partial {
695− generate_partial_inner(doc, opts.dev, &mut classes)
452+ generate_partial_inner(doc, opts.dev)
696 } else {
453 } else {
697− generate_full_inner(doc, opts.dev, &mut classes)
454+ generate_full_inner(doc, opts.dev)
698 };
455 };
699 if opts.minify {
456 if opts.minify {
700 html = minify_html(&html);
457 html = minify_html(&html);
701 }
458 }
702− (html, classes)
459+ html
703}
460}
704
461
705pub fn generate(doc: &Document) -> String {
462pub fn generate(doc: &Document) -> String {
@@ -825,131 +582,266 @@ fn minify_html(html: &str) -> String {
825 result
582 result
826}
583}
827
584
828−fn generate_full_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
585+fn generate_full_inner(doc: &Document, dev: bool) -> String {
829− // Without `@page` the output is a fragment
830− let Some(page) = &doc.page else {
831− return generate_partial_inner(doc, dev, classes);
832− };
833 let mut styles = StyleCollector::new();
586 let mut styles = StyleCollector::new();
834 let mut ctx = GenContext {
587 let mut ctx = GenContext {
835 dev,
588 dev,
836 depth: 0,
589 depth: 0,
590+ image_count: 0,
837 has_interactive: false,
591 has_interactive: false,
838− in_text: false,
839− flow: Flow::default(),
840− in_picture: false,
841 };
592 };
842
593
843− // The page is the root element: `<body>` is a column (the reset makes
594+ // Check if document has @main for skip-to-content link
844− // it one, filling the viewport), `@page`'s styles are its class, and
595+ let has_main = has_tag(&doc.nodes, "main");
845− // the page's top-level elements are its children
846− let root = Element {
847− kind: ElementKind::El,
848− attrs: page.styles.clone(),
849− argument: None,
850− children: Vec::new(),
851− line_num: 0,
852− function: None,
853− };
854− let mut own = Flow::of(&root);
855− let top = Flow::default();
856− let site = Site {
857− kind: &root.kind,
858− parent: &top,
859− has_overlay_children: holds_overlays(&doc.nodes),
860− inline: false,
861− root: true,
862− };
863− let body_class = element_class(&root, &site, &mut styles);
864− own.class = body_class.clone();
865− ctx.flow = own;
866
596
867 let mut body = String::new();
597 let mut body = String::new();
868− generate_children(
869− &doc.nodes,
870− Some(Layout::Column),
871− &mut body,
872− &mut styles,
873− &mut ctx,
874− );
875−
876− let element_css = build_element_css(doc, &styles, dev);
877− let nl = if dev { "\n" } else { "" };
878
598
879− let mut meta_html = String::new();
599+ // Skip-to-content link for accessibility (only when @main exists)
880− // A `@meta viewport` of the page's own replaces the usual one
600+ if has_main {
881− if !doc.meta_tags.iter().any(|(name, _)| name == "viewport") {
601+ if dev {
882− meta_html.push_str(&format!(
602+ body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>\n");
883− "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">{}",
603+ } else {
884− nl
604+ body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>");
885− ));
605+ }
886− }
887− meta_html.push_str(&format!(
888− "<title>{}</title>{}",
889− html_escape(&page.title),
890− nl
891− ));
892− for (name, content) in &doc.meta_tags {
893− meta_html.push_str(&format!(
894− "<meta name=\"{}\" content=\"{}\">{}",
895− html_escape(name),
896− html_escape(content),
897− nl
898− ));
899− }
900− for (property, content) in &doc.og_tags {
901− meta_html.push_str(&format!(
902− "<meta property=\"og:{}\" content=\"{}\">{}",
903− html_escape(property),
904− html_escape(content),
905− nl
906− ));
907− }
908− if let Some(path) = &page.favicon {
909− meta_html.push_str(&format!(
910− "<link rel=\"icon\" href=\"{}\">{}",
911− html_escape(path),
912− nl
913− ));
914 }
606 }
915− for block in &doc.head_blocks {
607+
916− meta_html.push_str(block);
608+ for node in &doc.nodes {
917− meta_html.push_str(nl);
609+ generate_node(node, None, &mut body, &mut styles, &mut ctx);
918 }
610 }
919
611
920− // `@page`'s HTML attributes go on `<html>`
612+
613+ let element_css = build_element_css(doc, &styles, dev);
614+
615+ // Build meta tags string
616+ let meta_html = if doc.meta_tags.is_empty() {
617+ String::new()
618+ } else {
619+ let mut m = String::new();
620+ for (name, content) in &doc.meta_tags {
621+ if dev {
622+ m.push_str(&format!(
623+ "<meta name=\"{}\" content=\"{}\">\n",
624+ html_escape(name),
625+ html_escape(content)
626+ ));
627+ } else {
628+ m.push_str(&format!(
629+ "<meta name=\"{}\" content=\"{}\">",
630+ html_escape(name),
631+ html_escape(content)
632+ ));
633+ }
634+ }
635+ m
636+ };
637+
638+ // Build OG meta tags
639+ let og_html = if doc.og_tags.is_empty() {
640+ String::new()
641+ } else {
642+ let mut o = String::new();
643+ for (property, content) in &doc.og_tags {
644+ if dev {
645+ o.push_str(&format!(
646+ "<meta property=\"og:{}\" content=\"{}\">\n",
647+ html_escape(property),
648+ html_escape(content)
649+ ));
650+ } else {
651+ o.push_str(&format!(
652+ "<meta property=\"og:{}\" content=\"{}\">",
653+ html_escape(property),
654+ html_escape(content)
655+ ));
656+ }
657+ }
658+ o
659+ };
660+
661+ // Build head blocks string
662+ let head_html = if doc.head_blocks.is_empty() {
663+ String::new()
664+ } else {
665+ let mut h = String::new();
666+ for block in &doc.head_blocks {
667+ h.push_str(block);
668+ if dev {
669+ h.push('\n');
670+ }
671+ }
672+ h
673+ };
674+
675+ let lang_attr = match &doc.lang {
676+ Some(lang) => format!(" lang=\"{}\"", html_escape(lang)),
677+ None => String::new(),
678+ };
679+
680+ let favicon_html = match &doc.favicon {
681+ Some(path) => {
682+ // Try to read and inline the favicon
683+ if let Ok(data) = std::fs::read(path) {
684+ let mime = if path.ends_with(".ico") {
685+ "image/x-icon"
686+ } else if path.ends_with(".png") {
687+ "image/png"
688+ } else if path.ends_with(".svg") {
689+ "image/svg+xml"
690+ } else {
691+ "image/x-icon"
692+ };
693+ let b64 = base64_encode(&data);
694+ if dev {
695+ format!(
696+ "<link rel=\"icon\" href=\"data:{};base64,{}\">\n",
697+ mime, b64
698+ )
699+ } else {
700+ format!("<link rel=\"icon\" href=\"data:{};base64,{}\">", mime, b64)
701+ }
702+ } else {
703+ // Fall back to href
704+ if dev {
705+ format!("<link rel=\"icon\" href=\"{}\">\n", html_escape(path))
706+ } else {
707+ format!("<link rel=\"icon\" href=\"{}\">", html_escape(path))
708+ }
709+ }
710+ }
711+ None => String::new(),
712+ };
713+
714+ // Canonical URL
715+ let canonical_html = match &doc.canonical {
716+ Some(url) => {
717+ if dev {
718+ format!("<link rel=\"canonical\" href=\"{}\">\n", html_escape(url))
719+ } else {
720+ format!("<link rel=\"canonical\" href=\"{}\">", html_escape(url))
721+ }
722+ }
723+ None => String::new(),
724+ };
725+
726+ // Base URL
727+ let base_html = match &doc.base_url {
728+ Some(url) => {
729+ if dev {
730+ format!("<base href=\"{}\">\n", html_escape(url))
731+ } else {
732+ format!("<base href=\"{}\">", html_escape(url))
733+ }
734+ }
735+ None => String::new(),
736+ };
737+
738+ // Preload hints
921− let mut root_attrs = String::new();
739+ let mut preload_html = String::new();
922− for attr in html_attrs(&page.html_attrs) {
740+ // Explicit preload hints from the document
923− push_html_attr(&mut root_attrs, attr);
741+ for hint in &doc.preload_hints {
742+ if dev {
743+ preload_html.push_str(&format!(
744+ "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>\n",
745+ html_escape(&hint.href),
746+ hint.as_type,
747+ if hint.crossorigin { " crossorigin" } else { "" }
748+ ));
749+ } else {
750+ preload_html.push_str(&format!(
751+ "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>",
752+ html_escape(&hint.href),
753+ hint.as_type,
754+ if hint.crossorigin { " crossorigin" } else { "" }
755+ ));
756+ }
924 }
757 }
925− let mut body_attrs = String::new();
926− emit_class_attr(&mut body_attrs, body_class.as_deref(), None);
927
758
928 // Focus-visible CSS for interactive elements (accessibility)
759 // Focus-visible CSS for interactive elements (accessibility)
929 let focus_visible_css = if ctx.has_interactive {
760 let focus_visible_css = if ctx.has_interactive {
930− let focusable = ":where(a,button,input,select,textarea):focus-visible";
931 if dev {
761 if dev {
932− format!(
933− "{}{} {{ outline: 2px solid currentColor; outline-offset: 2px; }}\n",
762+ "a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n"
934− OWN_ELEMENTS, focusable
935− )
936 } else {
763 } else {
937− format!(
938− "{}{}{{outline:2px solid currentColor;outline-offset:2px}}",
764+ "a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}"
939− OWN_ELEMENTS, focusable
940− )
941 }
765 }
942 } else {
766 } else {
943− String::new()
767+ ""
944 };
768 };
945− let reset_css = reset_css(dev, true, &focus_visible_css);
946− classes.extend(styles.classes());
947
769
770+ // Skip-to-content CSS (visually hidden but accessible)
771+ let skip_link_css = if has_main {
772+ if dev {
773+ ".hl-skip { position: absolute; left: -9999px; top: auto; width: 1px; height: 1px; overflow: hidden; z-index: 9999; padding: 8px 16px; background: #000; color: #fff; text-decoration: none; font-size: 14px; }\n.hl-skip:focus { left: 8px; top: 8px; width: auto; height: auto; overflow: visible; }\n"
774+ } else {
775+ ".hl-skip{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;z-index:9999;padding:8px 16px;background:#000;color:#fff;text-decoration:none;font-size:14px}.hl-skip:focus{left:8px;top:8px;width:auto;height:auto;overflow:visible}"
776+ }
777+ } else {
778+ ""
779+ };
780+
781+ let reset_css = reset_css(dev, focus_visible_css, skip_link_css);
782+
783+ match &doc.page_title {
784+ Some(title) => {
785+ if dev {
948− format!(
786+ format!(
949− "<!DOCTYPE html>{nl}<html{root_attrs}>{nl}<head>{nl}<meta charset=\"utf-8\">{nl}\
787+ "\
950− {meta_html}<style>{nl}{reset_css}{element_css}</style>{nl}</head>{nl}\
788+<!DOCTYPE html>
951− <body{body_attrs}>{nl}{body}</body>{nl}</html>{nl}",
789+<html{lang_attr}>
952− )
790+<head>
791+<meta charset=\"utf-8\">
792+<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
793+<title>{title}</title>
794+{base_html}{canonical_html}{preload_html}{meta_html}{og_html}{favicon_html}{head_html}\
795+<style>
796+{reset_css}{element_css}\
797+</style>
798+</head>
799+<body>
800+{body}\
801+</body>
802+</html>
803+",
804+ title = html_escape(title),
805+ lang_attr = lang_attr,
806+ base_html = base_html,
807+ canonical_html = canonical_html,
808+ preload_html = preload_html,
809+ meta_html = meta_html,
810+ favicon_html = favicon_html,
811+ head_html = head_html,
812+ og_html = og_html,
813+ reset_css = reset_css,
814+ element_css = element_css,
815+ body = body,
816+ )
817+ } else {
818+ format!(
819+ "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{base_html}{canonical_html}{preload_html}{meta_html}{og_html}{favicon_html}{head_html}<style>{reset_css}{element_css}</style></head><body>{body}</body></html>",
820+ title = html_escape(title),
821+ lang_attr = lang_attr,
822+ base_html = base_html,
823+ canonical_html = canonical_html,
824+ preload_html = preload_html,
825+ meta_html = meta_html,
826+ og_html = og_html,
827+ favicon_html = favicon_html,
828+ head_html = head_html,
829+ reset_css = reset_css,
830+ element_css = element_css,
831+ body = body,
832+ )
833+ }
834+ }
835+ None => {
836+ if element_css.is_empty() {
837+ body
838+ } else if dev {
839+ format!("<style>\n{}</style>\n{}", element_css, body)
840+ } else {
841+ format!("<style>{}</style>{}", element_css, body)
842+ }
843+ }
844+ }
953}
845}
954
846
955/// Assemble every CSS block the document needs (font faces, custom
847/// Assemble every CSS block the document needs (font faces, custom
@@ -958,16 +850,21 @@ fn generate_full_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> S
958fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
850fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
959 let mut element_css = String::new();
851 let mut element_css = String::new();
960
852
961− // Collect the CSS custom properties declared with `@let --name` so they
853+ // Collect all CSS custom properties (explicit `@let --name` / `@theme`
854+ // tokens, plus any auto-extracted repeats) so they can be emitted in a
962− // can be emitted in a single `:root` block below.
855+ // single `:root` block below.
963 let mut root_vars: Vec<(String, String)> = Vec::new();
856 let mut root_vars: Vec<(String, String)> = Vec::new();
964 for (name, value) in &doc.css_vars {
857 for (name, value) in &doc.css_vars {
965 root_vars.push((name.clone(), value.clone()));
858 root_vars.push((name.clone(), value.clone()));
966 }
859 }
967
860
968− // Generated rules always go in htmlang's layers, so unlayered user CSS
861+ // Generated rules always go in `@layer htmlang`, so unlayered user CSS
969 // (`@style`, `@raw`) overrides them regardless of specificity.
862 // (`@style`, `@raw`) overrides them regardless of specificity.
970− let styles_css = styles.to_css_formatted(dev, &doc.scoped_css);
863+ let styles_css = styles.to_css_formatted(dev);
864+ // Fold literal values declared via `@theme` / `@let --name` back into
865+ // `var(--name)` references so the generated CSS actually uses the
866+ // custom properties emitted in `:root`.
867+ let styles_css = substitute_css_vars(&styles_css, &root_vars);
971
868
972 // Emit the :root block first so the cascade picks up the custom
869 // Emit the :root block first so the cascade picks up the custom
973 // properties before the class rules consume them.
870 // properties before the class rules consume them.
@@ -992,121 +889,71 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
992
889
993 element_css.push_str(&styles_css);
890 element_css.push_str(&styles_css);
994
891
892+ // @keyframes
893+ for (name, kf_body) in &doc.keyframes {
894+ if dev {
895+ element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
896+ } else {
897+ element_css.push_str(&format!("@keyframes {}{{{}}}", name, kf_body));
898+ }
899+ }
900+
901+ // Keyframes for the standard library's `$skeleton` bundle, when used
902+ if element_css.contains("hl-skeleton") {
903+ if dev {
904+ element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
905+ } else {
906+ element_css.push_str("@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}");
907+ }
908+ }
909+
995 // @style blocks (custom CSS)
910 // @style blocks (custom CSS)
996 for block in &doc.custom_css {
911 for block in &doc.custom_css {
997 if dev {
912 if dev {
998 element_css.push_str(block);
913 element_css.push_str(block);
999 element_css.push('\n');
914 element_css.push('\n');
1000 } else {
915 } else {
916+ let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1001− element_css.push_str(&compact_css(block));
917+ element_css.push_str(&minified);
1002 }
918 }
1003 }
919 }
1004
920
1005 element_css
921 element_css
1006}
922}
1007
923
1008−/// An `@style` body on one line: each line trimmed, and the lines joined with
1009−/// a space, so a value that goes on over two lines (`margin: 0` then `auto;`)
1010−/// keeps its words apart.
1011−fn compact_css(block: &str) -> String {
1012− block
1013− .lines()
1014− .map(str::trim)
1015− .filter(|l| !l.is_empty())
1016− .collect::<Vec<_>>()
1017− .join(" ")
1018−}
1019−
1020−/// htmlang's own elements: those with a generated class, or the class of a
1021−/// function's scoped `@style`. The reset's rules for elements are kept to
1022−/// them, so `@raw` and `@markdown` content, and the page a fragment is put
1023−/// into, keep their own.
1024−const OWN_ELEMENTS: &str = ":where([class^=\"hl-\"],[class*=\" hl-\"])";
1025−
1026−/// htmlang's cascade layers, from the one that loses to the one that wins:
1027−/// the reset; the defaults of each element's kind (a heading's `margin:0`,
1028−/// a column's `display:flex`); the styles an element gets from an
1029−/// element prefix on an element around it (`@td:padding 8`) and from a
1030−/// function's scoped `@style`; and the element's own styles. So a
1031−/// scope's styles win over the defaults, and what an element says about
1032−/// itself wins over both. CSS outside any layer (`@style`, `@raw`) wins
1033−/// over all of them.
1034−const LAYERS: [&str; 4] = ["hl-reset", "hl-kind", "hl-inside", "htmlang"];
1035−
1036/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
924/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1037−/// every layer, so an unlayered rule here would override the generated
925+/// every layer, so an unlayered `a{color:inherit}` would override
1038−/// ones. A page resets `box-sizing` everywhere and makes `<body>` a column
926+/// `@link [color red]`.
1039−/// that fills the window; a fragment, which goes into a page it doesn't
1040−/// own, touches only htmlang's own elements. In both, an element htmlang
1041−/// lays out stays hidden while it is `hidden`, a closed `<dialog>` or a
1042−/// popover that isn't showing: its generated `display` would otherwise
1043−/// beat the browser's `display: none`.
1044−fn reset_css(dev: bool, page: bool, focus_visible_css: &str) -> String {
927+fn reset_css(dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
1045− let own = OWN_ELEMENTS;
928+ let base = if dev {
1046− let mut rules = Vec::new();
929+ "*, *::before, *::after { box-sizing: border-box; }\nbody { margin: 0; font-family: system-ui, -apple-system, sans-serif; }\nimg { display: block; }\na { text-decoration: none; color: inherit; }\n"
1047− if page {
1048− rules.push(if dev {
1049− "*, *::before, *::after { box-sizing: border-box; }".to_string()
1050− } else {
1051− "*,*::before,*::after{box-sizing:border-box}".to_string()
1052− });
1053− rules.push(if dev {
1054− "body { margin: 0; font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; min-height: 100dvh; }".to_string()
1055− } else {
1056− "body{margin:0;font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;min-height:100dvh}".to_string()
1057− });
1058− } else {
1059− rules.push(if dev {
1060− format!("{own}, {own}::before, {own}::after {{ box-sizing: border-box; }}")
1061− } else {
1062− format!("{own},{own}::before,{own}::after{{box-sizing:border-box}}")
1063− });
1064− }
1065− let hidden = "[hidden]:not([hidden=\"until-found\"]),dialog:not([open]):not(:popover-open),\
1066− [popover]:not(:popover-open):not(dialog[open])";
1067− rules.push(if dev {
1068− format!("{own}:where({hidden}) {{ display: none !important; }}")
1069 } else {
930 } else {
1070− format!("{own}:where({hidden}){{display:none!important}}")
931+ "*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}"
1071− });
932+ };
1072− let mut rules = rules.join(if dev { "\n" } else { "" });
1073− if dev {
1074− rules.push('\n');
1075− }
1076− rules.push_str(focus_visible_css);
933+ let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
1077 if dev {
934 if dev {
1078− format!(
1079− "@layer {};\n@layer hl-reset {{\n{}}}\n",
935+ format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
1080− LAYERS.join(", "),
1081− rules
1082− )
1083 } else {
936 } else {
1084− format!("@layer {};@layer hl-reset{{{}}}", LAYERS.join(","), rules)
937+ format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
1085 }
938 }
1086}
939}
1087
940
1088/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
941/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1089−fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
942+fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1090 let mut styles = StyleCollector::new();
943 let mut styles = StyleCollector::new();
1091 let mut ctx = GenContext {
944 let mut ctx = GenContext {
1092 dev,
945 dev,
1093 depth: 0,
946 depth: 0,
947+ image_count: 0,
1094 has_interactive: false,
948 has_interactive: false,
1095− in_text: false,
1096− flow: Flow::default(),
1097− in_picture: false,
1098 };
949 };
1099 let mut body = String::new();
950 let mut body = String::new();
1100
951
1101− generate_children(&doc.nodes, None, &mut body, &mut styles, &mut ctx);
952+ for node in &doc.nodes {
1102−
953+ generate_node(node, None, &mut body, &mut styles, &mut ctx);
1103− let mut element_css = build_element_css(doc, &styles, dev);
1104− // htmlang's own elements get the reset's rules for elements (which
1105− // also puts htmlang's layers in order)
1106− if !styles.entries.is_empty() || !doc.scoped_css.is_empty() {
1107− element_css.insert_str(0, &reset_css(dev, false, ""));
1108 }
954 }
1109− classes.extend(styles.classes());
955+
956+ let element_css = build_element_css(doc, &styles, dev);
1110
957
1111 if element_css.is_empty() {
958 if element_css.is_empty() {
1112 body
959 body
@@ -1121,20 +968,22 @@ fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -
1121// Node generation
968// Node generation
1122// ---------------------------------------------------------------------------
969// ---------------------------------------------------------------------------
1123
970
1124−/// Write `node`, which is inside an element with the layout `parent`
1125−/// (`None` at the top of the page).
1126fn generate_node(
971fn generate_node(
1127 node: &Node,
972 node: &Node,
1128− parent: Option<Layout>,
973+ parent_kind: Option<&ElementKind>,
1129 out: &mut String,
974 out: &mut String,
1130 styles: &mut StyleCollector,
975 styles: &mut StyleCollector,
1131 ctx: &mut GenContext,
976 ctx: &mut GenContext,
1132) {
977) {
1133 match node {
978 match node {
1134− Node::Element(elem) => generate_element(elem, parent, out, styles, ctx),
979+ Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
1135 Node::Text(segments) => {
980 Node::Text(segments) => {
1136− // In a row, column or grid each line of text is a child
981+ let needs_wrap = matches!(
1137− let needs_wrap = parent.is_some_and(Layout::is_container);
982+ parent_kind,
983+ Some(ElementKind::Row | ElementKind::Column | ElementKind::El)
984+ ) || parent_kind
985+ .and_then(ElementKind::spec)
986+ .is_some_and(|spec| spec.wraps_text);
1138 if needs_wrap {
987 if needs_wrap {
1139 out.push_str(&ctx.indent());
988 out.push_str(&ctx.indent());
1140 out.push_str("<span>");
989 out.push_str("<span>");
@@ -1153,156 +1002,73 @@ fn generate_node(
1153 }
1002 }
1154}
1003}
1155
1004
1156−/// What goes between two things written one after the other in an element
1005+
1157−/// with this layout (`None`: the top of the page): text flows, so its lines
1006+/// Emit the argument of an element whose argument is an HTML attribute
1158−/// and children are joined with a space; in HTML's own layout, and at the
1007+/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`).
1159−/// top of the page, two lines of text are separated by a line break (a
1008+fn emit_argument_attr(out: &mut String, elem: &Element) {
1160−/// space, except where whitespace is kept, as in `@pre`).
1009+ if let Some(TagSpec {
1161−fn separator(layout: Option<Layout>, previous_is_text: bool, next: &Node) -> Option<char> {
1010+ arg: TagArg::Attr(attr),
1162− match layout {
1011+ ..
1163− Some(Layout::Text) => Some(' '),
1012+ }) = elem.kind.spec()
1164− Some(Layout::Native) | None if previous_is_text && matches!(next, Node::Text(_)) => {
1013+ && let Some(value) = &elem.argument
1165− Some('\n')
1014+ {
1166− }
1015+ out.push(' ');
1167− _ => None,
1016+ out.push_str(attr);
1017+ out.push_str("=\"");
1018+ out.push_str(&html_escape(value));
1019+ out.push('"');
1168 }
1020 }
1169}
1021}
1170
1022
1171−/// Write the children of an element (or of a `@fragment`, which has no
1172−/// element of its own) laid out as `layout` (`None`: the top of the page).
1173−/// The first line of an element's text is its first child.
1174−fn generate_children(
1175− children: &[Node],
1176− layout: Option<Layout>,
1177− out: &mut String,
1178− styles: &mut StyleCollector,
1179− ctx: &mut GenContext,
1180−) {
1181− generate_run(children, layout, &mut None, out, styles, ctx);
1182−}
1183−
1184−/// [`generate_children`], with `previous` saying whether what was written
1185−/// last was a line of text (`None`: nothing yet). A `@fragment`'s children
1186−/// are written as if they stood in its place, so its lines are separated
1187−/// from the lines around it like any other lines.
1188−fn generate_run(
1189− children: &[Node],
1190− layout: Option<Layout>,
1191− previous: &mut Option<bool>,
1192− out: &mut String,
1193− styles: &mut StyleCollector,
1194− ctx: &mut GenContext,
1195−) {
1196− for child in children {
1197− if let Node::Element(elem) = child
1198− && elem.kind == ElementKind::Fragment
1199− {
1200− generate_run(&elem.children, layout, previous, out, styles, ctx);
1201− continue;
1202− }
1203− let start = out.len();
1204− if let Some(previous_is_text) = *previous
1205− && let Some(sep) = separator(layout, previous_is_text, child)
1206− {
1207− out.push(sep);
1208− }
1209− let body = out.len();
1210− generate_node(child, layout, out, styles, ctx);
1211− // A child that writes nothing (an empty `@fragment`) takes no
1212− // separator either, so text never gets two spaces in a row
1213− if out.len() == body {
1214− out.truncate(start);
1215− continue;
1216− }
1217− *previous = Some(matches!(child, Node::Text(_)));
1218− }
1219−}
1220−
1221−/// Emit the element's leading argument as its attribute (`@iframe URL` →
1222−/// `src="URL"`, `@form /submit` → `action="/submit"`, `@link /a` →
1223−/// `href="/a"`), first, before the class. `@image [inline] photo.png` puts
1224−/// the file itself into the page.
1225−fn emit_argument_attr(out: &mut String, elem: &Element, in_picture: bool) {
1226− let (Some(attr), Some(value)) = (elem.kind.leading_attribute(in_picture), &elem.argument)
1227− else {
1228− return;
1229− };
1230− // `@image [inline]`'s file is already a `data:` URI (see the parser)
1231− let value = html_escape(value);
1232− out.push(' ');
1233− out.push_str(attr);
1234− out.push_str("=\"");
1235− out.push_str(&value);
1236− out.push('"');
1237−}
1238−
1239−/// A file's bytes as a `data:` URI, its type from its extension.
1240−pub(crate) fn data_uri(path: &str, data: &[u8]) -> String {
1241− let mime = match path
1242− .rsplit('.')
1243− .next()
1244− .map(str::to_ascii_lowercase)
1245− .as_deref()
1246− {
1247− Some("png") => "image/png",
1248− Some("jpg" | "jpeg") => "image/jpeg",
1249− Some("gif") => "image/gif",
1250− Some("webp") => "image/webp",
1251− Some("avif") => "image/avif",
1252− Some("svg") => "image/svg+xml",
1253− Some("ico") => "image/x-icon",
1254− _ => "application/octet-stream",
1255− };
1256− format!("data:{};base64,{}", mime, base64_encode(data))
1257−}
1258−
1259/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1023/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1260−/// are emitted with the generated class) and flags like `required`.
1024+/// are emitted with the generated class) and bare booleans like `required`.
1261fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1025fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1262− for attr in html_attrs(attrs) {
1026+ for attr in attrs {
1263 let key = attr.key.as_str();
1027 let key = attr.key.as_str();
1264− if key != "id" && key != "class" {
1028+ if attr.html && key != "id" && key != "class" {
1265− push_html_attr(out, attr);
1029+ out.push(' ');
1030+ out.push_str(key);
1031+ out.push_str("=\"");
1032+ out.push_str(&html_escape(strip_string_quotes(
1033+ attr.value.as_deref().unwrap_or(""),
1034+ )));
1035+ out.push('"');
1036+ } else if !attr.html
1037+ && attr.value.is_none()
1038+ && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&key)
1039+ {
1040+ out.push(' ');
1041+ out.push_str(key);
1266 }
1042 }
1267 }
1043 }
1268}
1044}
1269
1045
1270−/// The HTML attributes of `attrs`, `key=value` ones and flags, each name
1046+/// If the value is wrapped in a single pair of matching quotes (e.g.
1271−/// once: the later one wins, as a style does. (Of two attributes with one
1047+/// `"Avatar"`), return the inner content. Quotes act as source-level
1272−/// name, the browser keeps the first, so a bundle's attribute or a
1048+/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute
1273−/// function's own would win over one written after it.)
1049+/// values. Multi-quoted values like `"h h" "s m"` are left unchanged —
1274−fn html_attrs(attrs: &[Attribute]) -> impl Iterator<Item = &Attribute> {
1050+/// those are real string tokens (used e.g. by CSS `grid-template-areas`).
1275− let is_html =
1051+fn strip_string_quotes(val: &str) -> &str {
1276− |a: &Attribute| a.html || (a.value.is_none() && crate::vocab::is_html_flag(&a.key));
1052+ let bytes = val.as_bytes();
1277− attrs.iter().enumerate().filter_map(move |(i, attr)| {
1053+ if bytes.len() < 2 {
1278− let later = attrs[i + 1..]
1054+ return val;
1279− .iter()
1055+ }
1280− .any(|other| is_html(other) && other.key == attr.key);
1056+ let first = bytes[0];
1281− (is_html(attr) && !later).then_some(attr)
1057+ let last = bytes[bytes.len() - 1];
1282− })
1058+ if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1283−}
1059+ let inner = &val[1..val.len() - 1];
1284−
1060+ if !inner.as_bytes().contains(&first) {
1285−/// ` key="value"`, or ` key` for a flag.
1061+ return inner;
1286−fn push_html_attr(out: &mut String, attr: &Attribute) {
1062+ }
1287− out.push(' ');
1288− out.push_str(&attr.key);
1289− if attr.html {
1290− out.push_str("=\"");
1291− // Quoted text has already lost its quotes (see parser.rs)
1292− out.push_str(&html_escape(attr.value.as_deref().unwrap_or("")));
1293− out.push('"');
1294 }
1063 }
1064+ val
1295}
1065}
1296
1066
1297// True if any direct child is `@in-front` or `@behind`. Such children render
1067// True if any direct child is `@in-front` or `@behind`. Such children render
1298// as absolutely positioned overlays, so the parent automatically becomes a
1068// as absolutely positioned overlays, so the parent automatically becomes a
1299// positioning context (position:relative + isolation:isolate).
1069// positioning context (position:relative + isolation:isolate).
1300fn has_overlay_children(elem: &Element) -> bool {
1070fn has_overlay_children(elem: &Element) -> bool {
1301− holds_overlays(&elem.children)
1302−}
1303−
1304−fn holds_overlays(children: &[Node]) -> bool {
1305− children.iter().any(|child| {
1071+ elem.children.iter().any(|child| {
1306 matches!(
1072 matches!(
1307 child,
1073 child,
1308 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
1074 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
@@ -1312,32 +1078,48 @@ fn holds_overlays(children: &[Node]) -> bool {
1312
1078
1313fn generate_element(
1079fn generate_element(
1314 elem: &Element,
1080 elem: &Element,
1315− parent: Option<Layout>,
1081+ parent_kind: Option<&ElementKind>,
1316 out: &mut String,
1082 out: &mut String,
1317 styles: &mut StyleCollector,
1083 styles: &mut StyleCollector,
1318 ctx: &mut GenContext,
1084 ctx: &mut GenContext,
1319) {
1085) {
1320 // Self-closing elements
1086 // Self-closing elements
1321− if elem.kind.layout() == Layout::Void {
1087+ if elem.kind == ElementKind::Image || elem.kind.spec().is_some_and(|spec| spec.void) {
1322− generate_self_closing(elem, parent, out, styles, ctx);
1088+ generate_self_closing(elem, parent_kind, out, styles, ctx);
1323 return;
1089 return;
1324 }
1090 }
1325− // A verbatim element (@script) writes its body as it is, with no HTML
1091+ // @script renders as <script> with raw body content (no HTML escaping)
1326− // escaping; it isn't shown, so it has no class (its styles are an error)
1327− if elem.kind.is_verbatim() {
1092+ if elem.kind == ElementKind::Script {
1328− let tag = elem.kind.spec().map_or("script", |spec| spec.html);
1329 out.push_str(&ctx.indent());
1093 out.push_str(&ctx.indent());
1330− out.push('<');
1094+ out.push_str("<script");
1331− out.push_str(tag);
1095+ // Pass through src, type, defer, async, etc.
1332− emit_argument_attr(out, elem, false);
1096+ for attr in &elem.attrs {
1333− let (id, class) = extract_id_class(&elem.attrs);
1097+ let key = attr.key.as_str();
1334− for (key, value) in [("id", id), ("class", class)] {
1098+ if matches!(
1335− if let Some(value) = value {
1099+ key,
1336− out.push_str(&format!(" {}=\"{}\"", key, html_escape(&value)));
1100+ "src"
1101+ | "type"
1102+ | "defer"
1103+ | "async"
1104+ | "crossorigin"
1105+ | "integrity"
1106+ | "nomodule"
1107+ | "id"
1108+ ) {
1109+ if let Some(val) = &attr.value {
1110+ out.push(' ');
1111+ out.push_str(key);
1112+ out.push_str("=\"");
1113+ out.push_str(&html_escape(val));
1114+ out.push('"');
1115+ } else {
1116+ out.push(' ');
1117+ out.push_str(key);
1118+ }
1337 }
1119 }
1338 }
1120 }
1339− emit_html_attrs(out, &elem.attrs);
1340 out.push('>');
1121 out.push('>');
1122+ // Children are raw JS code, not HTML
1341 for child in &elem.children {
1123 for child in &elem.children {
1342 match child {
1124 match child {
1343 Node::Text(segments) => {
1125 Node::Text(segments) => {
@@ -1351,9 +1133,7 @@ fn generate_element(
1351 _ => {}
1133 _ => {}
1352 }
1134 }
1353 }
1135 }
1354− out.push_str("</");
1136+ out.push_str("</script>");
1355− out.push_str(tag);
1356− out.push('>');
1357 out.push_str(ctx.nl());
1137 out.push_str(ctx.nl());
1358 return;
1138 return;
1359 }
1139 }
@@ -1364,26 +1144,26 @@ fn generate_element(
1364 return;
1144 return;
1365 }
1145 }
1366 if elem.kind == ElementKind::Fragment {
1146 if elem.kind == ElementKind::Fragment {
1367− // Render children without a wrapper element, as if they were
1147+ // Render children without a wrapper element
1368− // written where the fragment is
1148+ for child in &elem.children {
1369− generate_children(&elem.children, parent, out, styles, ctx);
1149+ generate_node(child, parent_kind, out, styles, ctx);
1150+ }
1370 return;
1151 return;
1371 }
1152 }
1372
1153
1373− let mut own = Flow::of(elem);
1374 let tag = match &elem.kind {
1154 let tag = match &elem.kind {
1375− ElementKind::Row | ElementKind::El => "div",
1155+ ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
1376 ElementKind::Text => "span",
1156 ElementKind::Text => "span",
1377 ElementKind::Paragraph => "p",
1157 ElementKind::Paragraph => "p",
1378 ElementKind::Link => "a",
1158 ElementKind::Link => "a",
1159+ ElementKind::Tag(spec)
1160+ if spec.name == "list" && elem.attrs.iter().any(|a| a.key == "ordered") =>
1161+ {
1162+ "ol"
1163+ }
1379 ElementKind::Tag(spec) => spec.html,
1164 ElementKind::Tag(spec) => spec.html,
1380 _ => "",
1165 _ => "",
1381 };
1166 };
1382− // Inside text, htmlang's own `<div>` is a `<span>`, which text can
1383− // hold (directly in text, a row, column or grid is also laid out
1384− // inline: see `Site::inline`)
1385− let in_text = ctx.in_text || parent == Some(Layout::Text);
1386− let tag = if in_text && tag == "div" { "span" } else { tag };
1387 let kind_label = elem.kind.name();
1167 let kind_label = elem.kind.name();
1388
1168
1389 // Track interactive elements for focus-visible CSS
1169 // Track interactive elements for focus-visible CSS
@@ -1394,8 +1174,14 @@ fn generate_element(
1394 }
1174 }
1395
1175
1396 // Compute CSS for each state and get a class name
1176 // Compute CSS for each state and get a class name
1397− let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1177+ let overlay_children = has_overlay_children(elem);
1398− own.class = gen_class.clone();
1178+ let gen_class = compute_class(
1179+ &elem.attrs,
1180+ &elem.kind,
1181+ parent_kind,
1182+ styles,
1183+ overlay_children,
1184+ );
1399 let (id, user_class) = extract_id_class(&elem.attrs);
1185 let (id, user_class) = extract_id_class(&elem.attrs);
1400
1186
1401 if ctx.dev && elem.line_num > 0 {
1187 if ctx.dev && elem.line_num > 0 {
@@ -1409,7 +1195,32 @@ fn generate_element(
1409 out.push('<');
1195 out.push('<');
1410 out.push_str(tag);
1196 out.push_str(tag);
1411
1197
1198+ // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1199+ if elem.kind.is_tag("main") && id.is_none() {
1200+ out.push_str(" id=\"hl-main\"");
1201+ }
1202+
1203+ if elem.kind == ElementKind::Link
1204+ && let Some(url) = &elem.argument
1205+ {
1206+ out.push_str(" href=\"");
1207+ out.push_str(&html_escape(url));
1208+ out.push('"');
1209+ // Auto rel="noopener noreferrer" and target="_blank" for external links
1210+ let is_external = url.starts_with("http://") || url.starts_with("https://");
1211+ if is_external {
1212+ let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1213+ let has_target = elem.attrs.iter().any(|a| a.key == "target");
1214+ if !has_rel {
1215+ out.push_str(" rel=\"noopener noreferrer\"");
1216+ }
1217+ if !has_target {
1218+ out.push_str(" target=\"_blank\"");
1219+ }
1220+ }
1221+ }
1222+
1412− emit_argument_attr(out, elem, ctx.in_picture);
1223+ emit_argument_attr(out, elem);
1413
1224
1414 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1225 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1415
1226
@@ -1430,27 +1241,28 @@ fn generate_element(
1430 }
1241 }
1431
1242
1432 out.push('>');
1243 out.push('>');
1433− // In `<pre>` and `<textarea>` every space and line break shows, so
1434− // readable (dev) output adds none there: a code sample's lines stay
1435− // exactly as written
1436− let outer_dev = ctx.dev;
1437− ctx.dev &= !matches!(tag, "pre" | "textarea");
1438 out.push_str(ctx.nl());
1244 out.push_str(ctx.nl());
1439
1245
1440− let layout = elem.kind.layout();
1246+ // Inline text argument
1247+ if renders_argument_as_text(&elem.kind)
1248+ && let Some(text) = &elem.argument
1249+ {
1250+ out.push_str(&html_escape(text));
1251+ }
1252+
1253+ // Children
1441 ctx.depth += 1;
1254 ctx.depth += 1;
1442− let outer_in_text = ctx.in_text;
1255+ let is_paragraph = elem.kind == ElementKind::Paragraph
1443− ctx.in_text = in_text || layout == Layout::Text;
1256+ || elem.kind.spec().is_some_and(|spec| spec.inline);
1444− let outer_flow = std::mem::replace(&mut ctx.flow, own);
1257+ for (i, child) in elem.children.iter().enumerate() {
1445− let outer_picture = std::mem::replace(&mut ctx.in_picture, elem.kind.is_tag("picture"));
1258+ generate_node(child, Some(&elem.kind), out, styles, ctx);
1446− generate_children(&elem.children, Some(layout), out, styles, ctx);
1259+ if is_paragraph && i < elem.children.len() - 1 {
1447− ctx.in_picture = outer_picture;
1260+ out.push(' ');
1448− ctx.flow = outer_flow;
1261+ }
1449− ctx.in_text = outer_in_text;
1262+ }
1450 ctx.depth -= 1;
1263 ctx.depth -= 1;
1451
1264
1452 out.push_str(&ctx.indent());
1265 out.push_str(&ctx.indent());
1453− ctx.dev = outer_dev;
1454 out.push_str("</");
1266 out.push_str("</");
1455 out.push_str(tag);
1267 out.push_str(tag);
1456 out.push('>');
1268 out.push('>');
@@ -1459,12 +1271,12 @@ fn generate_element(
1459
1271
1460fn generate_self_closing(
1272fn generate_self_closing(
1461 elem: &Element,
1273 elem: &Element,
1462− parent: Option<Layout>,
1274+ parent_kind: Option<&ElementKind>,
1463 out: &mut String,
1275 out: &mut String,
1464 styles: &mut StyleCollector,
1276 styles: &mut StyleCollector,
1465 ctx: &mut GenContext,
1277 ctx: &mut GenContext,
1466) {
1278) {
1467− let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1279+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1468 let (id, user_class) = extract_id_class(&elem.attrs);
1280 let (id, user_class) = extract_id_class(&elem.attrs);
1469
1281
1470 let (tag, kind_label) = match &elem.kind {
1282 let (tag, kind_label) = match &elem.kind {
@@ -1483,7 +1295,44 @@ fn generate_self_closing(
1483 out.push_str(&ctx.indent());
1295 out.push_str(&ctx.indent());
1484 out.push('<');
1296 out.push('<');
1485 out.push_str(tag);
1297 out.push_str(tag);
1486− emit_argument_attr(out, elem, ctx.in_picture);
1298+ emit_argument_attr(out, elem);
1299+
1300+ // Image src (with optional non-SVG base64 inlining)
1301+ if elem.kind == ElementKind::Image {
1302+ let src = elem.argument.as_deref().unwrap_or("");
1303+ let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
1304+ if is_inline && !src.is_empty() && !src.ends_with(".svg") {
1305+ let mime = if src.ends_with(".png") {
1306+ "image/png"
1307+ } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
1308+ "image/jpeg"
1309+ } else if src.ends_with(".gif") {
1310+ "image/gif"
1311+ } else if src.ends_with(".webp") {
1312+ "image/webp"
1313+ } else if src.ends_with(".avif") {
1314+ "image/avif"
1315+ } else {
1316+ "application/octet-stream"
1317+ };
1318+ if let Ok(data) = std::fs::read(src) {
1319+ let b64 = base64_encode(&data);
1320+ out.push_str(" src=\"data:");
1321+ out.push_str(mime);
1322+ out.push_str(";base64,");
1323+ out.push_str(&b64);
1324+ out.push('"');
1325+ } else {
1326+ out.push_str(" src=\"");
1327+ out.push_str(&html_escape(src));
1328+ out.push('"');
1329+ }
1330+ } else {
1331+ out.push_str(" src=\"");
1332+ out.push_str(&html_escape(src));
1333+ out.push('"');
1334+ }
1335+ }
1487
1336
1488 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1337 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1489
1338
@@ -1503,6 +1352,80 @@ fn generate_self_closing(
1503 ));
1352 ));
1504 }
1353 }
1505
1354
1355+ // Image optimization: auto-add loading="lazy" and decoding="async"
1356+ if elem.kind == ElementKind::Image {
1357+ // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
1358+ let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
1359+ if let Some(resp) = responsive_attr
1360+ && let Some(ref sizes_str) = resp.value
1361+ {
1362+ let widths: Vec<&str> = sizes_str.split_whitespace().collect();
1363+ if !widths.is_empty() {
1364+ let src = elem.argument.as_deref().unwrap_or("");
1365+ if !src.is_empty() {
1366+ // Generate srcset with width descriptors
1367+ // Convention: file-{width}.ext (e.g., photo-400.jpg)
1368+ let dot_pos = src.rfind('.').unwrap_or(src.len());
1369+ let base = &src[..dot_pos];
1370+ let ext = &src[dot_pos..];
1371+ let mut srcset_parts = Vec::new();
1372+ for w in &widths {
1373+ srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
1374+ }
1375+ out.push_str(" srcset=\"");
1376+ out.push_str(&srcset_parts.join(", "));
1377+ out.push('"');
1378+ // Generate sizes attribute
1379+ let max_width = widths.last().unwrap_or(&"100vw");
1380+ out.push_str(&format!(
1381+ " sizes=\"(max-width: {}px) 100vw, {}px\"",
1382+ max_width, max_width
1383+ ));
1384+ }
1385+ }
1386+ }
1387+
1388+ // Auto image dimensions: read local image file to inject width/height + aspect-ratio
1389+ let has_width = elem.attrs.iter().any(|a| a.key == "width");
1390+ let has_height = elem.attrs.iter().any(|a| a.key == "height");
1391+ if (!has_width || !has_height)
1392+ && let Some(ref src) = elem.argument
1393+ && !src.starts_with("http://")
1394+ && !src.starts_with("https://")
1395+ && !src.starts_with("data:")
1396+ && let Some((w, h)) = read_image_dimensions(src)
1397+ {
1398+ // Intrinsic size attributes only when neither dimension is set in
1399+ // CSS; with one CSS dimension, a leftover intrinsic attribute for
1400+ // the other would distort the image (200 wide but 1000 tall).
1401+ if !has_width && !has_height {
1402+ out.push_str(&format!(" width=\"{}\" height=\"{}\"", w, h));
1403+ }
1404+ // Auto aspect-ratio to prevent CLS
1405+ if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
1406+ out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
1407+ }
1408+ }
1409+
1410+ // Smart image loading: first 3 images get fetchpriority="high" (above the fold),
1411+ // subsequent images get loading="lazy" + decoding="async"
1412+ ctx.image_count += 1;
1413+ if ctx.image_count <= 3 {
1414+ // Above-the-fold: eager loading with high priority
1415+ if !elem.attrs.iter().any(|a| a.key == "fetchpriority") {
1416+ out.push_str(" fetchpriority=\"high\"");
1417+ }
1418+ } else {
1419+ // Below-the-fold: lazy loading
1420+ if !elem.attrs.iter().any(|a| a.key == "loading") {
1421+ out.push_str(" loading=\"lazy\"");
1422+ }
1423+ if !elem.attrs.iter().any(|a| a.key == "decoding") {
1424+ out.push_str(" decoding=\"async\"");
1425+ }
1426+ }
1427+ }
1428+
1506 out.push('>');
1429 out.push('>');
1507 out.push_str(ctx.nl());
1430 out.push_str(ctx.nl());
1508}
1431}
@@ -1518,10 +1441,7 @@ fn generate_text_segments(
1518 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1441 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1519 TextSegment::Inline(elem) => {
1442 TextSegment::Inline(elem) => {
1520 let mut buf = String::new();
1443 let mut buf = String::new();
1521− // An element inside a line of text is in text
1522− let outer_flow = std::mem::take(&mut ctx.flow);
1523− generate_element(elem, Some(Layout::Text), &mut buf, styles, ctx);
1444+ generate_element(elem, None, &mut buf, styles, ctx);
1524− ctx.flow = outer_flow;
1525 out.push_str(buf.trim_end());
1445 out.push_str(buf.trim_end());
1526 }
1446 }
1527 }
1447 }
@@ -1534,97 +1454,135 @@ fn generate_text_segments(
1534
1454
1535fn compute_class(
1455fn compute_class(
1536 attrs: &[Attribute],
1456 attrs: &[Attribute],
1537− site: &Site,
1457+ kind: &ElementKind,
1458+ parent_kind: Option<&ElementKind>,
1538 styles: &mut StyleCollector,
1459 styles: &mut StyleCollector,
1539− distinct: String,
1460+ has_overlay_children: bool,
1540) -> Option<String> {
1461) -> Option<String> {
1541− // Every condition an attribute is under, and none
1462+ let base = attrs_to_css(attrs, "", kind, parent_kind, has_overlay_children);
1542− let mut conditions = vec![Condition::default()];
1463+
1543− for attr in attrs.iter().filter(|a| !a.html) {
1464+ // Collect pseudo-state overrides
1544− let (condition, _) = Condition::of(&attr.key);
1465+ let mut pseudo = Vec::new();
1545− if !conditions.contains(&condition) {
1466+ for &(prefix, selector) in crate::vocab::PSEUDO_PREFIXES {
1546− conditions.push(condition);
1467+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1468+ if !css.is_empty() {
1469+ pseudo.push((selector.to_string(), css));
1470+ }
1471+ }
1472+
1473+ // Collect nth:EXPR: dynamic pseudo selectors
1474+ let mut nth_prefixes: Vec<String> = Vec::new();
1475+ for attr in attrs {
1476+ if attr.key.starts_with("nth:") {
1477+ let rest = &attr.key[4..];
1478+ if let Some(colon_pos) = rest.find(':') {
1479+ let prefix = format!("nth:{}:", &rest[..colon_pos]);
1480+ if !nth_prefixes.contains(&prefix) {
1481+ nth_prefixes.push(prefix);
1482+ }
1483+ }
1484+ }
1485+ }
1486+ for prefix in &nth_prefixes {
1487+ let expr = &prefix[4..prefix.len() - 1];
1488+ let selector = format!(":nth-child({})", expr);
1489+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1490+ if !css.is_empty() {
1491+ pseudo.push((selector, css));
1547 }
1492 }
1548 }
1493 }
1549− // Dedupe: if a property is declared twice within a single rule, keep only
1550− // the last occurrence.
1551− let rules: Vec<(Condition, String)> = conditions
1552− .into_iter()
1553− .map(|condition| {
1554− let css = dedupe_declarations(&attrs_to_css(attrs, &condition, site));
1555− (condition, css)
1556− })
1557− .collect();
1558− // The kind's defaults, less what the element sets itself (a user
1559− // `[list-style disc]` wins over the default `list-style:none` anyway,
1560− // so there is no need to ship both)
1561− let own = rules
1562− .iter()
1563− .find(|(condition, _)| *condition == Condition::default())
1564− .map_or("", |(_, css)| css.as_str());
1565− let own: Vec<&str> = declarations(own).map(|(name, _)| name).collect();
1566− let defaults: String = declarations(&dedupe_declarations(&default_css(site)))
1567− .filter(|(name, _)| !own.contains(name))
1568− .map(|(_, declaration)| declaration)
1569− .collect();
1570− styles.get_class(defaults, rules, distinct)
1571−}
1572
1494
1573−/// The defaults of an element's kind where it is: an overlay's positioning
1495+ // Collect has(...): dynamic pseudo selectors
1574−/// context, its layout, and its kind's own CSS (a heading's `margin:0`).
1496+ let mut has_prefixes: Vec<String> = Vec::new();
1575−fn default_css(site: &Site) -> String {
1497+ for attr in attrs {
1576− let mut css = String::new();
1498+ if attr.key.starts_with("has(")
1577− // Elements with @in-front / @behind children become positioning
1499+ && let Some(close) = attr.key.find("):")
1578− // contexts
1500+ {
1579− if site.has_overlay_children {
1501+ let prefix = format!("{}:", &attr.key[..close + 1]);
1580− css.push_str("position:relative;isolation:isolate;");
1502+ if !has_prefixes.contains(&prefix) {
1503+ has_prefixes.push(prefix);
1504+ }
1505+ }
1581 }
1506 }
1582− if !site.root {
1507+ for prefix in &has_prefixes {
1583− css.push_str(layout_css(site.kind.layout(), site.inline));
1508+ let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
1509+ let selector = format!(":has({})", inner);
1510+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1511+ if !css.is_empty() {
1512+ pseudo.push((selector, css));
1513+ }
1584 }
1514 }
1585− css.push_str(site.kind.css());
1586− css
1587−}
1588
1515
1589−/// The declarations of a rule body, as (property, `property:value;`), for
1516+ // Collect responsive overrides
1590−/// bodies htmlang wrote: every declaration ends in `;`, and a `;` inside
1517+ let mut responsive = Vec::new();
1591−/// quotes or parentheses doesn't end one.
1518+ for &(bp_name, _) in BREAKPOINTS {
1592−fn declarations(css: &str) -> impl Iterator<Item = (&str, &str)> {
1519+ let prefix = format!("{}:", bp_name);
1593− let mut rest = css;
1520+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1594− std::iter::from_fn(move || {
1595− if rest.is_empty() {
1521+ if !css.is_empty() {
1596− return None;
1522+ responsive.push((bp_name.to_string(), css));
1597 }
1523 }
1598− let (mut depth, mut quote, mut escaped) = (0i32, None, false);
1524+ }
1599− let mut end = rest.len();
1525+
1600− for (i, c) in rest.char_indices() {
1526+ // Collect container query overrides
1601− match c {
1527+ let mut container = Vec::new();
1602− _ if escaped => escaped = false,
1528+ for &(bp_name, _) in BREAKPOINTS {
1603− '\\' => escaped = true,
1529+ let prefix = format!("cq-{}:", bp_name);
1604− c if Some(c) == quote => quote = None,
1530+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1605− _ if quote.is_some() => {}
1531+ if !css.is_empty() {
1606− '"' | '\'' => quote = Some(c),
1532+ container.push((bp_name.to_string(), css));
1607− '(' => depth += 1,
1608− ')' => depth -= 1,
1609− ';' if depth == 0 => {
1610− end = i + 1;
1611− break;
1612− }
1613− _ => {}
1614− }
1615 }
1533 }
1616− let (declaration, after) = rest.split_at(end);
1617− rest = after;
1618− let name = declaration.split(':').next().unwrap_or("").trim();
1619− Some((name, declaration))
1620− })
1534+ }
1535+
1536+ let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
1537+ let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
1538+ let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind, has_overlay_children);
1539+ let motion_reduce =
1540+ attrs_to_css(attrs, "motion-reduce:", kind, parent_kind, has_overlay_children);
1541+ let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
1542+ let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
1543+
1544+ // Dedupe: if a property is declared twice within a single rule, keep only
1545+ // the last occurrence (element-kind defaults are written before
1546+ // user attributes, so a user [list-style disc] correctly overrides the
1547+ // default list-style:none, and we don't need to ship both).
1548+ let base = dedupe_declarations(&base);
1549+ let pseudo: Vec<(String, String)> = pseudo
1550+ .into_iter()
1551+ .map(|(sel, css)| (sel, dedupe_declarations(&css)))
1552+ .collect();
1553+ let responsive: Vec<(String, String)> = responsive
1554+ .into_iter()
1555+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1556+ .collect();
1557+ let dark = dedupe_declarations(&dark);
1558+ let print = dedupe_declarations(&print);
1559+ let motion_safe = dedupe_declarations(&motion_safe);
1560+ let motion_reduce = dedupe_declarations(&motion_reduce);
1561+ let landscape = dedupe_declarations(&landscape);
1562+ let portrait = dedupe_declarations(&portrait);
1563+ let container: Vec<(String, String)> = container
1564+ .into_iter()
1565+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1566+ .collect();
1567+
1568+ styles.get_class(
1569+ base,
1570+ pseudo,
1571+ responsive,
1572+ dark,
1573+ print,
1574+ motion_safe,
1575+ motion_reduce,
1576+ landscape,
1577+ portrait,
1578+ container,
1579+ )
1621}
1580}
1622
1581
1623/// Dedupe CSS declarations within a single rule body: for any property
1582/// Dedupe CSS declarations within a single rule body: for any property
1624/// declared more than once, keep only the last occurrence. Unparseable
1583/// declared more than once, keep only the last occurrence. Unparseable
1625−/// segments (no `:`) are preserved as-is. Semicolons inside parentheses or
1584+/// segments (no `:`) are preserved as-is. Semicolons inside parentheses are
1626−/// quoted strings are treated as part of a value, not as declaration
1585+/// treated as part of a value, not as declaration separators.
1627−/// separators.
1628fn dedupe_declarations(css: &str) -> String {
1586fn dedupe_declarations(css: &str) -> String {
1629 if css.is_empty() {
1587 if css.is_empty() {
1630 return String::new();
1588 return String::new();
@@ -1639,26 +1597,8 @@ fn dedupe_declarations(css: &str) -> String {
1639 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1597 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1640 let mut current = String::new();
1598 let mut current = String::new();
1641 let mut depth: i32 = 0;
1599 let mut depth: i32 = 0;
1642− // A quoted string's `;` (`content "a;b"`) is text; a backslash escapes
1643− // the character after it
1644− let mut quote: Option<char> = None;
1645− let mut escaped = false;
1646 for ch in css.chars() {
1600 for ch in css.chars() {
1647− if escaped {
1601+ if ch == '(' {
1648− escaped = false;
1649− current.push(ch);
1650− } else if ch == '\\' {
1651− escaped = true;
1652− current.push(ch);
1653− } else if let Some(q) = quote {
1654− if ch == q {
1655− quote = None;
1656− }
1657− current.push(ch);
1658− } else if ch == '"' || ch == '\'' {
1659− quote = Some(ch);
1660− current.push(ch);
1661− } else if ch == '(' {
1662 depth += 1;
1602 depth += 1;
1663 current.push(ch);
1603 current.push(ch);
1664 } else if ch == ')' {
1604 } else if ch == ')' {
@@ -1740,553 +1680,576 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
1740// Attribute → CSS mapping
1680// Attribute → CSS mapping
1741// ---------------------------------------------------------------------------
1681// ---------------------------------------------------------------------------
1742
1682
1743−/// Where an element is written, which its CSS depends on.
1683+/// (htmlang prefix, CSS selector suffix)
1744−struct Site<'a> {
1684+/// `display:flex;flex-direction:column;` — base layout for `@el` and every
1685+/// semantic wrapper that behaves like a column.
1686+const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
1687+
1688+
1689+
1690+fn attrs_to_css(
1691+ attrs: &[Attribute],
1692+ state_prefix: &str,
1745− kind: &'a ElementKind,
1693+ kind: &ElementKind,
1746− /// How its parent lays out its children: its `width fill`, `height
1694+ parent_kind: Option<&ElementKind>,
1747− /// fill` and `shrink` compile against it.
1748− parent: &'a Flow,
1749− /// It has `@in-front` / `@behind` children.
1750 has_overlay_children: bool,
1695 has_overlay_children: bool,
1751− /// A row, column or grid inside text, laid out inline.
1696+) -> String {
1752− inline: bool,
1697+ let mut css = String::new();
1753− /// The page's `<body>`, which the reset already makes a column.
1754− root: bool,
1755−}
1756
1698
1757−impl Site<'_> {
1699+ // Base element styles only for the default (non-state) pass
1758− /// Where an element in a parent with the layout `layout` and the flow
1700+ if state_prefix.is_empty() {
1759− /// `parent` is.
1701+ // Elements with @in-front / @behind children become positioning
1760− fn new<'a>(elem: &'a Element, layout: Option<Layout>, parent: &'a Flow) -> Site<'a> {
1702+ // contexts. Pushed before user attrs so an explicit `position` wins
1761− Site {
1703+ // via dedupe (only the last declaration of a property is kept).
1762− kind: &elem.kind,
1704+ if has_overlay_children {
1763− parent,
1705+ css.push_str("position:relative;isolation:isolate;");
1764− has_overlay_children: has_overlay_children(elem),
1706+ }
1765− inline: layout == Some(Layout::Text) && elem.kind.layout().is_container(),
1707+ match kind {
1766− root: false,
1708+ ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
1709+ ElementKind::Column | ElementKind::El => css.push_str(FLEX_COLUMN),
1710+ ElementKind::Paragraph => css.push_str("margin:0;"),
1711+ ElementKind::Tag(spec) => css.push_str(spec.css),
1712+ _ => {}
1767 }
1713 }
1768 }
1714 }
1769−}
1770−
1771−// ---------------------------------------------------------------------------
1772−// Direction: what a child's `fill` and `shrink` compile against
1773−// ---------------------------------------------------------------------------
1774
1715
1775−/// The way a flex row or column lays out its children.
1716+ for attr in attrs {
1776−#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1717+ if attr.html {
1777−enum Axis {
1718+ continue;
1778− Row,
1719+ }
1779− Column,
1720+ // Determine the effective key for this pass
1721+ let effective_key = if state_prefix.is_empty() {
1722+ if crate::vocab::is_prefixed(&attr.key) {
1723+ continue;
1724+ }
1725+ attr.key.as_str()
1726+ } else {
1727+ match attr.key.strip_prefix(state_prefix) {
1728+ Some(k) => k,
1729+ None => continue,
1780−}
1730+ }
1731+ };
1781
1732
1782−impl Axis {
1733+ let val = attr.value.as_deref();
1783− /// The direction a `flex-direction` or `flex-flow` value sets, if it
1784− /// names one (not `var(--dir)`, which only the browser knows).
1785− fn of_value(value: &str) -> Option<Axis> {
1786− value.split_whitespace().find_map(|word| match word {
1787− "row" | "row-reverse" => Some(Axis::Row),
1788− "column" | "column-reverse" => Some(Axis::Column),
1789− _ => None,
1790− })
1791− }
1792−}
1793
1734
1794−/// How an element lays out its children, as its CSS sets it: the
1735+ match effective_key {
1795−/// direction its children's `width`/`height` `fill` and `shrink` compile
1736+ // Layout
1796−/// against. That is the direction of `@row` or of a column, or the one its
1737+ "spacing" | "gap" => {
1797−/// own `flex-direction` (or `flex-flow`) sets, with or without media or
1738+ if let Some(v) = val {
1798−/// container prefixes. Like elm-ui's `.r > .wf`, the rules for a direction
1739+ push_css(&mut css, "gap", &css_px(v));
1799−/// set under a prefix are keyed on the element's class: `@media (...) {
1740+ }
1800−/// :where(.a)>.b {...} }`.
1741+ }
1801−#[derive(Clone, Default)]
1742+ "padding" => {
1802−struct Flow {
1743+ if let Some(v) = val {
1803− /// Without a prefix; `None` when the element isn't a flex row or column
1744+ push_css(&mut css, "padding", &css_px_multi(v));
1804− /// (a grid, text, HTML's own layout, the top of the page)
1745+ }
1805− base: Option<Axis>,
1746+ }
1806− /// The direction under each chain of at-rule prefixes that sets one, in
1747+ "padding-x" => {
1807− /// the order their blocks are written
1748+ if let Some(v) = val {
1808− changes: Vec<(Vec<usize>, Axis)>,
1749+ // Logical property covers both inline sides in one
1809− /// The element's generated class, which the rules for the changes are
1750+ // declaration; for symmetric values this is visually
1810− /// keyed on
1751+ // identical to padding-left/right in LTR and RTL.
1811− class: Option<String>,
1752+ push_css(&mut css, "padding-inline", &css_px(v));
1753+ }
1754+ }
1755+ "padding-y" => {
1756+ if let Some(v) = val {
1757+ push_css(&mut css, "padding-block", &css_px(v));
1758+ }
1759+ }
1760+ "padding-top" => {
1761+ if let Some(v) = val {
1762+ push_css(&mut css, "padding-top", &css_px(v));
1763+ }
1764+ }
1765+ "padding-bottom" => {
1766+ if let Some(v) = val {
1767+ push_css(&mut css, "padding-bottom", &css_px(v));
1768+ }
1769+ }
1770+ "padding-left" => {
1771+ if let Some(v) = val {
1772+ push_css(&mut css, "padding-left", &css_px(v));
1773+ }
1774+ }
1775+ "padding-right" => {
1776+ if let Some(v) = val {
1777+ push_css(&mut css, "padding-right", &css_px(v));
1778+ }
1812−}
1779+ }
1813
1780
1814−impl Flow {
1781+ // Sizing
1815− fn of(elem: &Element) -> Flow {
1782+ "width" => {
1816− let mut base = match elem.kind.layout() {
1783+ if let Some(v) = val {
1817− Layout::Row => Axis::Row,
1784+ match v {
1818− Layout::Column => Axis::Column,
1785+ "fill" => match parent_kind {
1819− _ => return Flow::default(),
1786+ Some(ElementKind::Row) => {
1820− };
1787+ push_css(&mut css, "flex", "1");
1821− let mut changes: Vec<(Vec<usize>, Axis)> = Vec::new();
1788+ push_css(&mut css, "min-width", "0");
1822− for attr in elem.attrs.iter().filter(|a| !a.html) {
1789+ }
1823− let (condition, name) = Condition::of(&attr.key);
1790+ _ => push_css(&mut css, "width", "100%"),
1824− if !matches!(name, "flex-direction" | "flex-flow") || !condition.selector.is_empty() {
1791+ },
1825− continue;
1792+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
1793+ _ => push_css(&mut css, "width", &css_px(v)),
1794+ }
1795+ }
1826 }
1796 }
1827− let Some(axis) = attr.value.as_deref().and_then(Axis::of_value) else {
1797+ "height" => {
1828− continue;
1798+ if let Some(v) = val {
1799+ match v {
1800+ "fill" => match parent_kind {
1801+ Some(ElementKind::Column) => {
1802+ push_css(&mut css, "flex", "1");
1803+ push_css(&mut css, "min-height", "0");
1804+ }
1805+ _ => push_css(&mut css, "height", "100%"),
1806+ },
1807+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
1808+ _ => push_css(&mut css, "height", &css_px(v)),
1809+ }
1810+ }
1829− };
1811+ }
1830− if condition.at.is_empty() {
1812+ "min-width" => {
1831− base = axis;
1813+ if let Some(v) = val {
1832− } else {
1814+ push_css(&mut css, "min-width", &css_px(v));
1833− // The later one wins, as in the CSS
1815+ }
1834− changes.retain(|(at, _)| *at != condition.at);
1816+ }
1835− changes.push((condition.at, axis));
1817+ "max-width" => {
1818+ if let Some(v) = val {
1819+ push_css(&mut css, "max-width", &css_px(v));
1820+ }
1821+ }
1822+ "min-height" => {
1823+ if let Some(v) = val {
1824+ push_css(&mut css, "min-height", &css_px(v));
1825+ }
1826+ }
1827+ "max-height" => {
1828+ if let Some(v) = val {
1829+ push_css(&mut css, "max-height", &css_px(v));
1830+ }
1836 }
1831 }
1837− }
1838− changes.sort_by_key(|(at, _)| at_order(at));
1839− Flow {
1840− base: Some(base),
1841− changes,
1842− class: None,
1843− }
1844− }
1845
1832
1846− /// The direction under the at-rules `at`: the latest one set among
1833+ // Alignment
1847− /// the conditions that hold there (`md:` holds at `lg:`), else the one
1834+ "center-x" => match parent_kind {
1848− /// without a prefix.
1835+ Some(ElementKind::Column) | Some(ElementKind::El) => {
1849− fn at(&self, at: &[usize]) -> Option<Axis> {
1836+ push_css(&mut css, "align-self", "center");
1850− let base = self.base?;
1837+ }
1851− let here = Condition::at_only(at);
1838+ _ => {
1852− let changed = self
1839+ push_css(&mut css, "margin-left", "auto");
1853− .changes
1840+ push_css(&mut css, "margin-right", "auto");
1854− .iter()
1841+ }
1855− .rev()
1842+ },
1856− .find(|(chain, _)| Condition::at_only(chain).holds_at(&here))
1843+ "center-y" => match parent_kind {
1857− .map(|&(_, axis)| axis);
1844+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
1858− Some(changed.unwrap_or(base))
1845+ _ => {
1859− }
1846+ push_css(&mut css, "margin-top", "auto");
1860−}
1847+ push_css(&mut css, "margin-bottom", "auto");
1848+ }
1849+ },
1850+ "align-left" => match parent_kind {
1851+ Some(ElementKind::Column) | Some(ElementKind::El) => {
1852+ push_css(&mut css, "align-self", "flex-start");
1853+ }
1854+ _ => push_css(&mut css, "margin-right", "auto"),
1855+ },
1856+ "align-right" => match parent_kind {
1857+ Some(ElementKind::Column) | Some(ElementKind::El) => {
1858+ push_css(&mut css, "align-self", "flex-end");
1859+ }
1860+ _ => push_css(&mut css, "margin-left", "auto"),
1861+ },
1862+ "align-top" => match parent_kind {
1863+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
1864+ _ => push_css(&mut css, "margin-bottom", "auto"),
1865+ },
1866+ "align-bottom" => match parent_kind {
1867+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
1868+ _ => push_css(&mut css, "margin-top", "auto"),
1869+ },
1870+
1871+ // Style
1872+ "border" => {
1873+ if let Some(v) = val {
1874+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
1875+ if parts.len() == 2 {
1876+ push_css(
1877+ &mut css,
1878+ "border",
1879+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
1880+ );
1881+ } else {
1882+ push_css(
1883+ &mut css,
1884+ "border",
1885+ &format!("{} solid currentColor", css_px(parts[0])),
1886+ );
1887+ }
1888+ }
1889+ }
1890+ "border-top" | "border-bottom" | "border-left" | "border-right" => {
1891+ if let Some(v) = val {
1892+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
1893+ if parts.len() == 2 {
1894+ push_css(
1895+ &mut css,
1896+ effective_key,
1897+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
1898+ );
1899+ } else {
1900+ push_css(
1901+ &mut css,
1902+ effective_key,
1903+ &format!("{} solid currentColor", css_px(parts[0])),
1904+ );
1905+ }
1906+ }
1907+ }
1908+ "rounded" => {
1909+ if let Some(v) = val {
1910+ push_css(&mut css, "border-radius", &css_px(v));
1911+ }
1912+ }
1913+ "bold" => push_css(&mut css, "font-weight", "bold"),
1914+ "italic" => push_css(&mut css, "font-style", "italic"),
1915+ "underline" => push_css(&mut css, "text-decoration", "underline"),
1916+ "size" => {
1917+ if let Some(v) = val {
1918+ push_css(&mut css, "font-size", &css_px(v));
1919+ }
1920+ }
1921+ "font" => {
1922+ if let Some(v) = val {
1923+ // `font "Inter, sans-serif"` quotes a whole font stack so
1924+ // its commas don't split the attribute list.
1925+ let v = match v.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
1926+ Some(stack) if stack.contains(',') => stack,
1927+ _ => v,
1928+ };
1929+ push_css(&mut css, "font-family", v);
1930+ }
1931+ }
1861
1932
1862−/// `width` or `height`.
1933+ // Typography
1863−#[derive(Clone, Copy, PartialEq, Eq)]
1934+ "line-height" => {
1864−enum Dim {
1935+ if let Some(v) = val {
1865− Width,
1936+ push_css(&mut css, "line-height", &css_line_height(v));
1866− Height,
1937+ }
1867−}
1938+ }
1939+ "letter-spacing" => {
1940+ if let Some(v) = val {
1941+ push_css(&mut css, "letter-spacing", &css_px(v));
1942+ }
1943+ }
1868
1944
1869−/// What a `width` or `height` says.
1945+ // Overflow & positioning
1870−#[derive(Clone, Copy, PartialEq, Eq)]
1946+ "top" => {
1871−enum Size {
1947+ if let Some(v) = val {
1872− Fill,
1948+ push_css(&mut css, "top", &css_px(v));
1873− Shrink,
1949+ }
1874− /// A size of its own (`width 300`)
1950+ }
1875− Set,
1951+ "right" => {
1876−}
1952+ if let Some(v) = val {
1953+ push_css(&mut css, "right", &css_px(v));
1954+ }
1955+ }
1956+ "bottom" => {
1957+ if let Some(v) = val {
1958+ push_css(&mut css, "bottom", &css_px(v));
1959+ }
1960+ }
1961+ "left" => {
1962+ if let Some(v) = val {
1963+ push_css(&mut css, "left", &css_px(v));
1964+ }
1965+ }
1877
1966
1878−impl Size {
1967+ // Display & visibility
1879− fn of(value: &str) -> Size {
1968+ "hidden" => push_css(&mut css, "display", "none"),
1880− match value.trim() {
1881− "fill" => Size::Fill,
1882− "shrink" => Size::Shrink,
1883− _ => Size::Set,
1884− }
1885− }
1886−}
1887
1969
1888−/// The declarations `fill` and `shrink` make in a parent laid out along
1970+ // Effects
1889−/// `axis` (`None`: not a flex row or column). Along the parent's direction,
1890−/// `fill` takes the remaining space and `shrink` keeps the content's size;
1891−/// across it (or outside flex), `fill` is the full size and `shrink` fits
1892−/// the content.
1893−fn sizing(dim: Dim, size: Size, axis: Option<Axis>) -> &'static [(&'static str, &'static str)] {
1894− match (dim, size, axis) {
1895− (_, Size::Set, _) => &[],
1896− (Dim::Width, Size::Fill, Some(Axis::Row)) => &[("flex", "1"), ("min-width", "0")],
1897− (Dim::Width, Size::Fill, _) => &[("width", "100%")],
1898− (Dim::Width, Size::Shrink, Some(Axis::Row)) => &[("flex-shrink", "0")],
1899− (Dim::Width, Size::Shrink, _) => &[("width", "fit-content")],
1900− (Dim::Height, Size::Fill, Some(Axis::Column)) => &[("flex", "1"), ("min-height", "0")],
1901− (Dim::Height, Size::Fill, _) => &[("height", "100%")],
1902− (Dim::Height, Size::Shrink, Some(Axis::Column)) => &[("flex-shrink", "0")],
1903− (Dim::Height, Size::Shrink, _) => &[("height", "fit-content")],
1904− }
1905−}
1906
1971
1907−/// Every property `fill` and `shrink` of `dim` can set, with its initial
1972+ // Flow
1908−/// value (`flex` covers `flex-shrink`).
1973+ "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
1909−fn sizing_properties(dim: Dim) -> [(&'static str, &'static str); 3] {
1910− match dim {
1911− Dim::Width => [
1912− ("flex", "0 1 auto"),
1913− ("min-width", "auto"),
1914− ("width", "auto"),
1915− ],
1916− Dim::Height => [
1917− ("flex", "0 1 auto"),
1918− ("min-height", "auto"),
1919− ("height", "auto"),
1920− ],
1921− }
1922−}
1923
1974
1924−/// What an element's attributes under some prefixes say about its size.
1975+ // Grid
1925−#[derive(Default)]
1976+ "grid-cols" => {
1926−struct Sizes {
1977+ if let Some(v) = val {
1927− /// The last `width` and `height`
1978+ if let Ok(n) = v.parse::<u32>() {
1928− width: Option<Size>,
1979+ push_css(
1929− height: Option<Size>,
1980+ &mut css,
1930− /// A `fill` or `shrink` for the width / height is among them (a later
1981+ "grid-template-columns",
1931− /// size may replace it)
1982+ &format!("repeat({},1fr)", n),
1932− width_word: bool,
1983+ );
1933− height_word: bool,
1984+ } else {
1934− /// It writes `flex`, one of its longhands (`flex-grow`, `flex-shrink`,
1985+ push_css(&mut css, "grid-template-columns", v);
1935− /// `flex-basis`), `min-width` or `min-height` itself
1986+ }
1936− flex: bool,
1987+ }
1937− flex_grow: bool,
1988+ }
1938− flex_shrink: bool,
1989+ "grid-rows" => {
1939− flex_basis: bool,
1990+ if let Some(v) = val {
1940− min_width: bool,
1991+ if let Ok(n) = v.parse::<u32>() {
1941− min_height: bool,
1992+ push_css(
1942−}
1993+ &mut css,
1994+ "grid-template-rows",
1995+ &format!("repeat({},1fr)", n),
1996+ );
1997+ } else {
1998+ push_css(&mut css, "grid-template-rows", v);
1999+ }
2000+ }
2001+ }
2002+ "col-span" => {
2003+ if let Some(v) = val {
2004+ push_css(&mut css, "grid-column", &format!("span {}", v));
2005+ }
2006+ }
2007+ "row-span" => {
2008+ if let Some(v) = val {
2009+ push_css(&mut css, "grid-row", &format!("span {}", v));
2010+ }
2011+ }
1943
2012
1944−impl Sizes {
2013+ // CSS containment for rendering performance
1945− /// Read the attributes under the conditions that hold wherever `at`
2014+ "contain" => {
1946− /// does, a later condition over an earlier one.
2015+ if let Some(v) = val {
1947− fn of(attrs: &[Attribute], at: &Condition) -> Sizes {
2016+ push_css(&mut css, "contain", v);
1948− let mut sizes = Sizes::default();
2017+ } else {
1949− let mut read: Vec<(Condition, &str, &str)> = Vec::new();
2018+ push_css(&mut css, "contain", "layout style paint");
1950− for attr in attrs.iter().filter(|a| !a.html) {
2019+ }
1951− let (condition, name) = Condition::of(&attr.key);
1952− let Some(value) = attr.value.as_deref().filter(|v| !v.trim().is_empty()) else {
1953− continue;
1954− };
1955− if condition.holds_at(at) {
1956− read.push((condition, name, value));
1957− }
1958− }
1959− read.sort_by_cached_key(|(condition, _, _)| condition.order());
1960− for (_, name, value) in read {
1961− match name {
1962− "width" => {
1963− let size = Size::of(value);
1964− sizes.width = Some(size);
1965− sizes.width_word |= size != Size::Set;
1966− }
1967− "height" => {
1968− let size = Size::of(value);
1969− sizes.height = Some(size);
1970− sizes.height_word |= size != Size::Set;
1971− }
1972− "flex" => sizes.flex = true,
1973− "flex-grow" => sizes.flex_grow = true,
1974− "flex-shrink" => sizes.flex_shrink = true,
1975− "flex-basis" => sizes.flex_basis = true,
1976− "min-width" => sizes.min_width = true,
1977− "min-height" => sizes.min_height = true,
1978− _ => {}
1979 }
2020 }
1980− }
2021+ "content-visibility" => {
1981− sizes
2022+ if let Some(v) = val {
1982− }
2023+ push_css(&mut css, "content-visibility", v);
1983−
2024+ } else {
1984− /// The element writes `property` itself: a layout word leaves it alone.
2025+ push_css(&mut css, "content-visibility", "auto");
1985− fn writes(&self, property: &str) -> bool {
2026+ }
1986− match property {
2027+ }
1987− "flex" => self.flex,
2028+
1988− "flex-grow" => self.flex || self.flex_grow,
2029+ // Outline (like border but doesn't affect layout)
1989− "flex-shrink" => self.flex || self.flex_shrink,
2030+ "outline" => {
1990− "flex-basis" => self.flex || self.flex_basis,
2031+ if let Some(v) = val {
1991− "min-width" => self.min_width,
2032+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
1992− "min-height" => self.min_height,
2033+ if parts.len() == 2 {
1993− "width" => self.width == Some(Size::Set),
2034+ push_css(
1994− "height" => self.height == Some(Size::Set),
2035+ &mut css,
1995− _ => false,
2036+ "outline",
1996− }
2037+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
1997− }
2038+ );
1998−
2039+ } else {
1999− /// Write a layout word's `property: value`, unless the element writes
2040+ push_css(
2000− /// the property itself. When it writes some of `flex`'s longhands, a
2041+ &mut css,
2001− /// `flex` is written as the others (`flex-shrink 0, width fill` still
2042+ "outline",
2002− /// grows).
2043+ &format!("{} solid currentColor", css_px(parts[0])),
2003− fn push(&self, css: &mut String, property: &str, value: &str) {
2044+ );
2004− if property == "flex" && !self.flex {
2005− let longhands = match value {
2006− "1" => [
2007− ("flex-grow", "1"),
2008− ("flex-shrink", "1"),
2009− ("flex-basis", "0%"),
2010− ],
2011− // The reset, `0 1 auto`
2012− _ => [
2013− ("flex-grow", "0"),
2014− ("flex-shrink", "1"),
2015− ("flex-basis", "auto"),
2016− ],
2017− };
2018− if longhands.iter().any(|&(p, _)| self.writes(p)) {
2019− for (longhand, value) in longhands {
2020− if !self.writes(longhand) {
2021− push_css(css, longhand, value);
2022 }
2045 }
2023 }
2046 }
2024− return;
2025 }
2047 }
2026− }
2027− if !self.writes(property) {
2028− push_css(css, property, value);
2029− }
2030− }
2031−}
2032
2048
2033−/// The rule that gives an element's `fill` and `shrink`, as its attributes
2049+ // Logical properties (i18n-aware)
2034−/// under the at-rules `at` set them, their meaning in a parent laid out
2050+ "padding-inline" => {
2035−/// along `axis`. Every other property a layout word could have set is put
2051+ if let Some(v) = val {
2036−/// back to its initial value, so the rule is right whichever earlier rule
2052+ push_css(&mut css, "padding-inline", &css_px_multi(v));
2037−/// it overrides. Properties the element writes itself are left alone.
2053+ }
2038−fn sizing_rule(attrs: &[Attribute], at: &[usize], axis: Axis) -> String {
2054+ }
2039− let sizes = Sizes::of(attrs, &Condition::at_only(at));
2055+ "padding-block" => {
2040− let mut sets: Vec<(&str, &str)> = Vec::new();
2056+ if let Some(v) = val {
2041− let mut resets: Vec<(&str, &str)> = Vec::new();
2057+ push_css(&mut css, "padding-block", &css_px_multi(v));
2042− for (dim, size, word) in [
2058+ }
2043− (Dim::Width, sizes.width, sizes.width_word),
2059+ }
2044− (Dim::Height, sizes.height, sizes.height_word),
2060+ "margin-inline" => {
2045− ] {
2061+ if let Some(v) = val {
2046− if !word {
2062+ push_css(&mut css, "margin-inline", &css_px_multi(v));
2047− continue;
2063+ }
2048− }
2064+ }
2049− if let Some(size) = size {
2065+ "margin-block" => {
2050− sets.extend_from_slice(sizing(dim, size, Some(axis)));
2066+ if let Some(v) = val {
2051− }
2067+ push_css(&mut css, "margin-block", &css_px_multi(v));
2052− resets.extend_from_slice(&sizing_properties(dim));
2068+ }
2053− }
2069+ }
2054− let mut css = String::new();
2055− let mut written: Vec<&str> = Vec::new();
2056− for &(property, value) in &resets {
2057− if sets.iter().any(|&(p, _)| p == property) || written.contains(&property) {
2058− continue;
2059− }
2060− written.push(property);
2061− sizes.push(&mut css, property, value);
2062− }
2063− for &(property, value) in &sets {
2064− sizes.push(&mut css, property, value);
2065− }
2066− css
2067−}
2068
2070
2069−/// The rules an element's `fill` and `shrink` need under each chain of
2071+ // Logical property start/end variants
2070−/// at-rules where the direction of `flow` (its parent's) changes: `(at,
2072+ "padding-inline-start"
2071−/// body)`.
2073+ | "padding-inline-end"
2072−fn sizing_changes(attrs: &[Attribute], flow: &Flow) -> Vec<(Vec<usize>, String)> {
2074+ | "padding-block-start"
2073− flow.changes
2075+ | "padding-block-end"
2074− .iter()
2076+ | "margin-inline-start"
2075− .map(|(at, axis)| (at.clone(), sizing_rule(attrs, at, *axis)))
2077+ | "margin-inline-end"
2076− .filter(|(_, body)| !body.is_empty())
2078+ | "margin-block-start"
2077− .collect()
2079+ | "margin-block-end" => {
2078−}
2080+ if let Some(v) = val {
2081+ push_css(&mut css, effective_key, &css_px(v));
2082+ }
2083+ }
2079
2084
2080−/// An element's generated class, with the rules keyed on its parent's
2085+ // Logical inset
2081−/// class that its `fill` and `shrink` need where the parent's direction
2086+ "inset-inline" | "inset-block" => {
2082−/// changes under a prefix (see [`Flow`]): `:where(.a)>.b`, one class of
2087+ if let Some(v) = val {
2083−/// specificity like any class rule.
2088+ push_css(&mut css, effective_key, &css_px_multi(v));
2084−fn element_class(elem: &Element, site: &Site, styles: &mut StyleCollector) -> Option<String> {
2089+ }
2085− let as_child = match site.parent.class {
2090+ }
2086− Some(_) => sizing_changes(&elem.attrs, site.parent),
2091+ "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
2087− None => Vec::new(),
2092+ if let Some(v) = val {
2088− };
2093+ push_css(&mut css, effective_key, &css_px(v));
2089− // Two elements with the same CSS but different rules keyed on them
2094+ }
2090− // need two classes
2095+ }
2091− let mut distinct = String::new();
2092− for (at, body) in &as_child {
2093− distinct.push_str(&format!("<{:?}{}", at, body));
2094− }
2095− let class = compute_class(&elem.attrs, site, styles, distinct)?;
2096− if let Some(parent) = &site.parent.class {
2097− for (at, body) in as_child {
2098− styles.add_keyed(at, format!(":where(.{})>.{}", parent, class), body);
2099− }
2100− }
2101− Some(class)
2102−}
2103
2096
2104−/// The `display` of a layout (nothing for text, native and void elements,
2097+ // Logical border
2105−/// which keep HTML's own).
2098+ "border-inline"
2106−fn layout_css(layout: Layout, inline: bool) -> &'static str {
2099+ | "border-block"
2107− match (layout, inline) {
2100+ | "border-inline-start"
2108− (Layout::Column, false) => "display:flex;flex-direction:column;",
2101+ | "border-inline-end"
2109− (Layout::Column, true) => "display:inline-flex;flex-direction:column;",
2102+ | "border-block-start"
2110− (Layout::Row, false) => "display:flex;flex-direction:row;",
2103+ | "border-block-end" => {
2111− (Layout::Row, true) => "display:inline-flex;flex-direction:row;",
2104+ if let Some(v) = val {
2112− (Layout::Grid, false) => "display:grid;",
2105+ push_css(&mut css, effective_key, v);
2113− (Layout::Grid, true) => "display:inline-grid;",
2106+ }
2114− _ => "",
2107+ }
2115− }
2116−}
2117
2108
2118−/// The declarations of the attributes under `condition` (the defaults of
2109+ // Logical border-radius
2119−/// the element's kind are [`default_css`]'s).
2110+ "border-start-start-radius"
2120−fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> String {
2111+ | "border-start-end-radius"
2121− let mut css = String::new();
2112+ | "border-end-start-radius"
2122− // The auto margins of `center-x`, `align-left`, ..., written at the end
2113+ | "border-end-end-radius" => {
2123− let mut aligned = String::new();
2114+ if let Some(v) = val {
2124− let kind = site.kind;
2115+ push_css(&mut css, effective_key, &css_px(v));
2125− // Under `children:` the styles go on the children, whose parent is this
2116+ }
2126− // element: htmlang's words that place an element in its parent are an
2117+ }
2127− // error there (the parser reports them), and left out
2128− let on_children = condition.on_children();
2129− let inside = condition.inside().is_some();
2130− // The direction `fill` and `shrink` compile against
2131− let axis = site.parent.at(&condition.at);
2132−
2133− // htmlang's words for laying out children (`spacing`, `wrap`,
2134− // `grid-cols`) mean nothing on an element that doesn't lay out its
2135− // children, which the parser reports; they are left out. Under
2136− // `children:` they go on the children, whose layout isn't known here.
2137− let lays_out_children = kind.layout().is_container() || on_children;
2138− fn under<'k>(key: &'k str, condition: &Condition) -> Option<&'k str> {
2139− let (c, name) = Condition::of(key);
2140− (c == *condition).then_some(name)
2141− }
2142−
2143− for (index, attr) in attrs.iter().enumerate() {
2144− if attr.html {
2145− continue;
2146− }
2147− let Some(effective_key) = under(&attr.key, condition) else {
2148− continue;
2149− };
2150− if on_children && crate::vocab::places_in_parent(effective_key, attr.value.as_deref()) {
2151− continue;
2152− }
2153− // Under an element prefix (`@td:`) the styles go on elements whose
2154− // layout and parent aren't known here: htmlang's own words are an
2155− // error there (the parser reports them), and left out
2156− if inside && crate::vocab::is_htmlang_word(effective_key, attr.value.as_deref()) {
2157− continue;
2158− }
2159
2118
2160− let val = attr.value.as_deref();
2119+ // Logical scroll margins & padding
2161− // A style whose value came out empty (a field a record doesn't
2120+ "scroll-margin-inline"
2162− // have, `${if()}` without its other branch) is left out
2121+ | "scroll-margin-block"
2163− if val.is_some_and(|v| v.trim().is_empty()) {
2122+ | "scroll-padding-inline"
2164− continue;
2123+ | "scroll-padding-block" => {
2165− }
2124+ if let Some(v) = val {
2125+ push_css(&mut css, effective_key, &css_px_multi(v));
2126+ }
2127+ }
2166
2128
2167− match effective_key {
2129+ // Logical sizing
2168− // htmlang's own words: they lay out children or place the
2130+ "inline-size" | "block-size" | "min-inline-size" | "max-inline-size"
2169− // element in its parent, and mean more than one CSS property
2131+ | "min-block-size" | "max-block-size" => {
2170− "spacing" if !lays_out_children => {}
2171− "spacing" => {
2172 if let Some(v) = val {
2132 if let Some(v) = val {
2173− push_css(&mut css, "gap", &with_px("gap", v));
2133+ push_css(&mut css, effective_key, &css_px(v));
2174 }
2134 }
2175 }
2135 }
2176
2136
2177− // Sizing: `fill` and `shrink` compile against the parent's
2137+ // Margin
2178− // direction. Only the last `width` (`height`) counts, as for
2138+ "margin" => {
2179− // any style written twice.
2139+ if let Some(v) = val {
2180− "width" | "height" if val.is_some_and(|v| Size::of(v) != Size::Set) => {
2140+ push_css(&mut css, "margin", &css_px_multi(v));
2181− let later = attrs[index + 1..].iter().any(|a| {
2182− !a.html
2183− && under(&a.key, condition) == Some(effective_key)
2184− && a.value.as_deref().is_some_and(|v| !v.trim().is_empty())
2185− });
2186− if later {
2187− continue;
2188 }
2141 }
2189− let dim = if effective_key == "width" {
2142+ }
2190− Dim::Width
2143+ "margin-x" => {
2191− } else {
2144+ if let Some(v) = val {
2192− Dim::Height
2145+ push_css(&mut css, "margin-inline", &css_px(v));
2193− };
2194− let size = Size::of(val.unwrap_or_default());
2195− // What the element writes itself, which a layout word
2196− // leaves alone
2197− let sizes = Sizes::of(attrs, condition);
2198− for &(property, value) in sizing(dim, size, axis) {
2199− sizes.push(&mut css, property, value);
2200− }
2201− }
2202−
2203− // A size of its own under a prefix, where a `fill` or
2204− // `shrink` that sets `flex` (along a flex parent's direction)
2205− // holds too: the word's `flex` and minimum are put back, so the
2206− // size is the one the element gets (`width fill, lg:width 200`
2207− // in a row)
2208− "width" | "height"
2209− if *condition != Condition::default()
2210− && attrs.iter().any(|a| {
2211− let (c, name) = Condition::of(&a.key);
2212− let along = match site.parent.at(&c.at) {
2213− Some(Axis::Row) => name == "width",
2214− Some(Axis::Column) => name == "height",
2215− None => false,
2216− };
2217− !a.html
2218− && name == effective_key
2219− && along
2220− && c != *condition
2221− && c.holds_at(condition)
2222− && a.value
2223− .as_deref()
2224− .is_some_and(|v| !v.trim().is_empty() && Size::of(v) != Size::Set)
2225− }) =>
2226− {
2227− let dim = if effective_key == "width" {
2228− Dim::Width
2229− } else {
2230− Dim::Height
2231− };
2232− let sizes = Sizes::of(attrs, condition);
2233− for (property, value) in sizing_properties(dim) {
2234− if property != effective_key {
2235− sizes.push(&mut css, property, value);
2236− }
2237 }
2146 }
2147+ }
2148+ "margin-y" => {
2238 if let Some(v) = val {
2149 if let Some(v) = val {
2239− push_css(&mut css, effective_key, &with_px(effective_key, v));
2150+ push_css(&mut css, "margin-block", &css_px(v));
2240 }
2151 }
2241 }
2152 }
2242
2153
2243− // Alignment: auto margins, which work along either direction.
2154+ // Container queries
2244− // They are written last, so a `margin` on the same element
2155+ "container" => {
2245− // keeps its other sides (`center-x, margin 20`)
2156+ push_css(&mut css, "container-type", "inline-size");
2246− "center-x" => {
2247− push_css(&mut aligned, "margin-left", "auto");
2248− push_css(&mut aligned, "margin-right", "auto");
2249 }
2157 }
2250− "center-y" => {
2158+
2251− push_css(&mut aligned, "margin-top", "auto");
2159+ // Inset (shorthand for top/right/bottom/left)
2252− push_css(&mut aligned, "margin-bottom", "auto");
2160+ "inset" => {
2161+ if let Some(v) = val {
2162+ push_css(&mut css, "inset", &css_px(v));
2163+ }
2253 }
2164 }
2254− "align-left" => push_css(&mut aligned, "margin-right", "auto"),
2255− "align-right" => push_css(&mut aligned, "margin-left", "auto"),
2256− "align-top" => push_css(&mut aligned, "margin-bottom", "auto"),
2257− "align-bottom" => push_css(&mut aligned, "margin-top", "auto"),
2258
2165
2259− "wrap" if lays_out_children => push_css(&mut css, "flex-wrap", "wrap"),
2166+ // Table
2260− "wrap" => {}
2167+ "border-spacing" => {
2168+ if let Some(v) = val {
2169+ push_css(&mut css, "border-spacing", &css_px(v));
2170+ }
2171+ }
2261
2172
2262− // Grid
2173+ // Text decoration
2263− "grid-cols" | "grid-rows" if !lays_out_children => {}
2174+ "text-decoration-thickness" => {
2264− "grid-cols" | "grid-rows" => {
2265 if let Some(v) = val {
2175 if let Some(v) = val {
2266− let property = if effective_key == "grid-cols" {
2176+ push_css(&mut css, "text-decoration-thickness", &css_px(v));
2267− "grid-template-columns"
2177+ }
2268− } else {
2178+ }
2269− "grid-template-rows"
2179+ "text-underline-offset" => {
2270− };
2180+ if let Some(v) = val {
2271− match v.parse::<u32>() {
2181+ push_css(&mut css, "text-underline-offset", &css_px(v));
2272− Ok(n) => push_css(&mut css, property, &format!("repeat({},1fr)", n)),
2182+ }
2273− Err(_) => push_css(&mut css, property, &with_px(property, v)),
2183+ }
2274− }
2184+
2185+ // Multi-column
2186+ "column-width" => {
2187+ if let Some(v) = val {
2188+ push_css(&mut css, "column-width", &css_px(v));
2189+ }
2190+ }
2191+
2192+ // New CSS properties
2193+ "column-gap" => {
2194+ if let Some(v) = val {
2195+ push_css(&mut css, "column-gap", &css_px(v));
2196+ }
2197+ }
2198+ "text-indent" => {
2199+ if let Some(v) = val {
2200+ push_css(&mut css, "text-indent", &css_px(v));
2201+ }
2202+ }
2203+ "flex-basis" => {
2204+ if let Some(v) = val {
2205+ push_css(&mut css, "flex-basis", &css_px(v));
2206+ }
2207+ }
2208+ "scroll-margin" => {
2209+ if let Some(v) = val {
2210+ push_css(&mut css, "scroll-margin", &css_px(v));
2211+ }
2212+ }
2213+ "scroll-margin-top"
2214+ | "scroll-margin-bottom"
2215+ | "scroll-margin-left"
2216+ | "scroll-margin-right" => {
2217+ if let Some(v) = val {
2218+ push_css(&mut css, effective_key, &css_px(v));
2275 }
2219 }
2276 }
2220 }
2277− "col-span" | "row-span" => {
2221+ "scroll-padding" => {
2278 if let Some(v) = val {
2222 if let Some(v) = val {
2279− let property = if effective_key == "col-span" {
2223+ push_css(&mut css, "scroll-padding", &css_px(v));
2280− "grid-column"
2224+ }
2225+ }
2226+ "scroll-padding-top"
2227+ | "scroll-padding-bottom"
2228+ | "scroll-padding-left"
2229+ | "scroll-padding-right" => {
2230+ if let Some(v) = val {
2231+ push_css(&mut css, effective_key, &css_px(v));
2232+ }
2233+ }
2234+
2235+ "content" => {
2236+ if let Some(v) = val {
2237+ // Wrap in quotes if not already quoted and not a CSS keyword
2238+ if v.starts_with('"')
2239+ || v.starts_with('\'')
2240+ || v == "none"
2241+ || v == "normal"
2242+ || v.starts_with("attr(")
2243+ || v.starts_with("counter(")
2244+ {
2245+ push_css(&mut css, "content", v);
2281 } else {
2246 } else {
2282− "grid-row"
2247+ push_css(&mut css, "content", &format!("\"{}\"", v));
2283− };
2248+ }
2284− push_css(&mut css, property, &format!("span {}", v));
2285 }
2249 }
2286 }
2250 }
2287
2251
2288− // CSS's `line-clamp` has no support yet without its
2252+ // --- CSS Shorthands ---
2289− // `-webkit-` fallback, which needs a box to clamp
2290 "line-clamp" => {
2253 "line-clamp" => {
2291 if let Some(v) = val {
2254 if let Some(v) = val {
2292 push_css(&mut css, "display", "-webkit-box");
2255 push_css(&mut css, "display", "-webkit-box");
@@ -2296,14 +2259,15 @@ fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> Stri
2296 }
2259 }
2297 }
2260 }
2298
2261
2299− // Every other name is a CSS property (standard, custom,
2262+ // Any other standard CSS property is copied through, with `px`
2300− // vendor-prefixed, or one htmlang doesn't know, which the
2263+ // added to bare numbers where the property takes a length.
2301− // parser warned about), written under its own name with the
2264+ key if crate::vocab::is_css_property(key) => {
2302− // value as written, except for `px` after a bare number where
2303− // the property takes a length
2304− key if crate::vocab::is_property_name(key) => {
2305 if let Some(v) = val {
2265 if let Some(v) = val {
2306− push_css(&mut css, key, &with_px(key, v));
2266+ if crate::vocab::is_length_property(key) {
2267+ push_css(&mut css, key, &css_px_multi(v));
2268+ } else {
2269+ push_css(&mut css, key, v);
2270+ }
2307 }
2271 }
2308 }
2272 }
2309
2273
@@ -2311,7 +2275,6 @@ fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> Stri
2311 }
2275 }
2312 }
2276 }
2313
2277
2314− css.push_str(&aligned);
2315 css
2278 css
2316}
2279}
2317
2280
@@ -2322,6 +2285,146 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
2322 css.push(';');
2285 css.push(';');
2323}
2286}
2324
2287
2288+/// Known CSS units — if a value ends with one, skip appending `px`.
2289+/// Format a length value: a bare number gets `px` appended; anything else
2290+/// (values with units, keywords like `auto`, functions like `calc(...)`) is
2291+/// passed through unchanged.
2292+fn css_px(value: &str) -> String {
2293+ let v = value.trim();
2294+ if v == "0" {
2295+ return "0".to_string();
2296+ }
2297+ let is_bare_number = !v.is_empty()
2298+ && v.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
2299+ && v.parse::<f64>().is_ok();
2300+ if is_bare_number {
2301+ format!("{}px", v)
2302+ } else {
2303+ v.to_string()
2304+ }
2305+}
2306+
2307+/// Rewrite literal values that match a declared CSS custom property (from
2308+/// `@theme` or `@let --name value`) to `var(--name)` references. Matches are
2309+/// anchored to CSS value boundaries so e.g. `#3b82f6` inside a longer hex or
2310+/// inside an identifier is not replaced. The `:root` block is emitted before
2311+/// this call runs, so its declarations are not affected.
2312+fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
2313+ if css.is_empty() || vars.is_empty() {
2314+ return css.to_string();
2315+ }
2316+ // Prefer longer values first so that if two vars share a prefix, the
2317+ // longer (more specific) match wins.
2318+ let mut pairs: Vec<(&str, String)> = vars
2319+ .iter()
2320+ .filter(|(name, value)| name.starts_with("--") && !value.is_empty())
2321+ .map(|(name, value)| (value.as_str(), format!("var({})", name)))
2322+ .collect();
2323+ pairs.sort_by_key(|(v, _)| std::cmp::Reverse(v.len()));
2324+ if pairs.is_empty() {
2325+ return css.to_string();
2326+ }
2327+
2328+ let is_boundary_before = |b: Option<u8>| match b {
2329+ None => true,
2330+ Some(c) => matches!(c, b':' | b' ' | b',' | b'(' | b';' | b'{' | b'\n' | b'\t'),
2331+ };
2332+ let is_boundary_after = |b: Option<u8>| match b {
2333+ None => true,
2334+ Some(c) => matches!(c, b';' | b'}' | b',' | b' ' | b')' | b'\n' | b'\t'),
2335+ };
2336+
2337+ let bytes = css.as_bytes();
2338+ let mut out = String::with_capacity(css.len());
2339+ let mut i = 0;
2340+ let mut prev: Option<u8> = None;
2341+ // Only declaration values are rewritten — never selectors or at-rule
2342+ // preludes such as `@media (min-width:768px)`. `blocks` records, for
2343+ // each open `{`, whether it holds declarations (a style rule) or nested
2344+ // rules (an at-rule like `@media` / `@layer`).
2345+ let mut blocks: Vec<bool> = Vec::new();
2346+ let mut prelude_start = 0;
2347+ let mut in_value = false;
2348+ while i < bytes.len() {
2349+ match bytes[i] {
2350+ b'{' => {
2351+ let prelude = css[prelude_start..i].trim_start();
2352+ blocks.push(!prelude.starts_with('@'));
2353+ in_value = false;
2354+ prelude_start = i + 1;
2355+ }
2356+ b'}' => {
2357+ blocks.pop();
2358+ in_value = false;
2359+ prelude_start = i + 1;
2360+ }
2361+ b';' => {
2362+ in_value = false;
2363+ prelude_start = i + 1;
2364+ }
2365+ b':' if blocks.last() == Some(&true) => in_value = true,
2366+ _ => {}
2367+ }
2368+ if in_value && is_boundary_before(prev) {
2369+ let mut matched = false;
2370+ for (val, repl) in &pairs {
2371+ let vb = val.as_bytes();
2372+ if i + vb.len() <= bytes.len() && &bytes[i..i + vb.len()] == vb {
2373+ let next = bytes.get(i + vb.len()).copied();
2374+ if is_boundary_after(next) {
2375+ out.push_str(repl);
2376+ prev = repl.as_bytes().last().copied();
2377+ i += vb.len();
2378+ matched = true;
2379+ break;
2380+ }
2381+ }
2382+ }
2383+ if matched {
2384+ continue;
2385+ }
2386+ }
2387+ // Advance by one UTF-8 code point.
2388+ let start = i;
2389+ i += 1;
2390+ while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
2391+ i += 1;
2392+ }
2393+ out.push_str(&css[start..i]);
2394+ prev = bytes.get(i - 1).copied();
2395+ }
2396+ out
2397+}
2398+
2399+
2400+/// Format a `line-height` value. CSS accepts either a unitless multiplier
2401+/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
2402+/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which
2403+/// CSS interprets as 24× font-size — almost never what anyone wants). Treat
2404+/// integers ≥ 2 as pixel lengths; anything with a decimal, an existing unit,
2405+/// or the value `0`/`1` passes through unchanged.
2406+fn css_line_height(value: &str) -> String {
2407+ let v = value.trim();
2408+ if v == "0" || v == "1" {
2409+ return v.to_string();
2410+ }
2411+ // Plain integer — treat as pixel length. Decimals, units, keywords and
2412+ // functions pass through.
2413+ if !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()) {
2414+ return format!("{}px", v);
2415+ }
2416+ v.to_string()
2417+}
2418+
2419+/// Format multiple space-separated values, each getting px if needed.
2420+fn css_px_multi(value: &str) -> String {
2421+ value
2422+ .split_whitespace()
2423+ .map(css_px)
2424+ .collect::<Vec<_>>()
2425+ .join(" ")
2426+}
2427+
2325fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2428fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2326 let mut id = None;
2429 let mut id = None;
2327 let mut class = None;
2430 let mut class = None;
@@ -2335,6 +2438,153 @@ fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2335 (id, class)
2438 (id, class)
2336}
2439}
2337
2440
2441+/// Read image dimensions from a local file by parsing the header bytes.
2442+/// Supports PNG, JPEG, GIF, WebP, AVIF, and SVG.
2443+fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
2444+ // SVG: parse as text for viewBox/width/height attributes
2445+ if path.ends_with(".svg") || path.ends_with(".SVG") {
2446+ let text = std::fs::read_to_string(path).ok()?;
2447+ return read_svg_dimensions(&text);
2448+ }
2449+
2450+ let data = std::fs::read(path).ok()?;
2451+ if data.len() < 12 {
2452+ return None;
2453+ }
2454+
2455+ // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
2456+ if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") {
2457+ let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
2458+ let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
2459+ return Some((w, h));
2460+ }
2461+
2462+ // GIF: "GIF87a" or "GIF89a", width/height at bytes 6-9 (little-endian)
2463+ if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
2464+ let w = u16::from_le_bytes([data[6], data[7]]) as u32;
2465+ let h = u16::from_le_bytes([data[8], data[9]]) as u32;
2466+ return Some((w, h));
2467+ }
2468+
2469+ // JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2)
2470+ if data.starts_with(b"\xff\xd8") {
2471+ let mut i = 2;
2472+ while i + 9 < data.len() {
2473+ if data[i] != 0xFF {
2474+ i += 1;
2475+ continue;
2476+ }
2477+ let marker = data[i + 1];
2478+ if marker == 0xC0 || marker == 0xC2 {
2479+ let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
2480+ let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
2481+ return Some((w, h));
2482+ }
2483+ if i + 3 < data.len() {
2484+ let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2485+ i += 2 + len;
2486+ } else {
2487+ break;
2488+ }
2489+ }
2490+ }
2491+
2492+ // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
2493+ if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
2494+ if &data[12..16] == b"VP8 " && data.len() >= 30 {
2495+ let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
2496+ let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
2497+ return Some((w, h));
2498+ }
2499+ if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
2500+ let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
2501+ let w = (bits & 0x3FFF) + 1;
2502+ let h = ((bits >> 14) & 0x3FFF) + 1;
2503+ return Some((w, h));
2504+ }
2505+ }
2506+
2507+ // AVIF: ISOBMFF container with "ftyp" box containing "avif"/"avis" brand,
2508+ // then "ispe" box with width/height
2509+ if data.len() >= 12 && &data[4..8] == b"ftyp" {
2510+ let brand = &data[8..12];
2511+ if brand == b"avif" || brand == b"avis" || brand == b"mif1" {
2512+ return read_avif_dimensions(&data);
2513+ }
2514+ }
2515+
2516+ None
2517+}
2518+
2519+/// Parse SVG viewBox or width/height attributes to get dimensions.
2520+fn read_svg_dimensions(text: &str) -> Option<(u32, u32)> {
2521+ // Try viewBox first: viewBox="minX minY width height"
2522+ if let Some(vb_start) = text.find("viewBox=\"") {
2523+ let rest = &text[vb_start + 9..];
2524+ if let Some(end) = rest.find('"') {
2525+ let parts: Vec<&str> = rest[..end].split_whitespace().collect();
2526+ if parts.len() == 4
2527+ && let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
2528+ && w > 0.0
2529+ && h > 0.0
2530+ {
2531+ return Some((w.round() as u32, h.round() as u32));
2532+ }
2533+ }
2534+ }
2535+ // Fall back to width/height attributes on <svg>
2536+ let svg_tag = text.find("<svg")?;
2537+ let tag_end = text[svg_tag..].find('>')? + svg_tag;
2538+ let tag = &text[svg_tag..tag_end];
2539+ let w = extract_svg_attr(tag, "width")?;
2540+ let h = extract_svg_attr(tag, "height")?;
2541+ Some((w, h))
2542+}
2543+
2544+fn extract_svg_attr(tag: &str, attr: &str) -> Option<u32> {
2545+ let needle = format!("{}=\"", attr);
2546+ let start = tag.find(&needle)? + needle.len();
2547+ let rest = &tag[start..];
2548+ let end = rest.find('"')?;
2549+ let val = rest[..end].trim_end_matches("px");
2550+ val.parse::<f64>().ok().map(|v| v.round() as u32)
2551+}
2552+
2553+/// Parse AVIF (ISOBMFF) container to find ispe box with image dimensions.
2554+fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
2555+ // Walk ISOBMFF boxes looking for "ispe" (image spatial extents)
2556+ let mut i = 0;
2557+ while i + 8 <= data.len() {
2558+ let box_size =
2559+ u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
2560+ let box_type = &data[i + 4..i + 8];
2561+ if box_size < 8 {
2562+ break;
2563+ }
2564+ let box_end = (i + box_size).min(data.len());
2565+ // ispe box: 4 bytes version/flags + 4 bytes width + 4 bytes height
2566+ if box_type == b"ispe" && box_end >= i + 20 {
2567+ let w = u32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
2568+ let h = u32::from_be_bytes([data[i + 16], data[i + 17], data[i + 18], data[i + 19]]);
2569+ return Some((w, h));
2570+ }
2571+ // Recurse into container boxes (meta, iprp, ipco)
2572+ if matches!(
2573+ box_type,
2574+ b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia"
2575+ ) {
2576+ let header_size = if box_type == b"meta" { 12 } else { 8 };
2577+ if i + header_size < box_end
2578+ && let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end])
2579+ {
2580+ return Some(dims);
2581+ }
2582+ }
2583+ i = box_end;
2584+ }
2585+ None
2586+}
2587+
2338fn html_escape(s: &str) -> String {
2588fn html_escape(s: &str) -> String {
2339 s.replace('&', "&amp;")
2589 s.replace('&', "&amp;")
2340 .replace('<', "&lt;")
2590 .replace('<', "&lt;")
@@ -2450,30 +2700,13 @@ fn vlq_encode(value: i64, out: &mut String) {
2450 }
2700 }
2451}
2701}
2452
2702
2453−#[cfg(test)]
2703+
2454−mod tests {
2704+/// Does the tree contain the table element `name` (e.g. `"main"`)?
2455− use super::*;
2705+fn has_tag(nodes: &[Node], name: &str) -> bool {
2456−
2706+ nodes.iter().any(|node| match node {
2457− #[test]
2707+ Node::Element(elem) => elem.kind.is_tag(name) || has_tag(&elem.children, name),
2458− fn a_name_is_a_stable_hash_of_the_style() {
2708+ _ => false,
2459− // The same in every build and on every platform
2709+ })
2460− assert_eq!(stable_hash(""), 0xf52a_15e9_a9b5_e89b);
2461− assert_eq!(hash_digits(35, 3), "z00");
2462− assert_eq!(hash_digits(u64::MAX, 13), "fsgs46211e5w3");
2463− }
2464−
2465− #[test]
2466− fn a_name_taken_in_the_file_takes_more_digits() {
2467− // These two styles' hashes end in the same 7 digits
2468− let mut styles = StyleCollector::new();
2469− let first = styles.get_class("width:129555px;".into(), Vec::new(), String::new());
2470− let second = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
2471− let (first, second) = (first.unwrap(), second.unwrap());
2472− assert_eq!(first.len(), 3 + CLASS_DIGITS, "{first}");
2473− assert_eq!(second.len(), 3 + CLASS_DIGITS + 1, "{second}");
2474− assert!(second.starts_with(&first), "{first} {second}");
2475− // The same style gets the name it got before
2476− let again = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
2477− assert_eq!(again.as_deref(), Some(second.as_str()));
2478− }
2479}
2710}
2711+
2712+
crates/htmlang-core/src/codegen_tests.rs+8−33
@@ -1,8 +1,6 @@
1#[cfg(test)]
1#[cfg(test)]
2mod tests {
2mod tests {
3− use crate::codegen::{
3+ use crate::codegen::{generate, generate_dev, generate_partial, short_class_name};
4− CodegenOptions, generate, generate_dev, generate_partial, generate_with_classes,
5− };
6 use crate::parser::parse;
4 use crate::parser::parse;
7
5
8 fn compile(src: &str) -> String {
6 fn compile(src: &str) -> String {
@@ -49,42 +47,19 @@ mod tests {
49
47
50 #[test]
48 #[test]
51 fn dev_mode_is_deterministic_across_runs() {
49 fn dev_mode_is_deterministic_across_runs() {
52− let src =
50+ let src = "@row [spacing 10]\n @text [bold] a\n @text [italic] b\n";
53− "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
54 let r1 = parse(src);
51 let r1 = parse(src);
55 let r2 = parse(src);
52 let r2 = parse(src);
56 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
53 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
57 }
54 }
58
55
59− /// The classes of `src`, compiled as a fragment
60− fn classes(src: &str) -> Vec<crate::codegen::Class> {
61− let options = CodegenOptions {
62− partial: true,
63− ..Default::default()
64− };
65− generate_with_classes(&parse(src).document, &options).1
66− }
67−
68 #[test]
56 #[test]
69− fn a_style_has_one_name_in_every_file() {
57+ fn short_class_name_is_stable() {
70− // A fragment's classes mean in a page what they mean in the
58+ assert_eq!(short_class_name(0), "a");
71− // fragment, whatever else either holds
59+ assert_eq!(short_class_name(25), "z");
72− let page = classes("@el [padding 10, background red] a\n@el [gap 4] b\n");
60+ // Higher indexes must produce unique, stable names.
73− let fragment = classes("@row [margin 2] x\n@el [padding 10, background red] y\n");
61+ let names: std::collections::HashSet<_> = (0..50_000).map(short_class_name).collect();
74− assert_eq!(page[0], fragment[1]);
62+ assert_eq!(names.len(), 50_000, "short_class_name collisions");
75− assert_ne!(page[1].style, fragment[0].style);
76− assert_ne!(page[1].name, fragment[0].name);
77− for class in page.iter().chain(&fragment) {
78− let digits = class.name.strip_prefix("hl-").expect("hl-");
79− assert_eq!(digits.len(), crate::codegen::CLASS_DIGITS, "{}", class.name);
80− assert!(
81− digits
82− .bytes()
83− .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase()),
84− "{}",
85− class.name
86− );
87− }
88 }
63 }
89
64
90 #[test]
65 #[test]
crates/htmlang-core/src/diagnostic.rsdeleted−245
@@ -1,245 +0,0 @@
1−//! Diagnostics: what the compiler reports about a source file.
2−//!
3−//! Every diagnostic has a stable `code` (see [`code`]), so tools such as the
4−//! language server key their quick fixes on the code rather than on the
5−//! wording of the message. `subject` and `suggestion` carry the name the
6−//! diagnostic is about and the name to use instead, when there is one.
7−
8−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9−pub enum Severity {
10− Error,
11− Warning,
12− Info,
13− Help,
14−}
15−
16−#[derive(Debug, Clone)]
17−pub struct Diagnostic {
18− /// Stable identifier of the kind of problem, e.g. `unknown-element`.
19− pub code: &'static str,
20− pub line: usize,
21− pub column: Option<usize>,
22− pub message: String,
23− pub severity: Severity,
24− pub source_line: Option<Box<str>>,
25− /// The name the diagnostic is about, as written (`@nosuch` without the
26− /// `@`, an attribute key, a definition's name).
27− pub subject: Option<Box<str>>,
28− /// The name to write instead of `subject` ("did you mean ...?").
29− pub suggestion: Option<Box<str>>,
30−}
31−
32−impl Diagnostic {
33− pub fn new(code: &'static str, severity: Severity, line: usize, message: String) -> Self {
34− Diagnostic {
35− code,
36− line,
37− column: None,
38− message,
39− severity,
40− source_line: None,
41− subject: None,
42− suggestion: None,
43− }
44− }
45−
46− pub fn error(code: &'static str, line: usize, message: String) -> Self {
47− Self::new(code, Severity::Error, line, message)
48− }
49−
50− pub fn warning(code: &'static str, line: usize, message: String) -> Self {
51− Self::new(code, Severity::Warning, line, message)
52− }
53−
54− pub fn column(mut self, column: usize) -> Self {
55− self.column = Some(column);
56− self
57− }
58−
59− pub fn source(mut self, source: impl Into<String>) -> Self {
60− self.source_line = Some(source.into().into_boxed_str());
61− self
62− }
63−
64− pub fn subject(mut self, subject: impl Into<String>) -> Self {
65− self.subject = Some(subject.into().into_boxed_str());
66− self
67− }
68−
69− pub fn suggest(mut self, suggestion: Option<impl Into<String>>) -> Self {
70− self.suggestion = suggestion.map(|s| s.into().into_boxed_str());
71− self
72− }
73−}
74−
75−/// The diagnostic codes. A code names a kind of problem and never changes,
76−/// even when a message is reworded.
77−pub mod code {
78− // Syntax: found while reading the file, before anything runs
79− pub const UNCLOSED_BRACKET: &str = "unclosed-bracket";
80− /// A `]` that ends an attribute list inside what reads as a `'...'`
81− /// string in a value (`hx-on:click=alert(']')`).
82− pub const UNBALANCED_QUOTE: &str = "unbalanced-quote";
83− pub const UNEXPECTED_BODY: &str = "unexpected-body";
84− pub const UNEXPECTED_ARGUMENT: &str = "unexpected-argument";
85− pub const MISSING_ARGUMENT: &str = "missing-argument";
86− pub const INVALID_LOOP: &str = "invalid-loop";
87− /// A `@let` whose name, parameters or body don't fit its kind.
88− pub const INVALID_DEFINITION: &str = "invalid-definition";
89− pub const STRAY_ELSE: &str = "stray-else";
90− /// Indentation with tabs and spaces mixed, which nests lines in ways
91− /// the eye doesn't see.
92− pub const MIXED_INDENTATION: &str = "mixed-indentation";
93−
94− // Names
95− pub const UNKNOWN_ELEMENT: &str = "unknown-element";
96− /// An attribute name htmlang doesn't know: a warning when CSS could
97− /// have the name (it is written to the CSS as it is), an error otherwise.
98− pub const UNKNOWN_ATTRIBUTE: &str = "unknown-attribute";
99− /// A misspelled prefix (`hovr:color red`).
100− pub const UNKNOWN_PREFIX: &str = "unknown-prefix";
101− /// A prefix the attribute can't take: a second one (`md:hover:`), or
102− /// one on an HTML attribute (`hover:required`).
103− pub const INVALID_PREFIX: &str = "invalid-prefix";
104− /// A style written without its value (`[padding]`).
105− pub const MISSING_VALUE: &str = "missing-value";
106− pub const HTML_ATTRIBUTE_FORM: &str = "html-attribute-form";
107− /// A second `@page`: a page has one root, so one `@page`.
108− pub const DUPLICATE_PAGE: &str = "duplicate-page";
109− pub const UNDEFINED_VARIABLE: &str = "undefined-variable";
110− /// An attribute, or an attribute's name, made from a variable.
111− pub const ATTRIBUTE_FROM_VARIABLE: &str = "attribute-from-variable";
112− pub const DUPLICATE_ATTRIBUTE: &str = "duplicate-attribute";
113−
114− // Values
115− /// A value that can't be written into the page's CSS (an error), or
116− /// one that can't mean what it says (a warning).
117− pub const INVALID_VALUE: &str = "invalid-value";
118− pub const INVALID_COLOR: &str = "invalid-color";
119− pub const INVALID_EXPRESSION: &str = "invalid-expression";
120− pub const INVALID_JSON: &str = "invalid-json";
121− /// A name htmlang doesn't know right after a `key=value`: the rest of
122− /// the value, which a comma ended (`hx-trigger=load, every 2s`).
123− pub const SPLIT_VALUE: &str = "split-value";
124− /// A leading argument taken as a URL that reads as text (`@link
125− /// [hx-get=/x] Open panel`, whose href is `Open`).
126− pub const TEXT_AS_URL: &str = "text-as-url";
127−
128− // Definitions and functions
129− pub const UNUSED_VARIABLE: &str = "unused-variable";
130− pub const UNUSED_BUNDLE: &str = "unused-bundle";
131− pub const UNUSED_FUNCTION: &str = "unused-function";
132− pub const RECURSIVE_CALL: &str = "recursive-call";
133− pub const NO_SINGLE_ROOT: &str = "no-single-root";
134− /// A call that leaves out a parameter without a default.
135− pub const MISSING_PARAMETER: &str = "missing-parameter";
136− /// A parameter passed `name=value`: parameters are written `name value`.
137− pub const PARAMETER_FORM: &str = "parameter-form";
138− /// A function named like a built-in element (which it replaces) or a
139− /// directive (which it can't replace).
140− pub const SHADOWS_BUILT_IN: &str = "shadows-built-in";
141− /// A `@slot NAME` block at a call whose function has no such slot.
142− pub const UNKNOWN_SLOT: &str = "unknown-slot";
143− /// Content passed to a function whose body has no `@children`.
144− pub const UNEXPECTED_CONTENT: &str = "unexpected-content";
145− /// A `@slot` or `@children` where it marks or fills nothing: outside
146− /// a function's body, inside an element at a call, or inline in text.
147− pub const MISPLACED_SLOT: &str = "misplaced-slot";
148− /// A slot name that isn't one name (`@slot my footer`).
149− pub const INVALID_SLOT_NAME: &str = "invalid-slot-name";
150−
151− // Files
152− pub const UNREADABLE_FILE: &str = "unreadable-file";
153− pub const CIRCULAR_INCLUDE: &str = "circular-include";
154− /// Two files of one build whose different styles got one class name:
155− /// a name is a hash of its style (`htmlang build` checks).
156− pub const CLASS_COLLISION: &str = "class-collision";
157− pub const UNSET_ENVIRONMENT: &str = "unset-environment";
158−
159− // Context: an attribute or element where it has no effect
160− /// An attribute the element ignores (a warning), or a layout word such
161− /// as `spacing` on an element that doesn't lay out its children (an
162− /// error: it is left out).
163− pub const NO_EFFECT: &str = "no-effect";
164− /// An element whose HTML ends a `<p>`, written inside a `@paragraph`.
165− pub const BLOCK_IN_PARAGRAPH: &str = "block-in-paragraph";
166−
167− // Accessibility
168− pub const MISSING_ALT: &str = "missing-alt";
169− pub const MISSING_INPUT_TYPE: &str = "missing-input-type";
170− pub const MISSING_LINK_TEXT: &str = "missing-link-text";
171− pub const MISSING_LABEL: &str = "missing-label";
172− pub const MISSING_TITLE: &str = "missing-title";
173− pub const MISSING_BUTTON_TEXT: &str = "missing-button-text";
174− pub const MISSING_CAPTIONS: &str = "missing-captions";
175− pub const LOW_CONTRAST: &str = "low-contrast";
176− pub const POSITIVE_TABINDEX: &str = "positive-tabindex";
177−
178− // `htmlang lint`
179− pub const DEEP_NESTING: &str = "deep-nesting";
180− pub const EMPTY_CONTAINER: &str = "empty-container";
181− pub const MISSING_BUTTON_TYPE: &str = "missing-button-type";
182−
183− /// A bug in the compiler itself.
184− pub const INTERNAL: &str = "internal";
185−
186− /// Every code, for tools and tests.
187− pub const ALL: &[&str] = &[
188− UNCLOSED_BRACKET,
189− UNBALANCED_QUOTE,
190− UNEXPECTED_BODY,
191− UNEXPECTED_ARGUMENT,
192− MISSING_ARGUMENT,
193− INVALID_LOOP,
194− INVALID_DEFINITION,
195− STRAY_ELSE,
196− MIXED_INDENTATION,
197− UNKNOWN_ELEMENT,
198− UNKNOWN_ATTRIBUTE,
199− UNKNOWN_PREFIX,
200− INVALID_PREFIX,
201− MISSING_VALUE,
202− HTML_ATTRIBUTE_FORM,
203− DUPLICATE_PAGE,
204− UNDEFINED_VARIABLE,
205− ATTRIBUTE_FROM_VARIABLE,
206− DUPLICATE_ATTRIBUTE,
207− INVALID_VALUE,
208− INVALID_COLOR,
209− INVALID_EXPRESSION,
210− INVALID_JSON,
211− SPLIT_VALUE,
212− TEXT_AS_URL,
213− UNUSED_VARIABLE,
214− UNUSED_BUNDLE,
215− UNUSED_FUNCTION,
216− RECURSIVE_CALL,
217− NO_SINGLE_ROOT,
218− MISSING_PARAMETER,
219− PARAMETER_FORM,
220− SHADOWS_BUILT_IN,
221− UNKNOWN_SLOT,
222− UNEXPECTED_CONTENT,
223− MISPLACED_SLOT,
224− INVALID_SLOT_NAME,
225− UNREADABLE_FILE,
226− CIRCULAR_INCLUDE,
227− CLASS_COLLISION,
228− UNSET_ENVIRONMENT,
229− NO_EFFECT,
230− BLOCK_IN_PARAGRAPH,
231− MISSING_ALT,
232− MISSING_INPUT_TYPE,
233− MISSING_LINK_TEXT,
234− MISSING_LABEL,
235− MISSING_TITLE,
236− MISSING_BUTTON_TEXT,
237− MISSING_CAPTIONS,
238− LOW_CONTRAST,
239− POSITIVE_TABINDEX,
240− DEEP_NESTING,
241− EMPTY_CONTAINER,
242− MISSING_BUTTON_TYPE,
243− INTERNAL,
244− ];
245−}
crates/htmlang-core/src/expr.rs+280−483
@@ -1,23 +1,18 @@
1//! Expressions: conditions (`@if $count > 2 and not $hidden`), computed
1//! Expressions: conditions (`@if $count > 2 and not $hidden`), computed
2−//! values (`@let gap = $base * 2`) and `${...}` in text and values
2+//! values (`@let gap = $base * 2`) and `if(cond, a, b)`.
3−//! (`${if($featured, 24, 0)}`).
4//!
3//!
5//! Expressions are tokenized and evaluated as a whole. Variables are looked
4//! Expressions are tokenized and evaluated as a whole. Variables are looked
6//! up while evaluating, never pasted into the text first, so a value that
5//! up while evaluating, never pasted into the text first, so a value that
7//! happens to contain `==` or spaces can't change how an expression parses.
6//! happens to contain `==` or spaces can't change how an expression parses.
8//!
7//!
9−//! Only what decides the result is evaluated: `if()` evaluates the branch
10−//! it takes, `and` and `or` stop at the first side that decides. The rest
11−//! is still parsed (and its function names and argument counts checked),
12−//! so `$n != 0 and 10 / $n > 1` is false for `$n` = 0, not an error.
13−//!
14//! ```text
8//! ```text
15//! expr := or
9//! expr := or
16//! or := and ("or" and)*
10//! or := and ("or" and)*
17//! and := not ("and" not)*
11//! and := not ("and" not)*
18//! not := "not" not | compare
12//! not := "not" not | compare
19−//! compare := range (("==" | "!=" | "<" | ">" | "<=" | ">=") range)?
13+//! compare := concat (("==" | "!=" | "<" | ">" | "<=" | ">=" | "contains"
20−//! range := sum (".." sum ("step" sum)?)?
14+//! | "starts-with" | "ends-with") concat)?
15+//! concat := sum ("~" sum)*
21//! sum := product (("+" | "-") product)*
16//! sum := product (("+" | "-") product)*
22//! product := unary (("*" | "/" | "%") unary)*
17//! product := unary (("*" | "/" | "%") unary)*
23//! unary := "-" unary | primary
18//! unary := "-" unary | primary
@@ -25,89 +20,68 @@
25//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
20//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
26//! ```
21//! ```
27//!
22//!
28−//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is text. Values are
23+//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string. Functions:
29−//! typed (see `value.rs`, which also defines truthiness and comparison):
24+//! `if(cond, a, b)`, text (`uppercase`, `lowercase`, `capitalize`, `trim`,
30−//! `$name` keeps whatever it holds, a list or a record included, and
25+//! `length`, `reverse`, `truncate(s, n)`, `replace(s, old, new)`,
31−//! `A..B` (with `step N`) is a list of whole numbers. Functions:
26+//! `default(s, fallback)`) and colors (`lighten(c, pct)`, `darken(c, pct)`,
32−//! `if(cond, a, b)` (`b` may be left out: empty), tests (`contains(s, x)`,
27+//! `alpha(c, a)`, `mix(c1, c2, pct)`). In text, `${expr}` interpolates an
33−//! `starts-with(s, x)`, `ends-with(s, x)`), text (`uppercase`, `lowercase`,
28+//! expression.
34−//! `capitalize`, `trim`, `length`, `reverse`, `truncate(s, n)`,
35−//! `replace(s, old, new)`, `default(s, fallback)`). A colour variant is
36−//! CSS, not a function here: `color-mix(in srgb, var(--brand), black 8%)`
37−//! is an attribute's value, written as it is.
38−//! `length`, `contains` and `reverse` work on the items of a list; the other
39−//! text functions take text, and a list is an error (a text with commas in
40−//! it is written in quotes or with `\,`, so it isn't a list).
41−//!
42−//! A `$name` ends as it does in text (see `interp.rs`), so `$post.title`
43−//! reads a field and `$a..$b` is two names. An undefined name is an error.
44
29
45use std::fmt;
30use std::fmt;
46
31
47−use crate::interp::{self, Problem, Reference, Scope};
48−
49−pub use crate::value::Value;
50−
51−/// Why an expression has no value.
52#[derive(Debug, Clone, PartialEq)]
32#[derive(Debug, Clone, PartialEq)]
53−pub enum Error {
33+pub enum Value {
54− /// `$name` has no definition; `offset` is where the `$` is.
34+ Num(f64),
55− Undefined { name: String, offset: usize },
35+ Str(String),
56− /// Anything else: a syntax error, a type error, division by zero.
36+ Bool(bool),
57− Invalid(String),
58}
37}
59
38
60−impl Error {
39+impl Value {
61− /// This error as a problem of a text in which the expression starts
40+ /// Empty strings, `false`, `0` and `"false"` / `"0"` are false.
62− /// at `base`.
41+ pub fn truthy(&self) -> bool {
63− pub(crate) fn at(self, base: usize) -> Problem {
64 match self {
42 match self {
65− Error::Undefined { name, offset } => Problem::Undefined {
43+ Value::Bool(b) => *b,
66− name,
44+ Value::Num(n) => *n != 0.0,
67− offset: base + offset,
45+ Value::Str(s) => !s.is_empty() && s != "false" && s != "0",
68− },
69− Error::Invalid(message) => Problem::Invalid {
70− message,
71− offset: base,
72− },
73 }
46 }
74 }
47 }
75−}
76
48
77−impl From<String> for Error {
49+ fn as_num(&self) -> Option<f64> {
78− fn from(message: String) -> Self {
50+ match self {
79− Error::Invalid(message)
51+ Value::Num(n) => Some(*n),
52+ Value::Str(s) => s.trim().parse().ok(),
53+ Value::Bool(_) => None,
54+ }
80 }
55 }
81}
56}
82
57
83−impl fmt::Display for Error {
58+impl fmt::Display for Value {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
60 match self {
86− Error::Undefined { name, .. } => write!(f, "undefined variable '${}'", name),
61+ Value::Num(n) if n.fract() == 0.0 && n.abs() < 1e15 => write!(f, "{}", *n as i64),
87− Error::Invalid(message) => f.write_str(message),
62+ Value::Num(n) => write!(f, "{}", n),
63+ Value::Str(s) => f.write_str(s),
64+ Value::Bool(b) => write!(f, "{}", b),
88 }
65 }
89 }
66 }
90}
67}
91
68
92−/// Evaluate `src` with the variables of `scope`. An undefined variable is
69+/// Resolves a variable name (the text after `$`). Returns `None` for
93−/// an error; a field that a record doesn't have is empty.
70+/// undefined variables.
94−pub fn eval(src: &str, scope: &dyn Scope) -> Result<Value, Error> {
71+pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<String>;
95− let mut tokens = Vec::new();
72+
96− tokenize(src, scope, 0, &mut tokens)?;
73+/// Evaluate `src`. Undefined variables are empty strings.
74+pub fn eval(src: &str, resolve: Resolver) -> Result<Value, String> {
75+ let tokens = tokenize(src)?;
97 let mut parser = Parser {
76 let mut parser = Parser {
98 tokens: &tokens,
77 tokens: &tokens,
99 pos: 0,
78 pos: 0,
100− scope,
79+ resolve,
101− skip: false,
102 };
80 };
103 let value = parser.expr()?;
81 let value = parser.expr()?;
104 match parser.tokens.get(parser.pos) {
82 match parser.tokens.get(parser.pos) {
105 None => Ok(value),
83 None => Ok(value),
106− Some((tok, _)) => Err(Error::Invalid(format!(
84+ Some(tok) => Err(format!("unexpected {} in `{}`", tok, src.trim())),
107− "unexpected {} in `{}`",
108− tok,
109− src.trim()
110− ))),
111 }
85 }
112}
86}
113
87
@@ -138,116 +112,84 @@ impl fmt::Display for Token {
138 }
112 }
139}
113}
140
114
141−const OPERATORS: &[&str] = &[
115+const OPERATORS: &[&str] = &["==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%", "~"];
142− "..", "==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%",
116+
143−];
117+fn tokenize(src: &str) -> Result<Vec<Token>, String> {
144−
118+ let chars: Vec<char> = src.chars().collect();
145−/// Read `src` into tokens, each with its byte offset (plus `base`).
119+ let mut tokens = Vec::new();
146−/// Variable names end as in text (see `interp.rs`), and `${...}` is a
147−/// variable (`${name}`) or a parenthesized expression.
148−fn tokenize(
149− src: &str,
150− scope: &dyn Scope,
151− base: usize,
152− tokens: &mut Vec<(Token, usize)>,
153−) -> Result<(), Error> {
154− let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
155 let mut i = 0;
120 let mut i = 0;
156− while let Some(c) = src[i..].chars().next() {
121+ let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
157− let at = base + i;
122+ while i < chars.len() {
123+ let c = chars[i];
158 if c.is_whitespace() {
124 if c.is_whitespace() {
159− i += c.len_utf8();
125+ i += 1;
160 } else if c == '(' {
126 } else if c == '(' {
161− tokens.push((Token::LParen, at));
127+ tokens.push(Token::LParen);
162 i += 1;
128 i += 1;
163 } else if c == ')' {
129 } else if c == ')' {
164− tokens.push((Token::RParen, at));
130+ tokens.push(Token::RParen);
165 i += 1;
131 i += 1;
166 } else if c == ',' {
132 } else if c == ',' {
167− tokens.push((Token::Comma, at));
133+ tokens.push(Token::Comma);
168 i += 1;
134 i += 1;
169 } else if c == '"' {
135 } else if c == '"' {
170 let start = i + 1;
136 let start = i + 1;
171− let end = src[start..]
137+ let end = chars[start..]
172− .find('"')
138+ .iter()
139+ .position(|&c| c == '"')
173 .map(|p| start + p)
140 .map(|p| start + p)
174 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
141 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
175− tokens.push((Token::Str(src[start..end].to_string()), at));
142+ tokens.push(Token::Str(chars[start..end].iter().collect()));
176 i = end + 1;
143 i = end + 1;
177 } else if c == '$' {
144 } else if c == '$' {
178− match interp::reference(&src[i + 1..], scope) {
145+ let start = i + 1;
179− Some((Reference::Var(path), len)) => {
146+ let mut end = start;
180− tokens.push((Token::Var(path), at));
147+ while end < chars.len() && is_var_char(chars[end]) {
181− i += 1 + len;
148+ end += 1;
182− }
149+ }
183− Some((Reference::Expr(inner), len)) => {
150+ while end > start && chars[end - 1] == '.' {
184− tokens.push((Token::LParen, at));
151+ end -= 1;
185− tokenize(inner, scope, at + 2, tokens)?;
186− tokens.push((Token::RParen, at + len));
187− i += 1 + len;
188− }
189− None => {
190− let n = interp::custom_property_len(&src[i + 1..]);
191− return Err(Error::Invalid(match n {
192− 0 => format!("`$` without a variable name in `{}`", src.trim()),
193− n => interp::custom_property_message(&src[i + 1..i + 1 + n]),
194− }));
195− }
196 }
152 }
153+ if end == start {
154+ return Err(format!("`$` without a variable name in `{}`", src.trim()));
155+ }
156+ tokens.push(Token::Var(chars[start..end].iter().collect()));
157+ i = end;
197 } else if let Some(op) = OPERATORS
158 } else if let Some(op) = OPERATORS
198 .iter()
159 .iter()
199− .find(|op| src[i..].starts_with(**op))
160+ .find(|op| chars[i..].iter().take(op.len()).copied().eq(op.chars()))
200 // A `-` followed by a letter starts a word (`-webkit-box`);
161 // A `-` followed by a letter starts a word (`-webkit-box`);
201 // inside words (`ease-in`) it is consumed by the word itself.
162 // inside words (`ease-in`) it is consumed by the word itself.
202− .filter(|op| **op != "-" || !src[i + 1..].starts_with(char::is_alphabetic))
163+ .filter(|op| **op != "-" || !chars.get(i + 1).is_some_and(|c| c.is_alphabetic()))
203 {
164 {
204− tokens.push((Token::Op(op), at));
165+ tokens.push(Token::Op(op));
205 i += op.len();
166 i += op.len();
206 } else if is_word_char(c) {
167 } else if is_word_char(c) {
207 let start = i;
168 let start = i;
208− // A word ends at `..`, so `1..5` is a range
169+ while i < chars.len() && is_word_char(chars[i]) {
209− while let Some(c) = src[i..]
170+ i += 1;
210− .chars()
211− .next()
212− .filter(|&c| is_word_char(c) && !src[i..].starts_with(".."))
213− {
214− i += c.len_utf8();
215 }
171 }
216− let word = &src[start..i];
172+ let word: String = chars[start..i].iter().collect();
217− tokens.push((
173+ tokens.push(match word.parse::<f64>() {
218− match word.parse::<f64>() {
174+ Ok(n) if !word.ends_with('.') => Token::Num(n),
219− Ok(n) if !word.ends_with('.') => Token::Num(n),
175+ _ => Token::Word(word),
220− _ => Token::Word(word.to_string()),
176+ });
221− },
222− at,
223− ));
224 } else {
177 } else {
225− return Err(Error::Invalid(format!(
178+ return Err(format!("unexpected `{}` in `{}`", c, src.trim()));
226− "unexpected `{}` in `{}`",
227− c,
228− src.trim()
229− )));
230 }
179 }
231 }
180 }
232− Ok(())
181+ Ok(tokens)
233}
182}
234
183
235struct Parser<'a> {
184struct Parser<'a> {
236− tokens: &'a [(Token, usize)],
185+ tokens: &'a [Token],
237 pos: usize,
186 pos: usize,
238− scope: &'a dyn Scope,
187+ resolve: Resolver<'a>,
239− /// Parsing a part whose value isn't needed (the branch `if()` doesn't
240− /// take, the side of `and`/`or` that doesn't decide): it is read and
241− /// checked for syntax, not evaluated.
242− skip: bool,
243}
188}
244
189
245−/// The value a skipped part stands for; nothing reads it.
246−const SKIPPED: Value = Value::Bool(false);
247−
248impl Parser<'_> {
190impl Parser<'_> {
249 fn peek(&self) -> Option<&Token> {
191 fn peek(&self) -> Option<&Token> {
250− self.tokens.get(self.pos).map(|(token, _)| token)
192+ self.tokens.get(self.pos)
251 }
193 }
252
194
253 fn eat_word(&mut self, word: &str) -> bool {
195 fn eat_word(&mut self, word: &str) -> bool {
@@ -270,167 +212,128 @@ impl Parser<'_> {
270 }
212 }
271 }
213 }
272
214
273− fn expect(&mut self, token: Token) -> Result<(), Error> {
215+ fn expect(&mut self, token: Token) -> Result<(), String> {
274 match self.peek() {
216 match self.peek() {
275 Some(t) if *t == token => {
217 Some(t) if *t == token => {
276 self.pos += 1;
218 self.pos += 1;
277 Ok(())
219 Ok(())
278 }
220 }
279− Some(t) => Err(format!("expected {} but found {}", token, t).into()),
221+ Some(t) => Err(format!("expected {} but found {}", token, t)),
280− None => Err(format!("expected {} at the end", token).into()),
222+ None => Err(format!("expected {} at the end", token)),
281 }
223 }
282 }
224 }
283
225
284− /// Parse with `f`, skipping evaluation when `skip` (or when already
226+ fn expr(&mut self) -> Result<Value, String> {
285− /// skipping).
286− fn skipping(
287− &mut self,
288− skip: bool,
289− f: impl FnOnce(&mut Self) -> Result<Value, Error>,
290− ) -> Result<Value, Error> {
291− let outer = self.skip;
292− self.skip |= skip;
293− let value = f(self);
294− self.skip = outer;
295− value
296− }
297−
298− fn expr(&mut self) -> Result<Value, Error> {
299 let mut left = self.and()?;
227 let mut left = self.and()?;
300 while self.eat_word("or") {
228 while self.eat_word("or") {
301− let decided = left.truthy();
229+ let right = self.and()?;
302− let right = self.skipping(decided, Self::and)?;
230+ left = Value::Bool(left.truthy() || right.truthy());
303− left = Value::Bool(decided || right.truthy());
304 }
231 }
305 Ok(left)
232 Ok(left)
306 }
233 }
307
234
308− fn and(&mut self) -> Result<Value, Error> {
235+ fn and(&mut self) -> Result<Value, String> {
309 let mut left = self.not()?;
236 let mut left = self.not()?;
310 while self.eat_word("and") {
237 while self.eat_word("and") {
311− let decided = !left.truthy();
238+ let right = self.not()?;
312− let right = self.skipping(decided, Self::not)?;
239+ left = Value::Bool(left.truthy() && right.truthy());
313− left = Value::Bool(!decided && right.truthy());
314 }
240 }
315 Ok(left)
241 Ok(left)
316 }
242 }
317
243
318− fn not(&mut self) -> Result<Value, Error> {
244+ fn not(&mut self) -> Result<Value, String> {
319 if self.eat_word("not") {
245 if self.eat_word("not") {
320 return Ok(Value::Bool(!self.not()?.truthy()));
246 return Ok(Value::Bool(!self.not()?.truthy()));
321 }
247 }
322 self.compare()
248 self.compare()
323 }
249 }
324
250
325− fn compare(&mut self) -> Result<Value, Error> {
251+ fn compare(&mut self) -> Result<Value, String> {
326− let left = self.range()?;
252+ let left = self.concat()?;
327− let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
253+ let op = if let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) {
254+ op
255+ } else if self.eat_word("contains") {
256+ "contains"
257+ } else if self.eat_word("starts-with") {
258+ "starts-with"
259+ } else if self.eat_word("ends-with") {
260+ "ends-with"
261+ } else {
328 return Ok(left);
262 return Ok(left);
329 };
263 };
330− let right = self.range()?;
264+ let right = self.concat()?;
331− if self.skip {
265+ let numbers = left.as_num().zip(right.as_num());
332− return Ok(SKIPPED);
266+ let (l, r) = (left.to_string(), right.to_string());
267+ Ok(Value::Bool(match (op, numbers) {
268+ ("==", Some((a, b))) => a == b,
269+ ("!=", Some((a, b))) => a != b,
270+ ("<", Some((a, b))) => a < b,
271+ (">", Some((a, b))) => a > b,
272+ ("<=", Some((a, b))) => a <= b,
273+ (">=", Some((a, b))) => a >= b,
274+ ("==", None) => l == r,
275+ ("!=", None) => l != r,
276+ ("<", None) => l < r,
277+ (">", None) => l > r,
278+ ("<=", None) => l <= r,
279+ (">=", None) => l >= r,
280+ ("contains", _) => l.contains(&r),
281+ ("starts-with", _) => l.starts_with(&r),
282+ ("ends-with", _) => l.ends_with(&r),
283+ _ => unreachable!(),
284+ }))
285+ }
286+
287+ fn concat(&mut self) -> Result<Value, String> {
288+ let mut left = self.sum()?;
289+ while self.eat_op(&["~"]).is_some() {
290+ let right = self.sum()?;
291+ left = Value::Str(format!("{}{}", left, right));
333 }
292 }
334− Ok(Value::Bool(left.compare(op, &right)?))
293+ Ok(left)
335− }
336−
337− /// `A..B` or `A..B step N`: the whole numbers from A to B.
338− fn range(&mut self) -> Result<Value, Error> {
339− let start = self.sum()?;
340− if self.eat_op(&[".."]).is_none() {
341− return Ok(start);
342− }
343− let end = self.sum()?;
344− let step = match self.eat_word("step") {
345− true => Some(self.sum()?),
346− false => None,
347− };
348− if self.skip {
349− return Ok(SKIPPED);
350− }
351− let number = |value: &Value| {
352− value.as_num().ok_or_else(|| {
353− Error::Invalid(format!(
354− "a range goes from one number to another, not from {} (`{}`)",
355− value.describe(),
356− value
357− ))
358− })
359− };
360− let step = match &step {
361− Some(step) => number(step)?,
362− None => 1.0,
363− };
364− Ok(crate::value::range(number(&start)?, number(&end)?, step)?)
365 }
294 }
366
295
367− fn sum(&mut self) -> Result<Value, Error> {
296+ fn sum(&mut self) -> Result<Value, String> {
368 let mut left = self.product()?;
297 let mut left = self.product()?;
369 while let Some(op) = self.eat_op(&["+", "-"]) {
298 while let Some(op) = self.eat_op(&["+", "-"]) {
370 let right = self.product()?;
299 let right = self.product()?;
371− left = self.arithmetic(op, &left, &right)?;
300+ left = arithmetic(op, &left, &right)?;
372 }
301 }
373 Ok(left)
302 Ok(left)
374 }
303 }
375
304
376− fn product(&mut self) -> Result<Value, Error> {
305+ fn product(&mut self) -> Result<Value, String> {
377 let mut left = self.unary()?;
306 let mut left = self.unary()?;
378 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
307 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
379 let right = self.unary()?;
308 let right = self.unary()?;
380− left = self.arithmetic(op, &left, &right)?;
309+ left = arithmetic(op, &left, &right)?;
381 }
310 }
382 Ok(left)
311 Ok(left)
383 }
312 }
384
313
385− fn unary(&mut self) -> Result<Value, Error> {
314+ fn unary(&mut self) -> Result<Value, String> {
386 if self.eat_op(&["-"]).is_some() {
315 if self.eat_op(&["-"]).is_some() {
387 let value = self.unary()?;
316 let value = self.unary()?;
388− if self.skip {
389− return Ok(SKIPPED);
390− }
391 return value
317 return value
392 .as_num()
318 .as_num()
393 .map(|n| Value::Num(-n))
319 .map(|n| Value::Num(-n))
394− .ok_or_else(|| Error::Invalid(format!("cannot negate `{}`", value)));
320+ .ok_or_else(|| format!("cannot negate `{}`", value));
395 }
321 }
396 self.primary()
322 self.primary()
397 }
323 }
398
324
399− fn primary(&mut self) -> Result<Value, Error> {
325+ fn primary(&mut self) -> Result<Value, String> {
400− let (token, at) = self
326+ let token = self
401− .tokens
327+ .peek()
402− .get(self.pos)
403 .cloned()
328 .cloned()
404 .ok_or_else(|| "expected a value at the end".to_string())?;
329 .ok_or_else(|| "expected a value at the end".to_string())?;
405 self.pos += 1;
330 self.pos += 1;
406 match token {
331 match token {
407 Token::Num(n) => Ok(Value::Num(n)),
332 Token::Num(n) => Ok(Value::Num(n)),
408− Token::Str(_) | Token::Var(_) if self.skip => Ok(SKIPPED),
333+ Token::Str(s) => Ok(Value::Str(interpolate(&s, self.resolve))),
409− Token::Str(s) => {
334+ Token::Var(name) => Ok(Value::Str((self.resolve)(&name).unwrap_or_default())),
410− let (text, problems) = interp::interpolate(&s, self.scope);
411− match problems.into_iter().next() {
412− None => Ok(Value::Str(text)),
413− Some(Problem::Undefined { name, offset }) => Err(Error::Undefined {
414− name,
415− offset: at + 1 + offset,
416− }),
417− Some(Problem::Invalid { message, .. } | Problem::Record { message, .. }) => {
418− Err(Error::Invalid(message))
419− }
420− Some(Problem::CustomProperty { name, .. }) => {
421− Err(Error::Invalid(interp::custom_property_message(&name)))
422− }
423− }
424− }
425− Token::Var(name) => {
426− interp::resolve(&name, self.scope).ok_or(Error::Undefined { name, offset: at })
427− }
428 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
335 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
429 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
336 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
430− Token::Word(name) if name == "if" && self.peek() == Some(&Token::LParen) => {
431− self.pos += 1;
432− self.choice()
433− }
434 Token::Word(name) if self.peek() == Some(&Token::LParen) => {
337 Token::Word(name) if self.peek() == Some(&Token::LParen) => {
435 self.pos += 1;
338 self.pos += 1;
436 let mut args = Vec::new();
339 let mut args = Vec::new();
@@ -442,11 +345,7 @@ impl Parser<'_> {
442 }
345 }
443 }
346 }
444 self.expect(Token::RParen)?;
347 self.expect(Token::RParen)?;
445− check_call(&name, args.len())?;
348+ call(&name, args)
446− if self.skip {
447− return Ok(SKIPPED);
448− }
449− Ok(call(&name, args)?)
450 }
349 }
451 Token::Word(w) => Ok(Value::Str(w)),
350 Token::Word(w) => Ok(Value::Str(w)),
452 Token::LParen => {
351 Token::LParen => {
@@ -454,43 +353,8 @@ impl Parser<'_> {
454 self.expect(Token::RParen)?;
353 self.expect(Token::RParen)?;
455 Ok(value)
354 Ok(value)
456 }
355 }
457− other => Err(format!("unexpected {}", other).into()),
356+ other => Err(format!("unexpected {}", other)),
458− }
459− }
460−}
461−
462−impl Parser<'_> {
463− /// `if(cond, a, b)` after its `(`: the branch that `cond` picks is
464− /// evaluated, the other only read. Without `b`, a false `cond` gives
465− /// an empty value.
466− fn choice(&mut self) -> Result<Value, Error> {
467− let arguments =
468− || Error::Invalid("if() takes 2 or 3 arguments: if(CONDITION, A, B)".into());
469− let condition = self.expr()?;
470− match self.peek() {
471− Some(Token::RParen) | None => return Err(arguments()),
472− _ => self.expect(Token::Comma)?,
473− }
474− let taken = condition.truthy();
475− let then = self.skipping(!taken, Self::expr)?;
476− let otherwise = if self.peek() == Some(&Token::Comma) {
477− self.pos += 1;
478− self.skipping(taken, Self::expr)?
479− } else {
480− Value::Str(String::new())
481− };
482− if self.peek() == Some(&Token::Comma) {
483− return Err(arguments());
484− }
485− self.expect(Token::RParen)?;
486− Ok(if taken { then } else { otherwise })
487− }
488−
489− fn arithmetic(&self, op: &str, left: &Value, right: &Value) -> Result<Value, Error> {
490− if self.skip {
491− return Ok(SKIPPED);
492 }
357 }
493− Ok(arithmetic(op, left, right)?)
494 }
358 }
495}
359}
496
360
@@ -510,137 +374,167 @@ fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
510 }))
374 }))
511}
375}
512
376
513−/// The built-in functions (other than `if`) and how many arguments each
377+fn is_var_char(c: char) -> bool {
514−/// takes.
378+ c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
515−const FUNCTIONS: &[(&str, usize)] = &[
516− ("contains", 2),
517− ("starts-with", 2),
518− ("ends-with", 2),
519− ("uppercase", 1),
520− ("lowercase", 1),
521− ("capitalize", 1),
522− ("trim", 1),
523− ("length", 1),
524− ("reverse", 1),
525− ("truncate", 2),
526− ("replace", 3),
527− ("default", 2),
528−];
529−
530−/// Check that `name` is a built-in function and gets `count` arguments.
531−fn check_call(name: &str, count: usize) -> Result<(), String> {
532− let Some(&(_, arity)) = FUNCTIONS.iter().find(|(n, _)| *n == name) else {
533− return Err(format!("unknown function `{}()`", name));
534− };
535− if count == arity {
536− Ok(())
537− } else {
538− Err(format!(
539− "{}() takes {} argument{}",
540− name,
541− arity,
542− if arity == 1 { "" } else { "s" }
543− ))
544− }
545}
379}
546
380
547−/// Call a built-in function with the right number of arguments (see
381+/// Call a built-in function.
548−/// [`check_call`]). The text functions take text (a number or `true` and
549−/// `false` count as their text); a list or a record is an error, except
550−/// for `length`, `contains`, `reverse` and `default`, which also work on
551−/// a list's items.
552fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
382fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
553− let text = |i: usize| -> Result<String, String> {
383+ let text = |i: usize| args.get(i).map(|v| v.to_string()).unwrap_or_default();
554− match &args[i] {
555− value @ Value::List(_) => Err(format!(
556− "{}() takes text, but `{}` is {}: to write one text with commas in \
557− it, quote it or write `\\,`",
558− name,
559− value,
560− value.describe()
561− )),
562− Value::Record(_) => Err(format!(
563− "{}() takes text, but argument {} is a record: use one of its fields",
564− name,
565− i + 1
566− )),
567− value => Ok(value.to_string()),
568− }
569− };
570 let num = |i: usize| {
384 let num = |i: usize| {
571 args.get(i)
385 args.get(i)
572 .and_then(Value::as_num)
386 .and_then(Value::as_num)
573 .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
387 .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
574 };
388 };
389+ let arity = |n: usize| {
390+ if args.len() == n {
391+ Ok(())
392+ } else {
393+ Err(format!("{}() takes {} argument{}", name, n, if n == 1 { "" } else { "s" }))
394+ }
395+ };
396+ let color = |rgb: (u8, u8, u8)| Value::Str(format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2));
575 Ok(match name {
397 Ok(match name {
576− "contains" => Value::Bool(match &args[0] {
398+ "if" => {
577− Value::List(list) => list.items.iter().any(|item| item.equals(&args[1])),
399+ arity(3)?;
578− _ => text(0)?.contains(&text(1)?),
400+ let mut args = args;
579− }),
401+ let otherwise = args.pop().unwrap();
580− "starts-with" => Value::Bool(text(0)?.starts_with(&text(1)?)),
402+ let then = args.pop().unwrap();
581− "ends-with" => Value::Bool(text(0)?.ends_with(&text(1)?)),
403+ if args[0].truthy() { then } else { otherwise }
582− "uppercase" => Value::Str(text(0)?.to_uppercase()),
404+ }
583− "lowercase" => Value::Str(text(0)?.to_lowercase()),
405+ "uppercase" => { arity(1)?; Value::Str(text(0).to_uppercase()) }
406+ "lowercase" => { arity(1)?; Value::Str(text(0).to_lowercase()) }
584 "capitalize" => {
407 "capitalize" => {
585− let s = text(0)?;
408+ arity(1)?;
409+ let s = text(0);
586 let mut chars = s.chars();
410 let mut chars = s.chars();
587 Value::Str(match chars.next() {
411 Value::Str(match chars.next() {
588 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
412 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
589 None => String::new(),
413 None => String::new(),
590 })
414 })
591 }
415 }
592− "trim" => Value::Str(text(0)?.trim().to_string()),
416+ "trim" => { arity(1)?; Value::Str(text(0).trim().to_string()) }
593− "length" => Value::Num(match &args[0] {
417+ "length" => { arity(1)?; Value::Num(text(0).chars().count() as f64) }
594− Value::List(list) => list.items.len(),
418+ "reverse" => { arity(1)?; Value::Str(text(0).chars().rev().collect()) }
595− Value::Record(fields) => fields.len(),
596− _ => text(0)?.chars().count(),
597− } as f64),
598− "reverse" => match &args[0] {
599− Value::List(list) => Value::list(list.items.iter().rev().cloned().collect()),
600− _ => Value::Str(text(0)?.chars().rev().collect()),
601− },
602 "truncate" => {
419 "truncate" => {
603− let (s, n) = (text(0)?, num(1)? as usize);
420+ arity(2)?;
421+ let (s, n) = (text(0), num(1)? as usize);
604 Value::Str(if s.chars().count() > n {
422 Value::Str(if s.chars().count() > n {
605 format!("{}...", s.chars().take(n).collect::<String>())
423 format!("{}...", s.chars().take(n).collect::<String>())
606 } else {
424 } else {
607 s
425 s
608 })
426 })
609 }
427 }
610− "replace" => Value::Str(text(0)?.replace(&text(1)?, &text(2)?)),
428+ "replace" => { arity(3)?; Value::Str(text(0).replace(&text(1), &text(2))) }
611 "default" => {
429 "default" => {
612− let empty = match &args[0] {
430+ arity(2)?;
613− Value::List(list) => list.items.is_empty(),
431+ let s = text(0);
614− Value::Record(_) => false,
432+ Value::Str(if s.is_empty() { text(1) } else { s })
615− value => value.to_string().is_empty(),
433+ }
434+ "lighten" | "darken" | "alpha" | "mix" => {
435+ let expected = if name == "mix" { 3 } else { 2 };
436+ arity(expected)?;
437+ let Some(rgb) = parse_hex_rgb(&text(0)) else {
438+ return Err(format!("{}() needs a hex color, got `{}`", name, text(0)));
616 };
439 };
617− let mut args = args;
440+ match name {
618− args.swap_remove(usize::from(empty))
441+ "lighten" => color(lighten_color(rgb, num(1)? / 100.0)),
442+ "darken" => color(darken_color(rgb, num(1)? / 100.0)),
443+ "alpha" => {
444+ let a = (num(1)?.clamp(0.0, 1.0) * 255.0) as u8;
445+ Value::Str(format!("#{:02x}{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2, a))
446+ }
447+ _ => {
448+ let Some(other) = parse_hex_rgb(&text(1)) else {
449+ return Err(format!("mix() needs a hex color, got `{}`", text(1)));
450+ };
451+ color(mix_colors(rgb, other, num(2)? / 100.0))
452+ }
453+ }
619 }
454 }
620 _ => return Err(format!("unknown function `{}()`", name)),
455 _ => return Err(format!("unknown function `{}()`", name)),
621 })
456 })
622}
457}
623
458
459+pub(crate) fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
460+ let s = s.strip_prefix('#')?;
461+ if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
462+ return None;
463+ }
464+ match s.len() {
465+ 3 => {
466+ let r = u8::from_str_radix(&s[0..1], 16).ok()?;
467+ let g = u8::from_str_radix(&s[1..2], 16).ok()?;
468+ let b = u8::from_str_radix(&s[2..3], 16).ok()?;
469+ Some((r * 17, g * 17, b * 17))
470+ }
471+ 6 => {
472+ let r = u8::from_str_radix(&s[0..2], 16).ok()?;
473+ let g = u8::from_str_radix(&s[2..4], 16).ok()?;
474+ let b = u8::from_str_radix(&s[4..6], 16).ok()?;
475+ Some((r, g, b))
476+ }
477+ _ => None,
478+ }
479+}
480+
481+fn lighten_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
482+ let r = rgb.0 as f64 + (255.0 - rgb.0 as f64) * amount.clamp(0.0, 1.0);
483+ let g = rgb.1 as f64 + (255.0 - rgb.1 as f64) * amount.clamp(0.0, 1.0);
484+ let b = rgb.2 as f64 + (255.0 - rgb.2 as f64) * amount.clamp(0.0, 1.0);
485+ (r.round() as u8, g.round() as u8, b.round() as u8)
486+}
487+
488+fn darken_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
489+ let factor = 1.0 - amount.clamp(0.0, 1.0);
490+ let r = (rgb.0 as f64 * factor).round() as u8;
491+ let g = (rgb.1 as f64 * factor).round() as u8;
492+ let b = (rgb.2 as f64 * factor).round() as u8;
493+ (r, g, b)
494+}
495+
496+fn mix_colors(c1: (u8, u8, u8), c2: (u8, u8, u8), weight: f64) -> (u8, u8, u8) {
497+ let w = weight.clamp(0.0, 1.0);
498+ let r = (c1.0 as f64 * (1.0 - w) + c2.0 as f64 * w).round() as u8;
499+ let g = (c1.1 as f64 * (1.0 - w) + c2.1 as f64 * w).round() as u8;
500+ let b = (c1.2 as f64 * (1.0 - w) + c2.2 as f64 * w).round() as u8;
501+ (r, g, b)
502+}
503+
504+
505+/// Replace `$name` references inside a string literal.
506+fn interpolate(s: &str, resolve: Resolver) -> String {
507+ let mut out = String::with_capacity(s.len());
508+ let mut rest = s;
509+ while let Some(pos) = rest.find('$') {
510+ out.push_str(&rest[..pos]);
511+ let after = &rest[pos + 1..];
512+ let end = after.find(|c: char| !is_var_char(c)).unwrap_or(after.len());
513+ if end == 0 {
514+ out.push('$');
515+ } else {
516+ out.push_str(&resolve(&after[..end]).unwrap_or_default());
517+ }
518+ rest = &after[end..];
519+ }
520+ out.push_str(rest);
521+ out
522+}
523+
624#[cfg(test)]
524#[cfg(test)]
625mod tests {
525mod tests {
626 use super::*;
526 use super::*;
627
527
628− use crate::interp::tests::{Map, record, text_list};
629−
630− fn scope() -> Map {
631− Map::new(&[
632− ("count", "3"),
633− ("name", "World"),
634− ("theme", "dark"),
635− ("tricky", "a == b"),
636− ("empty", ""),
637− ])
638− .with("tags", text_list(&["rust", "web dev"]))
639− .with("post", record(&[("title", "Hi")]))
640− }
641−
642 fn ev(src: &str) -> Value {
528 fn ev(src: &str) -> Value {
643− eval(src, &scope()).unwrap()
529+ let resolve = |name: &str| match name {
530+ "count" => Some("3".to_string()),
531+ "name" => Some("World".to_string()),
532+ "theme" => Some("dark".to_string()),
533+ "tricky" => Some("a == b".to_string()),
534+ "empty" => Some(String::new()),
535+ _ => None,
536+ };
537+ eval(src, &resolve).unwrap()
644 }
538 }
645
539
646 #[test]
540 #[test]
@@ -658,8 +552,7 @@ mod tests {
658 assert!(ev("$theme == dark").truthy());
552 assert!(ev("$theme == dark").truthy());
659 assert!(ev("$theme != light and not $empty").truthy());
553 assert!(ev("$theme != light and not $empty").truthy());
660 assert!(!ev("$count < 2 or $empty").truthy());
554 assert!(!ev("$count < 2 or $empty").truthy());
661− assert!(ev("contains($name, or)").truthy());
555+ assert!(ev("$name contains or").truthy());
662− assert!(ev("starts-with($name, Wo) and ends-with($name, ld)").truthy());
663 assert!(ev("uppercase($name) == WORLD").truthy());
556 assert!(ev("uppercase($name) == WORLD").truthy());
664 assert!(ev("#3b82f6 != red").truthy());
557 assert!(ev("#3b82f6 != red").truthy());
665 }
558 }
@@ -673,64 +566,10 @@ mod tests {
673
566
674 #[test]
567 #[test]
675 fn strings_and_if() {
568 fn strings_and_if() {
569+ assert_eq!(ev("\"Hello \" ~ $name").to_string(), "Hello World");
676 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
570 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
677 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
571 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
678− assert_eq!(ev("${name}").to_string(), "World");
572+ assert_eq!(ev("$missing").to_string(), "");
679− assert_eq!(ev("${$count + 1} * 2").to_string(), "8");
680− }
681−
682− #[test]
683− fn only_the_branch_taken_is_evaluated() {
684− let zero = Map::new(&[("n", "0"), ("on", "true")]);
685− let ev0 = |src: &str| eval(src, &zero).map(|v| v.to_string());
686− assert_eq!(ev0("if($n != 0, 10 / $n, 0)"), Ok("0".into()));
687− assert_eq!(ev0("if($n == 0, none, 10 / $n)"), Ok("none".into()));
688− assert_eq!(ev0("if($on, 24)"), Ok("24".into()));
689− assert_eq!(ev0("if(not $on, 24)"), Ok("".into()));
690− assert_eq!(
691− ev0("if($on, \"#10b981\", \"var(--muted)\")"),
692− Ok("#10b981".into())
693− );
694− // and / or stop at the side that decides
695− assert_eq!(ev0("$n != 0 and 10 / $n > 1"), Ok("false".into()));
696− assert_eq!(ev0("$n == 0 or 10 / $n > 1"), Ok("true".into()));
697− assert_eq!(ev0("$on or truncate(abc, x)"), Ok("true".into()));
698− // The branch not taken is still read: its syntax, function names and
699− // argument counts are checked; its variables and values are not
700− assert_eq!(ev0("if($on, 1, $undefined)"), Ok("1".into()));
701− assert!(eval("if($on, 1, (2)", &zero).is_err());
702− assert!(eval("if($on, 1, nope(2))", &zero).is_err());
703− assert!(eval("if($on, 1, uppercase(a, b))", &zero).is_err());
704− // if() itself takes 2 or 3 arguments
705− assert!(eval("if($on)", &zero).is_err());
706− assert!(eval("if($on, 1, 2, 3)", &zero).is_err());
707− // Something else after the condition is what is reported
708− let error = eval("if($n.x, 1, 2)", &zero).unwrap_err().to_string();
709− assert!(error.contains("`.x`"), "{}", error);
710− // The taken branch still reports its errors
711− assert!(eval("if($n == 0, 10 / $n, 0)", &zero).is_err());
712− assert!(eval("$n == 0 and 10 / $n > 1", &zero).is_err());
713− }
714−
715− #[test]
716− fn names_and_fields() {
717− assert_eq!(ev("$post.title").to_string(), "Hi");
718− // A field the record doesn't have is empty
719− assert_eq!(ev("default($post.draft, no)").to_string(), "no");
720− assert_eq!(
721− eval("$missing == 1", &scope()),
722− Err(Error::Undefined {
723− name: "missing".into(),
724− offset: 0
725− })
726− );
727− assert_eq!(
728− eval("\"a $nope\"", &scope()),
729− Err(Error::Undefined {
730− name: "nope".into(),
731− offset: 3
732− })
733− );
734 }
573 }
735
574
736 #[test]
575 #[test]
@@ -740,62 +579,20 @@ mod tests {
740 assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
579 assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
741 assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
580 assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
742 assert_eq!(ev("default($empty, none)").to_string(), "none");
581 assert_eq!(ev("default($empty, none)").to_string(), "none");
743− }
582+ assert_eq!(ev("darken(#ffffff, 50)").to_string(), "#808080");
744−
583+ assert_eq!(ev("mix(#000000, #ffffff, 50)").to_string(), "#808080");
745− #[test]
584+ assert_eq!(ev("alpha(#3b82f6, 0.5)").to_string(), "#3b82f67f");
746− fn lists() {
747− assert_eq!(ev("length($tags)").to_string(), "2");
748− assert!(ev("contains($tags, \"web dev\")").truthy());
749− assert!(!ev("contains($tags, web)").truthy());
750− assert_eq!(ev("$tags").to_string(), "rust, web dev");
751− assert_eq!(ev("reverse($tags)").to_string(), "web dev, rust");
752− assert_eq!(ev("default($tags, none)").to_string(), "rust, web dev");
753− // Text functions don't guess what a list means
754− let error = eval("uppercase($tags)", &scope()).unwrap_err().to_string();
755− assert!(error.contains("a list of 2 items"), "{}", error);
756− assert!(eval("truncate($post, 2)", &scope()).is_err());
757− }
758−
759− #[test]
760− fn ranges() {
761− assert_eq!(ev("1..5").to_string(), "1, 2, 3, 4, 5");
762− assert_eq!(ev("reverse(1..5)").to_string(), "5, 4, 3, 2, 1");
763− assert_eq!(ev("length(0..10 step 5)").to_string(), "3");
764− assert_eq!(ev("$count..1").to_string(), "3, 2, 1");
765− assert_eq!(ev("1..$count + 1").to_string(), "1, 2, 3, 4");
766− assert!(ev("contains(1..3, 2)").truthy());
767− assert!(eval("1..x", &scope()).is_err());
768− assert!(eval("1..5 step 0", &scope()).is_err());
769− }
770−
771− #[test]
772− fn truthiness_and_comparison_follow_the_types() {
773− let zero = Map::new(&[("z", "0.0"), ("upper", "#FFF")]);
774− assert!(!eval("$z", &zero).unwrap().truthy());
775− assert!(eval("$z == 0", &zero).unwrap().truthy());
776− assert!(!eval("$upper == #fff", &zero).unwrap().truthy());
777− assert_eq!(ev("100 / 3").to_string(), "33.3333");
778− assert!(eval("1..3 < 2", &scope()).is_err());
779 }
585 }
780
586
781 #[test]
587 #[test]
782 fn errors_are_reported() {
588 fn errors_are_reported() {
783− let none = Map::new(&[]);
589+ let none = |_: &str| None;
784 assert!(eval("dark * 2", &none).is_err());
590 assert!(eval("dark * 2", &none).is_err());
785 assert!(eval("1 +", &none).is_err());
591 assert!(eval("1 +", &none).is_err());
786 assert!(eval("(1", &none).is_err());
592 assert!(eval("(1", &none).is_err());
787 assert!(eval("1 / 0", &none).is_err());
593 assert!(eval("1 / 0", &none).is_err());
788 assert!(eval("nope(1)", &none).is_err());
594 assert!(eval("nope(1)", &none).is_err());
789 assert!(eval("uppercase(a, b)", &none).is_err());
595 assert!(eval("uppercase(a, b)", &none).is_err());
790− // Colour maths is CSS's (`color-mix()`), and a custom property is
596+ assert!(eval("darken(red, 10)", &none).is_err());
791− // read with `var()`
792− for gone in ["darken", "lighten", "alpha", "mix"] {
793− let error = eval(&format!("{}(red, 10)", gone), &none).unwrap_err();
794− assert!(error.to_string().contains("unknown function"), "{}", error);
795− }
796− let error = eval("$--brand == red", &none).unwrap_err().to_string();
797− assert!(error.contains("`var(--brand)`"), "{}", error);
798− let error = eval("\"a $--brand\"", &none).unwrap_err().to_string();
799− assert!(error.contains("`var(--brand)`"), "{}", error);
800 }
597 }
801}
598}
crates/htmlang-core/src/interp.rsdeleted−651
@@ -1,651 +0,0 @@
1−//! Variables in htmlang strings: `$name`, `$record.field` and `${...}`.
2−//!
3−//! The syntax tree finds every slot first: a run of text, an attribute's
4−//! value, an element's argument, a `@let` value, a file path, the title of
5−//! `@page`, the value of `@meta`. Each slot is then interpolated here, once.
6−//! A reference fills its own slot, and what it inserts is never read again
7−//! as htmlang: a value can't become an attribute name, a whole attribute, a
8−//! comma between attributes, markup or another `$name`.
9−//!
10−//! - `$` followed by a letter or `_` starts a name, which goes on with
11−//! letters, digits, `_` and `-` (a `-` only when a name character follows
12−//! it).
13−//! - `$--name` is an error: a custom property is read one way, with
14−//! `var(--name)`, in the browser, so it follows a token redefined under
15−//! `dark:`. See [`custom_property_len`].
16−//! - `.field` continues the name only when the value is a record or a list
17−//! (`$post.title`, `$tags.0`). After `@let lang fr`, `$lang.json` is
18−//! `fr.json`, and `$a..$b` is two names.
19−//! - `${name}` is the same name, delimited (`${size}px`), and `${EXPR}`
20−//! inserts the value of an expression (see `expr.rs`).
21−//! - A `$` before anything else is text: `$5`, `$$`, a `$` at the end.
22−//! - An undefined name is an error. A field that a record doesn't have is
23−//! empty, and so are its fields (`$post.author.name` when `$post` has no
24−//! `author`), so optional fields of `@data` records work.
25−//! - Quoted text (`@let arrow "→ "`, `@card [quote "→ "]`) remembers its
26−//! quotes: a CSS value gets them (`content $arrow` is `content:"→ "`),
27−//! text and HTML attribute values don't. See [`Sink`].
28−
29−use crate::expr;
30−use crate::value::Value;
31−
32−/// The variables in scope, as the evaluator stores them.
33−pub trait Scope {
34− /// The value of a variable, by its name (without fields).
35− fn get(&self, name: &str) -> Option<Value>;
36−}
37−
38−/// Where a slot's value goes, which decides what quoted text inserts.
39−#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40−pub enum Sink {
41− /// Text, an HTML attribute value, a path, a parameter: quoted text
42− /// inserts what it says, without the quotes.
43− Text,
44− /// A CSS value: quoted text inserts itself with its quotes, or, inside a
45− /// quoted string of the value, what it says with `"` escaped as `\"`.
46− Css,
47−}
48−
49−/// Whether a CSS value is inside a quoted string after `text`, when it
50−/// was (`inside`) before it. A backslash inside a string escapes the next
51−/// character.
52−fn css_string_after(text: &str, mut inside: bool) -> bool {
53− let mut chars = text.chars();
54− while let Some(c) = chars.next() {
55− match c {
56− '\\' if inside => {
57− chars.next();
58− }
59− '"' => inside = !inside,
60− _ => {}
61− }
62− }
63− inside
64−}
65−
66−/// `text` as it goes inside a CSS string: `"` escaped as `\"`. A backslash
67−/// and the character after it are a CSS escape and stay as written
68−/// (`\201C`), but a backslash at the end is doubled, so it can't escape
69−/// the rest of the string (`a\` gives `a\\`).
70−pub fn css_string_body(text: &str) -> String {
71− let mut out = String::with_capacity(text.len());
72− let mut chars = text.chars();
73− while let Some(c) = chars.next() {
74− match c {
75− '\\' => {
76− out.push('\\');
77− out.push(chars.next().unwrap_or('\\'));
78− }
79− '"' => out.push_str("\\\""),
80− _ => out.push(c),
81− }
82− }
83− out
84−}
85−
86−/// A problem found while interpolating, at a byte offset into the text.
87−#[derive(Debug, Clone, PartialEq)]
88−pub enum Problem {
89− /// A name with no definition.
90− Undefined { name: String, offset: usize },
91− /// `$--name`: a custom property, which is read with `var(--name)`.
92− CustomProperty { name: String, offset: usize },
93− /// A `${...}` whose expression doesn't evaluate.
94− Invalid { message: String, offset: usize },
95− /// A record put where text goes, which it has none of: it would print
96− /// nothing.
97− Record { message: String, offset: usize },
98−}
99−
100−/// The problem with putting `value`, written `written`, where text goes:
101−/// a record has no text.
102−fn no_text(written: &str, value: &Value, offset: usize) -> Option<Problem> {
103− let name = written.strip_prefix('$').map(|name| {
104− name.strip_prefix('{')
105− .and_then(|n| n.strip_suffix('}'))
106− .map_or(name, str::trim)
107− });
108− let fields = match value {
109− Value::Record(fields) => fields,
110− // A list of records, which would print only what isn't a record
111− Value::List(list)
112− if list.written.is_none()
113− && list
114− .items
115− .iter()
116− .any(|item| matches!(item, Value::Record(_))) =>
117− {
118− let example = match (name, list.items.first()) {
119− (Some(name), Some(Value::Record(fields))) if is_path(name) => {
120− match fields.first() {
121− Some((field, _)) => format!(", such as `${}.0.{}`", name, field),
122− None => String::new(),
123− }
124− }
125− _ => String::new(),
126− };
127− return Some(Problem::Record {
128− message: format!(
129− "'{}' is a list of records, which has no text of its own: loop over it \
130− with @each, or write a field of one of its items{}",
131− written, example
132− ),
133− offset,
134− });
135− }
136− _ => return None,
137− };
138− let example = match (name, fields.first()) {
139− (Some(name), Some((field, _))) if is_path(name) => {
140− format!(", such as `${}.{}`", name, field)
141− }
142− _ => String::new(),
143− };
144− Some(Problem::Record {
145− message: format!(
146− "'{}' is a record, which has no text of its own: write one of its fields{}",
147− written, example
148− ),
149− offset,
150− })
151−}
152−
153−fn is_name_char(c: char) -> bool {
154− c.is_alphanumeric() || c == '_'
155−}
156−
157−/// The length of a word at the start of `s` whose first character passes
158−/// `first`: name characters, with `-` between them.
159−fn word_len(s: &str, first: impl Fn(char) -> bool) -> usize {
160− let Some(c) = s.chars().next().filter(|&c| first(c)) else {
161− return 0;
162− };
163− let mut end = c.len_utf8();
164− let mut i = end;
165− while let Some(c) = s[i..].chars().next() {
166− if is_name_char(c) {
167− i += c.len_utf8();
168− end = i;
169− } else if c == '-' {
170− let rest = &s[i..];
171− let dashes = rest.len() - rest.trim_start_matches('-').len();
172− if !rest[dashes..].starts_with(is_name_char) {
173− break;
174− }
175− i += dashes;
176− } else {
177− break;
178− }
179− }
180− end
181−}
182−
183−/// The length of the variable name at the start of `s` (the text after a
184−/// `$`), by syntax alone, or 0 when `s` doesn't start one.
185−pub fn name_len(s: &str) -> usize {
186− word_len(s, |c| c.is_alphabetic() || c == '_')
187−}
188−
189−/// The length of the custom property's name `--name` at the start of `s`
190−/// (the text after a `$`), or 0. `$--name` is not a variable and not text
191−/// either: it is an error that says to write `var(--name)`.
192−pub fn custom_property_len(s: &str) -> usize {
193− match s.strip_prefix("--") {
194− Some(rest) => match word_len(rest, |c| c.is_alphanumeric() || c == '_') {
195− 0 => 0,
196− n => n + 2,
197− },
198− None => 0,
199− }
200−}
201−
202−/// What is wrong with `$NAME`, where `name` is a custom property's name
203−/// (`--brand`).
204−pub fn custom_property_message(name: &str) -> String {
205− format!(
206− "`${}` is not a variable: a custom property is read with `var({})`",
207− name, name
208− )
209−}
210−
211−/// Whether `s` is a whole name path, `name` or `name.field.field`.
212−fn is_path(s: &str) -> bool {
213− let n = name_len(s);
214− if n == 0 || matches!(&s[..n], "true" | "false" | "not" | "and" | "or") {
215− return false;
216− }
217− let mut rest = &s[n..];
218− while let Some(field) = rest.strip_prefix('.') {
219− let f = word_len(field, is_name_char);
220− if f == 0 {
221− return false;
222− }
223− rest = &field[f..];
224− }
225− rest.is_empty()
226−}
227−
228−/// What a name path leads to so far: a value, or a field that a record
229−/// (or an item that a list) doesn't have, which is empty, and so is every
230−/// field of it.
231−#[derive(Clone)]
232−enum Step {
233− Value(Value),
234− Missing,
235−}
236−
237−impl Step {
238− /// The field `name` of this step, or `None` when it has no fields
239− /// (text, a number, true or false).
240− fn field(&self, name: &str) -> Option<Step> {
241− let Step::Value(value) = self else {
242− return Some(Step::Missing);
243− };
244− match value {
245− Value::Record(fields) => Some(
246− fields
247− .iter()
248− .find(|(key, _)| key == name)
249− .map_or(Step::Missing, |(_, value)| Step::Value(value.clone())),
250− ),
251− Value::List(list) => Some(
252− name.parse::<usize>()
253− .ok()
254− .and_then(|i| list.items.get(i).cloned())
255− .map_or(Step::Missing, Step::Value),
256− ),
257− _ => None,
258− }
259− }
260−
261− fn value(self) -> Value {
262− match self {
263− Step::Value(value) => value,
264− Step::Missing => Value::empty(),
265− }
266− }
267−}
268−
269−/// The name path at the start of `s` (after a `$`) and its length: the
270−/// name, and each `.field` while the value so far is a record or a list,
271−/// or a field one of them doesn't have.
272−fn path_at(s: &str, scope: &dyn Scope) -> Option<(String, usize)> {
273− let n = name_len(s);
274− if n == 0 {
275− return None;
276− }
277− let mut path = s[..n].to_string();
278− let mut len = n;
279− let mut step = scope.get(&path).map(Step::Value);
280− while let Some(rest) = s[len..].strip_prefix('.') {
281− let f = word_len(rest, is_name_char);
282− let Some(field) = step
283− .as_ref()
284− .filter(|_| f > 0)
285− .and_then(|v| v.field(&rest[..f]))
286− else {
287− break;
288− };
289− path = format!("{}.{}", path, &rest[..f]);
290− len += 1 + f;
291− step = Some(field);
292− }
293− Some((path, len))
294−}
295−
296−/// A reference, after its `$`.
297−#[derive(Debug, Clone, PartialEq)]
298−pub enum Reference<'a> {
299− /// `$name`, `$name.field` or `${name}`: the name path.
300− Var(String),
301− /// `${EXPR}`: the expression.
302− Expr(&'a str),
303−}
304−
305−/// The reference that `s` (the text after a `$`) starts with, and its
306−/// length. `None` when the `$` is text.
307−pub fn reference<'a>(s: &'a str, scope: &dyn Scope) -> Option<(Reference<'a>, usize)> {
308− if s.starts_with('{') {
309− let close = matching_brace(s)?;
310− let inner = &s[1..close];
311− let reference = if is_path(inner.trim()) {
312− Reference::Var(inner.trim().to_string())
313− } else {
314− Reference::Expr(inner)
315− };
316− return Some((reference, close + 1));
317− }
318− let (path, len) = path_at(s, scope)?;
319− Some((Reference::Var(path), len))
320−}
321−
322−/// The value of a name path, or `None` when it is undefined. A field that
323−/// a record doesn't have is empty, and so is any field of it.
324−pub fn resolve(path: &str, scope: &dyn Scope) -> Option<Value> {
325− let mut parts = path.split('.');
326− let mut step = Step::Value(scope.get(parts.next()?)?);
327− for field in parts {
328− step = step.field(field)?;
329− }
330− Some(step.value())
331−}
332−
333−/// Fill in the `$name`s and `${...}`s of one slot's text, for text (see
334−/// [`Sink::Text`]). Each one that can't be filled is left as written and
335−/// reported.
336−pub fn interpolate(text: &str, scope: &dyn Scope) -> (String, Vec<Problem>) {
337− interpolate_for(text, scope, Sink::Text)
338−}
339−
340−/// [`interpolate`] for a slot whose value goes to `sink`.
341−pub fn interpolate_for(text: &str, scope: &dyn Scope, sink: Sink) -> (String, Vec<Problem>) {
342− let mut problems = Vec::new();
343− if !text.contains('$') {
344− return (text.to_string(), problems);
345− }
346− let mut out = String::with_capacity(text.len());
347− let mut pos = 0;
348− // Whether the CSS value is inside a quoted string at `pos`
349− let mut in_string = false;
350− while let Some(found) = text[pos..].find('$') {
351− let dollar = pos + found;
352− out.push_str(&text[pos..dollar]);
353− if sink == Sink::Css {
354− in_string = css_string_after(&text[pos..dollar], in_string);
355− }
356− let after = &text[dollar + 1..];
357− let Some((reference, len)) = reference(after, scope) else {
358− let n = custom_property_len(after);
359− if n > 0 {
360− problems.push(Problem::CustomProperty {
361− name: after[..n].to_string(),
362− offset: dollar,
363− });
364− }
365− out.push_str(&text[dollar..dollar + 1 + n]);
366− pos = dollar + 1 + n;
367− continue;
368− };
369− let written = &text[dollar..dollar + 1 + len];
370− let insert = |value: String, quoted: Option<String>| match (sink, quoted) {
371− (Sink::Css, Some(css)) if in_string => {
372− // The string's body, without its quotes
373− let body = css.strip_prefix('"').unwrap_or(&css);
374− body.strip_suffix('"').unwrap_or(body).to_string()
375− }
376− (Sink::Css, Some(css)) => css,
377− (Sink::Css, None) if in_string => css_string_body(&value),
378− _ => value,
379− };
380− match reference {
381− Reference::Var(path) => match resolve(&path, scope) {
382− Some(value) => match no_text(written, &value, dollar) {
383− Some(problem) => problems.push(problem),
384− None => out.push_str(&insert(
385− value.to_string(),
386− value.quoted_css().map(str::to_string),
387− )),
388− },
389− None => {
390− problems.push(Problem::Undefined {
391− name: path,
392− offset: dollar,
393− });
394− out.push_str(written);
395− }
396− },
397− Reference::Expr(source) => match expr::eval(source, scope) {
398− Ok(value) => match no_text(written, &value, dollar) {
399− Some(problem) => problems.push(problem),
400− None => out.push_str(&insert(value.to_string(), None)),
401− },
402− Err(error) => {
403− problems.push(match error {
404− // At the `${`
405− expr::Error::Invalid(message) => Problem::Invalid {
406− message,
407− offset: dollar,
408− },
409− // At the name, inside the braces
410− error => error.at(dollar + 2),
411− });
412− out.push_str(written);
413− }
414− },
415− }
416− pos = dollar + 1 + len;
417− }
418− out.push_str(&text[pos..]);
419− (out, problems)
420−}
421−
422−/// The names `text` refers to, by syntax alone: the first name of each
423−/// path, including those inside `${...}`.
424−pub fn names(text: &str) -> Vec<&str> {
425− name_spans(text)
426− .into_iter()
427− .map(|span| &text[span])
428− .collect()
429−}
430−
431−/// Where the names of [`names`] are in `text`, as byte ranges of the name
432−/// alone: after the `$` of `$name`, inside the braces of `${name}`.
433−pub fn name_spans(text: &str) -> Vec<std::ops::Range<usize>> {
434− let mut found = Vec::new();
435− collect_name_spans(text, 0, &mut found);
436− found
437−}
438−
439−fn collect_name_spans(text: &str, base: usize, found: &mut Vec<std::ops::Range<usize>>) {
440− let mut pos = 0;
441− while let Some(i) = text[pos..].find('$') {
442− let after = pos + i + 1;
443− let rest = &text[after..];
444− pos = after;
445− // `\$` is a dollar sign (and `\\$` a backslash before a name)
446− let backslashes = text[..after - 1].len() - text[..after - 1].trim_end_matches('\\').len();
447− if backslashes % 2 == 1 {
448− continue;
449− }
450− if rest.starts_with('{')
451− && let Some(close) = matching_brace(rest)
452− {
453− let inner = &rest[1..close];
454− let trimmed = inner.trim_start();
455− if is_path(trimmed.trim_end()) {
456− let start = base + after + 1 + inner.len() - trimmed.len();
457− found.push(start..start + name_len(trimmed));
458− } else {
459− collect_name_spans(inner, base + after + 1, found);
460− }
461− pos = after + close + 1;
462− continue;
463− }
464− let n = name_len(rest);
465− if n > 0 {
466− found.push(base + after..base + after + n);
467− pos = after + n;
468− }
469− }
470−}
471−
472−/// Index of the `}` matching the `{` that `s` starts with (skipping
473−/// braces inside string literals).
474−pub(crate) fn matching_brace(s: &str) -> Option<usize> {
475− let mut depth = 0;
476− let mut in_string = false;
477− for (i, c) in s.char_indices() {
478− match c {
479− '"' => in_string = !in_string,
480− _ if in_string => {}
481− '{' => depth += 1,
482− '}' => {
483− depth -= 1;
484− if depth == 0 {
485− return Some(i);
486− }
487− }
488− _ => {}
489− }
490− }
491− None
492−}
493−
494−#[cfg(test)]
495−pub(crate) mod tests {
496− use super::*;
497− use std::collections::HashMap;
498−
499− /// Variables by name: text from `new`, any value from `with`.
500− pub(crate) struct Map(pub HashMap<String, Value>);
501−
502− impl Map {
503− pub(crate) fn new(pairs: &[(&str, &str)]) -> Self {
504− Map(pairs
505− .iter()
506− .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
507− .collect())
508− }
509−
510− pub(crate) fn with(mut self, name: &str, value: Value) -> Self {
511− self.0.insert(name.to_string(), value);
512− self
513− }
514− }
515−
516− impl Scope for Map {
517− fn get(&self, name: &str) -> Option<Value> {
518− self.0.get(name).cloned()
519− }
520− }
521−
522− pub(crate) fn text_list(items: &[&str]) -> Value {
523− Value::list(items.iter().map(|s| Value::Str(s.to_string())).collect())
524− }
525−
526− pub(crate) fn record(fields: &[(&str, &str)]) -> Value {
527− Value::Record(std::rc::Rc::new(
528− fields
529− .iter()
530− .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
531− .collect(),
532− ))
533− }
534−
535− fn vars() -> Map {
536− Map::new(&[("lang", "fr"), ("n", "3"), ("n-1", "two")])
537− .with("post", record(&[("title", "Hello")]))
538− .with("tags", text_list(&["a", "b"]))
539− }
540−
541− fn fill(text: &str) -> String {
542− interpolate(text, &vars()).0
543− }
544−
545− #[test]
546− fn names_end_where_they_must() {
547− assert_eq!(fill("locales/$lang.json"), "locales/fr.json");
548− assert_eq!(fill("$post.title!"), "Hello!");
549− assert_eq!(fill("$tags.1 and $tags"), "b and a, b");
550− assert_eq!(fill("${lang}uage"), "fruage");
551− assert_eq!(fill("$lang- $lang:"), "fr- fr:");
552− assert_eq!(fill("$n-1"), "two");
553− assert_eq!(fill("1..$n"), "1..3");
554− // A name ends at `..`
555− assert_eq!(fill("$n..$n $post.title..x"), "3..3 Hello..x");
556− }
557−
558− #[test]
559− fn a_dollar_before_anything_else_is_text() {
560− assert_eq!(fill("$5 and $$ and $"), "$5 and $$ and $");
561− assert_eq!(fill("a $-x ${"), "a $-x ${");
562− }
563−
564− #[test]
565− fn a_custom_property_is_not_a_variable() {
566− let (out, problems) = interpolate("x $--brand-dark $-- $--1", &vars());
567− assert_eq!(out, "x $--brand-dark $-- $--1");
568− assert_eq!(
569− problems,
570− [
571− Problem::CustomProperty {
572− name: "--brand-dark".into(),
573− offset: 2
574− },
575− Problem::CustomProperty {
576− name: "--1".into(),
577− offset: 20
578− }
579− ]
580− );
581− assert!(custom_property_message("--brand").contains("`var(--brand)`"));
582− assert_eq!(names("$--e $f"), ["f"]);
583− }
584−
585− #[test]
586− fn missing_fields_are_empty_and_undefined_names_are_errors() {
587− assert_eq!(fill("[$post.summary]"), "[]");
588− let (out, problems) = interpolate("x $nope y", &vars());
589− assert_eq!(out, "x $nope y");
590− assert_eq!(
591− problems,
592− [Problem::Undefined {
593− name: "nope".into(),
594− offset: 2
595− }]
596− );
597− }
598−
599− #[test]
600− fn expressions() {
601− assert_eq!(fill("${$n * 2}px"), "6px");
602− let (_, problems) = interpolate("a ${$n + $m}", &vars());
603− assert_eq!(
604− problems,
605− [Problem::Undefined {
606− name: "m".into(),
607− offset: 9
608− }]
609− );
610− }
611−
612− #[test]
613− fn quoted_text_keeps_its_quotes_only_in_css() {
614− let quoted = Value::Quoted(crate::ast::Quoted {
615− text: r#"a"b"#.into(),
616− css: r#""a\"b""#.into(),
617− });
618− let scope = Map::new(&[("n", r#"x"y"#)]).with("q", quoted);
619− let fill = |text: &str, sink| interpolate_for(text, &scope, sink).0;
620− assert_eq!(fill("$q", Sink::Text), r#"a"b"#);
621− assert_eq!(fill("$q", Sink::Css), r#""a\"b""#);
622− // Inside a CSS string, what it says, escaped as the string needs
623− assert_eq!(
624− fill(r#""($q) $n" $q"#, Sink::Css),
625− r#""(a\"b) x\"y" "a\"b""#
626− );
627− assert_eq!(fill(r#""\" $q""#, Sink::Css), r#""\" a\"b""#);
628− // What an unquoted value holds goes into a string that stays valid
629− let scope = Map::new(&[("end", r"a\"), ("esc", r"\201C"), ("q", r#"x\"y"#)]);
630− let fill = |text: &str| interpolate_for(text, &scope, Sink::Css).0;
631− assert_eq!(fill(r#""$end""#), r#""a\\""#);
632− assert_eq!(fill(r#""$esc""#), r#""\201C""#);
633− assert_eq!(fill(r#""$q""#), r#""x\"y""#);
634− }
635−
636− #[test]
637− fn inserted_values_are_never_read_again() {
638− let scope = Map::new(&[("a", "$b"), ("b", "no")]);
639− assert_eq!(interpolate("$a", &scope).0, "$b");
640− }
641−
642− #[test]
643− fn names_by_syntax() {
644− assert_eq!(names("$a.b ${c} ${upper($d)} $5 $--e"), ["a", "c", "d"]);
645− assert_eq!(names(r"\$a \\$b \\\$c"), ["b"]);
646− assert_eq!(
647− name_spans("x ${ size }px ${$n + ${m}} $a- b"),
648− [5..9, 17..18, 23..24, 28..29]
649− );
650− }
651−}
crates/htmlang-core/src/lib.rs−4
@@ -1,11 +1,7 @@
1pub mod ast;
1pub mod ast;
2pub mod codegen;
2pub mod codegen;
3−pub mod diagnostic;
4pub mod expr;
3pub mod expr;
5−pub mod interp;
6pub mod parser;
4pub mod parser;
7−pub mod syntax;
8−pub mod value;
9pub mod vocab;
5pub mod vocab;
10
6
11#[cfg(test)]
7#[cfg(test)]
crates/htmlang-core/src/parser.rs+3395−6271
@@ -1,193 +1,89 @@
1use std::collections::{HashMap, HashSet};
1use std::collections::{HashMap, HashSet};
2+use std::fmt;
2use std::path::{Path, PathBuf};
3use std::path::{Path, PathBuf};
3−use std::rc::Rc;
4
4
5use crate::ast::*;
5use crate::ast::*;
6−use crate::diagnostic::code;
7−pub use crate::diagnostic::{Diagnostic, Severity};
8−use crate::interp::{self, Sink};
9−use crate::syntax::{self, DirectiveArgs, LetForm, NodeKind, Segment, Tree};
10−use crate::value::{self, Value};
11
6
12// ---------------------------------------------------------------------------
7// ---------------------------------------------------------------------------
13// Public types
8// Public types
14// ---------------------------------------------------------------------------
9// ---------------------------------------------------------------------------
15
10
11+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12+pub enum Severity {
13+ Error,
14+ Warning,
15+ Info,
16+ Help,
17+}
18+
19+#[derive(Debug, Clone)]
20+pub struct Diagnostic {
21+ pub line: usize,
22+ pub column: Option<usize>,
23+ pub message: String,
24+ pub severity: Severity,
25+ pub source_line: Option<String>,
26+}
27+
16pub struct ParseResult {
28pub struct ParseResult {
17 pub document: Document,
29 pub document: Document,
18 pub diagnostics: Vec<Diagnostic>,
30 pub diagnostics: Vec<Diagnostic>,
19 pub included_files: Vec<PathBuf>,
31 pub included_files: Vec<PathBuf>,
20}
32}
21
33
22−/// An error that stops one line from being evaluated.
34+#[derive(Debug)]
23−type ParseError = Diagnostic;
35+struct ParseError {
24−
25−// ---------------------------------------------------------------------------
26−// Internal types
27−// ---------------------------------------------------------------------------
28−
29−#[derive(Clone)]
30−struct FnDef {
31− /// The parameters in the order they are declared; one without a
32− /// default is required.
33− params: Vec<syntax::Param>,
34− /// The definition's line and its text (when it is one physical line),
35− /// and its node, for diagnostics about a default.
36 line: usize,
36 line: usize,
37− source: Option<String>,
37+ message: String,
38− node: usize,
39− body: Vec<syntax::Node>,
40− /// The file it is defined in, when that isn't the file being compiled
41− /// (see `ParseContext::current_file`).
42− file: Option<String>,
43− /// The slots its body declares (`@slot NAME`), in order.
44− slots: Vec<String>,
45− /// Whether its body has a `@children` for the content of a call.
46− has_children: bool,
47− /// Its scoped `@style`: its class (`hl-fn-NAME-HASH`), and the
48− /// stylesheet nested under it, written into the page once the function
49− /// is called (see `Document::scoped_css`).
50− scoped: Option<(String, String)>,
51− /// What was visible where it was defined: its body and its defaults
52− /// see this, plus its parameters.
53− env: Env,
54− /// It is named like a built-in element and its body calls that name
55− /// outside any `@if`, meaning the element (`@let @nav` around `@nav`):
56− /// it would call itself forever, so a call writes nothing (the
57− /// definition's error says so).
58− wraps_itself: bool,
59}
38}
60
39
61−impl FnDef {
40+impl fmt::Display for ParseError {
62− fn is_param(&self, name: &str) -> bool {
41+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
63− self.params.iter().any(|p| p.name == name)
42+ write!(f, "line {}: {}", self.line, self.message)
64 }
43 }
65}
44}
66
45
67−/// What a name means: every `@let`, parameter, loop variable and `@data`
46+// ---------------------------------------------------------------------------
68−/// binds one, and they share one namespace.
47+// Internal types
69−#[derive(Clone)]
48+// ---------------------------------------------------------------------------
70−enum Binding {
71− Value(Value),
72− Bundle(Rc<Vec<Attribute>>),
73− Function(Rc<FnDef>),
74−}
75
49
76−/// The names visible at a line: one frame per block it is in (the
50+#[derive(Clone)]
77−/// standard library, the file, an element's children, an `@if` branch, an
51+enum LineContent {
78−/// iteration of `@each`, a function's body), the innermost last. A
52+ Normal(String),
79−/// definition goes into the innermost frame, so it is visible from its
53+ Raw(String),
80−/// line to the end of its block. A function keeps the frames visible
81−/// where it is defined; a frame is shared until one side changes it.
82−#[derive(Clone, Default)]
83−struct Env {
84− frames: Vec<Rc<HashMap<String, Binding>>>,
85}
54}
86
55
87−impl Env {
56+#[derive(Clone)]
88− fn push(&mut self) {
57+struct Line {
89− self.frames.push(Rc::default());
58+ indent: usize,
90− }
59+ content: LineContent,
91−
60+ line_num: usize,
92− fn pop(&mut self) {
93− self.frames.pop();
94− }
95−
96− /// Give `name` a meaning in the innermost frame, replacing what it
97− /// meant there and hiding what it means outside it.
98− fn define(&mut self, name: &str, binding: Binding) {
99− if self.frames.is_empty() {
100− self.push();
101− }
102− if let Some(frame) = self.frames.last_mut() {
103− Rc::make_mut(frame).insert(name.to_string(), binding);
104− }
105− }
106−
107− /// What `name` means here: the innermost definition.
108− fn get(&self, name: &str) -> Option<&Binding> {
109− self.frames.iter().rev().find_map(|frame| frame.get(name))
110− }
111−
112− fn value(&self, name: &str) -> Option<&Value> {
113− match self.get(name)? {
114− Binding::Value(value) => Some(value),
115− _ => None,
116− }
117− }
118−
119− fn bundle(&self, name: &str) -> Option<&Rc<Vec<Attribute>>> {
120− match self.get(name)? {
121− Binding::Bundle(attrs) => Some(attrs),
122− _ => None,
123− }
124− }
125−
126− fn function(&self, name: &str) -> Option<&Rc<FnDef>> {
127− match self.get(name)? {
128− Binding::Function(function) => Some(function),
129− _ => None,
130− }
131− }
132−
133− /// Every name visible here, with what it means.
134− fn visible(&self) -> HashMap<&str, &Binding> {
135− let mut names = HashMap::new();
136− for frame in &self.frames {
137− for (name, binding) in frame.iter() {
138− names.insert(name.as_str(), binding);
139− }
140− }
141− names
142− }
143−
144− /// The names of the values visible here.
145− fn value_names(&self) -> Vec<&str> {
146− self.visible()
147− .into_iter()
148− .filter(|(_, binding)| matches!(binding, Binding::Value(_)))
149− .map(|(name, _)| name)
150− .collect()
151− }
152−
153− /// The names of the bundles visible here.
154− fn bundle_names(&self) -> Vec<&str> {
155− self.visible()
156− .into_iter()
157− .filter(|(_, binding)| matches!(binding, Binding::Bundle(_)))
158− .map(|(name, _)| name)
159− .collect()
160− }
161}
61}
162
62
163−impl interp::Scope for Env {
63+#[derive(Clone)]
164− fn get(&self, name: &str) -> Option<Value> {
64+struct FnDef {
165− self.value(name).cloned()
65+ params: Vec<String>,
166− }
66+ defaults: HashMap<String, String>,
67+ body_lines: Vec<Line>,
167}
68}
168
69
169struct ParseContext {
70struct ParseContext {
170 /// Source line currently being parsed, for diagnostics raised deep
71 /// Source line currently being parsed, for diagnostics raised deep
171 /// inside helpers that don't take a line number.
72 /// inside helpers that don't take a line number.
172 current_line: usize,
73 current_line: usize,
173− /// What the `@page` said, once one has run
74+ /// Functions whose body has a scoped @style block.
174− page: Option<Page>,
75+ scoped_functions: std::collections::HashSet<String>,
175− /// Where that `@page` is, for the error a second one gets
76+ page_title: Option<String>,
176− page_at: Option<String>,
77+ lang: Option<String>,
78+ favicon: Option<String>,
177 meta_tags: Vec<(String, String)>,
79 meta_tags: Vec<(String, String)>,
178 head_blocks: Vec<String>,
80 head_blocks: Vec<String>,
179− /// The first `@meta` or `@head` that ran, for the warning a file
81+ variables: HashMap<String, String>,
180− /// without `@page` gets: a fragment has no `<head>` for them.
82+ defines: HashMap<String, Vec<Attribute>>,
181− head_at: Option<usize>,
83+ functions: HashMap<String, FnDef>,
182− /// What each name means at the line being evaluated.
84+ keyframes: Vec<(String, String)>,
183− env: Env,
184− /// Every function whose definition ran, by name (the last one), for
185− /// the checks of code that never runs.
186− defined_functions: HashMap<String, Rc<FnDef>>,
187 css_vars: Vec<(String, String)>,
85 css_vars: Vec<(String, String)>,
188 custom_css: Vec<String>,
86 custom_css: Vec<String>,
189− /// The scoped `@style` of each function called so far
190− scoped_css: Vec<String>,
191 og_tags: Vec<(String, String)>,
87 og_tags: Vec<(String, String)>,
192 diagnostics: Vec<Diagnostic>,
88 diagnostics: Vec<Diagnostic>,
193 base_path: Option<PathBuf>,
89 base_path: Option<PathBuf>,
@@ -207,550 +103,44 @@ struct ParseContext {
207 fn_lines: HashMap<String, usize>,
103 fn_lines: HashMap<String, usize>,
208 /// Line numbers of attribute bundle definitions (name -> line)
104 /// Line numbers of attribute bundle definitions (name -> line)
209 define_lines: HashMap<String, usize>,
105 define_lines: HashMap<String, usize>,
210− /// Syntax nodes evaluated at least once, by id.
106+ /// Canonical URL
211− visited: HashSet<usize>,
107+ canonical: Option<String>,
212− /// The id the next parsed tree starts at, so ids are unique across
108+ /// Base URL for relative links
213− /// the file, its includes and the standard library.
109+ base_url: Option<String>,
214− next_id: usize,
110+ /// Track @import paths for circular dependency detection
215− /// The file's tree and those of the files it includes, with the
111+ import_stack: Vec<PathBuf>,
216− /// include chain that leads to each, for the checks of code that never
217− /// runs.
218− trees: Vec<(Rc<Tree>, Option<String>)>,
219− /// The files each `@include` line brought in (by the line's node id),
220− /// with the include chain that leads to each, so the checks of
221− /// `@slot` and `@children` see an included file's lines where the
222− /// `@include` is written.
223− included_by: Rc<IncludedBy>,
224− /// Every function defined anywhere in the file or the files it
225− /// includes, whether or not its definition runs.
226− namespace: HashSet<String>,
227− /// The line being evaluated, and its text as written when it is one
228− /// physical line, for diagnostics that point at a column.
229− current_source: (usize, Option<String>),
230− /// Variable diagnostics already reported, so a line evaluated many
231− /// times (in a loop, in a function) reports each problem once.
232− /// Keyed by the syntax node (ids are unique across files), line,
233− /// column and message.
234− reported: HashSet<(usize, usize, Option<usize>, String)>,
235− /// The id of the syntax node being evaluated.
236− current_node: usize,
237− /// The file whose lines are being evaluated, when it isn't the file
238− /// being compiled: the standard library, or an included file (by its
239− /// include chain).
240− current_file: Option<String>,
241− /// A call went deeper than [`MAX_CALL_DEPTH`]: the calls it is nested
242− /// in expand to nothing, until the outermost one returns.
243− too_deep: bool,
244− /// A bundle's attributes are being read: a name that could be a
245− /// parameter of the function the bundle is passed to is checked where
246− /// the bundle is used instead.
247− in_bundle: bool,
248− /// `@page`'s attributes are being read: a name close to `favicon` is
249− /// a misspelled word of `@page`'s.
250− in_page: bool,
251−}
252−
253−/// How deeply function calls may nest, so that a function that calls
254−/// itself without an `@if` that stops it is an error instead of a hang.
255−const MAX_CALL_DEPTH: usize = 64;
256−
257−/// A CSS rule at the top level of a stylesheet that starts with `&`
258−/// (outside any `{}`, strings and comments).
259−fn has_top_level_ampersand(css: &str) -> bool {
260− let mut depth = 0usize;
261− let mut chars = css.chars().peekable();
262− let mut at_rule_start = true;
263− while let Some(c) = chars.next() {
264− match c {
265− '/' if chars.peek() == Some(&'*') => {
266− chars.next();
267− let mut last = ' ';
268− for c in chars.by_ref() {
269− if last == '*' && c == '/' {
270− break;
271− }
272− last = c;
273− }
274− continue;
275− }
276− '"' | '\'' => {
277− for d in chars.by_ref() {
278− if d == c {
279− break;
280− }
281− }
282− }
283− '{' => depth += 1,
284− '}' => depth = depth.saturating_sub(1),
285− '&' if depth == 0 && at_rule_start => return true,
286− _ => {}
287− }
288− at_rule_start = match c {
289− '{' | '}' | ';' => true,
290− c if c.is_whitespace() => at_rule_start,
291− _ => false,
292− };
293− }
294− false
295}
112}
296
113
297−/// Whether a file is a library (see [`Tree::is_library`]), with the files
114+struct Parser {
298−/// its `@include` lines name read relative to `base`.
115+ lines: Vec<Line>,
299−fn is_library_file(
116+ pos: usize,
300− tree: &Tree,
301− base: Option<&Path>,
302− ctx: &mut ParseContext,
303− seen: &mut Vec<PathBuf>,
304−) -> bool {
305− tree.is_library_with(&mut |path| {
306− if path.contains('$') {
307− return false;
308− }
309− let resolved = base.map_or_else(|| PathBuf::from(path), |base| base.join(path));
310− if seen.contains(&resolved) {
311− return false;
312− }
313− seen.push(resolved.clone());
314− let Ok(text) = ctx.read_file(&resolved) else {
315− return false;
316− };
317− let included = syntax::parse(&text);
318− is_library_file(&included, resolved.parent(), ctx, seen)
319− })
320}
117}
321
118
322−/// The frames around the file's top level: the standard library's and
323−/// the file's own (see [`load_prelude`]).
324−const FILE_FRAMES: usize = 2;
325−
326−/// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes.
327−struct Evaluator;
328−
329impl ParseContext {
119impl ParseContext {
330− /// Bind a value to `name` in the innermost block.
120+ /// Evaluate an expression (see `expr.rs`), reporting errors at `line`.
331− fn bind(&mut self, name: &str, value: Value) {
121+ fn eval(&mut self, src: &str, line: usize) -> Option<crate::expr::Value> {
332− self.env.define(name, Binding::Value(value));
333− }
334−
335− /// Evaluate an expression (see `expr.rs`) written at `line` and
336− /// `column` (when known), reporting its errors there.
337− fn eval(
338− &mut self,
339− src: &str,
340− line: usize,
341− column: Option<usize>,
342− ) -> Option<crate::expr::Value> {
343 track_var_refs(src, &mut self.used_variables);
122 track_var_refs(src, &mut self.used_variables);
344− match crate::expr::eval(src, &self.env) {
123+ let vars = &self.variables;
124+ let resolve = |name: &str| vars.get(name).cloned();
125+ let result = crate::expr::eval(src, &resolve);
126+ match result {
345 Ok(value) => Some(value),
127 Ok(value) => Some(value),
346− Err(error) => {
128+ Err(message) => {
347− // Without the line to point into, show the expression
129+ self.diagnostics.push(Diagnostic {
348− let shown = matches!(&self.current_source, (current, Some(_)) if *current == line);
130+ line,
349− let before = self.diagnostics.len();
131+ column: None,
350− self.report(error.at(0), line, column);
132+ message: format!("invalid expression: {}", message),
351− if !shown && let Some(diagnostic) = self.diagnostics.get_mut(before) {
133+ severity: Severity::Error,
352− diagnostic.source_line = Some(src.into());
134+ source_line: Some(src.to_string()),
353− }
135+ });
354 None
136 None
355 }
137 }
356 }
138 }
357 }
139 }
358
140
359 /// Evaluate a condition; an invalid one is reported and counts as false.
141 /// Evaluate a condition; an invalid one is reported and counts as false.
360− fn condition(&mut self, src: &str, line: usize, column: Option<usize>) -> bool {
142+ fn condition(&mut self, src: &str, line: usize) -> bool {
361− self.eval(src, line, column)
143+ self.eval(src, line).is_some_and(|value| value.truthy())
362− .is_some_and(|value| value.truthy())
363− }
364−
365− /// Fill in one slot of htmlang text written at `line` and `column`
366− /// (when known), for `sink`: its escapes stand for themselves and its
367− /// variables are filled in (see `interp.rs`). What can't be filled is
368− /// left as written and reported; the flag says whether everything was
369− /// filled.
370− fn fill(
371− &mut self,
372− raw: &str,
373− line: usize,
374− column: Option<usize>,
375− sink: Sink,
376− ) -> (String, bool) {
377− let (filled, ok) = self.fill_protected(raw, line, column, sink);
378− (restore_escapes(&filled), ok)
379− }
380−
381− /// [`fill`](Self::fill) for text.
382− fn interpolate_text(&mut self, raw: &str, line: usize, column: Option<usize>) -> String {
383− self.fill(raw, line, column, Sink::Text).0
384− }
385−
386− /// [`fill`](Self::fill), with the escapes still placeholders (see
387− /// [`protect_escapes`]).
388− fn fill_protected(
389− &mut self,
390− raw: &str,
391− line: usize,
392− column: Option<usize>,
393− sink: Sink,
394− ) -> (String, bool) {
395− let mut protected = protect_escapes(raw);
396− if sink == Sink::Css {
397− protected = keep_css_string_escapes(&protected);
398− }
399− if !protected.contains('$') {
400− return (protected, true);
401− }
402− track_var_refs(&protected, &mut self.used_variables);
403− let (out, problems) = interp::interpolate_for(&protected, &self.env, sink);
404− let filled = problems.is_empty();
405− for mut problem in problems {
406− match &mut problem {
407− interp::Problem::Undefined { offset, .. }
408− | interp::Problem::CustomProperty { offset, .. }
409− | interp::Problem::Invalid { offset, .. }
410− | interp::Problem::Record { offset, .. } => {
411− // An offset into the protected text, as one into `raw`
412− *offset = protected[..*offset]
413− .chars()
414− .map(|c| escape_of(c).map_or(c.len_utf8(), str::len))
415− .sum();
416− }
417− }
418− self.report(problem, line, column);
419− }
420− (out, filled)
421− }
422−
423− /// Fill in a value slot (an attribute's value, a `@let` value, `@meta`'s
424− /// value) for `sink`, and say whether it is quoted text: one `"..."`, or
425− /// one variable that holds quoted text. Quoted text keeps its quotes
426− /// only in CSS.
427− fn fill_value(
428− &mut self,
429− raw: &str,
430− line: usize,
431− column: Option<usize>,
432− sink: Sink,
433− ) -> (String, Option<Quoted>, bool) {
434− if let Some(inner) = syntax::quoted_string(raw) {
435− let (css, ok) = self.fill(raw, line, column, Sink::Css);
436− // Already reported by the CSS pass, so filled in quietly
437− let (text, _) = interp::interpolate(&protect_escapes(inner), &self.env);
438− let quoted = Quoted {
439− text: restore_escapes(&text),
440− css,
441− };
442− let out = match sink {
443− Sink::Css => quoted.css.clone(),
444− Sink::Text => quoted.text.clone(),
445− };
446− return (out, Some(quoted), ok);
447− }
448− let (out, ok) = self.fill(raw, line, column, sink);
449− let quoted = whole_reference(raw, &self.env).and_then(|path| {
450− match interp::resolve(&path, &self.env)? {
451− Value::Quoted(quoted) => Some(quoted),
452− _ => None,
453− }
454− });
455− (out, quoted, ok)
456− }
457−
458− /// The value of a value slot (a `@let` value, the source of `@each`, a
459− /// parameter's value or default), written at `line` and `column`:
460− ///
461− /// - exactly one `$name` or `${...}` is the value it holds or gives,
462− /// whatever its type (a list, a record, quoted text);
463− /// - one `"..."` is quoted text;
464− /// - commas outside quotes, brackets and `${...}`, and not escaped
465− /// (`\,`), make a list, which prints as written;
466− /// - `A..B` or `A..B step N` with whole numbers is a range;
467− /// - anything else is text, with its variables filled in.
468− ///
469− /// What can't be filled in is reported; the flag says whether
470− /// everything was.
471− fn slot_value(&mut self, raw: &str, line: usize, column: Option<usize>) -> (Value, bool) {
472− let trimmed = raw.trim();
473− let column = column.map(|c| c + (raw.len() - raw.trim_start().len()));
474− if let Some(value) = self.whole_value(trimmed, line, column) {
475− return value;
476− }
477− if syntax::quoted_string(trimmed).is_some() {
478− let (text, quoted, ok) = self.fill_value(trimmed, line, column, Sink::Text);
479− return (quoted.map_or(Value::Str(text), Value::Quoted), ok);
480− }
481− let parts = syntax::split_list(trimmed);
482− if parts.len() > 1 {
483− // Each item is a value of its own; the list prints as written
484− let mut items = Vec::new();
485− let mut written = String::new();
486− let mut ok = true;
487− let mut end = 0;
488− for part in parts {
489− let item = &trimmed[part.clone()];
490− let lead = item.len() - item.trim_start().len();
491− written.push_str(&trimmed[end..part.start + lead]);
492− end = part.start + lead + item.trim().len();
493− if item.trim().is_empty() {
494− continue;
495− }
496− let at = column.map(|c| c + part.start + lead);
497− let (value, text, filled) = self.item_value(item.trim(), line, at);
498− ok &= filled;
499− written.push_str(&text);
500− items.push(value);
501− }
502− written.push_str(&trimmed[end..]);
503− let list = value::List {
504− items: Rc::new(items),
505− written: Some(written.into()),
506− };
507− return (Value::List(list), ok);
508− }
509− let (text, ok) = self.fill(trimmed, line, column, Sink::Text);
510− match value::written_range(&text) {
511− Some(Ok(range)) => (range, ok),
512− Some(Err(message)) => {
513− self.report(
514− interp::Problem::Invalid { message, offset: 0 },
515− line,
516− column,
517− );
518− (Value::list(Vec::new()), false)
519− }
520− None => (Value::Str(text), ok),
521− }
522− }
523−
524− /// An item of a list written in the source (see
525− /// [`slot_value`](Self::slot_value)), and how it is written in the
526− /// list's text.
527− fn item_value(
528− &mut self,
529− item: &str,
530− line: usize,
531− column: Option<usize>,
532− ) -> (Value, String, bool) {
533− let (value, ok) = match self.whole_value(item, line, column) {
534− Some(value) => value,
535− None if syntax::quoted_string(item).is_some() => {
536− let (text, quoted, ok) = self.fill_value(item, line, column, Sink::Text);
537− (quoted.map_or(Value::Str(text), Value::Quoted), ok)
538− }
539− None => {
540− let (text, ok) = self.fill(item, line, column, Sink::Text);
541− (Value::Str(text), ok)
542− }
543− };
544− // Quoted text keeps its quotes in the list's text, as written
545− let text = value
546− .quoted_css()
547− .map_or_else(|| value.to_string(), str::to_string);
548− (value, text, ok)
549− }
550−
551− /// The value of a slot that is exactly one `$name` or `${...}`, with its
552− /// type; `None` when it is anything else.
553− fn whole_value(
554− &mut self,
555− raw: &str,
556− line: usize,
557− column: Option<usize>,
558− ) -> Option<(Value, bool)> {
559− let after = raw.strip_prefix('$')?;
560− let (reference, len) = interp::reference(after, &self.env)?;
561− if len != after.len() {
562− return None;
563− }
564− track_var_refs(raw, &mut self.used_variables);
565− let problem = match reference {
566− interp::Reference::Var(path) => match interp::resolve(&path, &self.env) {
567− Some(value) => return Some((value, true)),
568− None => interp::Problem::Undefined {
569− name: path,
570− offset: 0,
571− },
572− },
573− interp::Reference::Expr(source) => match crate::expr::eval(source, &self.env) {
574− Ok(value) => return Some((value, true)),
575− Err(crate::expr::Error::Invalid(message)) => {
576− interp::Problem::Invalid { message, offset: 0 }
577− }
578− Err(error) => error.at(2),
579− },
580− };
581− self.report(problem, line, column);
582− Some((Value::Str(raw.to_string()), false))
583− }
584−
585− /// Report a variable that can't be filled in, or an invalid `${...}`,
586− /// at `offset` from `column`.
587− fn report(&mut self, problem: interp::Problem, line: usize, column: Option<usize>) {
588− let (line_text, column) = match &self.current_source {
589− // The column is only shown with the line it points into
590− (current, Some(text)) if *current == line => (Some(text.clone()), column),
591− _ => (None, None),
592− };
593− let diagnostic = match problem {
594− interp::Problem::Undefined { name, offset } => {
595− let column = column.map(|c| c + offset);
596− let mut diagnostic = self.undefined(&name, line);
597− if let Some(column) = column {
598− diagnostic = diagnostic.column(column);
599− }
600− diagnostic
601− }
602− interp::Problem::Invalid { message, offset } => {
603− let diagnostic = Diagnostic::error(
604− code::INVALID_EXPRESSION,
605− line,
606− format!("invalid expression: {}", message),
607− );
608− match column {
609− Some(column) => diagnostic.column(column + offset),
610− None => diagnostic,
611− }
612− }
613− interp::Problem::CustomProperty { name, offset } => {
614− let diagnostic = Diagnostic::error(
615− code::INVALID_VALUE,
616− line,
617− interp::custom_property_message(&name),
618− )
619− .subject(format!("${}", name))
620− .suggest(Some(format!("var({})", name)));
621− match column {
622− Some(column) => diagnostic.column(column + offset),
623− None => diagnostic,
624− }
625− }
626− interp::Problem::Record { message, offset } => {
627− let diagnostic = Diagnostic::error(code::INVALID_VALUE, line, message);
628− match column {
629− Some(column) => diagnostic.column(column + offset),
630− None => diagnostic,
631− }
632− }
633− };
634− let diagnostic = match line_text {
635− Some(text) => diagnostic.source(text),
636− None => diagnostic,
637− };
638− self.push_once(diagnostic);
639− }
640−
641− /// Report a diagnostic unless the node being evaluated already did,
642− /// so a line evaluated many times (in a loop, in a function) reports
643− /// each problem once.
644− fn push_once(&mut self, diagnostic: Diagnostic) {
645− let key = (
646− self.current_node,
647− diagnostic.line,
648− diagnostic.column,
649− diagnostic.message.clone(),
650− );
651− if self.reported.insert(key) {
652− self.diagnostics.push(diagnostic);
653− }
654− }
655−
656− /// The error for `$name` with no definition: what the name is if it is
657− /// something else, or the closest defined name.
658− fn undefined(&mut self, name: &str, line: usize) -> Diagnostic {
659− if self.env.bundle(name).is_some() {
660− // Reported here, so not also as unused
661− self.used_defines.insert(name.to_string());
662− return Diagnostic::error(
663− code::UNDEFINED_VARIABLE,
664− line,
665− format!(
666− "'${}' is an attribute bundle, not a value: it goes in an attribute list \
667− as a whole attribute, `[${}]`",
668− name, name
669− ),
670− )
671− .subject(name);
672− }
673− if self.env.function(name).is_some() {
674− self.used_functions.insert(name.to_string());
675− return Diagnostic::error(
676− code::UNDEFINED_VARIABLE,
677− line,
678− format!(
679− "'${}' is a function, not a value: it is called as an element, `@{}`",
680− name, name
681− ),
682− )
683− .subject(name);
684− }
685− // `$i-label` where `$i` is defined: a `-` joins a name
686− if let Some((defined, rest)) = name
687− .match_indices('-')
688− .map(|(i, _)| (&name[..i], &name[i..]))
689− .find(|(before, _)| self.env.value(before).is_some())
690− {
691− return Diagnostic::error(
692− code::UNDEFINED_VARIABLE,
693− line,
694− format!(
695− "undefined variable '${}': a `-` is part of a name. For '${}' followed \
696− by '{}', write `${{{}}}{}`",
697− name, defined, rest, defined, rest
698− ),
699− )
700− .subject(name);
701− }
702− let suggestion = suggest_var_name(name, &self.env.value_names());
703− let message = match (&suggestion, self.let_lines.get(name)) {
704− (Some(closest), _) => format!(
705− "undefined variable '${}', did you mean '${}'?",
706− name, closest
707− ),
708− // Defined, but not where this line can see it
709− (None, Some(_)) => format!(
710− "undefined variable '${}': a `@let` defines it, but not where this line \
711− can see it. A definition is visible from its line to the end of its \
712− block, and a function's body sees what is defined above the function",
713− name
714− ),
715− (None, None) => format!("undefined variable '${}'", name),
716− };
717− Diagnostic::error(code::UNDEFINED_VARIABLE, line, message)
718− .subject(name)
719− .suggest(suggestion)
720− }
721−
722− /// Note the node being evaluated, for diagnostics.
723− fn enter(&mut self, node: &syntax::Node) {
724− self.current_line = node.span.line;
725− self.current_node = node.id;
726− let text =
727− (node.line_count <= 1).then(|| format!("{}{}", " ".repeat(node.indent), node.source));
728− self.current_source = (node.span.line, text);
729− }
730−
731− /// Parse a file's text into a tree whose ids don't clash with the
732− /// trees parsed before it.
733− fn parse_tree(&mut self, text: &str) -> Rc<Tree> {
734− let tree = syntax::parse_from(text, self.next_id);
735− self.next_id = tree.end_id;
736− Rc::new(tree)
737− }
738−
739− /// Read a file (once), relative to the current file.
740− fn read_file(&mut self, resolved: &Path) -> std::io::Result<String> {
741− if let Some(cached) = self.file_cache.get(resolved) {
742− return Ok(cached.clone());
743− }
744− let text = std::fs::read_to_string(resolved)?;
745− self.file_cache.insert(resolved.to_path_buf(), text.clone());
746− Ok(text)
747− }
748−
749− fn resolve(&self, filename: &str) -> PathBuf {
750− match &self.base_path {
751− Some(base) => base.join(filename),
752− None => PathBuf::from(filename),
753− }
754 }
144 }
755}
145}
756
146
@@ -759,19 +149,15 @@ impl ParseContext {
759const PRELUDE: &str = include_str!("std.hl");
149const PRELUDE: &str = include_str!("std.hl");
760
150
761fn load_prelude(ctx: &mut ParseContext) {
151fn load_prelude(ctx: &mut ParseContext) {
762− // The library's definitions are in a block of their own, around the
152+ let mut prelude = Parser {
763− // file's, so the file's own `@let` of the same name hides them
153+ lines: preprocess(PRELUDE),
764− ctx.env.push();
154+ pos: 0,
765− let tree = ctx.parse_tree(PRELUDE);
155+ };
766− collect_namespace(&tree.nodes, None, &mut HashSet::new(), ctx);
156+ let _ = prelude.parse_children(0, ctx);
767− ctx.current_file = Some("std.hl".to_string());
768− let _ = Evaluator.eval_block(&tree.nodes, ctx);
769− ctx.current_file = None;
770 // Library definitions aren't the file's own: never report them unused.
157 // Library definitions aren't the file's own: never report them unused.
771 ctx.fn_lines.clear();
158 ctx.fn_lines.clear();
772 ctx.define_lines.clear();
159 ctx.define_lines.clear();
773 ctx.let_lines.clear();
160 ctx.let_lines.clear();
774− ctx.env.push();
775}
161}
776
162
777pub fn parse(input: &str) -> ParseResult {
163pub fn parse(input: &str) -> ParseResult {
@@ -779,18 +165,22 @@ pub fn parse(input: &str) -> ParseResult {
779}
165}
780
166
781pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
167pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
168+ let lines = preprocess(input);
169+ let mut parser = Parser { lines, pos: 0 };
782 let mut ctx = ParseContext {
170 let mut ctx = ParseContext {
783 current_line: 0,
171 current_line: 0,
784− page: None,
172+ scoped_functions: std::collections::HashSet::new(),
785− page_at: None,
173+ page_title: None,
174+ lang: None,
175+ favicon: None,
786 meta_tags: Vec::new(),
176 meta_tags: Vec::new(),
787 head_blocks: Vec::new(),
177 head_blocks: Vec::new(),
788− head_at: None,
178+ variables: HashMap::new(),
789− env: Env::default(),
179+ defines: HashMap::new(),
790− defined_functions: HashMap::new(),
180+ functions: HashMap::new(),
181+ keyframes: Vec::new(),
791 css_vars: Vec::new(),
182 css_vars: Vec::new(),
792 custom_css: Vec::new(),
183 custom_css: Vec::new(),
793− scoped_css: Vec::new(),
794 og_tags: Vec::new(),
184 og_tags: Vec::new(),
795 diagnostics: Vec::new(),
185 diagnostics: Vec::new(),
796 base_path: base_path.map(|p| p.to_path_buf()),
186 base_path: base_path.map(|p| p.to_path_buf()),
@@ -804,85 +194,30 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
804 let_lines: HashMap::new(),
194 let_lines: HashMap::new(),
805 fn_lines: HashMap::new(),
195 fn_lines: HashMap::new(),
806 define_lines: HashMap::new(),
196 define_lines: HashMap::new(),
807− visited: HashSet::new(),
197+ canonical: None,
808− next_id: 0,
198+ base_url: None,
809− trees: Vec::new(),
199+ import_stack: Vec::new(),
810− included_by: Rc::default(),
811− namespace: HashSet::new(),
812− current_source: (0, None),
813− reported: HashSet::new(),
814− current_node: 0,
815− current_file: None,
816− too_deep: false,
817− in_bundle: false,
818− in_page: false,
819 };
200 };
820 load_prelude(&mut ctx);
201 load_prelude(&mut ctx);
821− let tree = ctx.parse_tree(input);
202+ let nodes = parser.parse_children(0, &mut ctx);
822− ctx.diagnostics.extend(tree.diagnostics.iter().cloned());
203+ validate_tree(&nodes, None, &mut ctx.diagnostics);
823− ctx.trees.push((tree.clone(), None));
204+ check_unused(&mut ctx);
824− collect_namespace(
825− &tree.nodes,
826− base_path.map(Path::to_path_buf),
827− &mut HashSet::new(),
828− &mut ctx,
829− );
830− let nodes = Evaluator.eval_block(&tree.nodes, &mut ctx);
831− check_unevaluated(&mut ctx);
832− check_slot_places(&mut ctx);
833− validate_tree(&nodes, None, false, &mut ctx.diagnostics);
834− // A library's definitions are for the files that include it
835− let library = is_library_file(&tree, base_path, &mut ctx, &mut Vec::new());
836− let exported: HashSet<String> = if library {
837− tree.nodes
838− .iter()
839− .filter_map(|node| match node.directive().map(|d| &d.args) {
840− Some(DirectiveArgs::Let(def)) => Some(def.name.clone()),
841− _ => None,
842− })
843− .collect()
844− } else {
845− HashSet::new()
846− };
847− check_unused(&mut ctx, &exported);
848− if let (None, Some(line)) = (&ctx.page, ctx.head_at)
849− && !library
850− {
851− ctx.diagnostics.push(Diagnostic::warning(
852− code::NO_EFFECT,
853− line,
854− "a file without @page is a fragment, which has no <head>: @meta and @head are \
855− left out of it (give the file a @page, or put them in the page that includes it)"
856− .to_string(),
857− ));
858− }
859− dedupe(&mut ctx.diagnostics);
860− // What the file itself defines at its top level
861− let file = ctx.env.frames.last().cloned().unwrap_or_default();
862− let mut variables = HashMap::new();
863− let mut defines = HashMap::new();
864− for (name, binding) in file.iter() {
865− match binding {
866− Binding::Value(value) => {
867− variables.insert(name.clone(), value.to_string());
868− }
869− Binding::Bundle(attrs) => {
870− defines.insert(name.clone(), attrs.to_vec());
871− }
872− Binding::Function(_) => {}
873− }
874− }
875 ParseResult {
205 ParseResult {
876 document: Document {
206 document: Document {
877− page: ctx.page,
207+ page_title: ctx.page_title,
208+ lang: ctx.lang,
209+ favicon: ctx.favicon,
878 meta_tags: ctx.meta_tags,
210 meta_tags: ctx.meta_tags,
879 head_blocks: ctx.head_blocks,
211 head_blocks: ctx.head_blocks,
880− variables,
212+ variables: ctx.variables,
881− defines,
213+ defines: ctx.defines,
214+ keyframes: ctx.keyframes,
882 css_vars: ctx.css_vars,
215 css_vars: ctx.css_vars,
883 custom_css: ctx.custom_css,
216 custom_css: ctx.custom_css,
884− scoped_css: ctx.scoped_css,
885 og_tags: ctx.og_tags,
217 og_tags: ctx.og_tags,
218+ canonical: ctx.canonical,
219+ base_url: ctx.base_url,
220+ preload_hints: collect_image_preload_hints(&nodes),
886 nodes,
221 nodes,
887 },
222 },
888 diagnostics: ctx.diagnostics,
223 diagnostics: ctx.diagnostics,
@@ -890,878 +225,1499 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
890 }
225 }
891}
226}
892
227
893−// ---------------------------------------------------------------------------
228+/// Scan nodes for @image elements and generate preload hints for early images.
894−// Code that never runs: an untaken branch, an uncalled function, a loop over
229+fn collect_image_preload_hints(nodes: &[Node]) -> Vec<crate::ast::PreloadHint> {
895−// an empty list
230+ let mut hints = Vec::new();
896−// ---------------------------------------------------------------------------
231+ // Only preload the first few images (above-the-fold heuristic)
232+ collect_images_recursive(nodes, &mut hints, 3);
233+ hints
234+}
897
235
898−/// Collect the functions defined anywhere in `nodes` and in the files they
236+fn collect_images_recursive(nodes: &[Node], hints: &mut Vec<crate::ast::PreloadHint>, max: usize) {
899−/// include (by a literal path), into `ctx.namespace`.
900−fn collect_namespace(
901− nodes: &[syntax::Node],
902− base: Option<PathBuf>,
903− seen: &mut HashSet<PathBuf>,
904− ctx: &mut ParseContext,
905−) {
906− let mut includes = Vec::new();
907 for node in nodes {
237 for node in nodes {
908− node.walk(&mut |node| match node.directive().map(|d| &d.args) {
238+ if hints.len() >= max {
909− Some(DirectiveArgs::Let(def)) if matches!(def.form, LetForm::Function(_)) => {
239+ return;
910− ctx.namespace.insert(def.name.clone());
911− }
912− Some(DirectiveArgs::Text(Some(path))) if node.is_directive("include") => {
913− includes.push(path.raw.clone());
914− }
915− _ => {}
916− });
917− }
918− for path in includes {
919− let path = path.trim_matches('"');
920− if path.contains('$') {
921− continue;
922 }
240 }
923− let resolved = match &base {
241+ if let Node::Element(elem) = node {
924− Some(base) => base.join(path),
242+ if elem.kind == ElementKind::Image
925− None => PathBuf::from(path),
243+ && let Some(ref src) = elem.argument
926− };
244+ && !src.is_empty()
927− if !seen.insert(resolved.clone()) {
245+ && !src.starts_with("data:")
928− continue;
246+ && !src.starts_with('#')
247+ {
248+ hints.push(crate::ast::PreloadHint {
249+ href: src.clone(),
250+ as_type: "image".to_string(),
251+ crossorigin: false,
252+ });
253+ }
254+ collect_images_recursive(&elem.children, hints, max);
929 }
255 }
930− let Ok(text) = ctx.read_file(&resolved) else {
931− continue;
932− };
933− let tree = syntax::parse(&text);
934− let parent = resolved.parent().map(Path::to_path_buf);
935− collect_namespace(&tree.nodes, parent, seen, ctx);
936 }
256 }
937}
257}
938
258
939−/// Check the lines that were never evaluated: element and function names,
259+// ---------------------------------------------------------------------------
940−/// and the attributes of built-in elements (those that don't depend on a
260+// Preprocessing: strip comments/blanks, collapse @raw blocks
941−/// variable). Names that come from data are only checked when evaluated.
261+// ---------------------------------------------------------------------------
942−fn check_unevaluated(ctx: &mut ParseContext) {
262+
943− let trees = std::mem::take(&mut ctx.trees);
263+fn preprocess(input: &str) -> Vec<Line> {
944− let mut seen = HashSet::new();
264+ let raw_lines: Vec<&str> = input.lines().collect();
945− for (tree, chain) in &trees {
265+ let mut lines = Vec::new();
946− let before = ctx.diagnostics.len();
266+ let mut i = 0;
947− for node in &tree.nodes {
267+
948− check_unevaluated_node(node, ctx);
268+ while i < raw_lines.len() {
949− }
269+ let line = raw_lines[i];
950− let mut found = ctx.diagnostics.split_off(before);
270+ let trimmed = line.trim();
951− if let Some(chain) = chain {
271+
952− for d in &mut found {
272+ if trimmed.is_empty() || trimmed.starts_with("--") {
953− d.message = format!("{}\n in {}", d.message, chain);
273+ i += 1;
954− }
274+ continue;
955 }
275 }
956− // A file included twice is checked twice: report each problem once
957− found.retain(|d| seen.insert((d.line, d.message.clone())));
958− ctx.diagnostics.extend(found);
959− }
960− ctx.trees = trees;
961−}
962
276
963−fn check_unevaluated_node(node: &syntax::Node, ctx: &mut ParseContext) {
277+ let indent = line.len() - line.trim_start().len();
964− if !ctx.visited.contains(&node.id) {
278+
965− let line = node.span.line;
279+ // Inline `@markdown` and `@script` blocks keep their bodies verbatim:
966− // A reference from code that doesn't run still counts as a use
280+ // in Markdown, blank lines separate paragraphs, `---` is a rule (not
967− track_var_refs(&node.source, &mut ctx.used_variables);
281+ // a comment) and code indentation matters; in JavaScript, newlines,
968− let before = ctx.diagnostics.len();
282+ // `{...}` and `$` must reach the output untouched.
969− match &node.kind {
283+ let verbatim_body = trimmed == "@markdown"
970− NodeKind::Element(element) => {
284+ || trimmed == "@script"
971− for head in &element.chain {
285+ || trimmed.starts_with("@script ")
972− check_head(head, line, ctx);
286+ || trimmed.starts_with("@script[");
287+ if verbatim_body {
288+ lines.push(Line {
289+ indent,
290+ content: LineContent::Normal(trimmed.to_string()),
291+ line_num: i + 1,
292+ });
293+ let body_start = i + 1;
294+ let mut body_end = body_start;
295+ let mut j = body_start;
296+ while j < raw_lines.len() {
297+ let l = raw_lines[j];
298+ if l.trim().is_empty() {
299+ j += 1;
300+ continue;
973 }
301 }
974− check_unevaluated_content(node, element, ctx);
302+ if l.len() - l.trim_start().len() <= indent {
975− if let Some(text) = &element.text {
303+ break;
976− check_inline_heads(text, line, ctx);
977 }
304 }
305+ j += 1;
306+ body_end = j;
978 }
307 }
979− NodeKind::Text(text) => check_inline_heads(text, line, ctx),
308+ let body = &raw_lines[body_start..body_end];
980− // `@page`'s attributes are checked like an element's (a layout's
309+ let body_indent = body
981− // `@page` is in a function that may not run in this file)
310+ .iter()
982− NodeKind::Directive(syntax::Directive {
311+ .filter(|l| !l.trim().is_empty())
983− args:
312+ .map(|l| l.len() - l.trim_start().len())
984− DirectiveArgs::Page {
313+ .min()
985− attrs: Some(list), ..
314+ .unwrap_or(0);
986− },
315+ if !body.is_empty() {
987− ..
316+ let text: Vec<&str> = body
988− }) => {
989− let words: Vec<String> = crate::vocab::PAGE_WORDS
990 .iter()
317 .iter()
991− .map(|w| w.to_string())
318+ .map(|l| l.get(body_indent..).unwrap_or("").trim_end())
992 .collect();
319 .collect();
993− ctx.in_page = true;
320+ lines.push(Line {
994− check_attrs(&list.attrs, line, ctx, &words);
321+ indent: indent + 1,
995− ctx.in_page = false;
322+ content: LineContent::Raw(text.join("\n")),
996− check_page_tokens(&list.attrs, line, ctx);
323+ line_num: body_start + 1,
324+ });
325+ }
326+ i = body_end.max(body_start);
327+ continue;
328+ }
329+
330+ // Handle @raw """..."""
331+ if let Some(raw_rest) = trimmed.strip_prefix("@raw") {
332+ let after_raw = raw_rest.trim_start();
333+ if let Some(after_open) = after_raw.strip_prefix("\"\"\"") {
334+ // Single-line: @raw """content"""
335+ if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
336+ let content = &after_open[..after_open.len() - 3];
337+ lines.push(Line {
338+ indent,
339+ content: LineContent::Raw(content.to_string()),
340+ line_num: i + 1,
341+ });
342+ i += 1;
343+ continue;
344+ }
345+
346+ // Multiline: collect until closing """
347+ let mut raw_content = String::new();
348+ if !after_open.is_empty() {
349+ raw_content.push_str(after_open);
350+ raw_content.push('\n');
351+ }
352+ i += 1;
353+ while i < raw_lines.len() {
354+ if raw_lines[i].trim() == "\"\"\"" {
355+ i += 1;
356+ break;
357+ }
358+ raw_content.push_str(raw_lines[i]);
359+ raw_content.push('\n');
360+ i += 1;
361+ }
362+
363+ lines.push(Line {
364+ indent,
365+ content: LineContent::Raw(raw_content.trim_end_matches('\n').to_string()),
366+ line_num: i,
367+ });
368+ continue;
997 }
369 }
998− _ => {}
999 }
370 }
1000− let text = written_line(node);
371+
1001− for d in &mut ctx.diagnostics[before..] {
372+ // Join continuation lines for multi-line attribute brackets
1002− d.source_line.get_or_insert_with(|| text.as_str().into());
373+ let first_line_num = i + 1;
374+ let mut full = trimmed.to_string();
375+ while open_attr_depth(&full) > 0 && i + 1 < raw_lines.len() {
376+ i += 1;
377+ let next = raw_lines[i].trim();
378+ if next.is_empty() || next.starts_with("--") {
379+ continue;
380+ }
381+ full.push(' ');
382+ full.push_str(next);
1003 }
383 }
384+
385+ lines.push(Line {
386+ indent,
387+ content: LineContent::Normal(full),
388+ line_num: first_line_num,
389+ });
390+ i += 1;
1004 }
391 }
1005− for child in &node.children {
1006− check_unevaluated_node(child, ctx);
1007− }
1008−}
1009
392
1010−/// Check the content passed to the calls of a line that was never
393+ lines
1011−/// evaluated: its `@slot NAME` blocks, and, for a function without
394+}
1012−/// `@children`, any other content.
395+
1013−fn check_unevaluated_content(
396+/// Bracket depth left open at the end of `line`, counting only attribute
1014− node: &syntax::Node,
397+/// lists: a `[` that starts the line or follows an `@name` token (optionally
1015− element: &syntax::ElementLine,
398+/// with one more word, as in `@let name [`). Brackets in text content, such
1016− ctx: &mut ParseContext,
399+/// as `@text [bold] Use [ to open`, are ignored.
1017−) {
400+fn open_attr_depth(line: &str) -> i32 {
1018− let calls: Vec<Option<(String, Rc<FnDef>)>> = element
401+ if !line.starts_with('@') && !line.starts_with('[') {
1019− .chain
402+ return 0;
1020− .iter()
403+ }
1021− .map(|head| {
404+ let bytes = line.as_bytes();
1022− let function = ctx.defined_functions.get(&head.name)?;
405+ let mut depth: i32 = 0;
1023− Some((head.name.clone(), function.clone()))
406+ for (i, &b) in bytes.iter().enumerate() {
1024− })
407+ match b {
1025− .collect();
408+ b'[' if depth > 0 => depth += 1,
1026− check_fillers(node, element, &calls, ctx);
409+ b'[' => {
1027− let last = calls.len() - 1;
410+ // Is this the start of an attribute list?
1028− for (i, call) in calls.iter().enumerate() {
411+ let before = line[..i].trim_end();
1029− let Some((name, function)) = call else {
412+ let last_directive = before
1030− continue;
413+ .rsplit([']', '>'])
1031− };
414+ .next()
1032− if function.has_children || function.wraps_itself {
415+ .unwrap_or("")
1033− continue;
416+ .trim();
1034− }
417+ let tokens: Vec<&str> = last_directive.split_whitespace().collect();
1035− let content = if i == last {
418+ let starts_list = match tokens.as_slice() {
1036− element.text.is_some() || holds_content(&node.children, ctx)
419+ [] => before.is_empty() || before.ends_with('>'),
1037− } else {
420+ [name] => name.starts_with('@'),
1038− element.chain[i + 1].name != "slot" || !slot_is_built_in(ctx)
421+ [name, _] => name.starts_with('@'),
1039− };
422+ _ => false,
1040− if content {
423+ };
1041− let head = &element.chain[i];
424+ if starts_list {
1042− ctx.push_once(no_children(
425+ depth = 1;
1043− name,
426+ } else if before.ends_with(']') {
1044− node.span.line,
427+ // Text after a closed attribute list: stop scanning.
1045− name_column(head, node.span.line),
428+ return 0;
1046− ));
1047− }
1048− }
1049−}
1050−
1051−/// Check a head that was never evaluated: its name, and its attributes
1052−/// (for a call, see [`check_call`]).
1053−fn check_head(head: &syntax::Head, line: usize, ctx: &mut ParseContext) {
1054− let is_function =
1055− ctx.namespace.contains(&head.name) || ctx.defined_functions.contains_key(&head.name);
1056− if is_function {
1057− ctx.used_functions.insert(head.name.clone());
1058− if let Some(function) = ctx.defined_functions.get(&head.name).cloned()
1059− && !function.wraps_itself
1060− {
1061− check_call(head, &function, line, ctx);
1062− }
1063− return;
1064− }
1065− let kind = match parse_element_kind(&head.name, line, ctx) {
1066− Ok(kind) => kind,
1067− Err(e) => {
1068− ctx.diagnostics.push(e);
1069− return;
1070− }
1071− };
1072− if let Some(list) = &head.attrs {
1073− check_attrs(&list.attrs, line, ctx, &[]);
1074− // A word for laying out children on an element that doesn't lay
1075− // them out, as where the code runs (see `validate_tree`)
1076− if !kind.layout().is_container() && !has_no_element(&kind) {
1077− for attr in &list.attrs {
1078− let base = crate::vocab::base_attribute(&attr.key);
1079− if !attr.html
1080− && attr.choice.is_none()
1081− && attr.prefixed.is_none()
1082− && crate::vocab::CONTAINER_ATTRIBUTES.contains(&base)
1083− && !crate::vocab::split_prefixes(&attr.key)
1084− .0
1085− .contains(&"children:")
1086− {
1087− ctx.push_once(not_a_container(&kind, line, base).column(attr.span.column));
1088 }
429 }
1089 }
430 }
431+ b']' if depth > 0 => depth -= 1,
432+ _ => {}
1090 }
433 }
1091 }
434 }
435+ depth
1092}
436}
1093
437
1094−/// The attributes of a list that is never evaluated that can be checked
438+// ---------------------------------------------------------------------------
1095−/// together: not bundles or `if()`s. Names are checked; a value with a
439+// Parser
1096−/// variable in it depends on what the variable holds, so only a literal
440+// ---------------------------------------------------------------------------
1097−/// value is (the other is checked as an empty value).
1098−/// `@page`'s own rules (see `check_page_attr`) for a list that isn't
1099−/// evaluated, as written: a value made from a variable counts as given.
1100−fn check_page_tokens(tokens: &[syntax::Attr], line: usize, ctx: &mut ParseContext) {
1101− for token in tokens {
1102− match &token.choice {
1103− Some(choice) => {
1104− for branch in &choice.branches {
1105− check_page_tokens(branch.attrs(), line, ctx);
1106− }
1107− }
1108− // Under prefixes there are only styles
1109− None if token.key.starts_with('$') || token.prefixed.is_some() => {}
1110− None => {
1111− let value = token.value.as_deref().map(|v| v.trim_matches('"'));
1112− let value = match value {
1113− Some(v) if v.contains('$') => Some("$"),
1114− other => other,
1115− };
1116− check_page_attr(
1117− &token.key,
1118− token.html,
1119− value,
1120− line,
1121− Some(token.span.column),
1122− ctx,
1123− );
1124− }
1125− }
1126− }
1127−}
1128
441
1129−fn literal_attrs(tokens: &[syntax::Attr]) -> Vec<syntax::Attr> {
442+impl Parser {
1130− // A prefixed group is checked with its prefixes, as far as its own
443+ fn parse_children(&mut self, min_indent: usize, ctx: &mut ParseContext) -> Vec<Node> {
1131− // attributes can be; a prefixed bundle or `if()` depends on what it holds
444+ let mut nodes = Vec::new();
1132− let group = |a: &syntax::Attr| {
445+
1133− let prefixed = a.prefixed.as_ref()?;
446+ while self.pos < self.lines.len() {
1134− let syntax::Branch::Group { list, trailing } = &prefixed.target else {
447+ let indent = self.lines[self.pos].indent;
1135− return None;
448+ if indent < min_indent {
1136− };
449+ break;
1137− let mut list = list.clone();
1138− list.attrs = literal_attrs(&list.attrs);
1139− let mut a = a.clone();
1140− a.prefixed = Some(Box::new(syntax::Prefixed {
1141− target: syntax::Branch::Group {
1142− list,
1143− trailing: trailing.clone(),
1144− },
1145− ..(**prefixed).clone()
1146− }));
1147− Some(a)
1148− };
1149− let groups = tokens.iter().filter_map(group);
1150− tokens
1151− .iter()
1152− .filter(|a| {
1153− let bundle = a.key.starts_with('$') && a.value.is_none() && !a.html;
1154− a.choice.is_none() && a.prefixed.is_none() && !bundle
1155− })
1156− .map(|a| {
1157− let mut a = a.clone();
1158− let variable = |v: &String| v.contains('$');
1159− if !a.key.contains('$') && a.value.as_ref().is_some_and(variable) {
1160− a.value = Some(String::new());
1161− a.raw = format!("{} ", a.key);
1162 }
450 }
1163− a
1164− })
1165− .chain(groups)
1166− .collect()
1167−}
1168
451
1169−/// Check a call that was never evaluated: how it passes its parameters
452+ match self.parse_line(ctx) {
1170−/// (none written `name=value`, and every one without a default passed,
453+ Ok(Some(new_nodes)) => nodes.extend(new_nodes),
1171−/// when no bundle or `if()` could pass it), and its other attributes, like
454+ Ok(None) => {}
1172−/// those of an element.
455+ Err(e) => {
1173−fn check_call(head: &syntax::Head, function: &FnDef, line: usize, ctx: &mut ParseContext) {
456+ // Error recovery: record the diagnostic and continue parsing
1174− let name = head.name.as_str();
457+ // to report multiple errors in a single pass
1175− let attrs = head.attrs.as_ref().map_or(&[][..], |list| &list.attrs[..]);
458+ let source = if self.pos > 0 && self.pos <= self.lines.len() {
1176− written_parameter_forms(name, function, attrs, line, ctx);
459+ match &self.lines[self.pos.saturating_sub(1)].content {
1177− if let Some(list) = &head.attrs {
460+ LineContent::Normal(s) => Some(s.clone()),
1178− let params: Vec<String> = function.params.iter().map(|p| p.name.clone()).collect();
461+ LineContent::Raw(s) => Some(s.clone()),
1179− check_attrs(&list.attrs, line, ctx, &params);
462+ }
1180− }
463+ } else {
1181− let unknown = attrs
464+ None
1182− .iter()
465+ };
1183− .any(|a| a.choice.is_some() || (a.key.starts_with('$') && a.value.is_none()));
466+ ctx.diagnostics.push(Diagnostic {
1184− if unknown {
467+ line: e.line,
1185− return;
468+ column: None,
1186− }
469+ message: e.message,
1187− for param in function.params.iter().filter(|p| p.default.is_none()) {
470+ severity: Severity::Error,
1188− if !attrs.iter().any(|a| a.key == param.name) {
471+ source_line: source,
1189− let column = name_column(head, line);
472+ });
1190− ctx.push_once(missing_parameter(name, &param.name, line, column));
473+ // Skip forward past any deeper-indented children of the errored line
474+ while self.pos < self.lines.len() && self.lines[self.pos].indent > indent {
475+ self.pos += 1;
476+ }
477+ }
478+ }
1191 }
479 }
1192− }
1193−}
1194
480
1195−/// Report each parameter a call's list writes `name=value`, with the value
481+ nodes
1196−/// as written and its column; returns their names, so the evaluated call
1197−/// doesn't report them again.
1198−fn written_parameter_forms(
1199− name: &str,
1200− function: &FnDef,
1201− attrs: &[syntax::Attr],
1202− line: usize,
1203− ctx: &mut ParseContext,
1204−) -> Vec<String> {
1205− let mut reported = Vec::new();
1206− for attr in attrs {
1207− if !(attr.html && function.is_param(&attr.key)) {
1208− continue;
1209− }
1210− let at = if attr.span.line == 0 {
1211− line
1212− } else {
1213− attr.span.line
1214− };
1215− let value = attr.value.as_deref().unwrap_or("");
1216− let diagnostic = parameter_form(name, &attr.key, value, at).column(attr.span.column);
1217− ctx.push_once(match &ctx.current_source {
1218− (current, Some(text)) if *current == at => diagnostic.source(text.clone()),
1219− _ => diagnostic,
1220− });
1221− reported.push(attr.key.clone());
1222 }
482 }
1223− reported
1224−}
1225
483
1226−fn check_inline_heads(text: &syntax::Text, line: usize, ctx: &mut ParseContext) {
484+ fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
1227− for segment in &text.segments {
485+ let line_num = self.lines[self.pos].line_num;
1228− if let Segment::Inline(inline) = segment {
486+ ctx.current_line = line_num;
1229− check_head(&inline.head, line, ctx);
487+ if let LineContent::Normal(text) = &self.lines[self.pos].content
1230− if let Some(text) = &inline.text {
488+ && let Some(filter) = old_filter_syntax(text)
1231− check_inline_heads(text, line, ctx);
489+ {
1232− }
490+ ctx.diagnostics.push(Diagnostic {
491+ line: line_num,
492+ column: None,
493+ message: format!(
494+ "`${}|{}` filters are functions now: write `${{{}(${})}}` (run `htmlang upgrade`)",
495+ filter.0, filter.1, filter.1, filter.0
496+ ),
497+ severity: Severity::Warning,
498+ source_line: Some(text.clone()),
499+ });
1233 }
500 }
1234− }
501+ let current_indent = self.lines[self.pos].indent;
1235−}
1236−
1237−// ---------------------------------------------------------------------------
1238−// Evaluation
1239−// ---------------------------------------------------------------------------
1240
502
1241−/// The `@else` lines that continue the `@if` or `@each` before `from`, and
503+ // Handle raw content
1242−/// the position after them.
504+ if let LineContent::Raw(s) = &self.lines[self.pos].content {
1243−fn else_branches(block: &[syntax::Node], from: usize, is_if: bool) -> (Vec<&syntax::Node>, usize) {
505+ let content = s.clone();
1244− let mut branches = Vec::new();
506+ self.pos += 1;
1245− let mut next = from;
507+ return Ok(Some(vec![Node::Raw(content)]));
1246− let mut i = from;
1247− while i < block.len() {
1248− let node = &block[i];
1249− i += 1;
1250− if node.is_trivia() {
1251− continue;
1252 }
508 }
1253− let condition = match node.directive().map(|d| &d.args) {
509+
1254− Some(DirectiveArgs::Else { condition }) => condition.is_some(),
510+ // Normal content — clone to release borrow. Raw is already handled
1255− _ => break,
511+ // above; a let-else pattern avoids a panic path if new LineContent
512+ // variants are added in the future.
513+ let LineContent::Normal(content) = &self.lines[self.pos].content else {
514+ return Err(ParseError {
515+ line: line_num,
516+ message: "internal: unexpected line content variant".to_string(),
517+ });
1256 };
518 };
1257− if condition && !is_if {
519+ let content = content.clone();
1258− break;
520+ self.pos += 1;
1259− }
521+
1260− branches.push(node);
522+ // --- Directives ---
1261− next = i;
523+
1262− if !condition {
524+ // Removed syntax gets a pointer to its replacement — unless the
1263− break;
525+ // name is a user function (e.g. `@let divider`).
526+ if content.starts_with('@')
527+ && !ctx.functions.contains_key(extract_element_name(&content))
528+ && let Some(hint) = removed_syntax_hint(&content)
529+ {
530+ return Err(ParseError {
531+ line: line_num,
532+ message: hint,
533+ });
1264 }
534 }
1265− }
1266− (branches, next)
1267−}
1268
535
1269−impl Evaluator {
536+ // @page [lang en, favicon /f.png, canonical URL, base URL] Title
1270− fn eval_block(&mut self, block: &[syntax::Node], ctx: &mut ParseContext) -> Vec<Node> {
537+ if let Some(rest) = content.strip_prefix("@page ") {
1271− let mut nodes = Vec::new();
538+ let rest = rest.trim_start();
1272− let mut i = 0;
539+ let title = if rest.starts_with('[') {
1273− while i < block.len() {
540+ let (attrs, title) = parse_attr_brackets_no_validate(rest, line_num, ctx)?;
1274− let node = &block[i];
541+ for attr in attrs {
1275− i += 1;
542+ let value = attr.value.unwrap_or_default();
1276− if node.is_trivia() {
543+ match attr.key.as_str() {
1277− continue;
544+ "lang" => ctx.lang = Some(value),
1278− }
545+ "favicon" => ctx.favicon = Some(value),
1279− let result = match node.directive().map(|d| d.name()) {
546+ "canonical" => ctx.canonical = Some(value),
1280− Some(name @ ("if" | "each")) => {
547+ "base" => ctx.base_url = Some(value),
1281− let (branches, next) = else_branches(block, i, name == "if");
548+ other => ctx.diagnostics.push(Diagnostic {
1282− i = next;
549+ line: line_num,
1283− if name == "if" {
550+ column: None,
1284− Ok(Some(self.eval_if(node, &branches, ctx)))
551+ message: format!(
1285− } else {
552+ "unknown @page attribute '{}' (expected lang, favicon, canonical or base)",
1286− self.eval_each(node, branches.first().copied(), ctx)
553+ other
554+ ),
555+ severity: Severity::Warning,
556+ source_line: Some(content.clone()),
557+ }),
1287 }
558 }
1288 }
559 }
1289− // Reported as a syntax error
560+ title
1290− Some("else") => {
561+ } else {
1291− ctx.visited.insert(node.id);
562+ rest.to_string()
1292− Ok(None)
1293− }
1294− _ => self.eval(node, ctx),
1295 };
563 };
1296− match result {
564+ ctx.page_title = Some(substitute_vars(title.trim(), &ctx.variables));
1297− Ok(Some(new_nodes)) => nodes.extend(new_nodes),
565+ return Ok(None);
1298− Ok(None) => {}
1299− // Record the error and go on, to report several in one pass
1300− Err(mut e) => {
1301− e.source_line
1302− .get_or_insert_with(|| node.source.as_str().into());
1303− ctx.diagnostics.push(e);
1304− }
1305− }
1306 }
566 }
1307− nodes
1308− }
1309
567
1310− /// Evaluate a block in its own scope: what it defines is visible to
1311− /// the end of the block, and not after it.
1312− fn eval_scoped(&mut self, block: &[syntax::Node], ctx: &mut ParseContext) -> Vec<Node> {
1313− ctx.env.push();
1314− let nodes = self.eval_block(block, ctx);
1315− ctx.env.pop();
1316− nodes
1317− }
1318
568
1319− /// `@if` with its `@else if` / `@else` branches. Every condition is
569+ if let Some(rest) = content.strip_prefix("@let ") {
1320− /// checked (and its errors reported); the first that holds picks the
570+ let rest = rest.trim();
1321− /// branch.
571+
1322− fn eval_if(
572+ // A `"""` value opens a multi-line string, whose indented lines
1323− &mut self,
573+ // are its content — not a function body.
1324− node: &syntax::Node,
574+ let opens_triple_quote = rest.split_once(' ').is_some_and(|(_, v)| {
1325− branches: &[&syntax::Node],
575+ let v = v.trim();
1326− ctx: &mut ParseContext,
576+ v.strip_prefix("= ").unwrap_or(v).trim_start().starts_with("\"\"\"")
1327− ) -> Vec<Node> {
577+ });
1328− let mut chosen = None;
578+ // Check if next lines are indented (function/component definition)
1329− for branch in std::iter::once(node).chain(branches.iter().copied()) {
579+ let has_body = !opens_triple_quote
1330− ctx.visited.insert(branch.id);
580+ && self.pos < self.lines.len()
1331− ctx.enter(branch);
581+ && self.lines[self.pos].indent > current_indent;
1332− let holds = match branch.directive().map(|d| &d.args) {
582+
1333− Some(DirectiveArgs::Condition(condition))
583+ if has_body {
1334− | Some(DirectiveArgs::Else {
584+ // Function definition: @let name $param1 $param2=default
1335− condition: Some(condition),
585+ let parts: Vec<&str> = rest.split_whitespace().collect();
1336− }) => {
586+ if parts.is_empty() {
1337− !condition.raw.is_empty()
587+ return Err(ParseError {
1338− && ctx.condition(
588+ line: line_num,
1339− &condition.raw,
589+ message: "@let with body requires a name".to_string(),
1340− branch.span.line,
590+ });
1341− Some(condition.span.column),
591+ }
1342− )
592+ let name = parts[0].to_string();
593+ let mut params = Vec::new();
594+ let mut defaults = HashMap::new();
595+ for part in &parts[1..] {
596+ let part = part.strip_prefix('$').unwrap_or(part);
597+ if let Some((param_name, default_val)) = part.split_once('=') {
598+ params.push(param_name.to_string());
599+ defaults.insert(param_name.to_string(), default_val.to_string());
600+ } else {
601+ params.push(part.to_string());
602+ }
1343 }
603 }
1344− Some(DirectiveArgs::Else { condition: None }) => true,
1345− // A syntax error, already reported
1346− _ => false,
1347− };
1348− if holds && chosen.is_none() {
1349− chosen = Some(&branch.children);
1350− }
1351− }
1352− chosen.map_or_else(Vec::new, |body| self.eval_scoped(body, ctx))
1353− }
1354
604
1355− fn eval(
605+ // Collect body lines (all lines indented deeper than @let)
1356− &mut self,
606+ let mut body_lines = Vec::new();
1357− node: &syntax::Node,
607+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1358− ctx: &mut ParseContext,
608+ body_lines.push(self.lines[self.pos].clone());
1359− ) -> Result<Option<Vec<Node>>, ParseError> {
609+ self.pos += 1;
1360− ctx.visited.insert(node.id);
1361− ctx.enter(node);
1362− match &node.kind {
1363− NodeKind::Blank | NodeKind::Comment => Ok(None),
1364− // `@code` and `@textarea` show their body as text; `@script`'s
1365− // goes into the page as it is
1366− NodeKind::Verbatim(body) if body.escaped => {
1367− let text = body.text.trim_matches('\n').to_string();
1368− Ok(Some(vec![Node::Text(vec![TextSegment::Plain(text)])]))
1369− }
1370− NodeKind::Verbatim(body) => Ok(Some(vec![Node::Raw(body.text.clone())])),
1371− NodeKind::Directive(directive) => {
1372− let mut nodes = self.eval_directive(node, directive, ctx)?;
1373− // A directive that takes no body: the lines indented under
1374− // it (a syntax error) are evaluated as its siblings. A
1375− // @let's body belongs to it even under a value (also an
1376− // error), so it is never evaluated in place.
1377− let takes_body = directive.spec.body != BodyKind::None;
1378− if !takes_body {
1379− let siblings = self.eval_block(&node.children, ctx);
1380− nodes.get_or_insert_with(Vec::new).extend(siblings);
1381 }
610 }
1382− Ok(nodes)
1383− }
1384− NodeKind::Element(element) => self.eval_element(node, element, ctx).map(Some),
1385− NodeKind::Text(text) => {
1386− let mut nodes = vec![Node::Text(text_segments(text, ctx))];
1387− // Text takes no body: lines indented under it are its
1388− // siblings
1389− nodes.extend(self.eval_block(&node.children, ctx));
1390− Ok(Some(nodes))
1391− }
1392− }
1393− }
1394
611
1395− fn eval_directive(
612+ // An @style block at the top of the body is scoped to the
1396− &mut self,
613+ // function: its rules apply inside a `.hl-NAME` wrapper.
1397− node: &syntax::Node,
614+ let (body_lines, style_lines) = split_style_block(body_lines);
1398− directive: &syntax::Directive,
615+ if !style_lines.is_empty() {
1399− ctx: &mut ParseContext,
616+ let scope_class = format!("hl-{}", name);
1400− ) -> Result<Option<Vec<Node>>, ParseError> {
617+ let scoped_css: String = style_lines
1401− let line_num = node.span.line;
618+ .iter()
1402− let content = &node.source;
619+ .filter_map(|line| match &line.content {
1403− match (directive.name(), &directive.args) {
620+ LineContent::Normal(s) if !s.trim().is_empty() => {
1404− // @page [lang=en, favicon /f.png, background #f8fafc] Title
621+ Some(format!(".{} {}\n", scope_class, s.trim()))
1405− ("page", DirectiveArgs::Page { attrs, title }) => {
622+ }
1406− let page = read_page(attrs.as_ref(), title.as_ref(), line_num, ctx);
623+ _ => None,
1407− // A page has one root: a second `@page` (a layout called
624+ })
1408− // twice, or one called after the page's own) is an error
625+ .collect();
1409− let here = match &ctx.current_file {
626+ if !scoped_css.is_empty() {
1410− Some(file) => format!("line {} of {}", line_num, file),
627+ ctx.custom_css.push(scoped_css);
1411− None => format!("line {}", line_num),
628+ }
1412− };
629+ ctx.scoped_functions.insert(name.clone());
1413− if let Some(first) = &ctx.page_at {
1414− let again = if *first == here {
1415− "this @page runs a second time (in a loop, or in a function called \
1416− twice)"
1417− .to_string()
1418− } else {
1419− format!("a second @page (this page already has one, on {})", first)
1420− };
1421− let diagnostic = Diagnostic::error(
1422− code::DUPLICATE_PAGE,
1423− line_num,
1424− format!(
1425− "{}: a page has one @page, the root element that styles its <body>",
1426− again
1427− ),
1428− )
1429− .source(content.clone())
1430− .subject("@page");
1431− ctx.diagnostics.push(diagnostic);
1432− } else {
1433− ctx.page_at = Some(here);
1434− ctx.page = Some(page);
1435 }
630 }
1436− Ok(None)
631+
632+ ctx.fn_lines.entry(name.clone()).or_insert(line_num);
633+ ctx.functions.insert(
634+ name,
635+ FnDef {
636+ params,
637+ defaults,
638+ body_lines,
639+ },
640+ );
641+ return Ok(None);
1437 }
642 }
1438
643
1439− ("let", DirectiveArgs::Let(def)) => {
644+ if let Some((name, value)) = rest.split_once(' ') {
1440− let name = def.name.as_str();
645+ let value = value.trim();
1441− // Values, bundles and functions share one namespace: a
646+ // `@let name = EXPR` computes its value (see expr.rs); any
1442− // definition replaces whatever the name meant in its block
647+ // other value is literal text with `$var` interpolation.
1443− match &def.form {
648+ if let Some(expression) = value.strip_prefix('=') {
1444− LetForm::Function(function) => {
649+ let value = ctx
1445− self.define_function(name, &function.params, node, ctx);
650+ .eval(expression.trim(), line_num)
1446− }
651+ .map(|v| v.to_string())
1447− // `@let name = EXPR` computes its value (see expr.rs)
652+ .unwrap_or_default();
1448− LetForm::Computed(expression) => {
653+ if name.starts_with("--") {
1449− let value = ctx
654+ ctx.css_vars.push((name.to_string(), value.clone()));
1450− .eval(&expression.raw, line_num, Some(expression.span.column))
1451− .unwrap_or_else(Value::empty);
1452− set_variable(name, value, line_num, ctx);
1453− }
1454− // Attribute bundle: @let name [attr1, attr2, ...]
1455− LetForm::Bundle(list) => {
1456− ctx.in_bundle = true;
1457− let attrs = parse_attr_list(&list.attrs, line_num, ctx, true, &[]);
1458− ctx.in_bundle = false;
1459− ctx.env.define(name, Binding::Bundle(Rc::new(attrs)));
1460− ctx.define_lines.entry(name.to_string()).or_insert(line_num);
1461− }
1462− // `@let size 16px`, `@let fruits apple, banana`,
1463− // `@let post $posts.0`: see `slot_value`
1464− LetForm::Value(value) => {
1465− let (value, _) =
1466− ctx.slot_value(&value.raw, value.span.line, Some(value.span.column));
1467− set_variable(name, value, line_num, ctx);
1468− }
1469− // Quoted text: `@let arrow "→ "`
1470− LetForm::Quoted(value) => {
1471− let raw = format!("\"{}\"", value.raw);
1472− let column = value.span.column.saturating_sub(1);
1473− let (text, quoted, _) =
1474− ctx.fill_value(&raw, value.span.line, Some(column), Sink::Text);
1475− let value = quoted.map_or(Value::Str(text), Value::Quoted);
1476− set_variable(name, value, line_num, ctx);
1477 }
655 }
656+ ctx.variables.insert(name.to_string(), value);
657+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
658+ return Ok(None);
1478 }
659 }
1479− Ok(None)
1480− }
1481
660
1482− // @meta NAME VALUE; `og:` names become Open Graph property tags
661+ // Attribute bundle: @let name [attr1, attr2, ...]
1483− ("meta", DirectiveArgs::Text(Some(arg))) => {
662+ if value.starts_with('[') {
1484− let Some((name, value)) = arg.raw.split_once(char::is_whitespace) else {
663+ let (attrs, _) = parse_attr_brackets(value, line_num, ctx)?;
1485− ctx.diagnostics.push(
664+ ctx.defines.insert(name.to_string(), attrs);
1486− Diagnostic::error(
665+ ctx.define_lines.entry(name.to_string()).or_insert(line_num);
1487− code::MISSING_ARGUMENT,
1488− line_num,
1489− format!("@meta needs a name and a value: `@meta {} VALUE`", arg.raw),
1490− )
1491− .source(content.clone()),
1492− );
1493 return Ok(None);
666 return Ok(None);
667+ }
668+
669+ // Multi-line @let with triple quotes: @let name """..."""
670+ let value_str;
671+ let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
672+ if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
673+ // Single-line triple-quote: @let name """value"""
674+ value_str = after_open[..after_open.len() - 3].to_string();
675+ value_str.as_str()
676+ } else {
677+ // Multi-line: collect indented body lines until closing """
678+ let mut lines_buf = String::new();
679+ if !after_open.is_empty() {
680+ lines_buf.push_str(after_open);
681+ lines_buf.push('\n');
682+ }
683+ while self.pos < self.lines.len() {
684+ match &self.lines[self.pos].content {
685+ LineContent::Normal(s) if s.trim() == "\"\"\"" => {
686+ self.pos += 1;
687+ break;
688+ }
689+ LineContent::Normal(s) => {
690+ lines_buf.push_str(s);
691+ lines_buf.push('\n');
692+ }
693+ LineContent::Raw(s) => {
694+ lines_buf.push_str(s);
695+ lines_buf.push('\n');
696+ }
697+ }
698+ self.pos += 1;
699+ }
700+ value_str = lines_buf.trim_end_matches('\n').to_string();
701+ value_str.as_str()
702+ }
703+ } else if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
704+ // Support quoted string interpolation: @let greeting "Hello $name"
705+ &value[1..value.len() - 1]
706+ } else {
707+ value
1494 };
708 };
1495− let value_column = arg.span.column + arg.raw.len() - value.trim_start().len();
709+ track_var_refs(value, &mut ctx.used_variables);
1496− // An HTML attribute value: quoted text loses its quotes
710+ let value = substitute_vars(value, &ctx.variables);
1497− let (value, _, _) =
711+ if name.starts_with("--") {
1498− ctx.fill_value(value.trim(), line_num, Some(value_column), Sink::Text);
712+ // CSS custom property
1499− // The same tag twice says nothing more (a layout called
713+ ctx.css_vars.push((name.to_string(), value.clone()));
1500− // twice): it is written once
1501− let (tags, tag) = match name.trim().strip_prefix("og:") {
1502− Some(property) => (&mut ctx.og_tags, (property.to_string(), value)),
1503− None => (&mut ctx.meta_tags, (name.trim().to_string(), value)),
1504− };
1505− if !tags.contains(&tag) {
1506− tags.push(tag);
1507 }
714 }
1508− ctx.head_at.get_or_insert(line_num);
715+ ctx.variables.insert(name.to_string(), value);
1509− Ok(None)
716+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
1510 }
717 }
718+ return Ok(None);
719+ }
1511
720
1512− ("head", _) => {
721+ // @meta NAME VALUE; `og:` names become Open Graph property tags
1513− let text = verbatim_content(node);
722+ if let Some(rest) = content.strip_prefix("@meta ") {
1514− if !text.trim().is_empty() {
723+ let rest = rest.trim();
1515− ctx.head_blocks.push(text.trim().to_string());
724+ if let Some((name, value)) = rest.split_once(' ') {
1516− ctx.head_at.get_or_insert(line_num);
725+ let value = substitute_vars(value.trim(), &ctx.variables);
726+ match name.trim().strip_prefix("og:") {
727+ Some(property) => ctx.og_tags.push((property.to_string(), value)),
728+ None => ctx.meta_tags.push((name.trim().to_string(), value)),
1517 }
729 }
1518− Ok(None)
1519 }
730 }
731+ return Ok(None);
732+ }
1520
733
1521− // @style: raw CSS
734+
1522− ("style", _) => {
735+ if content == "@head" || content.starts_with("@head ") {
1523− let text = verbatim_content(node);
736+ // Collect indented body lines as raw head content
1524− // Only the @style at the top of a function's body is
737+ let mut head_content = String::new();
1525− // scoped; anywhere else `&` is `:scope`, the whole page
738+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1526− if has_top_level_ampersand(&text) {
739+ match &self.lines[self.pos].content {
1527− ctx.push_once(Diagnostic::warning(
740+ LineContent::Normal(s) => {
1528− code::INVALID_VALUE,
741+ head_content.push_str(s.trim());
1529− node.span.line,
742+ head_content.push('\n');
1530− "`&` in this @style is the whole page: only an @style at the top of a \
743+ }
1531− function's body is scoped to the function, with `&` its root element"
744+ LineContent::Raw(s) => {
1532− .to_string(),
745+ head_content.push_str(s);
1533− ));
746+ head_content.push('\n');
747+ }
1534 }
748 }
1535− if !text.trim().is_empty() {
749+ self.pos += 1;
1536− ctx.custom_css.push(text.trim().to_string());
750+ }
751+ let trimmed = head_content.trim().to_string();
752+ if !trimmed.is_empty() {
753+ ctx.head_blocks.push(trimmed);
754+ }
755+ return Ok(None);
756+ }
757+
758+ // --- @style block (raw CSS) ---
759+ if content.trim() == "@style" {
760+ let mut style_content = String::new();
761+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
762+ match &self.lines[self.pos].content {
763+ LineContent::Normal(s) => {
764+ style_content.push_str(s.trim());
765+ style_content.push('\n');
766+ }
767+ LineContent::Raw(s) => {
768+ style_content.push_str(s);
769+ style_content.push('\n');
770+ }
1537 }
771 }
1538− Ok(None)
772+ self.pos += 1;
1539 }
773 }
774+ let trimmed = style_content.trim().to_string();
775+ if !trimmed.is_empty() {
776+ ctx.custom_css.push(trimmed);
777+ }
778+ return Ok(None);
779+ }
1540
780
1541− // @markdown: an indented block, or a file
781+
1542− ("markdown", DirectiveArgs::Text(file)) => {
782+ // --- @markdown block or file (convert markdown to HTML) ---
1543− let Some(file) = file else {
783+ if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
1544− let md_lines: Vec<String> = verbatim_text(&node.children)
784+ let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
1545− .lines()
785+ if arg.is_empty() {
1546− .map(String::from)
786+ // Inline markdown block: indented children
1547− .collect();
787+ let mut md_lines = Vec::new();
1548− return Ok(Some(vec![Node::Raw(markdown_to_html(&md_lines))]));
788+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
789+ match &self.lines[self.pos].content {
790+ LineContent::Normal(s) => md_lines.push(s.clone()),
791+ LineContent::Raw(s) => md_lines.extend(s.lines().map(String::from)),
792+ }
793+ self.pos += 1;
794+ }
795+ let html = markdown_to_html(&md_lines);
796+ return Ok(Some(vec![Node::Raw(html)]));
797+ } else {
798+ // External markdown file: @markdown file.md
799+ let filename = substitute_vars(arg, &ctx.variables);
800+ let resolved = match &ctx.base_path {
801+ Some(base) => base.join(&filename),
802+ None => PathBuf::from(&filename),
1549 };
803 };
1550− let filename = ctx.interpolate_text(&file.raw, line_num, Some(file.span.column));
804+ let md_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1551− let resolved = ctx.resolve(&filename);
805+ cached.clone()
1552− let md_text = match ctx.read_file(&resolved) {
806+ } else {
1553− Ok(text) => text,
807+ match std::fs::read_to_string(&resolved) {
1554− Err(e) => {
808+ Ok(text) => {
1555− ctx.diagnostics.push(
809+ ctx.file_cache.insert(resolved.clone(), text.clone());
1556− Diagnostic::error(
810+ text
1557− code::UNREADABLE_FILE,
811+ }
1558− line_num,
812+ Err(e) => {
1559− format!("cannot read markdown '{}': {}", filename, e),
813+ ctx.diagnostics.push(Diagnostic {
1560− )
814+ line: line_num,
1561− .source(content.clone()),
815+ column: None,
1562− );
816+ message: format!("cannot read markdown '{}': {}", filename, e),
1563− return Ok(None);
817+ severity: Severity::Error,
818+ source_line: Some(content.clone()),
819+ });
820+ return Ok(None);
821+ }
1564 }
822 }
1565 };
823 };
1566 ctx.included_files.push(resolved);
824 ctx.included_files.push(resolved);
1567 let md_lines: Vec<String> = md_text.lines().map(|l| l.to_string()).collect();
825 let md_lines: Vec<String> = md_text.lines().map(|l| l.to_string()).collect();
1568− Ok(Some(vec![Node::Raw(markdown_to_html(&md_lines))]))
826+ let html = markdown_to_html(&md_lines);
827+ return Ok(Some(vec![Node::Raw(html)]));
1569 }
828 }
829+ }
830+
1570
831
1571− ("include", DirectiveArgs::Text(Some(path))) => {
1572− self.eval_include(node.id, path, line_num, content, ctx)
1573− }
1574
832
1575− ("data", DirectiveArgs::Data { name, source, .. }) => {
833+ // --- @assert directive (compile-time assertions) ---
1576− self.eval_data(name, source, line_num, content, ctx);
834+
1577− Ok(None)
835+ if let Some(rest) = content.strip_prefix("@assert ") {
836+ let rest = rest.trim();
837+ if !ctx.condition(rest, line_num) {
838+ ctx.diagnostics.push(Diagnostic {
839+ line: line_num,
840+ column: None,
841+ message: format!("assertion failed: {}", rest),
842+ severity: Severity::Error,
843+ source_line: Some(content.clone()),
844+ });
1578 }
845 }
846+ return Ok(None);
847+ }
1579
848
1580− // @raw: the rest of its line, or its indented body, verbatim
849+ if let Some(rest) = content.strip_prefix("@include ") {
1581− ("raw", _) => {
850+ let rest = rest.trim();
1582− let text = verbatim_content(node);
851+ let (filename, alias) = if let Some((file_part, alias_part)) = rest.rsplit_once(" as ")
1583− let text = text.trim_end_matches('\n');
852+ {
1584− Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())]))
853+ (
854+ file_part.trim().to_string(),
855+ Some(alias_part.trim().to_string()),
856+ )
857+ } else {
858+ (rest.to_string(), None)
859+ };
860+ // Strip quotes from filename
861+ let filename =
862+ if filename.starts_with('"') && filename.ends_with('"') && filename.len() >= 2 {
863+ filename[1..filename.len() - 1].to_string()
864+ } else {
865+ filename
866+ };
867+ let filename = substitute_vars(&filename, &ctx.variables);
868+
869+ // Glob support: if filename contains *, expand to multiple imports
870+ if filename.contains('*') {
871+ let base_dir = match &ctx.base_path {
872+ Some(base) => base.clone(),
873+ None => PathBuf::from("."),
874+ };
875+ let pattern_path = Path::new(&filename);
876+ let (glob_dir, glob_pattern) = match pattern_path.parent() {
877+ Some(dir) if !dir.as_os_str().is_empty() => (
878+ base_dir.join(dir),
879+ pattern_path
880+ .file_name()
881+ .unwrap_or_default()
882+ .to_string_lossy()
883+ .to_string(),
884+ ),
885+ _ => (base_dir.clone(), filename.clone()),
886+ };
887+ let matched_files = match std::fs::read_dir(&glob_dir) {
888+ Ok(entries) => {
889+ let mut files: Vec<PathBuf> = entries
890+ .flatten()
891+ .filter(|e| {
892+ let name = e.file_name().to_string_lossy().to_string();
893+ glob_match(&glob_pattern, &name)
894+ })
895+ .map(|e| e.path())
896+ .collect();
897+ files.sort();
898+ files
899+ }
900+ Err(e) => {
901+ ctx.diagnostics.push(Diagnostic {
902+ line: line_num,
903+ column: None,
904+ message: format!(
905+ "cannot read directory for glob '{}': {}",
906+ filename, e
907+ ),
908+ severity: Severity::Error,
909+ source_line: Some(content.clone()),
910+ });
911+ return Ok(None);
912+ }
913+ };
914+ if matched_files.is_empty() {
915+ ctx.diagnostics.push(Diagnostic {
916+ line: line_num,
917+ column: None,
918+ message: format!("no files matched glob pattern '{}'", filename),
919+ severity: Severity::Warning,
920+ source_line: Some(content.clone()),
921+ });
922+ return Ok(None);
923+ }
924+ let mut matched_nodes = Vec::new();
925+ for file_path in matched_files {
926+ let rel_name = file_path
927+ .strip_prefix(&base_dir)
928+ .unwrap_or(&file_path)
929+ .to_string_lossy()
930+ .to_string();
931+ // Synthesize an @include line for each matched file
932+ let import_line = match &alias {
933+ Some(pfx) => {
934+ let stem = file_path
935+ .file_stem()
936+ .unwrap_or_default()
937+ .to_string_lossy()
938+ .to_string();
939+ format!("@include \"{}\" as {}.{}", rel_name, pfx, stem)
940+ }
941+ None => format!("@include \"{}\"", rel_name),
942+ };
943+ let synth_lines = preprocess(&import_line);
944+ let mut synth_parser = Parser {
945+ lines: synth_lines,
946+ pos: 0,
947+ };
948+ matched_nodes.extend(synth_parser.parse_children(0, ctx));
949+ }
950+ return Ok(Some(matched_nodes));
1585 }
951 }
1586
952
1587− // A header with a syntax error (reported), or a directive
953+ let resolved = match &ctx.base_path {
1588− // without its argument
954+ Some(base) => base.join(&filename),
1589− _ => Ok(None),
955+ None => PathBuf::from(&filename),
1590− }
956+ };
1591− }
1592
957
1593− fn eval_include(
958+ if ctx.include_stack.contains(&resolved) || ctx.import_stack.contains(&resolved) {
1594− &mut self,
959+ let cycle_chain = format_include_chain(&ctx.import_stack);
1595− id: usize,
960+ ctx.diagnostics.push(Diagnostic {
1596− path: &syntax::Arg,
961+ line: line_num,
1597− line_num: usize,
962+ column: None,
1598− content: &str,
963+ message: format!(
1599− ctx: &mut ParseContext,
1600− ) -> Result<Option<Vec<Node>>, ParseError> {
1601− let (filename, column) = match path.raw.strip_prefix('"').and_then(|f| f.strip_suffix('"'))
1602− {
1603− Some(quoted) => (quoted, path.span.column + 1),
1604− None => (path.raw.as_str(), path.span.column),
1605− };
1606− let filename = ctx.interpolate_text(filename, line_num, Some(column));
1607− let resolved = ctx.resolve(&filename);
1608−
1609− if ctx.include_stack.contains(&resolved) {
1610− let cycle_chain = format_include_chain(&ctx.include_stack);
1611− ctx.diagnostics.push(
1612− Diagnostic::error(
1613− code::CIRCULAR_INCLUDE,
1614− line_num,
1615− format!(
1616 "circular include '{}' (cycle: {} → {})",
964 "circular include '{}' (cycle: {} → {})",
1617 filename, cycle_chain, filename
965 filename, cycle_chain, filename
1618 ),
966 ),
1619− )
967+ severity: Severity::Error,
1620− .source(content),
968+ source_line: Some(content.clone()),
1621− );
969+ });
1622− return Ok(None);
1623− }
1624−
1625− let imported_text = match ctx.read_file(&resolved) {
1626− Ok(text) => text,
1627− Err(e) => {
1628− ctx.diagnostics.push(
1629− Diagnostic::error(
1630− code::UNREADABLE_FILE,
1631− line_num,
1632− format!("cannot include '{}': {}", filename, e),
1633− )
1634− .source(content),
1635− );
1636 return Ok(None);
970 return Ok(None);
1637 }
971 }
1638− };
1639−
1640− ctx.included_files.push(resolved.clone());
1641− ctx.include_stack.push(resolved.clone());
1642− let saved_base = ctx.base_path.clone();
1643− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1644−
1645− let diag_count_before = ctx.diagnostics.len();
1646− let import_chain = format_include_chain(&ctx.include_stack);
1647− let tree = ctx.parse_tree(&imported_text);
1648− ctx.diagnostics.extend(tree.diagnostics.iter().cloned());
1649− ctx.trees.push((tree.clone(), Some(import_chain.clone())));
1650− let files = Rc::make_mut(&mut ctx.included_by).entry(id).or_default();
1651− if !files.iter().any(|(_, chain)| *chain == import_chain) {
1652− files.push((tree.clone(), import_chain.clone()));
1653− }
1654− let saved_file = ctx.current_file.replace(import_chain.clone());
1655− let defined_before: HashSet<String> = ctx
1656− .let_lines
1657− .keys()
1658− .chain(ctx.define_lines.keys())
1659− .chain(ctx.fn_lines.keys())
1660− .cloned()
1661− .collect();
1662− let included_nodes = self.eval_block(&tree.nodes, ctx);
1663− ctx.current_file = saved_file;
1664− // An included file's definitions aren't reported unused here, where
1665− // their lines aren't this file's: a library's are there to be
1666− // picked from, and any other file reports its own when checked
1667− let keep = |name: &String, _: &mut usize| defined_before.contains(name);
1668− ctx.let_lines.retain(keep);
1669− ctx.define_lines.retain(keep);
1670− ctx.fn_lines.retain(keep);
1671−
1672− // Annotate new diagnostics with import chain
1673− for d in &mut ctx.diagnostics[diag_count_before..] {
1674− d.message = format!("{}\n in {}", d.message, import_chain);
1675− }
1676
972
1677− ctx.base_path = saved_base;
973+ let imported_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1678− ctx.include_stack.pop();
974+ cached.clone()
1679− Ok(Some(included_nodes))
975+ } else {
1680− }
976+ match std::fs::read_to_string(&resolved) {
977+ Ok(text) => {
978+ ctx.file_cache.insert(resolved.clone(), text.clone());
979+ text
980+ }
981+ Err(e) => {
982+ ctx.diagnostics.push(Diagnostic {
983+ line: line_num,
984+ column: None,
985+ message: format!("cannot include '{}': {}", filename, e),
986+ severity: Severity::Error,
987+ source_line: Some(content.clone()),
988+ });
989+ return Ok(None);
990+ }
991+ }
992+ };
1681
993
1682− /// @data $name file.json values as $name.key
994+ ctx.included_files.push(resolved.clone());
1683− /// @data $name dir/*.json a list of the files' records
995+ ctx.include_stack.push(resolved.clone());
1684− /// @data $name [...] / {...} inline JSON
996+ ctx.import_stack.push(resolved.clone());
1685− /// @data $name env:NAME [DEFAULT] an environment variable
997+ let saved_base = ctx.base_path.clone();
1686− fn eval_data(
998+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1687− &mut self,
999+
1688− prefix: &str,
1000+ let diag_count_before = ctx.diagnostics.len();
1689− source: &syntax::Arg,
1001+ let mut included_nodes = Vec::new();
1690− line_num: usize,
1002+
1691− content: &str,
1003+ if let Some(ref prefix) = alias {
1692− ctx: &mut ParseContext,
1004+ // Snapshot just the *key sets* before parsing — much cheaper
1693− ) {
1005+ // than cloning the full HashMaps, since we only need to know
1694− let prefix = prefix.to_string();
1006+ // which definitions are new afterwards.
1695− let filename = source.raw.as_str();
1007+ let fn_keys_before: std::collections::HashSet<String> =
1696− if let Some(env) = filename.strip_prefix("env:") {
1008+ ctx.functions.keys().cloned().collect();
1697− let (var, default) = match env.split_once(char::is_whitespace) {
1009+ let define_keys_before: std::collections::HashSet<String> =
1698− Some((var, default)) => (var, Some(default.trim())),
1010+ ctx.defines.keys().cloned().collect();
1699− None => (env, None),
1011+ let var_keys_before: std::collections::HashSet<String> =
1012+ ctx.variables.keys().cloned().collect();
1013+
1014+ let imported_lines = preprocess(&imported_text);
1015+ let mut imported_parser = Parser {
1016+ lines: imported_lines,
1017+ pos: 0,
1018+ };
1019+ let _discarded_nodes = imported_parser.parse_children(0, ctx);
1020+
1021+ // Remove newly added entries and re-insert them under the prefix.
1022+ let new_fn_keys: Vec<String> = ctx
1023+ .functions
1024+ .keys()
1025+ .filter(|k| !fn_keys_before.contains(*k))
1026+ .cloned()
1027+ .collect();
1028+ for name in new_fn_keys {
1029+ if let Some(def) = ctx.functions.remove(&name) {
1030+ ctx.functions.insert(format!("{}.{}", prefix, name), def);
1031+ }
1032+ }
1033+ let new_define_keys: Vec<String> = ctx
1034+ .defines
1035+ .keys()
1036+ .filter(|k| !define_keys_before.contains(*k))
1037+ .cloned()
1038+ .collect();
1039+ for name in new_define_keys {
1040+ if let Some(attrs) = ctx.defines.remove(&name) {
1041+ ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
1042+ }
1043+ }
1044+ let new_var_keys: Vec<String> = ctx
1045+ .variables
1046+ .keys()
1047+ .filter(|k| !var_keys_before.contains(*k))
1048+ .cloned()
1049+ .collect();
1050+ for name in new_var_keys {
1051+ if let Some(val) = ctx.variables.remove(&name)
1052+ && !name.starts_with("__")
1053+ {
1054+ ctx.variables.insert(format!("{}.{}", prefix, name), val);
1055+ }
1056+ }
1057+ } else {
1058+ let included_lines = preprocess(&imported_text);
1059+ let mut included_parser = Parser {
1060+ lines: included_lines,
1061+ pos: 0,
1062+ };
1063+ included_nodes = included_parser.parse_children(0, ctx);
1064+ }
1065+
1066+ // Annotate new diagnostics with import chain
1067+ let import_chain = format_include_chain(&ctx.include_stack);
1068+ for d in &mut ctx.diagnostics[diag_count_before..] {
1069+ d.message = format!("{}\n in {}", d.message, import_chain);
1070+ }
1071+
1072+ ctx.base_path = saved_base;
1073+ ctx.include_stack.pop();
1074+ ctx.import_stack.pop();
1075+ return Ok(Some(included_nodes));
1076+ }
1077+
1078+
1079+
1080+ // --- @data (load JSON file into variables) ---
1081+
1082+ // @data file.json top-level keys become variables
1083+ // @data $name file.json values as $name.key
1084+ // @data $name dir/*.json each file as $name.STEM.key; $name
1085+ // lists the stems, $name._count counts them
1086+ // @data $name env:NAME [DEFAULT] an environment variable
1087+ if let Some(rest) = content.strip_prefix("@data ") {
1088+ let rest = rest.trim();
1089+ let (prefix, filename) = match rest.strip_prefix('$') {
1090+ Some(named) => match named.split_once(char::is_whitespace) {
1091+ Some((name, source)) => (name.to_string(), source.trim().to_string()),
1092+ None => {
1093+ return Err(ParseError {
1094+ line: line_num,
1095+ message: "@data requires: @data $name SOURCE or @data file.json"
1096+ .to_string(),
1097+ });
1098+ }
1099+ },
1100+ None => (String::new(), rest.to_string()),
1700 };
1101 };
1701− let default_column = default.map(|d| source.span.column + filename.len() - d.len());
1102+
1702− let value = std::env::var(var)
1103+ if let Some(env) = filename.strip_prefix("env:") {
1703− .ok()
1104+ let (var, default) = match env.split_once(char::is_whitespace) {
1704− .or_else(|| default.map(|d| ctx.interpolate_text(d, line_num, default_column)));
1105+ Some((var, default)) => (var, Some(default.trim())),
1705− if value.is_none() {
1106+ None => (env, None),
1706− ctx.diagnostics.push(
1107+ };
1707− Diagnostic::warning(
1108+ if prefix.is_empty() {
1708− code::UNSET_ENVIRONMENT,
1109+ return Err(ParseError {
1709− line_num,
1110+ line: line_num,
1710− format!(
1111+ message: format!("@data env:{} needs a name: @data $name env:{}", var, var),
1112+ });
1113+ }
1114+ let value = std::env::var(var)
1115+ .ok()
1116+ .or_else(|| default.map(|d| substitute_vars(d, &ctx.variables)));
1117+ if value.is_none() {
1118+ ctx.diagnostics.push(Diagnostic {
1119+ line: line_num,
1120+ column: None,
1121+ message: format!(
1711 "environment variable '{}' is not set and has no default",
1122 "environment variable '{}' is not set and has no default",
1712 var
1123 var
1713 ),
1124 ),
1125+ severity: Severity::Warning,
1126+ source_line: Some(content.clone()),
1127+ });
1128+ }
1129+ ctx.variables.insert(prefix, value.unwrap_or_default());
1130+ return Ok(None);
1131+ }
1132+
1133+ let filename = substitute_vars(&filename, &ctx.variables);
1134+ if filename.contains('*') {
1135+ if prefix.is_empty() {
1136+ return Err(ParseError {
1137+ line: line_num,
1138+ message: format!("@data {} needs a name: @data $name {}", filename, filename),
1139+ });
1140+ }
1141+ self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
1142+ return Ok(None);
1143+ }
1144+
1145+ let filename = substitute_vars(&filename, &ctx.variables);
1146+ let resolved = match &ctx.base_path {
1147+ Some(base) => base.join(&filename),
1148+ None => PathBuf::from(&filename),
1149+ };
1150+
1151+ let json_text = match std::fs::read_to_string(&resolved) {
1152+ Ok(text) => text,
1153+ Err(e) => {
1154+ ctx.diagnostics.push(Diagnostic {
1155+ line: line_num,
1156+ column: None,
1157+ message: format!("cannot load data '{}': {}", filename, e),
1158+ severity: Severity::Error,
1159+ source_line: Some(content.clone()),
1160+ });
1161+ return Ok(None);
1162+ }
1163+ };
1164+
1165+ match parse_json_with_error(&json_text) {
1166+ Ok(json) => {
1167+ if prefix.is_empty() {
1168+ // No prefix: top-level object keys become variables directly
1169+ if let JsonValue::Object(pairs) = &json {
1170+ for (key, val) in pairs {
1171+ let mut sub = HashMap::new();
1172+ flatten_json(key, val, &mut sub);
1173+ for (k, v) in sub {
1174+ ctx.variables.insert(k, v);
1175+ }
1176+ }
1177+ } else {
1178+ ctx.diagnostics.push(Diagnostic {
1179+ line: line_num,
1180+ column: None,
1181+ message: "@data without prefix requires a JSON object at top level"
1182+ .to_string(),
1183+ severity: Severity::Error,
1184+ source_line: Some(content.clone()),
1185+ });
1186+ }
1187+ } else {
1188+ let mut sub = HashMap::new();
1189+ flatten_json(&prefix, &json, &mut sub);
1190+ for (k, v) in sub {
1191+ ctx.variables.insert(k, v);
1192+ }
1193+ }
1194+ }
1195+ Err(detail) => {
1196+ ctx.diagnostics.push(Diagnostic {
1197+ line: line_num,
1198+ column: None,
1199+ message: format!("invalid JSON in '{}': {}", filename, detail),
1200+ severity: Severity::Error,
1201+ source_line: Some(content.clone()),
1202+ });
1203+ }
1204+ }
1205+
1206+ ctx.included_files.push(resolved);
1207+ return Ok(None);
1208+ }
1209+
1210+
1211+ // --- @if / @else ---
1212+
1213+ if let Some(rest) = content.strip_prefix("@if ") {
1214+ let result = ctx.condition(rest.trim(), line_num);
1215+
1216+ // Collect then-body lines
1217+ let mut then_lines = Vec::new();
1218+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1219+ then_lines.push(self.lines[self.pos].clone());
1220+ self.pos += 1;
1221+ }
1222+
1223+ // Build branches: [(condition_result, body_lines), ...]
1224+ let mut branches: Vec<(bool, Vec<Line>)> = vec![(result, then_lines)];
1225+
1226+ // Check for @else if / @else chains at same indent
1227+ loop {
1228+ if self.pos >= self.lines.len() || self.lines[self.pos].indent != current_indent {
1229+ break;
1230+ }
1231+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1232+ let trimmed = s.trim();
1233+ if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
1234+ self.pos += 1; // consume @else if
1235+ let cond_result =
1236+ ctx.condition(else_if_cond.trim(), self.lines[self.pos - 1].line_num);
1237+ let mut body = Vec::new();
1238+ while self.pos < self.lines.len()
1239+ && self.lines[self.pos].indent > current_indent
1240+ {
1241+ body.push(self.lines[self.pos].clone());
1242+ self.pos += 1;
1243+ }
1244+ branches.push((cond_result, body));
1245+ } else if trimmed == "@else" {
1246+ self.pos += 1; // consume @else
1247+ let mut body = Vec::new();
1248+ while self.pos < self.lines.len()
1249+ && self.lines[self.pos].indent > current_indent
1250+ {
1251+ body.push(self.lines[self.pos].clone());
1252+ self.pos += 1;
1253+ }
1254+ // @else is always true (fallback)
1255+ branches.push((true, body));
1256+ break;
1257+ } else {
1258+ break;
1259+ }
1260+ } else {
1261+ break;
1262+ }
1263+ }
1264+
1265+ // Pick the first branch whose condition is true
1266+ let body_lines = branches
1267+ .into_iter()
1268+ .find(|(cond, _)| *cond)
1269+ .map(|(_, lines)| lines)
1270+ .unwrap_or_default();
1271+
1272+ if body_lines.is_empty() {
1273+ return Ok(None);
1274+ }
1275+
1276+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1277+ let adjusted: Vec<Line> = body_lines
1278+ .iter()
1279+ .map(|l| Line {
1280+ indent: l.indent - min_indent,
1281+ content: l.content.clone(),
1282+ line_num: l.line_num,
1283+ })
1284+ .collect();
1285+
1286+ // Scope variables: @let inside @if doesn't leak out
1287+ let saved_vars = ctx.variables.clone();
1288+ let mut body_parser = Parser {
1289+ lines: adjusted,
1290+ pos: 0,
1291+ };
1292+ let nodes = body_parser.parse_children(0, ctx);
1293+ ctx.variables = saved_vars;
1294+ return Ok(Some(nodes));
1295+ }
1296+
1297+
1298+ if content.trim() == "@else" || content.trim().starts_with("@else if ") {
1299+ return Err(ParseError {
1300+ line: line_num,
1301+ message: "@else without matching @if".to_string(),
1302+ });
1303+ }
1304+
1305+ // --- @each loop ---
1306+
1307+ if let Some(rest) = content.strip_prefix("@each ") {
1308+ let rest = rest.trim();
1309+ // Support: @each $var in list OR @each $var, $index in list
1310+ // OR @each $name, $url in Alice /alice, Bob /bob (destructuring)
1311+ let (var_names, list_str) = if let Some((before_in, after_in)) = rest.split_once(" in ")
1312+ {
1313+ let before_in = before_in.trim();
1314+ let vars: Vec<String> = before_in
1315+ .split(',')
1316+ .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
1317+ .collect();
1318+ (vars, after_in.trim().to_string())
1319+ } else {
1320+ return Err(ParseError {
1321+ line: line_num,
1322+ message: "@each requires: @each $var in list".to_string(),
1323+ });
1324+ };
1325+ let var_name = var_names[0].clone();
1326+ let index_var = var_names.get(1).cloned();
1327+
1328+ // Strip a trailing `[page N]` pagination suffix so it doesn't end
1329+ // up in the last item; it's parsed from the raw line below.
1330+ let list_str = match list_str.rfind("[page ") {
1331+ Some(pos) if list_str.trim_end().ends_with(']') => {
1332+ list_str[..pos].trim_end().to_string()
1333+ }
1334+ _ => list_str,
1335+ };
1336+ track_var_refs(&list_str, &mut ctx.used_variables);
1337+ let list_str = substitute_vars(&list_str, &ctx.variables);
1338+ // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
1339+ let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
1340+ let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
1341+ (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
1342+ } else {
1343+ (rest.trim(), 1i64)
1344+ };
1345+ if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
1346+ {
1347+ numeric_range(start, end, step)
1348+ } else {
1349+ list_str
1350+ .split(',')
1351+ .map(|s| s.trim().to_string())
1352+ .filter(|s| !s.is_empty())
1353+ .collect()
1354+ }
1355+ } else {
1356+ list_str
1357+ .split(',')
1358+ .map(|s| s.trim().to_string())
1359+ .filter(|s| !s.is_empty())
1360+ .collect()
1361+ };
1362+
1363+ // Pagination: check for [page N] or [page N per P] suffix
1364+ // Parse from the original rest before substitution
1365+ let (items, pagination_info) = {
1366+ // Check if list_str ended with a page directive (already consumed in items)
1367+ // Instead, check the raw rest for [page ...] syntax
1368+ let raw_rest = rest;
1369+ let page_size = if let Some(page_pos) = raw_rest.find("[page ") {
1370+ let after = &raw_rest[page_pos + 6..];
1371+ if let Some(close) = after.find(']') {
1372+ let page_spec = after[..close].trim();
1373+ page_spec.parse::<usize>().ok()
1374+ } else {
1375+ None
1376+ }
1377+ } else {
1378+ None
1379+ };
1380+
1381+ if let Some(size) = page_size {
1382+ let current_page: usize = ctx
1383+ .variables
1384+ .get("_page")
1385+ .and_then(|v| v.parse().ok())
1386+ .unwrap_or(1)
1387+ .max(1);
1388+ let size = size.max(1);
1389+ let total_items = items.len();
1390+ let total_pages = total_items.div_ceil(size);
1391+ let start = (current_page - 1).saturating_mul(size);
1392+ let end = start.saturating_add(size).min(total_items);
1393+ let page_items: Vec<String> = if start < total_items {
1394+ items[start..end].to_vec()
1395+ } else {
1396+ Vec::new()
1397+ };
1398+ (
1399+ page_items,
1400+ Some((current_page, total_pages, size, total_items)),
1714 )
1401 )
1715− .source(content),
1402+ } else {
1716− );
1403+ (items, None)
1404+ }
1405+ };
1406+
1407+ // Collect body lines
1408+ let mut body_lines = Vec::new();
1409+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1410+ body_lines.push(self.lines[self.pos].clone());
1411+ self.pos += 1;
1717 }
1412 }
1718− ctx.bind(&prefix, Value::Str(value.unwrap_or_default()));
1413+
1719− return;
1414+ // Check for @else block (empty-state fallback)
1415+ let mut else_lines = Vec::new();
1416+ if self.pos < self.lines.len()
1417+ && self.lines[self.pos].indent == current_indent
1418+ && let LineContent::Normal(ref s) = self.lines[self.pos].content
1419+ && s.trim() == "@else"
1420+ {
1421+ self.pos += 1; // consume @else
1422+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1423+ else_lines.push(self.lines[self.pos].clone());
1424+ self.pos += 1;
1425+ }
1426+ }
1427+
1428+ if body_lines.is_empty() {
1429+ return Ok(None);
1430+ }
1431+
1432+ // If list is empty, render @else body
1433+ if items.is_empty() {
1434+ if else_lines.is_empty() {
1435+ return Ok(None);
1436+ }
1437+ let min_indent = else_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1438+ let adjusted: Vec<Line> = else_lines
1439+ .iter()
1440+ .map(|l| Line {
1441+ indent: l.indent - min_indent,
1442+ content: l.content.clone(),
1443+ line_num: l.line_num,
1444+ })
1445+ .collect();
1446+ let mut body_parser = Parser {
1447+ lines: adjusted,
1448+ pos: 0,
1449+ };
1450+ let saved_vars = ctx.variables.clone();
1451+ let nodes = body_parser.parse_children(0, ctx);
1452+ ctx.variables = saved_vars;
1453+ return Ok(Some(nodes));
1454+ }
1455+
1456+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1457+ let adjusted: Vec<Line> = body_lines
1458+ .iter()
1459+ .map(|l| Line {
1460+ indent: l.indent - min_indent,
1461+ content: l.content.clone(),
1462+ line_num: l.line_num,
1463+ })
1464+ .collect();
1465+
1466+ let saved_vars = ctx.variables.clone();
1467+ let mut all_nodes = Vec::new();
1468+
1469+ // Inject pagination variables if pagination is active
1470+ if let Some((page, total_pages, page_size, total_items)) = pagination_info {
1471+ ctx.variables.insert("_page".to_string(), page.to_string());
1472+ ctx.variables
1473+ .insert("_total_pages".to_string(), total_pages.to_string());
1474+ ctx.variables
1475+ .insert("_page_size".to_string(), page_size.to_string());
1476+ ctx.variables
1477+ .insert("_total_items".to_string(), total_items.to_string());
1478+ }
1479+
1480+ let has_extra_vars = var_names.len() > 2
1481+ || (var_names.len() == 2 && items.first().is_some_and(|it| it.contains(' ')));
1482+
1483+ for (i, item) in items.iter().enumerate() {
1484+ // Always expose $_index for the current iteration
1485+ ctx.variables.insert("_index".to_string(), i.to_string());
1486+ if has_extra_vars {
1487+ // Destructuring: split item by spaces and assign to each variable
1488+ let parts: Vec<&str> = item.splitn(var_names.len(), ' ').collect();
1489+ for (vi, vn) in var_names.iter().enumerate() {
1490+ let val = parts.get(vi).unwrap_or(&"").to_string();
1491+ ctx.variables.insert(vn.clone(), val);
1492+ }
1493+ } else {
1494+ ctx.variables.insert(var_name.clone(), item.clone());
1495+ if let Some(ref idx_name) = index_var {
1496+ ctx.variables.insert(idx_name.clone(), i.to_string());
1497+ }
1498+ }
1499+ let mut body_parser = Parser {
1500+ lines: adjusted.clone(),
1501+ pos: 0,
1502+ };
1503+ let nodes = body_parser.parse_children(0, ctx);
1504+ all_nodes.extend(nodes);
1505+ }
1506+
1507+ ctx.variables = saved_vars;
1508+ return Ok(Some(all_nodes));
1720 }
1509 }
1721
1510
1722− // Inline JSON: @data $links [{"label": "Home", "url": "/"}]
1511+
1723− let (json_text, source) = if filename.starts_with(['[', '{']) {
1512+
1724− (filename.to_string(), "the inline data".to_string())
1513+ // --- @warn / @debug ---
1725− } else {
1514+
1726− let filename = ctx.interpolate_text(filename, line_num, Some(source.span.column));
1515+ if let Some(rest) = content.strip_prefix("@warn ") {
1727− if filename.contains('*') {
1516+ let msg = substitute_vars(rest.trim(), &ctx.variables);
1728− self.load_data_glob(&prefix, &filename, line_num, content, ctx);
1517+ ctx.diagnostics.push(Diagnostic {
1729− return;
1518+ line: line_num,
1519+ column: None,
1520+ message: msg,
1521+ severity: Severity::Warning,
1522+ source_line: Some(content.clone()),
1523+ });
1524+ return Ok(None);
1525+ }
1526+
1527+
1528+ // --- @keyframes directive ---
1529+
1530+ if let Some(rest) = content.strip_prefix("@keyframes ") {
1531+ let name = rest.trim().to_string();
1532+ if name.is_empty() {
1533+ return Err(ParseError {
1534+ line: line_num,
1535+ message: "@keyframes requires a name".to_string(),
1536+ });
1730 }
1537 }
1731− let resolved = ctx.resolve(&filename);
1538+ // Collect body lines (indented deeper)
1732− match std::fs::read_to_string(&resolved) {
1539+ let mut body = String::new();
1733− Ok(text) => {
1540+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1734− ctx.included_files.push(resolved);
1541+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1735− (text, format!("'{}'", filename))
1542+ let trimmed = s.trim();
1543+ // Support htmlang-style: from [opacity 0] / to [opacity 1] / 50% [transform scale(1.5)]
1544+ if let Some(kf_css) = parse_keyframe_line(trimmed) {
1545+ body.push_str(&kf_css);
1546+ } else {
1547+ body.push_str(trimmed);
1548+ }
1736 }
1549 }
1737− Err(e) => {
1550+ self.pos += 1;
1738− ctx.diagnostics.push(
1551+ }
1739− Diagnostic::error(
1552+ ctx.keyframes.push((name, body));
1740− code::UNREADABLE_FILE,
1553+ return Ok(None);
1741− line_num,
1554+ }
1742− format!("cannot load data '{}': {}", filename, e),
1555+
1743− )
1556+
1744− .source(content),
1557+ // --- @extends (template inheritance) ---
1745− );
1558+
1746− return;
1559+ if let Some(rest) = content.strip_prefix("@extends ") {
1560+ let filename = substitute_vars(rest.trim(), &ctx.variables);
1561+ let resolved = match &ctx.base_path {
1562+ Some(base) => base.join(&filename),
1563+ None => PathBuf::from(&filename),
1564+ };
1565+
1566+ if ctx.include_stack.contains(&resolved) {
1567+ ctx.diagnostics.push(Diagnostic {
1568+ line: line_num,
1569+ column: None,
1570+ message: format!("circular extends '{}'", filename),
1571+ severity: Severity::Error,
1572+ source_line: Some(content.clone()),
1573+ });
1574+ return Ok(None);
1575+ }
1576+
1577+ let extends_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1578+ cached.clone()
1579+ } else {
1580+ match std::fs::read_to_string(&resolved) {
1581+ Ok(text) => {
1582+ ctx.file_cache.insert(resolved.clone(), text.clone());
1583+ text
1584+ }
1585+ Err(e) => {
1586+ ctx.diagnostics.push(Diagnostic {
1587+ line: line_num,
1588+ column: None,
1589+ message: format!("cannot extend '{}': {}", filename, e),
1590+ severity: Severity::Error,
1591+ source_line: Some(content.clone()),
1592+ });
1593+ return Ok(None);
1594+ }
1595+ }
1596+ };
1597+
1598+ // Collect slot blocks defined in the extending file. Everything
1599+ // else becomes the default content, filling the layout's
1600+ // @children.
1601+ let mut slot_contents: HashMap<String, Vec<Line>> = HashMap::new();
1602+ let mut default_lines: Vec<Line> = Vec::new();
1603+ while self.pos < self.lines.len() {
1604+ let line_indent = self.lines[self.pos].indent;
1605+ if line_indent < current_indent {
1606+ break;
1607+ }
1608+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1609+ let trimmed = s.trim();
1610+ if let Some(slot_name) = trimmed.strip_prefix("@slot ") {
1611+ let slot_name = slot_name.trim().to_string();
1612+ self.pos += 1;
1613+ let mut slot_lines = Vec::new();
1614+ while self.pos < self.lines.len()
1615+ && self.lines[self.pos].indent > line_indent
1616+ {
1617+ slot_lines.push(self.lines[self.pos].clone());
1618+ self.pos += 1;
1619+ }
1620+ slot_contents.insert(slot_name, slot_lines);
1621+ continue;
1622+ }
1747 }
1623 }
1624+ default_lines.push(self.lines[self.pos].clone());
1625+ self.pos += 1;
1748 }
1626 }
1749− };
1627+ let default_nodes = if default_lines.is_empty() {
1750− match parse_json_with_error(&json_text) {
1628+ Vec::new()
1751− Ok(json) => ctx.bind(&prefix, json_value(json)),
1629+ } else {
1752− Err(detail) => ctx.diagnostics.push(
1630+ let min_indent = default_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1753− Diagnostic::error(
1631+ let adjusted: Vec<Line> = default_lines
1754− code::INVALID_JSON,
1632+ .iter()
1755− line_num,
1633+ .map(|l| Line {
1756− format!("invalid JSON in {}: {}", source, detail),
1634+ indent: l.indent - min_indent,
1757− )
1635+ content: l.content.clone(),
1758− .source(content),
1636+ line_num: l.line_num,
1759− ),
1637+ })
1638+ .collect();
1639+ let mut default_parser = Parser {
1640+ lines: adjusted,
1641+ pos: 0,
1642+ };
1643+ default_parser.parse_children(0, ctx)
1644+ };
1645+
1646+ // Parse slot contents into nodes
1647+ let mut slot_nodes: HashMap<String, Vec<Node>> = HashMap::new();
1648+ for (name, lines) in &slot_contents {
1649+ if lines.is_empty() {
1650+ continue;
1651+ }
1652+ let min_indent = lines.iter().map(|l| l.indent).min().unwrap_or(0);
1653+ let adjusted: Vec<Line> = lines
1654+ .iter()
1655+ .map(|l| Line {
1656+ indent: l.indent - min_indent,
1657+ content: l.content.clone(),
1658+ line_num: l.line_num,
1659+ })
1660+ .collect();
1661+ let mut slot_parser = Parser {
1662+ lines: adjusted,
1663+ pos: 0,
1664+ };
1665+ let nodes = slot_parser.parse_children(0, ctx);
1666+ slot_nodes.insert(name.clone(), nodes);
1667+ }
1668+
1669+ // Parse the base layout file
1670+ ctx.included_files.push(resolved.clone());
1671+ ctx.include_stack.push(resolved.clone());
1672+ let saved_base = ctx.base_path.clone();
1673+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1674+
1675+ let extends_lines = preprocess(&extends_text);
1676+ let mut extends_parser = Parser {
1677+ lines: extends_lines,
1678+ pos: 0,
1679+ };
1680+ let layout_nodes = extends_parser.parse_children(0, ctx);
1681+
1682+ ctx.base_path = saved_base;
1683+ ctx.include_stack.pop();
1684+
1685+ // Fill @slot placeholders and @children in the layout
1686+ let result_nodes = replace_children_and_slots(layout_nodes, &default_nodes, &slot_nodes);
1687+ return Ok(Some(result_nodes));
1688+ }
1689+
1690+
1691+ // --- Function call ---
1692+
1693+ if content.starts_with('@') {
1694+ let name = extract_element_name(&content);
1695+ if ctx.functions.contains_key(name) {
1696+ ctx.used_functions.insert(name.to_string());
1697+ let nodes = self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
1698+ return Ok(Some(nodes));
1699+ }
1700+ }
1701+
1702+ // --- Elements ---
1703+
1704+ if content.starts_with('@') || content.starts_with('[') {
1705+ let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
1706+ return Ok(Some(vec![inline_svg(node, line_num, ctx)]));
1760 }
1707 }
1708+
1709+ // --- Bare text ---
1710+
1711+ let var_warnings =
1712+ check_undefined_vars(&content, &ctx.variables, line_num, current_indent);
1713+ ctx.diagnostics.extend(var_warnings);
1714+ track_var_refs(&content, &mut ctx.used_variables);
1715+ let segments = parse_text_segments(&content, ctx);
1716+ Ok(Some(vec![Node::Text(segments)]))
1761 }
1717 }
1762
1718
1763− /// `@data $name dir/*.json`: a list with one record per matching file
1719+ /// `@data $name dir/*.json`: load every matching file as
1764− /// (in name order), whose `file` is the file's name without extension.
1720+ /// `$name.STEM.key`, list the stems in `$name` and count them.
1765 fn load_data_glob(
1721 fn load_data_glob(
1766 &mut self,
1722 &mut self,
1767 name: &str,
1723 name: &str,
@@ -1791,20 +1747,18 @@ impl Evaluator {
1791 })
1747 })
1792 .collect(),
1748 .collect(),
1793 Err(e) => {
1749 Err(e) => {
1794− ctx.diagnostics.push(
1750+ ctx.diagnostics.push(Diagnostic {
1795− Diagnostic::new(
1751+ line: line_num,
1796− code::UNREADABLE_FILE,
1752+ column: None,
1797− Severity::Error,
1753+ message: format!("cannot read directory for '{}': {}", pattern, e),
1798− line_num,
1754+ severity: Severity::Error,
1799− format!("cannot read directory for '{}': {}", pattern, e),
1755+ source_line: Some(content.to_string()),
1800− )
1756+ });
1801− .source(content.to_string()),
1802− );
1803 return;
1757 return;
1804 }
1758 }
1805 };
1759 };
1806 files.sort();
1760 files.sort();
1807− let mut records = Vec::new();
1761+ let mut stems = Vec::new();
1808 for file in files {
1762 for file in files {
1809 let stem = file
1763 let stem = file
1810 .file_stem()
1764 .file_stem()
@@ -1814,454 +1768,135 @@ impl Evaluator {
1814 let text = match std::fs::read_to_string(&file) {
1768 let text = match std::fs::read_to_string(&file) {
1815 Ok(text) => text,
1769 Ok(text) => text,
1816 Err(e) => {
1770 Err(e) => {
1817− ctx.diagnostics.push(
1771+ ctx.diagnostics.push(Diagnostic {
1818− Diagnostic::new(
1772+ line: line_num,
1819− code::UNREADABLE_FILE,
1773+ column: None,
1820− Severity::Error,
1774+ message: format!("cannot load data '{}': {}", file.display(), e),
1821− line_num,
1775+ severity: Severity::Error,
1822− format!("cannot load data '{}': {}", file.display(), e),
1776+ source_line: Some(content.to_string()),
1823− )
1777+ });
1824− .source(content.to_string()),
1825− );
1826 continue;
1778 continue;
1827 }
1779 }
1828 };
1780 };
1829 match parse_json_with_error(&text) {
1781 match parse_json_with_error(&text) {
1830− Ok(JsonValue::Object(mut pairs)) => {
1782+ Ok(json) => {
1831− if !pairs.iter().any(|(key, _)| key == "file") {
1783+ let mut vars = HashMap::new();
1832− pairs.push(("file".to_string(), JsonValue::Str(stem)));
1784+ flatten_json(&format!("{}.{}", name, stem), &json, &mut vars);
1833− }
1785+ ctx.variables.extend(vars);
1834− records.push(JsonValue::Object(pairs));
1786+ stems.push(stem);
1835 }
1787 }
1836− Ok(_) => ctx.diagnostics.push(
1788+ Err(detail) => ctx.diagnostics.push(Diagnostic {
1837− Diagnostic::new(
1789+ line: line_num,
1838− code::INVALID_JSON,
1790+ column: None,
1839− Severity::Error,
1791+ message: format!("invalid JSON in '{}': {}", file.display(), detail),
1840− line_num,
1792+ severity: Severity::Error,
1841− format!("'{}' should hold a JSON object", file.display()),
1793+ source_line: Some(content.to_string()),
1842− )
1794+ }),
1843− .source(content.to_string()),
1844− ),
1845− Err(detail) => ctx.diagnostics.push(
1846− Diagnostic::new(
1847− code::INVALID_JSON,
1848− Severity::Error,
1849− line_num,
1850− format!("invalid JSON in '{}': {}", file.display(), detail),
1851− )
1852− .source(content.to_string()),
1853− ),
1854 }
1795 }
1855 ctx.included_files.push(file);
1796 ctx.included_files.push(file);
1856 }
1797 }
1857− ctx.bind(name, json_value(JsonValue::Array(records)));
1798+ ctx.variables
1858− }
1799+ .insert(format!("{}._count", name), stems.len().to_string());
1859−
1800+ ctx.variables.insert(name.to_string(), stems.join(", "));
1860− /// `@each $item in LIST` or `@each $item, $index in LIST`, with the
1861− /// `@else` that follows it. LIST is a value (see
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.
1865− fn eval_each(
1866− &mut self,
1867− node: &syntax::Node,
1868− empty_branch: Option<&syntax::Node>,
1869− ctx: &mut ParseContext,
1870− ) -> Result<Option<Vec<Node>>, ParseError> {
1871− ctx.visited.insert(node.id);
1872− if let Some(branch) = empty_branch {
1873− ctx.visited.insert(branch.id);
1874− }
1875− ctx.enter(node);
1876− // A malformed header is a syntax error, already reported
1877− let Some(DirectiveArgs::Each(header)) = node.directive().map(|d| &d.args) else {
1878− return Ok(None);
1879− };
1880− let body = &node.children;
1881− let empty: &[syntax::Node] = empty_branch.map_or(&[], |b| &b.children);
1882− let (item, index) = (header.item.as_str(), header.index.as_deref());
1883− let list = &header.list;
1884− // `[...]` is an attribute list or JSON elsewhere, not a list here
1885− let bracketed = list.raw.starts_with('[') && list.raw.ends_with(']');
1886− let (source, filled) = match bracketed {
1887− true => (Value::empty(), false),
1888− false => ctx.slot_value(&list.raw, list.span.line, Some(list.span.column)),
1889− };
1890− let not_a_list = |message: String, ctx: &ParseContext| {
1891− let diagnostic = Diagnostic::error(code::INVALID_LOOP, list.span.line, message)
1892− .column(list.span.column)
1893− .subject(list.raw.as_str());
1894− match &ctx.current_source {
1895− (current, Some(text)) if *current == list.span.line => {
1896− diagnostic.source(text.clone())
1897− }
1898− _ => diagnostic,
1899− }
1900− };
1901− if bracketed {
1902− let inner = list.raw[1..list.raw.len() - 1].trim().to_string();
1903− let diagnostic = not_a_list(
1904− format!(
1905− "@each takes a list written without brackets, `@each ${} in {}`; \
1906− JSON data goes in `@data`",
1907− item, inner
1908− ),
1909− ctx,
1910− )
1911− .suggest(Some(inner));
1912− ctx.push_once(diagnostic);
1913− }
1914− let items: Vec<Value> = match source {
1915− Value::List(list) => list.items.to_vec(),
1916− // Reported (an undefined `$name`, brackets): there is nothing
1917− // to repeat
1918− _ if !filled => Vec::new(),
1919− Value::Record(_) => {
1920− let diagnostic = not_a_list(
1921− format!(
1922− "@each repeats for the items of a list, and `{}` is a record: \
1923− loop over a list of records, or use its fields",
1924− list.raw
1925− ),
1926− ctx,
1927− );
1928− ctx.push_once(diagnostic);
1929− Vec::new()
1930− }
1931− // Empty text (a field a record doesn't have) is no items; any
1932− // other value is one
1933− value if value.to_string().is_empty() => Vec::new(),
1934− value => vec![value],
1935− };
1936−
1937− if body.iter().all(syntax::Node::is_trivia) {
1938− return Ok(Some(Vec::new()));
1939− }
1940− if items.is_empty() {
1941− return Ok(Some(self.eval_scoped(empty, ctx)));
1942− }
1943− let mut nodes = Vec::new();
1944− for (i, value) in items.into_iter().enumerate() {
1945− ctx.env.push();
1946− ctx.bind(item, value);
1947− if let Some(index) = index {
1948− ctx.bind(index, Value::Num(i as f64));
1949− }
1950− nodes.extend(self.eval_block(body, ctx));
1951− ctx.env.pop();
1952− }
1953− Ok(Some(nodes))
1954 }
1801 }
1955
1802
1956− fn define_function(
1803+ fn expand_fn_call(
1957 &mut self,
1804 &mut self,
1958 name: &str,
1805 name: &str,
1959− params: &[syntax::Param],
1806+ content: &str,
1960− node: &syntax::Node,
1807+ current_indent: usize,
1808+ line_num: usize,
1961 ctx: &mut ParseContext,
1809 ctx: &mut ParseContext,
1962− ) {
1810+ ) -> Result<Vec<Node>, ParseError> {
1963− let line_num = node.span.line;
1811+ // Recursive function cycle detection
1964− let body = &node.children;
1812+ if ctx.fn_call_stack.contains(&name.to_string()) {
1965− // An @style block at the top of the body is scoped to the
1813+ return Err(ParseError {
1966− // function: its rules apply inside a `.hl-fn-NAME-HASH` wrapper (a
1814+ line: line_num,
1967− // generated class never has a second `-`, so the two can't meet).
1815+ message: format!(
1968− // The hash is the stylesheet's, so two functions of one name with
1816+ "recursive function call to @{} (call stack: {})",
1969− // two stylesheets, in two files, don't style each other's elements
1817+ name,
1970− // in a page that has both. It is written into the page when the
1818+ ctx.fn_call_stack.join(" -> ")
1971− // function is called.
1819+ ),
1972− let (style, body): (Vec<&syntax::Node>, Vec<&syntax::Node>) =
1973− body.iter().partition(|node| node.is_directive("style"));
1974− let scoped = (!style.is_empty()).then(|| {
1975− let css: String = style
1976− .iter()
1977− .map(|node| {
1978− ctx.visited.insert(node.id);
1979− verbatim_content(node)
1980− })
1981− .collect();
1982− let digits = crate::codegen::CLASS_DIGITS;
1983− let hash = crate::codegen::hash_digits(crate::codegen::stable_hash(&css), digits);
1984− let class = format!("hl-fn-{}-{}", name, hash);
1985− // Nested under the scope class, so any CSS works (multi-line
1986− // rules, at-rules)
1987− let css = if css.trim().is_empty() {
1988− String::new()
1989− } else {
1990− format!(".{} {{\n{}}}", class, css)
1991− };
1992− (class, css)
1993− });
1994−
1995− // A default is filled in only when a call leaves its parameter
1996− // out, so the names in it count as used here
1997− for default in params.iter().filter_map(|p| p.default.as_ref()) {
1998− track_var_refs(default, &mut ctx.used_variables);
1999− }
2000− ctx.fn_lines.entry(name.to_string()).or_insert(line_num);
2001− let wraps_itself = ElementKind::from_name(name).is_some()
2002− && body.iter().any(|node| calls_unguarded(node, name));
2003− if wraps_itself {
2004− ctx.push_once(
2005− Diagnostic::error(
2006− code::RECURSIVE_CALL,
2007− line_num,
2008− format!(
2009− "@{0} in the body of `@let @{0}` calls the function itself, not the \
2010− element @{0}, so it would never stop: give the function another name \
2011− (such as @site-{0})",
2012− name
2013− ),
2014− )
2015− .subject(name),
2016− );
2017− }
2018− for node in &body {
2019− node.walk(&mut |node| {
2020− if let Some(d) = marker_in_text(node) {
2021− ctx.push_once(d);
2022− }
2023 });
1820 });
2024 }
1821 }
2025− let body: Vec<syntax::Node> = body.into_iter().cloned().collect();
1822+ ctx.fn_call_stack.push(name.to_string());
2026− let (slots, has_children) = declared_slots(&body, ctx);
2027− let function = Rc::new(FnDef {
2028− params: params.to_vec(),
2029− line: line_num,
2030− source: ctx.current_source.1.clone(),
2031− node: node.id,
2032− body,
2033− file: ctx.current_file.clone(),
2034− slots,
2035− has_children,
2036− scoped,
2037− env: ctx.env.clone(),
2038− wraps_itself,
2039− });
2040− ctx.defined_functions
2041− .insert(name.to_string(), function.clone());
2042− ctx.env.define(name, Binding::Function(function));
2043− }
2044
1823
2045− /// An element line: a chain of elements and function calls, the last of
1824+ // Parse [param value, ...] arguments
2046− /// which gets the indented children.
1825+ let rest = &content[1 + name.len()..];
2047− fn eval_element(
1826+ let rest = rest.trim_start();
2048− &mut self,
2049− node: &syntax::Node,
2050− element: &syntax::ElementLine,
2051− ctx: &mut ParseContext,
2052− ) -> Result<Vec<Node>, ParseError> {
2053− // An unclosed attribute list is a syntax error, already reported
2054− if element
2055− .chain
2056− .iter()
2057− .any(|h| h.attrs.as_ref().is_some_and(|a| !a.closed))
2058− {
2059− return Ok(Vec::new());
2060− }
2061− let line_num = node.span.line;
2062− let last = element.chain.len() - 1;
2063− // Each head is read where it is written, left to right. The
2064− // children belong to the last one, so the chain is built right to
2065− // left, each link wrapping the next.
2066− let mut links = Vec::new();
2067− for (i, head) in element.chain.iter().enumerate() {
2068− let text = if i == last {
2069− element.text.as_ref()
2070− } else {
2071− None
2072− };
2073− links.push(resolve(head, text, line_num, &node.source, ctx)?);
2074− }
2075− let calls: Vec<Option<(String, Rc<FnDef>)>> = links
2076− .iter()
2077− .map(|link| match link {
2078− Resolved::Call(call) => Some((call.name.clone(), call.function.clone())),
2079− Resolved::Element(_) => None,
2080− })
2081− .collect();
2082− let mut current = self.eval_scoped(&node.children, ctx);
2083− // After the lines under the call ran, so the files they include
2084− // are read
2085− check_fillers(node, element, &calls, ctx);
2086− for link in links.into_iter().rev() {
2087− current = self.complete(link, current, ctx)?;
2088− }
2089− Ok(current)
2090− }
2091
1827
2092− /// Give a resolved `@name` its children: an element holds them, and a
1828+ let (args, trailing_text) = if rest.starts_with('[') {
2093− /// function's body puts them where its `@children` is.
1829+ parse_attr_brackets_no_validate(rest, line_num, ctx)?
2094− fn complete(
1830+ } else {
2095− &mut self,
1831+ (Vec::new(), rest.to_string())
2096− resolved: Resolved,
1832+ };
2097− children: Vec<Node>,
1833+
2098− ctx: &mut ParseContext,
1834+ // Clone function definition (releases borrow on ctx). If the function is
2099− ) -> Result<Vec<Node>, ParseError> {
1835+ // missing (shouldn't normally happen — callers check ctx.functions.contains_key
2100− match resolved {
1836+ // first — but we avoid panicking on malformed state).
2101− Resolved::Element(mut element) => {
1837+ let fn_def = match ctx.functions.get(name) {
2102− element.children.extend(children);
1838+ Some(def) => def.clone(),
2103− let line = element.line_num;
1839+ None => {
2104− Ok(vec![inline_svg(Node::Element(element), line, ctx)])
1840+ ctx.fn_call_stack.pop();
1841+ return Err(ParseError {
1842+ line: line_num,
1843+ message: format!("undefined function @{}", name),
1844+ });
2105 }
1845 }
2106− Resolved::Call(call) => self.expand_fn_call(*call, children, ctx),
1846+ };
2107− }
2108− }
2109
1847
2110− fn expand_fn_call(
1848+ // Parse caller's children, separating named slots from default children
2111− &mut self,
1849+ let all_caller_children = self.parse_children(current_indent + 1, ctx);
2112− call: Call,
2113− all_caller_children: Vec<Node>,
2114− ctx: &mut ParseContext,
2115− ) -> Result<Vec<Node>, ParseError> {
2116− let Call {
2117− name,
2118− function: fn_def,
2119− args,
2120− values,
2121− text: trailing_text,
2122− written_form,
2123− written_keys,
2124− line: line_num,
2125− name_at,
2126− source: content,
2127− } = call;
2128− let (name, content) = (name.as_str(), content.as_str());
2129−
2130− // Separate the caller's `@slot NAME` blocks from its other
2131− // children. A block for a slot the function doesn't declare was
2132− // reported where it is written (see `check_fillers`), like one
2133− // without a name.
2134 let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
1850 let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
2135 let mut caller_children = Vec::new();
1851 let mut caller_children = Vec::new();
2136 for child in all_caller_children {
1852 for child in all_caller_children {
2137− if let Node::Element(elem) = child {
1853+ if let Node::Element(ref elem) = child
2138− match elem.kind {
1854+ && let ElementKind::Slot(ref slot_name) = elem.kind
2139− ElementKind::Slot(slot_name) => {
1855+ && !slot_name.is_empty()
2140− slot_contents
1856+ {
2141− .entry(slot_name)
1857+ slot_contents
2142− .or_default()
1858+ .entry(slot_name.clone())
2143− .extend(elem.children);
1859+ .or_default()
2144− }
1860+ .extend(elem.children.clone());
2145− kind => caller_children.push(Node::Element(Element { kind, ..elem })),
2146− }
2147 continue;
1861 continue;
2148 }
1862 }
2149 caller_children.push(child);
1863 caller_children.push(child);
2150 }
1864 }
2151− // Text after the call, as in `@card [color red] New`, is content:
1865+ // Text after the call, as in `@badge [color red] New`, is content:
2152− // it goes first among the caller's children, and like them it is
1866+ // it goes first among the caller's children.
2153− // evaluated where the call is written.
1867+ let trailing_text = trailing_text.trim();
2154− if let Some(text) = &trailing_text {
1868+ if !trailing_text.is_empty() {
2155− caller_children.insert(0, Node::Text(text_segments(text, ctx)));
1869+ caller_children.insert(0, Node::Text(parse_text_segments(trailing_text, ctx)));
2156− }
2157− // Content goes where the body's `@children` is: without one, it
2158− // would be dropped
2159− if !fn_def.has_children && !caller_children.is_empty() {
2160− let (column, text) = match &name_at {
2161− Some((column, text)) => (Some(*column), text.as_str()),
2162− None => (None, content),
2163− };
2164− ctx.push_once(no_children(name, line_num, column).source(text));
2165− }
2166−
2167− if fn_def.wraps_itself {
2168− return Ok(Vec::new());
2169− }
2170− // Only the body is inside the call, so a function may appear in the
2171− // content its caller passes it. A function may call itself, under
2172− // a condition that stops it; one that doesn't stop goes too deep.
2173− if ctx.too_deep {
2174− return Ok(Vec::new());
2175− }
2176− if ctx.fn_call_stack.len() >= MAX_CALL_DEPTH {
2177− ctx.too_deep = true;
2178− return Err(too_deep(name, &ctx.fn_call_stack, line_num).source(content));
2179 }
1870 }
2180− let caller = (
2181− ctx.current_line,
2182− ctx.current_source.clone(),
2183− ctx.current_node,
2184− );
2185− ctx.fn_call_stack.push(name.to_string());
2186
1871
2187− // The body runs where the function is defined: it sees what was
1872+ // Save variable state, inject function parameters
2188− // visible there, the function itself (so it can call itself), and
1873+ let saved_vars = ctx.variables.clone();
2189− // its parameters, bound by name in the order they are declared. A
2190− // default is filled in at the call, seeing the same, with the
2191− // parameters before it.
2192− let caller_env = std::mem::replace(&mut ctx.env, fn_def.env.clone());
2193− ctx.env.push();
2194− ctx.env.define(name, Binding::Function(fn_def.clone()));
2195 let mut consumed = vec![false; args.len()];
1874 let mut consumed = vec![false; args.len()];
2196− for param in &fn_def.params {
1875+ for (i, param) in fn_def.params.iter().enumerate() {
2197− let mut passed = None;
1876+ let named = args.iter().position(|a| a.key == *param);
2198− for (j, arg) in args.iter().enumerate() {
1877+ // Positional fallback, unless that argument is named for a
2199− if arg.key != param.name {
1878+ // different parameter.
2200− continue;
1879+ let positional = args
2201− }
1880+ .get(i)
1881+ .filter(|a| !fn_def.params.contains(&a.key) && a.value.is_some())
1882+ .map(|_| i);
1883+ let value = named
1884+ .filter(|&j| args[j].value.is_some())
1885+ .or(positional)
1886+ .and_then(|j| {
1887+ consumed[j] = true;
1888+ args[j].value.clone()
1889+ })
1890+ .or_else(|| fn_def.defaults.get(param).cloned())
1891+ .unwrap_or_default();
1892+ if let Some(j) = named {
2202 consumed[j] = true;
1893 consumed[j] = true;
2203− // The later one wins, as for any attribute: one written
2204− // after a bundle overrides it, and only one written twice
2205− // in the call's own list is reported
2206− if passed.is_some() && written_keys.iter().filter(|k| **k == param.name).count() > 1
2207− {
2208− ctx.push_once(
2209− Diagnostic::warning(
2210− code::DUPLICATE_ATTRIBUTE,
2211− line_num,
2212− format!(
2213− "parameter '{}' of @{} is passed twice: the later one wins",
2214− param.name, name
2215− ),
2216− )
2217− .source(content)
2218− .subject(param.name.as_str()),
2219− );
2220− }
2221− // Written `name=value` in the call's own list is reported
2222− // as written; this is one from a bundle
2223− if arg.html && !written_form.contains(&arg.key) {
2224− let value = arg.value.as_deref().unwrap_or("");
2225− ctx.push_once(parameter_form(name, &arg.key, value, line_num).source(content));
2226− }
2227− passed = Some(j);
2228 }
1894 }
2229− let value = match passed {
1895+ ctx.variables.insert(param.clone(), value);
2230− // A parameter's name alone is true: `@post-card [featured]`
2231− Some(j) if args[j].value.is_none() => Value::Bool(true),
2232− // What the call passed, with its type: a record, a list,
2233− // quoted text
2234− Some(j) => values[j].clone().unwrap_or_else(|| {
2235− let arg = &args[j];
2236− match &arg.quoted {
2237− Some(quoted) => Value::Quoted(quoted.clone()),
2238− None => Value::Str(arg.value.clone().unwrap_or_default()),
2239− }
2240− }),
2241− None => match &param.default {
2242− Some(default) => {
2243− let before = ctx.diagnostics.len();
2244− let value = fill_default(&fn_def, param, default, ctx);
2245− point_at_call(ctx, before, &fn_def, name, line_num, content);
2246− value
2247− }
2248− None => {
2249− let (column, text) = match &name_at {
2250− Some((column, text)) => (Some(*column), text.as_str()),
2251− None => (None, content),
2252− };
2253− ctx.push_once(
2254− missing_parameter(name, &param.name, line_num, column).source(text),
2255− );
2256− Value::empty()
2257− }
2258− },
2259− };
2260− ctx.bind(&param.name, value);
2261 }
1896 }
2262 // Arguments that aren't parameters are attributes for the
1897 // Arguments that aren't parameters are attributes for the
2263 // function's root element, so a function can be styled like an
1898 // function's root element, so a function can be styled like an
2264− // element: `@card [background red] New`.
1899+ // element: `@badge [background red] New`.
2265 let forwarded: Vec<Attribute> = args
1900 let forwarded: Vec<Attribute> = args
2266 .iter()
1901 .iter()
2267 .zip(&consumed)
1902 .zip(&consumed)
@@ -2269,3880 +1904,1477 @@ impl Evaluator {
2269 .map(|(a, _)| a.clone())
1904 .map(|(a, _)| a.clone())
2270 .collect();
1905 .collect();
2271
1906
2272− // Evaluate the body with the parameters in scope, as lines of the
1907+ // Normalize body indentation so it parses from indent 0
2273− // file that defines the function
1908+ let min_indent = fn_def
2274− let caller_file = std::mem::replace(&mut ctx.current_file, fn_def.file.clone());
1909+ .body_lines
2275− let before = ctx.diagnostics.len();
1910+ .iter()
2276− let mut body_nodes = self.eval_block(&fn_def.body, ctx);
1911+ .map(|l| l.indent)
2277− ctx.current_file = caller_file;
1912+ .min()
2278− point_at_call(ctx, before, &fn_def, name, line_num, content);
1913+ .unwrap_or(0);
1914+ let adjusted: Vec<Line> = fn_def
1915+ .body_lines
1916+ .iter()
1917+ .map(|l| Line {
1918+ indent: l.indent - min_indent,
1919+ content: l.content.clone(),
1920+ line_num: l.line_num,
1921+ })
1922+ .collect();
2279
1923
2280− // Back to the caller's names and call stack
1924+ // Parse body with params in scope
2281− ctx.env = caller_env;
1925+ let mut body_parser = Parser {
2282− ctx.fn_call_stack.pop();
1926+ lines: adjusted,
2283− if ctx.fn_call_stack.is_empty() {
1927+ pos: 0,
2284− ctx.too_deep = false;
1928+ };
2285− }
1929+ let body_nodes = body_parser.parse_children(0, ctx);
2286− (ctx.current_line, ctx.current_source, ctx.current_node) = caller;
2287
1930
2288− // What the body wrote is the call's
1931+ // Restore variables and call stack
2289− belongs_to_call(&mut body_nodes, name, line_num);
1932+ ctx.variables = saved_vars;
1933+ ctx.fn_call_stack.pop();
2290
1934
2291 // Replace @children with caller's children and @slot with slot content
1935 // Replace @children with caller's children and @slot with slot content
2292 let mut result_nodes =
1936 let mut result_nodes =
2293 replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
1937 replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
2294
1938
2295− // Attributes that aren't parameters style the function's root
1939+ if !forwarded.is_empty() {
2296− // element, and a scoped @style's class goes on it too.
2297− let scope_class = fn_def.scoped.as_ref().map(|(class, _)| class.clone());
2298− if !forwarded.is_empty() || scope_class.is_some() {
2299 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
1940 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
2300 Node::Element(e) => Some(e),
1941 Node::Element(e) => Some(e),
2301 _ => None,
1942 _ => None,
2302 });
1943 });
2303− let root = match (roots.next(), roots.next()) {
1944+ match (roots.next(), roots.next()) {
2304− (Some(root), None) => Some(root),
1945+ (Some(root), None) => root.attrs.extend(forwarded),
2305− _ => None,
1946+ _ => ctx.diagnostics.push(Diagnostic {
2306− };
1947+ line: line_num,
2307− let mut problems = Vec::new();
1948+ column: None,
2308− match root {
1949+ message: format!(
2309− Some(root) => {
1950+ "attributes {} on @{} are not parameters, and its body has no single \
2310− // A call's `class=` adds to the root's own classes
1951+ root element to receive them",
2311− for attr in forwarded {
1952+ forwarded
2312− let existing = (attr.html && attr.key == "class")
1953+ .iter()
2313− .then(|| root.attrs.iter_mut().find(|a| a.html && a.key == "class"))
1954+ .map(|a| format!("'{}'", a.key))
2314− .flatten();
1955+ .collect::<Vec<_>>()
2315− match existing {
1956+ .join(", "),
2316− Some(class) => {
1957+ name
2317− let mut value = class.value.take().unwrap_or_default();
1958+ ),
2318− for name in attr.value.as_deref().unwrap_or("").split_whitespace() {
1959+ severity: Severity::Warning,
2319− if !value.split_whitespace().any(|c| c == name) {
1960+ source_line: Some(content.to_string()),
2320− value.push(' ');
1961+ }),
2321− value.push_str(name);
2322− }
2323− }
2324− class.value = Some(value.trim().to_string());
2325− }
2326− None => root.attrs.push(attr),
2327− }
2328− }
2329− // The scoped `@style` goes into the page once, when the
2330− // function is first called
2331− if let Some((_, css)) = &fn_def.scoped
2332− && !css.is_empty()
2333− && !ctx.scoped_css.contains(css)
2334− {
2335− ctx.scoped_css.push(css.clone());
2336− }
2337− if let Some(class) = scope_class {
2338− match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
2339− Some(attr) => {
2340− let existing = attr.value.take().unwrap_or_default();
2341− attr.value =
2342− Some(format!("{} {}", existing, class).trim().to_string());
2343− }
2344− None => root.attrs.push(Attribute {
2345− key: "class".to_string(),
2346− value: Some(class),
2347− html: true,
2348− quoted: None,
2349− }),
2350− }
2351− }
2352− }
2353− None => {
2354− if !forwarded.is_empty() {
2355− problems.push(format!(
2356− "attributes {} on @{} are not parameters, and its body has no single \
2357− root element to receive them",
2358− forwarded
2359− .iter()
2360− .map(|a| format!("'{}'", a.key))
2361− .collect::<Vec<_>>()
2362− .join(", "),
2363− name
2364− ));
2365− }
2366− if scope_class.is_some() {
2367− problems.push(format!(
2368− "@{} has a scoped @style, but its body has no single root element \
2369− to scope it to",
2370− name
2371− ));
2372− }
2373− }
2374− }
2375− for message in problems {
2376− ctx.diagnostics.push(
2377− Diagnostic::error(code::NO_SINGLE_ROOT, line_num, message)
2378− .source(content)
2379− .subject(name),
2380− );
2381 }
1962 }
2382 }
1963 }
2383
1964
1965+ // A function with a scoped @style wraps its output in the scope class
1966+ if ctx.scoped_functions.contains(name) {
1967+ let wrapper = Element {
1968+ kind: ElementKind::El,
1969+ attrs: vec![Attribute {
1970+ key: "class".to_string(),
1971+ value: Some(format!("hl-{}", name)),
1972+ html: true,
1973+ }],
1974+ argument: None,
1975+ children: result_nodes,
1976+ line_num,
1977+ };
1978+ result_nodes = vec![Node::Element(wrapper)];
1979+ }
1980+
2384 Ok(result_nodes)
1981 Ok(result_nodes)
2385 }
1982 }
2386−}
2387
1983
2388−/// A `@name` read where it is written: a built-in element, or a call.
1984+ fn parse_element_line(
2389−enum Resolved {
1985+ &mut self,
2390− Element(Element),
1986+ content: &str,
2391− Call(Box<Call>),
1987+ current_indent: usize,
2392−}
1988+ line_num: usize,
1989+ ctx: &mut ParseContext,
1990+ ) -> Result<Node, ParseError> {
1991+ let segments = split_chain(content);
2393
1992
2394−/// A call of a function, with its arguments evaluated where it is written.
1993+ // Parse each segment into an Element
2395−struct Call {
1994+ let mut elements: Vec<Element> = Vec::new();
2396− name: String,
1995+ for seg in &segments {
2397− function: Rc<FnDef>,
1996+ let elem = parse_single_element(seg.trim(), line_num, ctx)?;
2398− args: Vec<Attribute>,
1997+ elements.push(elem);
2399− /// The value of each argument that passes a parameter, with its type
1998+ }
2400− /// (see [`ParseContext::slot_value`]), in the order of `args`.
2401− values: Vec<Option<Value>>,
2402− /// Text after the attributes, which is content.
2403− text: Option<syntax::Text>,
2404− /// Parameters written `name=value`, already reported.
2405− written_form: Vec<String>,
2406− /// The keys written in the call's own list, not from a bundle.
2407− written_keys: Vec<String>,
2408− line: usize,
2409− /// The column of the call's name and the line it is on, for a missing
2410− /// parameter.
2411− name_at: Option<(usize, String)>,
2412− /// The line as written, for diagnostics.
2413− source: String,
2414−}
2415
1999
2416−/// What `@name` is where it is written: a call of the function `name`
2000+ // Parse indented children (belong to the innermost element)
2417−/// when one is defined, else the built-in element. A whole line, each link
2001+ let children = self.parse_children(current_indent + 1, ctx);
2418−/// of a chain and an inline element in text all resolve here.
2419−fn resolve(
2420− head: &syntax::Head,
2421− text: Option<&syntax::Text>,
2422− line: usize,
2423− source: &str,
2424− ctx: &mut ParseContext,
2425−) -> Result<Resolved, ParseError> {
2426− let Some(function) = ctx.env.function(&head.name).cloned() else {
2427− return parse_single_element(head, text, line, ctx).map(Resolved::Element);
2428− };
2429− ctx.used_functions.insert(head.name.clone());
2430− let params: Vec<String> = function.params.iter().map(|p| p.name.clone()).collect();
2431− let written = head.attrs.as_ref().map_or(&[][..], |list| &list.attrs[..]);
2432− let written_form = written_parameter_forms(&head.name, &function, written, line, ctx);
2433− let written_keys = written.iter().map(|a| a.key.clone()).collect();
2434− // Parameters are bound by name; every other attribute goes to the
2435− // root element, and is checked like any attribute
2436− let (args, values) = parse_attrs(written, line, ctx, true, &params)
2437− .into_iter()
2438− .unzip();
2439− let name_at = name_column(head, line).zip(match &ctx.current_source {
2440− (current, Some(text)) if *current == line => Some(text.clone()),
2441− _ => None,
2442− });
2443− Ok(Resolved::Call(Box::new(Call {
2444− name: head.name.clone(),
2445− function,
2446− args,
2447− values,
2448− text: text.cloned(),
2449− written_form,
2450− written_keys,
2451− line,
2452− name_at,
2453− source: source.to_string(),
2454− })))
2455−}
2456
2002
2457−/// A line of another file (the standard library, an included file) means
2003+ // Build chain right-to-left: rightmost gets children, each wraps the next
2458−/// nothing in this one: the problems reported since `before` while
2004+ let mut current_children = children;
2459−/// evaluating the function (its body, a default) are moved to the call.
2005+ for mut elem in elements.into_iter().rev() {
2460−fn point_at_call(
2006+ elem.children.extend(current_children);
2461− ctx: &mut ParseContext,
2007+ current_children = vec![Node::Element(elem)];
2462− before: usize,
2008+ }
2463− function: &FnDef,
2009+
2464− name: &str,
2010+ current_children.into_iter().next().ok_or(ParseError {
2465− line: usize,
2011+ line: line_num,
2466− source: &str,
2012+ message: "element chain produced no nodes (this is a parser bug)".to_string(),
2467−) {
2013+ })
2468− if function.file == ctx.current_file {
2469− return;
2470− }
2471− let defined_in = function
2472− .file
2473− .as_deref()
2474− .unwrap_or("the file being compiled");
2475− for d in &mut ctx.diagnostics[before..] {
2476− d.message = format!(
2477− "{}\n in @{} (line {} of {})",
2478− d.message, name, d.line, defined_in
2479− );
2480− d.line = line;
2481− d.column = None;
2482− d.subject = None;
2483− d.suggestion = None;
2484− d.source_line = Some(source.into());
2485 }
2014 }
2486}
2015}
2487
2016
2488−/// A call nested deeper than [`MAX_CALL_DEPTH`].
2017+// ---------------------------------------------------------------------------
2489−fn too_deep(name: &str, stack: &[String], line: usize) -> Diagnostic {
2018+// @children replacement
2490− let message = if stack.iter().any(|n| n == name) {
2019+// ---------------------------------------------------------------------------
2491− format!(
2492− "recursive function call to @{} goes more than {} calls deep: a function that \
2493− calls itself needs a condition that stops it (`@if`)",
2494− name, MAX_CALL_DEPTH
2495− )
2496− } else {
2497− format!(
2498− "function calls nest more than {} deep at @{} (starting with {})",
2499− MAX_CALL_DEPTH,
2500− name,
2501− stack
2502− .iter()
2503− .take(3)
2504− .map(|n| format!("@{}", n))
2505− .collect::<Vec<_>>()
2506− .join(" → ")
2507− )
2508− };
2509− Diagnostic::error(code::RECURSIVE_CALL, line, message).subject(name)
2510−}
2511
2020
2512−/// Mark what a function's body wrote as the call's: it gets the call's
2021+fn replace_children_and_slots(
2513−/// line, so what is reported about it points at the call, and the name of
2022+ nodes: Vec<Node>,
2514−/// the function, unless a call inside the body already claimed it.
2023+ caller_children: &[Node],
2515−fn belongs_to_call(nodes: &mut [Node], function: &str, line: usize) {
2024+ slot_contents: &HashMap<String, Vec<Node>>,
2516− fn claim(element: &mut Element, function: &str, line: usize) {
2025+) -> Vec<Node> {
2517− element.line_num = line;
2026+ let mut result = Vec::new();
2518− element.function.get_or_insert_with(|| function.to_string());
2519− belongs_to_call(&mut element.children, function, line);
2520− }
2521 for node in nodes {
2027 for node in nodes {
2522 match node {
2028 match node {
2523− Node::Element(element) => claim(element, function, line),
2029+ Node::Element(elem) if elem.kind == ElementKind::Children => {
2524− Node::Text(segments) => {
2030+ if caller_children.is_empty() && !elem.children.is_empty() {
2525− for segment in segments {
2031+ // Use @children's own children as default/fallback content
2526− if let TextSegment::Inline(element) = segment {
2032+ result.extend(elem.children);
2527− claim(element, function, line);
2033+ } else {
2528− }
2034+ result.extend(caller_children.iter().cloned());
2529 }
2035 }
2530 }
2036 }
2531− Node::Raw(_) => {}
2037+ Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) =>
2532− }
2038+ {
2533− }
2039+ if let ElementKind::Slot(ref name) = elem.kind {
2534−}
2040+ if let Some(content) = slot_contents.get(name) {
2535−
2041+ result.extend(content.iter().cloned());
2536−/// What an inline `{@name ...}` stands for in its line of text: an
2042+ }
2537−/// element; the text of a function whose body is text; or, for a body with
2043+ // If no content provided for this slot, use the slot's own children as default
2538−/// several roots, its nodes as a fragment.
2044+ else if !elem.children.is_empty() {
2539−fn inline_segments(mut nodes: Vec<Node>, line: usize) -> Vec<TextSegment> {
2045+ result.extend(elem.children);
2540− if let [Node::Element(_)] = nodes.as_slice()
2046+ }
2541− && let Some(Node::Element(element)) = nodes.pop()
2542− {
2543− return vec![TextSegment::Inline(element)];
2544− }
2545− if nodes.iter().all(|n| matches!(n, Node::Text(_))) {
2546− let mut segments = Vec::new();
2547− for node in nodes {
2548− if let Node::Text(run) = node {
2549− if !segments.is_empty() {
2550− segments.push(TextSegment::Plain(" ".to_string()));
2551 }
2047 }
2552− segments.extend(run);
2553 }
2048 }
2049+ Node::Element(mut elem) => {
2050+ elem.children =
2051+ replace_children_and_slots(elem.children, caller_children, slot_contents);
2052+ result.push(Node::Element(elem));
2053+ }
2054+ other => result.push(other),
2554 }
2055 }
2555− return segments;
2556 }
2056 }
2557− vec![TextSegment::Inline(Element {
2057+ result
2558− kind: ElementKind::Fragment,
2559− attrs: Vec::new(),
2560− argument: None,
2561− children: nodes,
2562− line_num: line,
2563− function: None,
2564− })]
2565}
2058}
2566
2059
2567−/// Fill in the default of a parameter that a call leaves out, like the
2060+// ---------------------------------------------------------------------------
2568−/// value of an attribute passed for it: variables are filled in, one
2061+// Element parsing
2569−/// `$name` keeps its type and one `"..."` is quoted text. It sees what the
2062+// ---------------------------------------------------------------------------
2570−/// body sees: the names visible where the function is defined, and the
2063+
2571−/// parameters before it. Its problems are reported at the definition, once.
2064+fn extract_element_name(content: &str) -> &str {
2572−fn fill_default(
2065+ let without_at = &content[1..];
2573− fn_def: &FnDef,
2066+ match without_at.find([' ', '[']) {
2574− param: &syntax::Param,
2067+ Some(i) => &without_at[..i],
2575− default: &str,
2068+ None => without_at,
2576− ctx: &mut ParseContext,
2577−) -> Value {
2578− // A default that uses a later parameter is an error in the
2579− // definition; the later one isn't bound yet, so don't report it again
2580− let later = fn_def
2581− .params
2582− .iter()
2583− .skip_while(|p| p.name != param.name)
2584− .skip(1);
2585− let names = interp::names(default);
2586− if later.clone().any(|p| names.contains(&p.name.as_str())) {
2587− return Value::empty();
2588 }
2069 }
2589− let line = param.span.line;
2590− let at_head = line == fn_def.line && fn_def.source.is_some();
2591− let column =
2592− at_head.then(|| param.span.column + (param.span.end - param.span.start) - default.len());
2593− let caller = (
2594− std::mem::replace(
2595− &mut ctx.current_source,
2596− (fn_def.line, fn_def.source.clone()),
2597− ),
2598− std::mem::replace(&mut ctx.current_node, fn_def.node),
2599− );
2600− let (value, _) = ctx.slot_value(default, line, column);
2601− (ctx.current_source, ctx.current_node) = caller;
2602− value
2603}
2070}
2604
2071
2605−/// Read `@page`'s attribute list and title. The page is the root element,
2072+fn parse_single_element(
2606−/// and its attributes are checked like any element's: `key=value` (and a
2073+ content: &str,
2607−/// bare boolean HTML attribute) go on `<html>`, styles on `<body>`, and
2074+ line_num: usize,
2608−/// `favicon FILE` is its one word of htmlang's own.
2609−fn read_page(
2610− attrs: Option<&syntax::AttrList>,
2611− title: Option<&syntax::Arg>,
2612− line: usize,
2613 ctx: &mut ParseContext,
2075 ctx: &mut ParseContext,
2614−) -> Page {
2076+) -> Result<Element, ParseError> {
2615− let mut page = Page::default();
2077+ let (kind, rest) = if content.starts_with('[') {
2616− if let Some(list) = attrs {
2078+ // Implicit @el
2617− let words: Vec<String> = crate::vocab::PAGE_WORDS
2079+ (ElementKind::El, content.to_string())
2618− .iter()
2080+ } else if let Some(without_at) = content.strip_prefix('@') {
2619− .map(|w| w.to_string())
2081+ match without_at.find([' ', '[']) {
2620− .collect();
2082+ Some(i) => {
2621− ctx.in_page = true;
2083+ let kind_str = &without_at[..i];
2622− let read = parse_attr_list(&list.attrs, line, ctx, true, &words);
2084+ let rest = if without_at.as_bytes()[i] == b'[' {
2623− ctx.in_page = false;
2085+ without_at[i..].to_string()
2624− // How many of each name have been read, to find where the next
2086+ } else {
2625− // one is written
2087+ without_at[i + 1..].to_string()
2626− let mut seen: HashMap<String, usize> = HashMap::new();
2088+ };
2627− for attr in read {
2089+ match parse_element_kind(kind_str, line_num) {
2628− let boolean = attr.value.is_none() && crate::vocab::is_html_flag(&attr.key);
2090+ Ok(kind) => (kind, rest),
2629− // Where it is written, when it is written in the list itself
2091+ Err(mut e) => {
2630− let nth = seen.entry(attr.key.clone()).or_default();
2092+ // Also suggest user-defined functions
2631− let column = list
2093+ if let Some(fn_suggestion) = suggest_fn_name(kind_str, ctx) {
2632− .attrs
2094+ e.message =
2633− .iter()
2095+ format!("{}, or did you mean @{}?", e.message, fn_suggestion);
2634− .filter(|token| token.key == attr.key)
2096+ }
2635− .nth(*nth)
2097+ return Err(e);
2636− .map(|token| token.span.column);
2098+ }
2637− *nth += 1;
2638− let value = attr
2639− .quoted
2640− .as_ref()
2641− .map(|q| q.text.clone())
2642− .or(attr.value.clone());
2643− if !check_page_attr(&attr.key, attr.html, value.as_deref(), line, column, ctx) {
2644− continue;
2645− }
2646− if words.contains(&attr.key) {
2647− if page.favicon.is_some() {
2648− let diagnostic = Diagnostic::warning(
2649− code::DUPLICATE_ATTRIBUTE,
2650− line,
2651− "duplicate attribute 'favicon': the later one wins".to_string(),
2652− )
2653− .subject("favicon");
2654− ctx.diagnostics.push(at_attribute(diagnostic, column, ctx));
2655 }
2099 }
2656− // The file itself goes into the page when it can be read
2657− // (relative to the file); otherwise it is linked
2658− page.favicon = value.map(|path| match std::fs::read(ctx.resolve(&path)) {
2659− Ok(data) => crate::codegen::data_uri(&path, &data),
2660− Err(_) => path,
2661− });
2662− } else if attr.html || boolean {
2663− page.html_attrs.push(attr);
2664− } else {
2665− page.styles.push(attr);
2666 }
2100 }
2101+ None => match parse_element_kind(without_at, line_num) {
2102+ Ok(kind) => (kind, String::new()),
2103+ Err(mut e) => {
2104+ if let Some(fn_suggestion) = suggest_fn_name(without_at, ctx) {
2105+ e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion);
2106+ }
2107+ return Err(e);
2108+ }
2109+ },
2667 }
2110 }
2668− }
2111+ } else {
2669− page.title = match title {
2112+ return Err(ParseError {
2670− Some(title) => ctx.interpolate_text(&title.raw, title.span.line, Some(title.span.column)),
2113+ line: line_num,
2671− None => String::new(),
2114+ message: format!("expected @element or [attrs], got: {}", content),
2115+ });
2672 };
2116 };
2673− page
2674−}
2675
2117
2676−/// `@page`'s own rules for one of its attributes, checked where the
2118+ // Parse optional [attrs]
2677−/// `@page` runs and where it doesn't (a layout that isn't called): its
2119+ let (attrs, rest) = if rest.starts_with('[') {
2678−/// word is written `favicon FILE`, and `inline` belongs to `@image`.
2120+ parse_attr_brackets(&rest, line_num, ctx)?
2679−/// Returns whether the attribute can be used (a `favicon` with its file,
2121+ } else {
2680−/// or any other attribute but `inline`).
2122+ (Vec::new(), rest)
2681−fn check_page_attr(
2682− key: &str,
2683− html: bool,
2684− value: Option<&str>,
2685− line: usize,
2686− column: Option<usize>,
2687− ctx: &mut ParseContext,
2688−) -> bool {
2689− let report = |ctx: &mut ParseContext, diagnostic: Diagnostic| {
2690− let diagnostic = at_attribute(diagnostic, column, ctx);
2691− ctx.diagnostics.push(diagnostic);
2692 };
2123 };
2693− if crate::vocab::PAGE_WORDS.contains(&key) {
2124+
2694− if html {
2125+ let rest = rest.trim().to_string();
2695− report(
2126+ track_var_refs(&rest, &mut ctx.used_variables);
2696− ctx,
2127+
2697− Diagnostic::error(
2128+ // For @link, first token of rest is URL, remainder is inline text
2698− code::PARAMETER_FORM,
2129+ let mut children = Vec::new();
2699− line,
2130+ let argument = if rest.is_empty() {
2700− format!(
2131+ None
2701− "'{0}' is @page's own word, not an HTML attribute: write `{0} {1}`",
2132+ } else if kind == ElementKind::Link {
2702− key,
2133+ let rest_sub = substitute_vars(&rest, &ctx.variables);
2703− value.filter(|v| !v.is_empty()).unwrap_or("FILE")
2134+ if let Some((url, text)) = rest_sub.split_once(' ') {
2704− ),
2135+ let text = text.trim();
2705− )
2136+ if !text.is_empty() {
2706− .subject(format!("{}=", key))
2137+ children.push(Node::Text(parse_text_segments(text, ctx)));
2707− .suggest(Some(format!("{} ", key))),
2138+ }
2708− );
2139+ Some(url.to_string())
2140+ } else {
2141+ Some(rest_sub)
2709 }
2142 }
2710− if value.is_none_or(|v| v.trim().is_empty()) {
2143+ } else {
2711− report(
2144+ Some(substitute_vars(&rest, &ctx.variables))
2712− ctx,
2145+ };
2713− Diagnostic::error(
2146+
2714− code::MISSING_VALUE,
2147+ // For @slot, the argument is the slot name
2715− line,
2148+ let kind = if let ElementKind::Slot(_) = kind {
2716− format!("'{0}' needs its file: `{0} favicon.png`", key),
2149+ ElementKind::Slot(argument.clone().unwrap_or_default())
2717− )
2150+ } else {
2718− .subject(key),
2151+ kind
2719− );
2152+ };
2720− return false;
2153+
2154+ // Every other element treats its argument as leading text content, as
2155+ // in `@el [padding 8] Hello` or `@paragraph Read {@link /more more}`.
2156+ let argument = match argument {
2157+ Some(_) if !argument_is_special(&kind) && !renders_argument_as_text(&kind) => {
2158+ children.insert(0, Node::Text(parse_text_segments(&rest, ctx)));
2159+ None
2721 }
2160 }
2722− return true;
2161+ other => other,
2162+ };
2163+
2164+ Ok(Element {
2165+ kind,
2166+ attrs,
2167+ argument,
2168+ children,
2169+ line_num,
2170+ })
2171+}
2172+
2173+/// Elements whose argument is not text content: a URL, a source, an action,
2174+/// or a slot name.
2175+fn argument_is_special(kind: &ElementKind) -> bool {
2176+ matches!(
2177+ kind,
2178+ ElementKind::Link | ElementKind::Image | ElementKind::Script | ElementKind::Slot(_)
2179+ ) || kind
2180+ .spec()
2181+ .is_some_and(|spec| matches!(spec.arg, TagArg::Attr(_)))
2182+}
2183+
2184+/// The directive names (without `@`) the parser recognizes, for tools such
2185+/// as the language server.
2186+pub fn known_directives() -> &'static [&'static str] {
2187+ KNOWN_DIRECTIVES
2188+}
2189+
2190+const KNOWN_DIRECTIVES: &[&str] = &[
2191+ "page",
2192+ "let",
2193+ "include",
2194+ "raw",
2195+ "keyframes",
2196+ "if",
2197+ "else",
2198+ "each",
2199+ "meta",
2200+ "head",
2201+ "style",
2202+ "warn",
2203+ "extends",
2204+ "assert",
2205+ "markdown",
2206+ "data",
2207+];
2208+
2209+fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
2210+ if let Some(kind) = ElementKind::from_name(s) {
2211+ return Ok(kind);
2723 }
2212 }
2724− if !html && crate::vocab::base_attribute(key) == "inline" {
2213+ if let Some(hint) = removed_syntax_hint(&format!("@{}", s)) {
2725− report(
2214+ return Err(ParseError {
2726− ctx,
2215+ line: line_num,
2727− Diagnostic::error(
2216+ message: hint,
2728− code::UNEXPECTED_ARGUMENT,
2217+ });
2729− line,
2730− "`inline` puts an image's file into the page, and @page has none: it only \
2731− goes on @image"
2732− .to_string(),
2733− )
2734− .subject("inline"),
2735− );
2736− return false;
2737 }
2218 }
2738− true
2219+ let all_known: Vec<&str> = ElementKind::all_names()
2220+ .chain(KNOWN_DIRECTIVES.iter().copied())
2221+ .collect();
2222+ let message = match suggest_closest(s, &all_known) {
2223+ Some(closest) => format!("unknown element @{}, did you mean @{}?", s, closest),
2224+ None => format!("unknown element @{}", s),
2225+ };
2226+ Err(ParseError {
2227+ line: line_num,
2228+ message,
2229+ })
2739}
2230}
2740
2231
2741−/// A parameter passed `name=value`, the form of an HTML attribute.
2232+/// Directives and element names removed when the language was simplified,
2742−fn parameter_form(function: &str, key: &str, value: &str, line: usize) -> Diagnostic {
2233+/// with what replaces each. `htmlang upgrade` rewrites all of them.
2743− Diagnostic::error(
2234+const REMOVED_SYNTAX: &[(&str, &str)] = &[
2744− code::PARAMETER_FORM,
2235+ ("@fn", "use `@let name $param` with an indented body"),
2745− line,
2236+ ("@define", "use `@let name [attributes]`"),
2746− format!(
2237+ ("@mixin", "use `@let name [attributes]`"),
2747− "'{}' is a parameter of @{}: parameters are written `name value`, so write `{} {}`",
2238+ ("@unless", "use `@if not <condition>`"),
2748− key, function, key, value
2239+ ("@for", "use `@each $i in 1..10`"),
2749− ),
2240+ ("@repeat", "use `@each $_ in 1..N`"),
2750− )
2241+ (
2751− .subject(format!("{}=", key))
2242+ "@switch",
2752− .suggest(Some(format!("{} ", key)))
2243+ "use `@match`, with `@let __switch [...]` inside a case for its attributes",
2753−}
2244+ ),
2245+ ("@use", "use `@include`"),
2246+ (
2247+ "@import",
2248+ "use `@include` (a file with only definitions emits no content)",
2249+ ),
2250+ ("@with", "use `@let alias $source`"),
2251+ (
2252+ "@layout",
2253+ "use `@extends` (page content outside `@slot` blocks fills `@children`)",
2254+ ),
2255+ ("@scope", "write the `@scope` rule in an `@style` block"),
2256+ (
2257+ "@starting-style",
2258+ "write the `@starting-style` rule in an `@style` block",
2259+ ),
2260+ ("@css-property", "write an `@property` rule in an `@style` block"),
2261+ ("@lang", "use `@page [lang ...] Title`"),
2262+ ("@favicon", "use `@page [favicon ...] Title`"),
2263+ ("@canonical", "use `@page [canonical ...] Title`"),
2264+ ("@base", "use `@page [base ...] Title`"),
2265+ ("@og", "use `@meta og:NAME VALUE`"),
2266+ ("@debug", "use `@warn`"),
2267+ ("@svg", "use `@image [inline] file.svg`"),
2268+ ("@match", "use `@if $x == a` / `@else if $x == b` / `@else`"),
2269+ ("@case", "use `@if $x == a` / `@else if $x == b` / `@else`"),
2270+ ("@default", "use `@else` in an `@if` chain"),
2271+ ("@theme", "use a `@let --name value` line per token"),
2272+ (
2273+ "@json-ld",
2274+ "put a `<script type=\"application/ld+json\">` in `@head`",
2275+ ),
2276+ ("@font-face", "write the `@font-face` rule in an `@style` block"),
2277+ (
2278+ "@manifest",
2279+ "write a manifest.json file and link it from `@head`",
2280+ ),
2281+ ("@breakpoint", "write the media query in an `@style` block"),
2282+ ("@deprecated", "remove it"),
2283+ (
2284+ "@collection",
2285+ "use `@data $name dir/*.json` (each file becomes `$name.STEM.key`)",
2286+ ),
2287+ ("@env", "use `@data $name env:NAME [default]`"),
2288+ (
2289+ "@fetch",
2290+ "download the data before building and use `@data $name file.json`",
2291+ ),
2292+ (
2293+ "@translations",
2294+ "put each locale's strings in a JSON file and use `@data $t locales/$lang.json`",
2295+ ),
2296+ ("@defer", "remove it: the content is already in the page"),
2297+ ("@log", "use `@warn`"),
2298+ (
2299+ "@component",
2300+ "use `@let`: an `@style` block in a function body is scoped to it",
2301+ ),
2302+ ("@col", "use `@column`"),
2303+ ("@p", "use `@paragraph`"),
2304+ ("@img", "use `@image`"),
2305+ ("@li", "use `@item`"),
2306+ ("@btn", "use `@button`"),
2307+ ("@ul", "use `@list`"),
2308+ ("@divider", "use `@hr`"),
2309+ ("@opt", "use `@option`"),
2310+];
2754
2311
2755−/// A call that leaves out a parameter without a default.
2312+/// Attributes that became standard-library bundles or plain CSS.
2756−fn missing_parameter(
2313+fn removed_attribute_hint(name: &str) -> Option<&'static str> {
2757− function: &str,
2314+ match name {
2758− param: &str,
2315+ "skeleton" => Some("use the `$skeleton` bundle"),
2759− line: usize,
2316+ "no-scrollbar" => Some("use the `$no-scrollbar` bundle"),
2760− column: Option<usize>,
2317+ "gradient" => Some("use `background linear-gradient(...)`"),
2761−) -> Diagnostic {
2318+ "animate" => Some("use `animation`"),
2762− let diagnostic = Diagnostic::error(
2319+ "inset-area" => Some("use `position-area`"),
2763− code::MISSING_PARAMETER,
2320+ "gap-x" => Some("use `column-gap`"),
2764− line,
2321+ "gap-y" => Some("use `row-gap`"),
2765− format!(
2322+ "shadow" => Some("use `box-shadow`"),
2766− "@{} needs '{}': the parameter has no default, so pass it as `{} VALUE`",
2323+ "blur" => Some("use `filter blur(...)`"),
2767− function, param, param
2324+ "backdrop-blur" => Some("use `backdrop-filter blur(...)`"),
2768− ),
2325+ "truncate" => Some("use the `$truncate` bundle"),
2769− )
2326+ "critical" => Some("remove it"),
2770− .subject(param);
2327+ "grid" => Some("use `@grid`, or `display grid`"),
2771− match column {
2328+ _ => None,
2772− Some(column) => diagnostic.column(column),
2773− None => diagnostic,
2774 }
2329 }
2775}
2330}
2776
2331
2777−/// The column of a call's `@name`, when it is on the call's line.
2332+/// If `content` starts with removed syntax, the error message for it.
2778−fn name_column(head: &syntax::Head, line: usize) -> Option<usize> {
2333+fn removed_syntax_hint(content: &str) -> Option<String> {
2779− (head.name_span.line == line).then_some(head.name_span.column)
2334+ let trimmed = content.trim_start();
2335+ REMOVED_SYNTAX.iter().find_map(|(name, replacement)| {
2336+ let rest = trimmed.strip_prefix(name)?;
2337+ if !(rest.is_empty() || rest.starts_with([' ', '['])) {
2338+ return None;
2339+ }
2340+ Some(format!(
2341+ "`{}` was removed: {} (run `htmlang upgrade` to rewrite it automatically)",
2342+ name, replacement
2343+ ))
2344+ })
2780}
2345}
2781
2346
2782−/// Define `$name`, or, for `--name`, the CSS custom property on `:root`
2347+/// Format include/import stack as a readable chain for error messages.
2783−/// (which gets quoted text with its quotes, and is read with
2348+fn format_include_chain(stack: &[PathBuf]) -> String {
2784−/// `var(--name)`, never as a variable). `@let name.field value` gives the
2349+ stack
2785−/// record `$name` that field (or the list `$name` a new item at that
2350+ .iter()
2786−/// index), as a new value in this block.
2351+ .map(|p| {
2787−fn set_variable(name: &str, value: Value, line_num: usize, ctx: &mut ParseContext) {
2352+ p.file_name()
2788− if name.starts_with("--") {
2353+ .unwrap_or_default()
2789− let css = value
2354+ .to_string_lossy()
2790− .quoted_css()
2355+ .to_string()
2791− .map_or_else(|| value.to_string(), str::to_string);
2356+ })
2792− let at_line = |diagnostic: Diagnostic, ctx: &ParseContext| match &ctx.current_source {
2357+ .collect::<Vec<_>>()
2793− (current, Some(text)) if *current == line_num => diagnostic.source(text.clone()),
2358+ .join(" → ")
2794− _ => diagnostic,
2359+}
2795− };
2360+
2796− // `:root` is one place for the whole page: a declaration inside a
2361+/// Levenshtein distance with a rolling two-row buffer (O(min(a,b)) memory) and an
2797− // block (an element, a loop, a function's body) would not be
2362+/// early-exit cutoff: if every cell in a row exceeds `cutoff`, no further cell can
2798− // kept to it, and each run would replace the last
2363+/// be <= `cutoff`, so we return `cutoff + 1` immediately. Used for typo-suggestion
2799− if ctx.env.frames.len() > FILE_FRAMES {
2364+/// hot paths where we only care about distances <= 2.
2800− let diagnostic = Diagnostic::error(
2365+fn levenshtein_bounded(a: &[char], b: &[char], cutoff: usize) -> usize {
2801− code::INVALID_DEFINITION,
2366+ let (a, b) = if a.len() > b.len() { (b, a) } else { (a, b) };
2802− line_num,
2367+ if b.len() - a.len() > cutoff {
2803− format!(
2368+ return cutoff + 1;
2804− "`@let {name}` declares {name} on :root, for the whole page, so it goes at \
2369+ }
2805− the top level of the file: to set it on an element and what is inside \
2370+ if a.is_empty() {
2806− it, write `[{name} value]` on that element"
2371+ return b.len();
2807− ),
2372+ }
2808− )
2373+
2809− .subject(name);
2374+ let mut prev: Vec<usize> = (0..=a.len()).collect();
2810− ctx.push_once(at_line(diagnostic, ctx));
2375+ let mut curr: Vec<usize> = vec![0; a.len() + 1];
2811− return;
2376+
2377+ for (j, &bc) in b.iter().enumerate() {
2378+ curr[0] = j + 1;
2379+ let mut row_min = curr[0];
2380+ for (i, &ac) in a.iter().enumerate() {
2381+ let cost = if ac == bc { 0 } else { 1 };
2382+ curr[i + 1] = (prev[i + 1] + 1).min(curr[i] + 1).min(prev[i] + cost);
2383+ row_min = row_min.min(curr[i + 1]);
2812 }
2384 }
2813− match css_breakout(&css) {
2385+ if row_min > cutoff {
2814− Some(reason) => {
2386+ return cutoff + 1;
2815− let diagnostic = Diagnostic::error(
2816− code::INVALID_VALUE,
2817− line_num,
2818− format!(
2819− "the value of '{}' has {}: `{}` can't be written into the page's CSS",
2820− name, reason, css
2821− ),
2822− )
2823− .subject(css.as_str());
2824− ctx.push_once(at_line(diagnostic, ctx));
2825− }
2826− None => match ctx.css_vars.iter_mut().find(|(n, _)| n == name) {
2827− Some(declared) => {
2828− let diagnostic = Diagnostic::warning(
2829− code::DUPLICATE_ATTRIBUTE,
2830− line_num,
2831− format!(
2832− "{name} is declared again: :root has one value for the whole page, \
2833− so every element gets this one, also above this line (a theme \
2834− redefines a token on an element: `[dark:{name} {css}]`)"
2835− ),
2836− )
2837− .subject(name);
2838− declared.1 = css;
2839− ctx.push_once(at_line(diagnostic, ctx));
2840− }
2841− None => ctx.css_vars.push((name.to_string(), css)),
2842− },
2843 }
2387 }
2844− return;
2388+ std::mem::swap(&mut prev, &mut curr);
2845 }
2389 }
2846− let mut path = name.split('.');
2390+ prev[a.len()]
2847− let root = path.next().unwrap_or(name);
2848− let fields: Vec<&str> = path.collect();
2849− let value = match fields.is_empty() {
2850− true => value,
2851− false => match with_field(ctx.env.value(root).cloned(), root, &fields, value) {
2852− Ok(value) => value,
2853− Err(message) => {
2854− let diagnostic =
2855− Diagnostic::error(code::INVALID_DEFINITION, line_num, message).subject(name);
2856− let diagnostic = match &ctx.current_source {
2857− (current, Some(text)) if *current == line_num => {
2858− diagnostic.source(text.clone())
2859− }
2860− _ => diagnostic,
2861− };
2862− ctx.push_once(diagnostic);
2863− // Reported: the name keeps what it held
2864− ctx.let_lines.entry(root.to_string()).or_insert(line_num);
2865− return;
2866− }
2867− },
2868− };
2869− ctx.bind(root, value);
2870− ctx.let_lines.entry(root.to_string()).or_insert(line_num);
2871}
2391}
2872
2392
2873−/// `value` (the value of `$name`) with the field at `path` set to `new`:
2393+fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
2874−/// a record's field, or the item of a list at an index it has. A name
2394+ let input_chars: Vec<char> = input.chars().collect();
2875−/// without a value, or one whose value is empty (a field a record doesn't
2395+ let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
2876−/// have), becomes a record; any other value has no fields to set.
2396+ let mut best = None;
2877−fn with_field(
2397+ let mut best_dist = usize::MAX;
2878− value: Option<Value>,
2398+ for &candidate in candidates {
2879− name: &str,
2399+ // Cheap length-based prune before allocating chars.
2880− path: &[&str],
2400+ let clen = candidate.chars().count();
2881− new: Value,
2401+ let diff = clen.abs_diff(input_chars.len());
2882−) -> Result<Value, String> {
2402+ if diff > max_allowed {
2883− let Some((field, rest)) = path.split_first() else {
2403+ continue;
2884− return Ok(new);
2885− };
2886− let inner = format!("{}.{}", name, field);
2887− match value {
2888− Some(Value::Record(fields)) => {
2889− let mut fields = fields.as_ref().clone();
2890− match fields.iter().position(|(key, _)| key == field) {
2891− Some(i) => {
2892− let old = std::mem::replace(&mut fields[i].1, Value::empty());
2893− fields[i].1 = with_field(Some(old), &inner, rest, new)?;
2894− }
2895− None => fields.push((field.to_string(), with_field(None, &inner, rest, new)?)),
2896− }
2897− Ok(Value::Record(Rc::new(fields)))
2898 }
2404 }
2899− Some(Value::List(list)) => {
2405+ let cand_chars: Vec<char> = candidate.chars().collect();
2900− let count = list.items.len();
2406+ let dist = levenshtein_bounded(&input_chars, &cand_chars, max_allowed);
2901− let Some(i) = field.parse::<usize>().ok().filter(|&i| i < count) else {
2407+ if dist < best_dist && dist <= max_allowed {
2902− return Err(format!(
2408+ best_dist = dist;
2903− "`${}` is {}, so `.{}` isn't one of its items (they are `.0` to `.{}`)",
2409+ best = Some(candidate);
2904− name,
2905− Value::List(list).describe(),
2906− field,
2907− count.saturating_sub(1)
2908− ));
2909− };
2910− let mut items = list.items.as_ref().clone();
2911− let old = std::mem::replace(&mut items[i], Value::empty());
2912− items[i] = with_field(Some(old), &inner, rest, new)?;
2913− // Its items changed, so it no longer prints as written
2914− Ok(Value::list(items))
2915 }
2410 }
2916− None => Ok(Value::Record(Rc::new(vec![(
2917− field.to_string(),
2918− with_field(None, &inner, rest, new)?,
2919− )]))),
2920− Some(value) if value.to_string().is_empty() => with_field(None, name, path, new),
2921− Some(value) => Err(format!(
2922− "`@let {}.{}` sets a field of a record, but `${}` is {} (`{}`), which has no fields",
2923− name,
2924− path.join("."),
2925− name,
2926− value.describe(),
2927− value
2928− )),
2929 }
2411 }
2412+ best
2930}
2413}
2931
2414
2932−/// What a verbatim directive holds (`@head`, `@style`, `@raw`): the rest
2415+/// Suggest the closest user-defined function name for typos.
2933−/// of its line (`@style .a { color: red }`), or else its indented block.
2416+fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
2934−/// The two together are a syntax error, reported by the tree; the line
2417+ let input_chars: Vec<char> = input.chars().collect();
2935−/// wins.
2418+ let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
2936−fn verbatim_content(node: &syntax::Node) -> String {
2419+ let mut best: Option<String> = None;
2937− match node.directive().map(|d| &d.args) {
2420+ let mut best_dist = usize::MAX;
2938− Some(DirectiveArgs::Text(Some(line))) => format!("{}\n", line.raw),
2421+ for name in ctx.functions.keys() {
2939− _ => verbatim_text(&node.children),
2422+ let nlen = name.chars().count();
2423+ if nlen.abs_diff(input_chars.len()) > max_allowed {
2424+ continue;
2425+ }
2426+ let name_chars: Vec<char> = name.chars().collect();
2427+ let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
2428+ if dist < best_dist && dist <= max_allowed {
2429+ best_dist = dist;
2430+ best = Some(name.clone());
2431+ }
2940 }
2432 }
2433+ best
2941}
2434}
2942
2435
2943−/// The text of a verbatim body (`@head`, `@style`, `@markdown`, `@raw`).
2436+/// Suggest the closest variable name for undefined `$var` references.
2944−fn verbatim_text(children: &[syntax::Node]) -> String {
2437+fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<String> {
2945− let mut text = String::new();
2438+ let input_chars: Vec<char> = input.chars().collect();
2946− for child in children {
2439+ let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
2947− if let NodeKind::Verbatim(body) = &child.kind {
2440+ let mut best: Option<String> = None;
2948− text.push_str(&body.text);
2441+ let mut best_dist = usize::MAX;
2949− text.push('\n');
2442+ for name in vars.keys() {
2443+ let nlen = name.chars().count();
2444+ if nlen.abs_diff(input_chars.len()) > max_allowed {
2445+ continue;
2446+ }
2447+ let name_chars: Vec<char> = name.chars().collect();
2448+ let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
2449+ if dist < best_dist && dist <= max_allowed {
2450+ best_dist = dist;
2451+ best = Some(name.clone());
2950 }
2452 }
2951 }
2453 }
2952− text
2454+ best
2953}
2455}
2954
2456
2955−// ---------------------------------------------------------------------------
2457+/// Check for undefined `$var` references and return "did you mean?" diagnostics.
2956−// @children replacement
2458+/// `indent` is the line's leading whitespace, which `input` has had trimmed;
2957−// ---------------------------------------------------------------------------
2459+/// reported columns and source lines include it.
2958−
2460+fn check_undefined_vars(
2959−fn replace_children_and_slots(
2461+ input: &str,
2960− nodes: Vec<Node>,
2462+ vars: &HashMap<String, String>,
2961− caller_children: &[Node],
2463+ line_num: usize,
2962− slot_contents: &HashMap<String, Vec<Node>>,
2464+ indent: usize,
2963−) -> Vec<Node> {
2465+) -> Vec<Diagnostic> {
2964− let mut result = Vec::new();
2466+ let mut warnings = Vec::new();
2965− for node in nodes {
2467+ if !input.contains('$') {
2966− match node {
2468+ return warnings;
2967− Node::Element(elem) if elem.kind == ElementKind::Children => {
2469+ }
2968− if caller_children.is_empty() && !elem.children.is_empty() {
2470+ let chars: Vec<char> = input.chars().collect();
2969− // Use @children's own children as default/fallback content
2471+ let mut i = 0;
2970− result.extend(elem.children);
2472+ while i < chars.len() {
2971− } else {
2473+ if chars[i] == '$'
2972− result.extend(caller_children.iter().cloned());
2474+ && i + 1 < chars.len()
2973− }
2475+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
2974− }
2476+ {
2975− Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) =>
2477+ let col = i;
2478+ let start = i + 1;
2479+ let mut end = start;
2480+ while end < chars.len()
2481+ && (chars[end].is_alphanumeric()
2482+ || chars[end] == '-'
2483+ || chars[end] == '_'
2484+ || chars[end] == '.')
2976 {
2485 {
2977− if let ElementKind::Slot(ref name) = elem.kind {
2486+ end += 1;
2978− if let Some(content) = slot_contents.get(name) {
2979− result.extend(content.iter().cloned());
2980− }
2981− // If no content provided for this slot, use the slot's own children as default
2982− else if !elem.children.is_empty() {
2983− result.extend(elem.children);
2984− }
2985− }
2986 }
2487 }
2987− Node::Element(mut elem) => {
2488+ while end > start && chars[end - 1] == '.' {
2988− elem.children =
2489+ end -= 1;
2989− replace_children_and_slots(elem.children, caller_children, slot_contents);
2990− result.push(Node::Element(elem));
2991 }
2490 }
2992− other => result.push(other),
2491+ let name: String = chars[start..end].iter().collect();
2492+ if !name.is_empty()
2493+ && !vars.contains_key(&name)
2494+ && let Some(closest) = suggest_var_name(&name, vars)
2495+ {
2496+ warnings.push(Diagnostic {
2497+ line: line_num,
2498+ column: Some(indent + col),
2499+ message: format!(
2500+ "undefined variable '${}', did you mean '${}'?",
2501+ name, closest
2502+ ),
2503+ severity: Severity::Warning,
2504+ source_line: Some(format!("{}{}", " ".repeat(indent), input)),
2505+ });
2506+ }
2507+ i = end;
2508+ } else {
2509+ i += 1;
2993 }
2510 }
2994 }
2511 }
2995− result
2512+ warnings
2996}
2513}
2997
2514
2998// ---------------------------------------------------------------------------
2515// ---------------------------------------------------------------------------
2999−// Where @slot and @children are written
2516+// Attribute parsing
3000// ---------------------------------------------------------------------------
2517// ---------------------------------------------------------------------------
3001
2518
3002−/// The files each `@include` line brought in, by the line's node id, with
2519+/// Attributes that expect purely numeric values (px-based) or values with CSS units.
3003−/// the include chain that leads to each.
2520+const NUMERIC_ATTRS: &[&str] = &[
3004−type IncludedBy = HashMap<usize, Vec<(Rc<Tree>, String)>>;
2521+ "spacing",
2522+ "gap",
2523+ "padding",
2524+ "padding-x",
2525+ "padding-y",
2526+ "padding-top",
2527+ "padding-bottom",
2528+ "padding-left",
2529+ "padding-right",
2530+ "min-width",
2531+ "max-width",
2532+ "min-height",
2533+ "max-height",
2534+ "rounded",
2535+ "size",
2536+ "column-gap",
2537+ "row-gap",
2538+ "top",
2539+ "right",
2540+ "bottom",
2541+ "left",
2542+ "letter-spacing",
2543+];
3005
2544
3006−/// The lines of the files an `@include` line brings in, and the include
2545+const CSS_UNIT_SUFFIXES: &[&str] = &[
3007−/// chain of each (for diagnostics).
2546+ "px", "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm",
3008−type Included<'a, 'f> = &'f dyn Fn(&syntax::Node) -> Vec<(&'a [syntax::Node], Option<&'a str>)>;
2547+ "mm", "in", "pt", "pc", "fr",
2548+];
3009
2549
3010−/// Whether `name` is a function here: defined so far, or anywhere in the
2550+fn has_css_unit(value: &str) -> bool {
3011−/// file and the files it includes.
2551+ CSS_UNIT_SUFFIXES.iter().any(|u| value.ends_with(u))
3012−fn is_function(name: &str, ctx: &ParseContext) -> bool {
2552+ || value.starts_with("var(")
3013− ctx.env.function(name).is_some() || ctx.namespace.contains(name)
2553+ || value.starts_with("calc(")
2554+ || value.starts_with("clamp(")
2555+ || value.starts_with("min(")
2556+ || value.starts_with("max(")
2557+}
2558+
2559+/// Attributes that accept numeric OR keyword values.
2560+const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
2561+const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
2562+
2563+fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext) {
2564+ let base_key = crate::vocab::base_attribute(attr.key.as_str());
2565+
2566+ if let Some(val) = &attr.value {
2567+ if NUMERIC_ATTRS.contains(&base_key) {
2568+ // All space-separated parts must be numeric or have a CSS unit
2569+ for part in val.split_whitespace() {
2570+ if part.parse::<f64>().is_err() && !has_css_unit(part) {
2571+ ctx.diagnostics.push(Diagnostic {
2572+ line: line_num,
2573+ column: None,
2574+ message: format!(
2575+ "'{}' expects a numeric value (with optional unit), got '{}'",
2576+ attr.key, val
2577+ ),
2578+ severity: Severity::Warning,
2579+ source_line: None,
2580+ });
2581+ return;
2582+ }
2583+ }
2584+ } else if NUMERIC_OR_KEYWORD_ATTRS.contains(&base_key) {
2585+ let is_keyword = SIZE_KEYWORDS.contains(&val.as_str());
2586+ let is_numeric = val.parse::<f64>().is_ok();
2587+ let has_unit = has_css_unit(val);
2588+ if !is_keyword && !is_numeric && !has_unit {
2589+ ctx.diagnostics.push(Diagnostic {
2590+ line: line_num,
2591+ column: None,
2592+ message: format!(
2593+ "'{}' expects a number or one of [{}], got '{}'",
2594+ attr.key,
2595+ SIZE_KEYWORDS.join(", "),
2596+ val
2597+ ),
2598+ severity: Severity::Warning,
2599+ source_line: None,
2600+ });
2601+ }
2602+ } else if base_key == "opacity" {
2603+ if let Ok(v) = val.parse::<f64>() {
2604+ if !(0.0..=1.0).contains(&v) {
2605+ ctx.diagnostics.push(Diagnostic {
2606+ line: line_num,
2607+ column: None,
2608+ message: format!("'opacity' should be between 0 and 1, got '{}'", val),
2609+ severity: Severity::Warning,
2610+ source_line: None,
2611+ });
2612+ }
2613+ } else {
2614+ ctx.diagnostics.push(Diagnostic {
2615+ line: line_num,
2616+ column: None,
2617+ message: format!("'opacity' expects a numeric value, got '{}'", val),
2618+ severity: Severity::Warning,
2619+ source_line: None,
2620+ });
2621+ }
2622+ } else if base_key == "z-index" {
2623+ if val.parse::<i32>().is_err() {
2624+ ctx.diagnostics.push(Diagnostic {
2625+ line: line_num,
2626+ column: None,
2627+ message: format!("'z-index' expects an integer, got '{}'", val),
2628+ severity: Severity::Warning,
2629+ source_line: None,
2630+ });
2631+ }
2632+ } else if base_key == "display" {
2633+ const DISPLAY_VALUES: &[&str] = &[
2634+ "none",
2635+ "block",
2636+ "inline",
2637+ "inline-block",
2638+ "flex",
2639+ "inline-flex",
2640+ "grid",
2641+ "inline-grid",
2642+ "table",
2643+ "table-row",
2644+ "table-cell",
2645+ "contents",
2646+ "flow-root",
2647+ "list-item",
2648+ ];
2649+ if !DISPLAY_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
2650+ let suggestion = suggest_closest(val, DISPLAY_VALUES);
2651+ let msg = match suggestion {
2652+ Some(s) => format!("unknown display value '{}', did you mean '{}'?", val, s),
2653+ None => format!("unknown display value '{}'", val),
2654+ };
2655+ ctx.diagnostics.push(Diagnostic {
2656+ line: line_num,
2657+ column: None,
2658+ message: msg,
2659+ severity: Severity::Warning,
2660+ source_line: None,
2661+ });
2662+ }
2663+ } else if base_key == "position" {
2664+ const POSITION_VALUES: &[&str] = &["static", "relative", "absolute", "fixed", "sticky"];
2665+ if !POSITION_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
2666+ let suggestion = suggest_closest(val, POSITION_VALUES);
2667+ let msg = match suggestion {
2668+ Some(s) => format!("unknown position value '{}', did you mean '{}'?", val, s),
2669+ None => format!("unknown position value '{}'", val),
2670+ };
2671+ ctx.diagnostics.push(Diagnostic {
2672+ line: line_num,
2673+ column: None,
2674+ message: msg,
2675+ severity: Severity::Warning,
2676+ source_line: None,
2677+ });
2678+ }
2679+ } else if base_key == "overflow" || base_key == "overflow-x" || base_key == "overflow-y" {
2680+ const OVERFLOW_VALUES: &[&str] = &["visible", "hidden", "scroll", "auto", "clip"];
2681+ if !OVERFLOW_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
2682+ let suggestion = suggest_closest(val, OVERFLOW_VALUES);
2683+ let msg = match suggestion {
2684+ Some(s) => format!("unknown overflow value '{}', did you mean '{}'?", val, s),
2685+ None => format!("unknown overflow value '{}'", val),
2686+ };
2687+ ctx.diagnostics.push(Diagnostic {
2688+ line: line_num,
2689+ column: None,
2690+ message: msg,
2691+ severity: Severity::Warning,
2692+ source_line: None,
2693+ });
2694+ }
2695+ } else if base_key == "text-align" {
2696+ const TEXT_ALIGN_VALUES: &[&str] =
2697+ &["left", "right", "center", "justify", "start", "end"];
2698+ if !TEXT_ALIGN_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
2699+ let suggestion = suggest_closest(val, TEXT_ALIGN_VALUES);
2700+ let msg = match suggestion {
2701+ Some(s) => format!("unknown text-align value '{}', did you mean '{}'?", val, s),
2702+ None => format!("unknown text-align value '{}'", val),
2703+ };
2704+ ctx.diagnostics.push(Diagnostic {
2705+ line: line_num,
2706+ column: None,
2707+ message: msg,
2708+ severity: Severity::Warning,
2709+ source_line: None,
2710+ });
2711+ }
2712+ } else if base_key == "cursor" {
2713+ const CURSOR_VALUES: &[&str] = &[
2714+ "auto",
2715+ "default",
2716+ "none",
2717+ "pointer",
2718+ "wait",
2719+ "text",
2720+ "move",
2721+ "not-allowed",
2722+ "crosshair",
2723+ "grab",
2724+ "grabbing",
2725+ "help",
2726+ "progress",
2727+ "col-resize",
2728+ "row-resize",
2729+ "n-resize",
2730+ "s-resize",
2731+ "e-resize",
2732+ "w-resize",
2733+ "zoom-in",
2734+ "zoom-out",
2735+ "context-menu",
2736+ "cell",
2737+ "copy",
2738+ "alias",
2739+ "no-drop",
2740+ ];
2741+ if !CURSOR_VALUES.contains(&val.as_str())
2742+ && !val.starts_with("url(")
2743+ && !val.starts_with("var(")
2744+ {
2745+ let suggestion = suggest_closest(val, CURSOR_VALUES);
2746+ let msg = match suggestion {
2747+ Some(s) => format!("unknown cursor value '{}', did you mean '{}'?", val, s),
2748+ None => format!("unknown cursor value '{}'", val),
2749+ };
2750+ ctx.diagnostics.push(Diagnostic {
2751+ line: line_num,
2752+ column: None,
2753+ message: msg,
2754+ severity: Severity::Warning,
2755+ source_line: None,
2756+ });
2757+ }
2758+ } else if base_key == "font-weight" {
2759+ const WEIGHT_VALUES: &[&str] = &[
2760+ "normal", "bold", "bolder", "lighter", "100", "200", "300", "400", "500", "600",
2761+ "700", "800", "900",
2762+ ];
2763+ if !WEIGHT_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
2764+ ctx.diagnostics.push(Diagnostic {
2765+ line: line_num,
2766+ column: None,
2767+ message: format!(
2768+ "'font-weight' expects a weight keyword or number 100-900, got '{}'",
2769+ val
2770+ ),
2771+ severity: Severity::Warning,
2772+ source_line: None,
2773+ });
2774+ }
2775+ } else if base_key == "color" || base_key == "background" {
2776+ // Validate named CSS colors (only if not hex, rgb, hsl, var, etc.)
2777+ if !val.starts_with('#')
2778+ && !val.starts_with("rgb")
2779+ && !val.starts_with("hsl")
2780+ && !val.starts_with("var(")
2781+ && !val.starts_with("linear-gradient")
2782+ && !val.starts_with("radial-gradient")
2783+ && !val.starts_with("conic-gradient")
2784+ && !val.starts_with("oklch")
2785+ && !val.starts_with("oklab")
2786+ && !val.starts_with("color(")
2787+ && !val.starts_with("light-dark(")
2788+ && !val.contains("url(")
2789+ && !val.contains(' ')
2790+ // skip shorthand multi-value
2791+ {
2792+ const NAMED_COLORS: &[&str] = &[
2793+ "transparent",
2794+ "currentcolor",
2795+ "inherit",
2796+ "initial",
2797+ "unset",
2798+ "black",
2799+ "white",
2800+ "red",
2801+ "green",
2802+ "blue",
2803+ "yellow",
2804+ "orange",
2805+ "purple",
2806+ "pink",
2807+ "brown",
2808+ "gray",
2809+ "grey",
2810+ "cyan",
2811+ "magenta",
2812+ "lime",
2813+ "olive",
2814+ "navy",
2815+ "teal",
2816+ "aqua",
2817+ "fuchsia",
2818+ "maroon",
2819+ "silver",
2820+ "coral",
2821+ "salmon",
2822+ "tomato",
2823+ "crimson",
2824+ "firebrick",
2825+ "darkred",
2826+ "indigo",
2827+ "violet",
2828+ "plum",
2829+ "orchid",
2830+ "thistle",
2831+ "lavender",
2832+ "gold",
2833+ "khaki",
2834+ "wheat",
2835+ "tan",
2836+ "sienna",
2837+ "chocolate",
2838+ "peru",
2839+ "beige",
2840+ "ivory",
2841+ "linen",
2842+ "snow",
2843+ "seashell",
2844+ "mintcream",
2845+ "skyblue",
2846+ "steelblue",
2847+ "royalblue",
2848+ "dodgerblue",
2849+ "cornflowerblue",
2850+ "slategray",
2851+ "slategrey",
2852+ "dimgray",
2853+ "dimgrey",
2854+ "lightgray",
2855+ "lightgrey",
2856+ "darkgray",
2857+ "darkgrey",
2858+ "gainsboro",
2859+ "whitesmoke",
2860+ ];
2861+ if !NAMED_COLORS.contains(&val.to_lowercase().as_str()) {
2862+ let lower = val.to_lowercase();
2863+ let suggestion = suggest_closest(&lower, NAMED_COLORS);
2864+ let msg = match suggestion {
2865+ Some(s) => format!("unknown color '{}', did you mean '{}'?", val, s),
2866+ None => format!("unknown color '{}'", val),
2867+ };
2868+ ctx.diagnostics.push(Diagnostic {
2869+ line: line_num,
2870+ column: None,
2871+ message: msg,
2872+ severity: Severity::Warning,
2873+ source_line: None,
2874+ });
2875+ }
2876+ }
2877+ }
2878+ }
3014}
2879}
3015
2880
3016−/// Whether `name` is one slot name: letters, digits, `-` and `_`,
2881+fn parse_attr_brackets(
3017−/// starting with a letter (like a function's name).
2882+ input: &str,
3018−fn is_slot_name(name: &str) -> bool {
2883+ line_num: usize,
3019− name.chars().next().is_some_and(char::is_alphabetic)
2884+ ctx: &mut ParseContext,
3020− && name
2885+) -> Result<(Vec<Attribute>, String), ParseError> {
3021− .chars()
2886+ parse_attr_brackets_inner(input, line_num, ctx, true)
3022− .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
3023}
2887}
3024
2888
3025−/// What a `@slot` or `@children` line is written in.
2889+fn parse_attr_brackets_no_validate(
3026−#[derive(Clone, Copy)]
2890+ input: &str,
3027−enum Place<'a> {
2891+ line_num: usize,
3028− /// The top of a file or of a function's body, or a built-in element
2892+ ctx: &mut ParseContext,
3029− /// (by its name).
2893+) -> Result<(Vec<Attribute>, String), ParseError> {
3030− Element(Option<&'a str>),
2894+ parse_attr_brackets_inner(input, line_num, ctx, false)
3031− /// A call: its content.
3032− Call,
3033}
2895}
3034
2896
3035−/// Where a line is, for [`slot_uses`].
2897+fn parse_attr_brackets_inner(
3036−#[derive(Clone, Copy)]
2898+ input: &str,
3037−struct At<'a> {
3038− /// Inside a function's body.
3039− body: bool,
3040− place: Place<'a>,
3041− /// Outside a body: the call (and its line) whose content it is in.
3042− call: Option<(&'a str, usize)>,
3043− /// The include chain of the file it is in, when that isn't the file
3044− /// being compiled.
3045− file: Option<&'a str>,
3046−}
3047−
3048−impl At<'_> {
3049− /// The top of a file, or of a function's body.
3050− fn top(body: bool) -> Self {
3051− At {
3052− body,
3053− place: Place::Element(None),
3054− call: None,
3055− file: None,
3056− }
3057− }
3058−}
3059−
3060−/// What [`slot_uses`] knows about the names and files it walks.
3061−struct Scan<'a, 'f> {
3062− is_function: &'f dyn Fn(&str) -> bool,
3063− included: Included<'a, 'f>,
3064− /// Visit the bodies of the functions defined in the lines too.
3065− into_lets: bool,
3066−}
3067−
3068−/// A `@slot` or `@children` written in a file, and where.
3069−struct SlotUse<'a> {
3070− node: &'a syntax::Node,
3071− head: &'a syntax::Head,
3072− /// The text after it, which is a slot's name; `None` when it isn't
3073− /// the last head of its chain.
3074− text: Option<&'a syntax::Text>,
3075− /// Written inline in a line of text, `{@slot x}`.
3076− inline: bool,
3077− at: At<'a>,
3078−}
3079−
3080−impl SlotUse<'_> {
3081− fn is_slot(&self) -> bool {
3082− self.head.name == "slot"
3083− }
3084−
3085− /// The slot's name as written.
3086− fn name(&self) -> &str {
3087− self.text.map_or("", |t| t.raw.trim())
3088− }
3089−
3090− /// A `@slot NAME` that marks a place in a function's body (not one
3091− /// that fills a slot of a call).
3092− fn declares(&self) -> bool {
3093− self.is_slot() && !self.inline && !matches!(self.at.place, Place::Call)
3094− }
3095−}
3096−
3097−/// Where each `@slot` and `@children` in `block` is written. A `@slot`
3098−/// directly under a call (under its `@if`, `@else` and `@each` too) fills
3099−/// one of its slots; anywhere else in a function's body it declares one.
3100−/// An included file's lines are where its `@include` is.
3101−fn slot_uses<'a>(
3102− block: &'a [syntax::Node],
3103− at: At<'a>,
3104− scan: &Scan<'a, '_>,
3105− out: &mut Vec<SlotUse<'a>>,
3106−) {
3107− fn inline<'a>(
3108− text: &'a syntax::Text,
3109− node: &'a syntax::Node,
3110− at: At<'a>,
3111− scan: &Scan<'a, '_>,
3112− out: &mut Vec<SlotUse<'a>>,
3113− ) {
3114− for segment in &text.segments {
3115− if let Segment::Inline(inline_element) = segment {
3116− let head = &inline_element.head;
3117− if matches!(head.name.as_str(), "slot" | "children")
3118− && !(scan.is_function)(&head.name)
3119− {
3120− out.push(SlotUse {
3121− node,
3122− head,
3123− text: inline_element.text.as_ref(),
3124− inline: true,
3125− at,
3126− });
3127− }
3128− if let Some(text) = &inline_element.text {
3129− inline(text, node, at, scan, out);
3130− }
3131− }
3132− }
3133− }
3134− for node in block {
3135− match &node.kind {
3136− NodeKind::Directive(directive) => match &directive.args {
3137− DirectiveArgs::Let(def) if matches!(def.form, LetForm::Function(_)) => {
3138− if scan.into_lets {
3139− let top = At {
3140− file: at.file,
3141− ..At::top(true)
3142− };
3143− slot_uses(&node.children, top, scan, out);
3144− }
3145− }
3146− // A value's body is an error of its own
3147− DirectiveArgs::Let(_) => {}
3148− _ if directive.spec.body == BodyKind::Verbatim => {}
3149− // `@if`, `@else` and `@each` put their lines in place, and
3150− // `@include` its file's; the lines under a directive that
3151− // takes no body are its siblings
3152− _ => {
3153− for (lines, file) in (scan.included)(node) {
3154− slot_uses(lines, At { file, ..at }, scan, out);
3155− }
3156− slot_uses(&node.children, at, scan, out);
3157− }
3158− },
3159− NodeKind::Element(line) => {
3160− let mut at = at;
3161− let last = line.chain.len() - 1;
3162− for (i, head) in line.chain.iter().enumerate() {
3163− let text = if i == last { line.text.as_ref() } else { None };
3164− let function = (scan.is_function)(&head.name);
3165− if !function && matches!(head.name.as_str(), "slot" | "children") {
3166− out.push(SlotUse {
3167− node,
3168− head,
3169− text,
3170− inline: false,
3171− at,
3172− });
3173− } else if let Some(text) = text {
3174− inline(text, node, at, scan, out);
3175− }
3176− if function {
3177− at.place = Place::Call;
3178− if !at.body {
3179− at.call = Some((&head.name, node.span.line));
3180− }
3181− } else {
3182− at.place = Place::Element(Some(&head.name));
3183− }
3184− }
3185− slot_uses(&node.children, at, scan, out);
3186− }
3187− NodeKind::Text(text) => {
3188− inline(text, node, at, scan, out);
3189− slot_uses(&node.children, at, scan, out);
3190− }
3191− NodeKind::Blank | NodeKind::Comment | NodeKind::Verbatim(_) => {}
3192− }
3193− }
3194−}
3195−
3196−/// The slots a function's body declares, and whether it has a
3197−/// `@children`, when it is defined. The files its `@include` lines bring
3198−/// in count too: they are read now, since they run only at a call.
3199−fn declared_slots(body: &[syntax::Node], ctx: &mut ParseContext) -> (Vec<String>, bool) {
3200− let mut files = HashMap::new();
3201− read_includes(
3202− body,
3203− ctx.base_path.clone(),
3204− &mut Vec::new(),
3205− ctx,
3206− &mut files,
3207− );
3208− let included = |node: &syntax::Node| match files.get(&node.id) {
3209− Some(tree) => vec![(&tree.nodes[..], None)],
3210− None => Vec::new(),
3211− };
3212− let is_function = |name: &str| is_function(name, ctx);
3213− slots_of(body, &is_function, &included)
3214−}
3215−
3216−/// Read and parse the files the `@include` lines in `nodes` bring in (by
3217−/// a literal path, relative to `base`), and the files those include, by
3218−/// the node id of each `@include` line.
3219−fn read_includes(
3220− nodes: &[syntax::Node],
3221− base: Option<PathBuf>,
3222− stack: &mut Vec<PathBuf>,
3223− ctx: &mut ParseContext,
3224− files: &mut HashMap<usize, Rc<Tree>>,
3225−) {
3226− let mut includes = Vec::new();
3227− for node in nodes {
3228− node.walk(&mut |node| {
3229− if let Some(DirectiveArgs::Text(Some(path))) = node.directive().map(|d| &d.args)
3230− && node.is_directive("include")
3231− {
3232− includes.push((node.id, path.raw.clone()));
3233− }
3234− });
3235− }
3236− for (id, path) in includes {
3237− let path = path.trim_matches('"');
3238− if path.contains('$') {
3239− continue;
3240− }
3241− let resolved = match &base {
3242− Some(base) => base.join(path),
3243− None => PathBuf::from(path),
3244− };
3245− if stack.contains(&resolved) {
3246− continue;
3247− }
3248− let Ok(text) = ctx.read_file(&resolved) else {
3249− continue;
3250− };
3251− let tree = ctx.parse_tree(&text);
3252− stack.push(resolved.clone());
3253− let parent = resolved.parent().map(Path::to_path_buf);
3254− read_includes(&tree.nodes, parent, stack, ctx, files);
3255− stack.pop();
3256− files.insert(id, tree);
3257− }
3258−}
3259−
3260−/// The slots a function's body declares (`@slot NAME`, in order), and
3261−/// whether it has a `@children` for the content of a call. A `@slot`
3262−/// directly under a call in the body fills that call's slot instead, so
3263−/// `is_function` says which names are functions. The files the body
3264−/// includes aren't read.
3265−pub fn function_slots(
3266− body: &[syntax::Node],
3267− is_function: &dyn Fn(&str) -> bool,
3268−) -> (Vec<String>, bool) {
3269− slots_of(body, is_function, &|_| Vec::new())
3270−}
3271−
3272−fn slots_of<'a>(
3273− body: &'a [syntax::Node],
3274− is_function: &dyn Fn(&str) -> bool,
3275− included: Included<'a, '_>,
3276−) -> (Vec<String>, bool) {
3277− let scan = Scan {
3278− is_function,
3279− included,
3280− into_lets: false,
3281− };
3282− let mut uses = Vec::new();
3283− slot_uses(body, At::top(true), &scan, &mut uses);
3284− let mut slots: Vec<String> = Vec::new();
3285− for slot in uses.iter().filter(|u| u.declares()) {
3286− let name = slot.name();
3287− if is_slot_name(name) && !slots.iter().any(|s| s == name) {
3288− slots.push(name.to_string());
3289− }
3290− }
3291− let has_children = uses.iter().any(|u| !u.is_slot() && !u.inline);
3292− (slots, has_children)
3293−}
3294−
3295−/// The line as written, indented, when it is one physical line, so a
3296−/// column points into it.
3297−fn written_line(node: &syntax::Node) -> String {
3298− match node.line_count <= 1 {
3299− true => format!("{}{}", " ".repeat(node.indent), node.source),
3300− false => node.source.clone(),
3301− }
3302−}
3303−
3304−/// The lines of the files an `@include` line brought in when it ran, from
3305−/// `included_by`.
3306−fn ran_includes<'a>(
3307− included_by: &'a IncludedBy,
3308− node: &syntax::Node,
3309−) -> Vec<(&'a [syntax::Node], Option<&'a str>)> {
3310− match included_by.get(&node.id) {
3311− Some(files) => files
3312− .iter()
3313− .map(|(tree, chain)| (&tree.nodes[..], Some(chain.as_str())))
3314− .collect(),
3315− None => Vec::new(),
3316− }
3317−}
3318−
3319−/// Check where every `@slot` and `@children` of the file and the files it
3320−/// includes is written, whether or not it runs: a slot has one name, a
3321−/// function's body marks places with them, and a call fills its slots
3322−/// with `@slot` blocks directly under it. An included file's lines are
3323−/// where its `@include` is (in a body, under a call), so an `@include`
3324−/// that never ran leaves its file unchecked.
3325−fn check_slot_places(ctx: &mut ParseContext) {
3326− let trees = std::mem::take(&mut ctx.trees);
3327− let included_by = ctx.included_by.clone();
3328− let is_function = |name: &str| is_function(name, ctx);
3329− let scan = Scan {
3330− is_function: &is_function,
3331− included: &|node| ran_includes(&included_by, node),
3332− into_lets: true,
3333− };
3334− let mut uses = Vec::new();
3335− for (tree, _) in trees.iter().filter(|(_, chain)| chain.is_none()) {
3336− slot_uses(&tree.nodes, At::top(false), &scan, &mut uses);
3337− }
3338− let found: Vec<Diagnostic> = uses
3339− .iter()
3340− .filter_map(|slot| {
3341− let mut d = slot_problem(slot)?;
3342− if d.column.is_none() {
3343− d = d.column(slot.head.name_span.column);
3344− }
3345− d = d.source(written_line(slot.node));
3346− if let Some(file) = slot.at.file {
3347− d.message = format!("{}\n in {}", d.message, file);
3348− }
3349− Some(d)
3350− })
3351− .collect();
3352− ctx.diagnostics.extend(found);
3353− ctx.trees = trees;
3354−}
3355−
3356−/// What is wrong with a `@slot` or `@children` where it is written.
3357−fn slot_problem(slot: &SlotUse) -> Option<Diagnostic> {
3358− let line = slot.node.span.line;
3359− let what = if slot.is_slot() {
3360− match slot.name() {
3361− "" => "@slot".to_string(),
3362− name => format!("@slot {}", name),
3363− }
3364− } else {
3365− "@children".to_string()
3366− };
3367− if slot.inline {
3368− return Some(Diagnostic::error(
3369− code::MISPLACED_SLOT,
3370− line,
3371− format!(
3372− "{} goes on a line of its own: inline in text it marks and fills nothing",
3373− what
3374− ),
3375− ));
3376− }
3377− if let Some(list) = &slot.head.attrs {
3378− return Some(
3379− Diagnostic::error(
3380− code::UNEXPECTED_ARGUMENT,
3381− line,
3382− format!(
3383− "{} takes no attributes: it is replaced by content, so style the \
3384− element around it",
3385− what
3386− ),
3387− )
3388− .column(list.span.column),
3389− );
3390− }
3391− if slot.is_slot() {
3392− let name = slot.name();
3393− if name.is_empty() {
3394− return Some(Diagnostic::error(
3395− code::MISSING_ARGUMENT,
3396− line,
3397− "@slot needs a name: `@slot footer` (the caller's content without a \
3398− name goes where `@children` is)"
3399− .to_string(),
3400− ));
3401− }
3402− if !is_slot_name(name) {
3403− let joined = name.split_whitespace().collect::<Vec<_>>().join("-");
3404− let suggestion = is_slot_name(&joined).then_some(joined);
3405− let column = slot.text.map(|t| t.span.column);
3406− let d = Diagnostic::error(
3407− code::INVALID_SLOT_NAME,
3408− line,
3409− format!(
3410− "'{}' is not a slot name: a slot's name is one word of letters, digits, \
3411− `-` and `_`, starting with a letter, and its content goes on the lines \
3412− under it",
3413− name
3414− ),
3415− )
3416− .subject(name)
3417− .suggest(suggestion);
3418− return Some(match column {
3419− Some(column) => d.column(column),
3420− None => d,
3421− });
3422− }
3423− if matches!(slot.at.place, Place::Call) || slot.at.body {
3424− return None;
3425− }
3426− let message = match (slot.at.call, slot.at.place) {
3427− (Some((call, call_line)), Place::Element(Some(element))) => format!(
3428− "{} is inside @{}, so it fills nothing: a @slot block that fills a slot of \
3429− @{} (line {}) goes directly under the call",
3430− what, element, call, call_line
3431− ),
3432− _ => format!(
3433− "{} is outside a function's body: `@slot NAME` marks a place in a body \
3434− (`@let @name`), and directly under a call it fills one",
3435− what
3436− ),
3437− };
3438− return Some(Diagnostic::error(code::MISPLACED_SLOT, line, message));
3439− }
3440− if slot.at.body {
3441− return None;
3442− }
3443− let message = match slot.at.call {
3444− Some((call, _)) => format!(
3445− "@children is outside a function's body, so it stands for nothing: the content \
3446− for @{} is written directly under the call",
3447− call
3448− ),
3449− None => "@children is outside a function's body: it marks where a body puts the \
3450− content of each call"
3451− .to_string(),
3452− };
3453− Some(Diagnostic::error(code::MISPLACED_SLOT, line, message))
3454−}
3455−
3456−/// A `@slot NAME` block that fills a slot of a call.
3457−struct Filler<'a> {
3458− node: &'a syntax::Node,
3459− text: &'a syntax::Text,
3460− /// The include chain of the file it is in, when that isn't the file
3461− /// being compiled.
3462− file: Option<&'a str>,
3463−}
3464−
3465−/// Whether `@slot` is the built-in element here, and not a function
3466−/// defined so far that took its name.
3467−fn slot_is_built_in(ctx: &ParseContext) -> bool {
3468− ctx.env.function("slot").is_none()
3469−}
3470−
3471−/// The `@slot NAME` blocks directly under a call, in `block` (under its
3472−/// `@if`, `@else` and `@each` too, and in the files its `@include` lines
3473−/// brought in).
3474−fn fillers<'a>(
3475− block: &'a [syntax::Node],
3476− file: Option<&'a str>,
3477− included: &'a IncludedBy,
3478− out: &mut Vec<Filler<'a>>,
3479−) {
3480− for node in block {
3481− match &node.kind {
3482− NodeKind::Directive(directive)
3483− if matches!(directive.name(), "if" | "else" | "each") =>
3484− {
3485− fillers(&node.children, file, included, out);
3486− }
3487− NodeKind::Directive(directive) if directive.name() == "include" => {
3488− for (lines, file) in ran_includes(included, node) {
3489− fillers(lines, file, included, out);
3490− }
3491− }
3492− NodeKind::Element(line) if line.chain.len() == 1 && line.chain[0].name == "slot" => {
3493− if let Some(text) = &line.text {
3494− out.push(Filler { node, text, file });
3495− }
3496− }
3497− _ => {}
3498− }
3499− }
3500−}
3501−
3502−/// Whether `block`, the lines under a call, holds content other than
3503−/// `@slot` blocks (under `@if`, `@else` and `@each` too): text, an
3504−/// element, raw HTML, Markdown or an included file. For code that doesn't
3505−/// run.
3506−fn holds_content(block: &[syntax::Node], ctx: &ParseContext) -> bool {
3507− block.iter().any(|node| match &node.kind {
3508− NodeKind::Directive(directive) => match directive.name() {
3509− "if" | "else" | "each" => holds_content(&node.children, ctx),
3510− name => matches!(name, "raw" | "markdown" | "include"),
3511− },
3512− NodeKind::Element(line) => {
3513− line.chain[0].name != "slot" || !slot_is_built_in(ctx) || line.chain.len() > 1
3514− }
3515− NodeKind::Text(_) => true,
3516− _ => false,
3517− })
3518−}
3519−
3520−/// Check the `@slot NAME` blocks passed to each call of a line against
3521−/// the slots its function declares. `calls` has the function of each head
3522−/// of the chain that is a call.
3523−fn check_fillers(
3524− node: &syntax::Node,
3525− element: &syntax::ElementLine,
3526− calls: &[Option<(String, Rc<FnDef>)>],
3527− ctx: &mut ParseContext,
3528−) {
3529− if !slot_is_built_in(ctx) {
3530− return;
3531− }
3532− let included = ctx.included_by.clone();
3533− let last = element.chain.len() - 1;
3534− for (i, call) in calls.iter().enumerate() {
3535− let Some((name, function)) = call else {
3536− continue;
3537− };
3538− let mut found = Vec::new();
3539− if i == last {
3540− fillers(&node.children, None, &included, &mut found);
3541− } else if i + 1 == last
3542− && element.chain[last].name == "slot"
3543− && let Some(text) = &element.text
3544− {
3545− // `@card > @slot footer`
3546− found.push(Filler {
3547− node,
3548− text,
3549− file: None,
3550− });
3551− }
3552− for filler in found {
3553− let slot = filler.text.raw.trim();
3554− if !is_slot_name(slot) || function.slots.iter().any(|s| s == slot) {
3555− continue;
3556− }
3557− let mut d = unknown_slot(name, slot, &function.slots, filler.text.span.line)
3558− .column(filler.text.span.column)
3559− .source(written_line(filler.node));
3560− if let Some(file) = filler.file {
3561− d.message = format!("{}\n in {}", d.message, file);
3562− }
3563− ctx.push_once(d);
3564− }
3565− }
3566−}
3567−
3568−/// A `@slot NAME` block for a slot the function doesn't declare.
3569−fn unknown_slot(function: &str, slot: &str, slots: &[String], line: usize) -> Diagnostic {
3570− let candidates: Vec<&str> = slots.iter().map(String::as_str).collect();
3571− let suggestion = suggest_closest(slot, &candidates);
3572− let message = match (slots.is_empty(), suggestion) {
3573− (true, _) => format!(
3574− "@{} has no slot '{}': its body declares no slots (`@slot NAME`)",
3575− function, slot
3576− ),
3577− (false, Some(close)) => format!(
3578− "@{} has no slot '{}', did you mean '{}'? (its slots: {})",
3579− function,
3580− slot,
3581− close,
3582− slots.join(", ")
3583− ),
3584− (false, None) => format!(
3585− "@{} has no slot '{}' (its slots: {})",
3586− function,
3587− slot,
3588− slots.join(", ")
3589− ),
3590− };
3591− Diagnostic::error(code::UNKNOWN_SLOT, line, message)
3592− .subject(slot)
3593− .suggest(suggestion)
3594−}
3595−
3596−/// Content passed to a function whose body has no `@children`.
3597−fn no_children(function: &str, line: usize, column: Option<usize>) -> Diagnostic {
3598− let d = Diagnostic::error(
3599− code::UNEXPECTED_CONTENT,
3600− line,
3601− format!(
3602− "@{0} takes no content: its body has no `@children`, so the text and lines \
3603− passed to it would be dropped (pass text as a parameter, `@{0} [name value]`, \
3604− or add `@children` to the body)",
3605− function
3606− ),
3607− )
3608− .subject(function);
3609− match column {
3610− Some(column) => d.column(column),
3611− None => d,
3612− }
3613−}
3614−
3615−// ---------------------------------------------------------------------------
3616−// Element parsing
3617−// ---------------------------------------------------------------------------
3618−
3619−/// Build the element for `@name [attrs] text` (on its own line, in a chain,
3620−/// or inline in text).
3621−fn parse_single_element(
3622− head: &syntax::Head,
3623− text: Option<&syntax::Text>,
3624− line_num: usize,
3625− ctx: &mut ParseContext,
3626−) -> Result<Element, ParseError> {
3627− let kind = parse_element_kind(&head.name, line_num, ctx)?;
3628− let mut attrs = head.attrs.as_ref().map_or_else(Vec::new, |list| {
3629− parse_attr_list(&list.attrs, line_num, ctx, true, &[])
3630− });
3631−
3632− // The leading argument: for an element whose row names a leading
3633− // attribute (`@link`'s href, `@image`'s src, `@form`'s action, ...),
3634− // the first token of the text fills it, split before anything is
3635− // filled in, and the rest is content (an error on an element without
3636− // content). `@slot`'s whole text is its name. Any other element's text
3637− // is content, parsed like any line of text, as in `@el [padding 8]
3638− // Hello` or `@h2 Meet {@text htmlang}`; its layout decides how it
3639− // combines with the lines under it.
3640− let mut children = Vec::new();
3641− let mut argument = None;
3642− let leading = kind.arg().attributes();
3643− match text {
3644− Some(text) if !leading.is_empty() => {
3645− let (token, rest) = text.split_leading();
3646− let given = attrs
3647− .iter()
3648− .find(|a| a.html && leading.contains(&a.key.as_str()))
3649− .map(|a| (a.key.clone(), a.value.clone().unwrap_or_default()));
3650− if let Some((key, value)) = given {
3651− // The attribute wins; the text is content, as if the
3652− // element had no leading argument
3653− ctx.push_once(leading_twice(&kind, &token, &key, &value, text, ctx));
3654− if !takes_no_text(&kind) {
3655− children.push(Node::Text(text_segments(text, ctx)));
3656− }
3657− } else if let Some(d) = bad_leading_token(&kind, &token, &text.raw, ctx) {
3658− // Nothing is taken as the argument; the line is content
3659− ctx.push_once(d);
3660− if !takes_no_text(&kind) {
3661− children.push(Node::Text(text_segments(text, ctx)));
3662− }
3663− } else {
3664− let column = Some(token.span.column);
3665− if let Some(d) = text_as_url(&kind, &token, ctx) {
3666− ctx.push_once(d);
3667− }
3668− argument = Some(
3669− ctx.fill_value(&token.raw, text.span.line, column, Sink::Text)
3670− .0,
3671− );
3672− if let Some(rest) = rest {
3673− if let Some(d) = chain_after_argument(&kind, &token.raw, &rest, ctx) {
3674− ctx.push_once(d);
3675− }
3676− if takes_no_text(&kind) {
3677− let d = after_the_leading_argument(&kind, &token.raw, &rest);
3678− let d = at_attribute(d, Some(rest.span.column), ctx);
3679− ctx.push_once(d);
3680− } else {
3681− children.push(Node::Text(text_segments(&rest, ctx)));
3682− }
3683− }
3684− }
3685− }
3686− Some(text) if matches!(kind, ElementKind::Slot(_)) => {
3687− argument =
3688− Some(ctx.interpolate_text(&text.raw, text.span.line, Some(text.span.column)));
3689− }
3690− Some(text) => children.push(Node::Text(text_segments(text, ctx))),
3691− // `@link [href=/about]`: the attribute is the leading argument, so
3692− // both forms compile the same (`@image [src=a.svg, inline]`). Not
3693− // for `@source`, whose attribute depends on where it is.
3694− None if leading.len() == 1 => {
3695− if let Some(i) = attrs.iter().position(|a| a.html && a.key == leading[0]) {
3696− argument = Some(attrs.remove(i).value.unwrap_or_default());
3697− }
3698− }
3699− None => {}
3700− }
3701−
3702− // For @slot, the argument is the slot name
3703− let kind = if let ElementKind::Slot(_) = kind {
3704− ElementKind::Slot(argument.clone().unwrap_or_default())
3705− } else {
3706− kind
3707− };
3708−
3709− Ok(Element {
3710− kind,
3711− attrs,
3712− argument,
3713− children,
3714− line_num,
3715− function: None,
3716− })
3717−}
3718−
3719−/// Whether `node` or a line inside it calls `@name` outside any `@if`
3720−/// (in a chain, or inline in text).
3721−fn calls_unguarded(node: &syntax::Node, name: &str) -> bool {
3722− if node.is_directive("if") || node.is_directive("else") {
3723− return false;
3724− }
3725− let text_calls = |text: &syntax::Text| {
3726− text.segments
3727− .iter()
3728− .any(|s| matches!(s, Segment::Inline(inline) if inline.head.name == name))
3729− };
3730− let here = match &node.kind {
3731− NodeKind::Element(element) => {
3732− element.chain.iter().any(|head| head.name == name)
3733− || element.text.as_ref().is_some_and(text_calls)
3734− }
3735− NodeKind::Text(text) => text_calls(text),
3736− _ => false,
3737− };
3738− here || node
3739− .children
3740− .iter()
3741− .any(|child| calls_unguarded(child, name))
3742−}
3743−
3744−/// `@td [padding 8] @children`: `@children` and `@slot NAME` mark where a
3745−/// call's content goes only on a line of their own; after an element's
3746−/// attributes, or in a verbatim body, they are text.
3747−fn marker_in_text(node: &syntax::Node) -> Option<Diagnostic> {
3748− let is_marker = |text: &str| {
3749− let text = text.trim();
3750− text == "@children" || text == "@slot" || text.starts_with("@slot ")
3751− };
3752− let mut line = node.span.line;
3753− let mut source = written_line(node);
3754− let (marker, message) = match &node.kind {
3755− NodeKind::Element(element) => {
3756− let text = element.text.as_ref()?;
3757− if !is_marker(&text.raw) {
3758− return None;
3759− }
3760− let name = &element.chain.last()?.name;
3761− let marker = text.raw.trim().to_string();
3762− let message = format!(
3763− "`{marker}` after @{name}'s attributes is text: it marks where a call's content \
3764− goes only on a line of its own, so write it on the next line, indented under \
3765− @{name}"
3766− );
3767− (marker, message)
3768− }
3769− NodeKind::Verbatim(verbatim) if verbatim.escaped => {
3770− let (i, written) = verbatim
3771− .text
3772− .lines()
3773− .enumerate()
3774− .find(|(_, l)| is_marker(l))?;
3775− let marker = written.trim().to_string();
3776− line += i;
3777− source = written.to_string();
3778− let message = format!(
3779− "`{marker}` in a verbatim body is text: the body is written as it is, so it can't \
3780− hold a call's content (pass the text as a parameter instead)"
3781− );
3782− (marker, message)
3783− }
3784− _ => return None,
3785− };
3786− Some(
3787− Diagnostic::error(code::MISPLACED_SLOT, line, message)
3788− .subject(marker)
3789− .source(source),
3790− )
3791−}
3792−
3793−/// A first word that can't be a leading argument: an inline element
3794−/// (`@link {@b x} y`), or a quote that isn't closed (`@link "a b`).
3795−fn bad_leading_token(
3796− kind: &ElementKind,
3797− token: &syntax::Arg,
3798− text: &str,
3799− ctx: &ParseContext,
3800−) -> Option<Diagnostic> {
3801− let what = leading_name(kind);
3802− let name = kind.name();
3803− let message = if token.raw.starts_with("{@") {
3804− format!(
3805− "@{0} takes its {1} first, before any text: write `@{0} VALUE {2}`, or give it as \
3806− `[{1}=VALUE]`",
3807− name,
3808− what,
3809− text.trim()
3810− )
3811− } else if token.raw.starts_with('"') && syntax::quoted_string(&token.raw).is_none() {
3812− format!(
3813− "@{}'s {} starts with a `\"` that isn't closed: close it (`\"...\"`), or write \
3814− `\\\"` for a quote in the value",
3815− name, what
3816− )
3817− } else {
3818− return None;
3819− };
3820− let d = Diagnostic::error(code::UNEXPECTED_ARGUMENT, token.span.line, message)
3821− .subject(token.raw.clone());
3822− Some(at_attribute(d, Some(token.span.column), ctx))
3823−}
3824−
3825−/// A first word taken as a URL that reads as text: a word with a capital
3826−/// and nothing a URL or a file name has, as in `@link [hx-get=/panel] Open
3827−/// panel`, whose href would be `Open`. It still is (a relative URL can be
3828−/// such a word), with a warning.
3829−fn text_as_url(kind: &ElementKind, token: &syntax::Arg, ctx: &ParseContext) -> Option<Diagnostic> {
3830− const URLS: &[&str] = &["href", "src", "srcset", "action", "data"];
3831− let word = token.raw.as_str();
3832− let reads_as_text = word.starts_with(char::is_uppercase)
3833− && !word.contains([
3834− '/', '.', ':', '#', '?', '=', '&', '%', '~', '$', '{', '\\', '"',
3835− ]);
3836− if !reads_as_text || !kind.arg().attributes().iter().any(|a| URLS.contains(a)) {
3837− return None;
3838− }
3839− let name = kind.name();
3840− let attr = leading_name(kind);
3841− let text = if takes_no_text(kind) {
3842− String::new()
3843− } else {
3844− format!(": write the {attr} before the text, or put the text on the lines under it")
3845− };
3846− let message = format!(
3847− "@{name} takes '{word}', its first word, as its {attr}{text}. A relative URL that \
3848− starts with a capital is written `./{word}`"
3849− );
3850− let d = Diagnostic::warning(code::TEXT_AS_URL, token.span.line, message).subject(word);
3851− Some(at_attribute(d, Some(token.span.column), ctx))
3852−}
3853−
3854−/// `@link /a > @image logo.png`: a `>` chains only after an element's
3855−/// name or its `[attributes]`, so after a leading argument it is text.
3856−/// Said when what follows names an element or a function.
3857−fn chain_after_argument(
3858− kind: &ElementKind,
3859− token: &str,
3860− rest: &syntax::Text,
3861− ctx: &ParseContext,
3862−) -> Option<Diagnostic> {
3863− let after = rest.raw.strip_prefix('>')?;
3864− let name_start = after.trim_start().strip_prefix('@')?;
3865− if after.len() == after.trim_start().len() {
3866− return None;
3867− }
3868− let name: String = name_start
3869− .chars()
3870− .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
3871− .collect();
3872− if ElementKind::from_name(&name).is_none() && ctx.env.function(&name).is_none() {
3873− return None;
3874− }
3875− let attr = leading_name(kind);
3876− let d = Diagnostic::warning(
3877− code::UNEXPECTED_ARGUMENT,
3878− rest.span.line,
3879− format!(
3880− "`{}` after @{}'s {} is text, not a chain: `>` chains after an element's name or \
3881− its [attributes], so write `@{} [{}={}] {}`",
3882− rest.raw.trim_end(),
3883− kind.name(),
3884− attr,
3885− kind.name(),
3886− attr,
3887− token,
3888− rest.raw.trim_end()
3889− ),
3890− )
3891− .subject(rest.raw.trim_end());
3892− Some(at_attribute(d, Some(rest.span.column), ctx))
3893−}
3894−
3895−/// A leading argument given twice, as the first token and as its
3896−/// attribute: `@link [href=/a] About`.
3897−fn leading_twice(
3898− kind: &ElementKind,
3899− token: &syntax::Arg,
3900− key: &str,
3901− value: &str,
3902− text: &syntax::Text,
3903− ctx: &ParseContext,
3904−) -> Diagnostic {
3905− let name = kind.name();
3906− let what = leading_name(kind);
3907− // The value as one token: quoted when it is empty or has a space in it
3908− let value = if !value.is_empty() && syntax::leading_token_len(value) == value.len() {
3909− value.to_string()
3910− } else {
3911− format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
3912− };
3913− let written = if takes_no_text(kind) {
3914− format!("@{} {}", name, value)
3915− } else {
3916− format!("@{} {} {}", name, value, text.raw)
3917− };
3918− let d = Diagnostic::error(
3919− code::DUPLICATE_ATTRIBUTE,
3920− text.span.line,
3921− format!(
3922− "@{} takes its first word '{}' as its {}, and it also has {}=: give it once, \
3923− as `{}`",
3924− name, token.raw, what, key, written
3925− ),
3926− )
3927− .subject(token.raw.clone());
3928− at_attribute(d, Some(token.span.column), ctx)
3929−}
3930−
3931−/// The attribute an element's leading argument fills, for messages.
3932−fn leading_name(kind: &ElementKind) -> &'static str {
3933− match kind.arg() {
3934− TagArg::Source => "srcset in @picture or src elsewhere",
3935− arg => arg.attribute(false).unwrap_or("argument"),
3936− }
3937−}
3938−
3939−/// Whether text after an element's leading argument has nowhere to go:
3940−/// an element without content (`@image`, `@source`, ...), or `@optgroup`,
3941−/// which holds only `@option` lines, so `@optgroup Citrus fruits` would
3942−/// otherwise make `Citrus` the label and drop `fruits`.
3943−fn takes_no_text(kind: &ElementKind) -> bool {
3944− kind.layout() == Layout::Void || kind.is_tag("optgroup")
3945−}
3946−
3947−/// Text after the leading argument of an element without content:
3948−/// `@image logo.png Our logo`.
3949−fn after_the_leading_argument(kind: &ElementKind, token: &str, rest: &syntax::Text) -> Diagnostic {
3950− let name = kind.name();
3951− let attr = leading_name(kind);
3952− let alt = if matches!(kind, ElementKind::Image) || kind.is_tag("area") {
3953− " For a text alternative, write `alt=...`."
3954− } else {
3955− ""
3956− };
3957− let holds = if kind.layout() == Layout::Void {
3958− "it has no content"
3959− } else {
3960− "it holds only the elements on the lines under it"
3961− };
3962− let bare = syntax::quoted_string(token).unwrap_or(token);
3963− Diagnostic::error(
3964− code::UNEXPECTED_ARGUMENT,
3965− rest.span.line,
3966− format!(
3967− "@{0} takes one word after its attributes, its {1} ('{2}'), and {5}, so '{3}' \
3968− would go nowhere. A value with a space in it is quoted: `\"{6} {3}\"`.{4}",
3969− name, attr, token, rest.raw, alt, holds, bare
3970− ),
3971− )
3972− .subject(rest.raw.clone())
3973−}
3974−
3975−/// The element named `name`, or an "unknown element" error that suggests
3976−/// the closest element, directive or function.
3977−fn parse_element_kind(
3978− name: &str,
3979− line_num: usize,
3980− ctx: &mut ParseContext,
3981−) -> Result<ElementKind, ParseError> {
3982− if let Some(kind) = ElementKind::from_name(name) {
3983− return Ok(kind);
3984− }
3985− // A known name in a place it can't go: say why instead of suggesting
3986− // the name itself.
3987− let misplaced = if crate::ast::directive(name).is_some() {
3988− Some(format!(
3989− "unknown element @{}: @{} is a directive, which goes at the start of its own line",
3990− name, name
3991− ))
3992− } else if ctx.env.bundle(name).is_some() {
3993− Some(format!(
3994− "unknown element @{}: '{}' is an attribute bundle, used as `[${}]` \
3995− (a function is defined with `@let @{}`)",
3996− name, name, name, name
3997− ))
3998− } else if ctx.env.value(name).is_some() {
3999− Some(format!(
4000− "unknown element @{}: '{}' is a value, used as `${}` \
4001− (a function is defined with `@let @{}`)",
4002− name, name, name, name
4003− ))
4004− } else if ctx.namespace.contains(name) {
4005− ctx.used_functions.insert(name.to_string());
4006− Some(format!(
4007− "unknown element @{}: the function @{} isn't visible here. A definition is \
4008− visible from its line to the end of its block, and a function's body sees what \
4009− is defined above the function, so define @{} above this line, outside the block \
4010− it is in",
4011− name, name, name
4012− ))
4013− } else if matches!(name, "svg" | "math" | "template") {
4014− Some(format!(
4015− "unknown element @{}: htmlang doesn't write <{}>; put its markup in the page with \
4016− @raw{}",
4017− name,
4018− name,
4019− if name == "svg" {
4020− " (or an SVG file with `@image [inline] file.svg`)"
4021− } else {
4022− ""
4023− }
4024− ))
4025− } else {
4026− None
4027− };
4028− if let Some(message) = misplaced {
4029− return Err(Diagnostic::error(code::UNKNOWN_ELEMENT, line_num, message).subject(name));
4030− }
4031− let all_known: Vec<&str> = ElementKind::all_names()
4032− .chain(DIRECTIVES.iter().map(|d| d.name))
4033− .collect();
4034− // An HTML element htmlang writes under its own name (`@a` is `@link`).
4035− let written_otherwise = crate::ast::HTML_NAMES_WRITTEN_OTHERWISE
4036− .iter()
4037− .find(|(html, _)| *html == name)
4038− .map(|(_, htmlang)| *htmlang);
4039− let closest = written_otherwise.or_else(|| suggest_closest(name, &all_known));
4040− let mut message = match (closest, written_otherwise) {
4041− (Some(closest), Some(_)) => format!(
4042− "unknown element @{}, did you mean @{}? (@{} writes <{}>)",
4043− name, closest, closest, name
4044− ),
4045− (Some(closest), None) => format!("unknown element @{}, did you mean @{}?", name, closest),
4046− (None, _) => format!("unknown element @{}", name),
4047− };
4048− let function = suggest_fn_name(name, ctx);
4049− if let Some(function) = &function {
4050− message = format!("{}, or did you mean @{}?", message, function);
4051− }
4052− Err(Diagnostic::error(code::UNKNOWN_ELEMENT, line_num, message)
4053− .subject(name)
4054− .suggest(closest.map(str::to_string).or(function)))
4055−}
4056−
4057−/// Format include/import stack as a readable chain for error messages.
4058−fn format_include_chain(stack: &[PathBuf]) -> String {
4059− stack
4060− .iter()
4061− .map(|p| {
4062− p.file_name()
4063− .unwrap_or_default()
4064− .to_string_lossy()
4065− .to_string()
4066− })
4067− .collect::<Vec<_>>()
4068− .join(" → ")
4069−}
4070−
4071−/// Levenshtein distance with a rolling two-row buffer (O(min(a,b)) memory) and an
4072−/// early-exit cutoff: if every cell in a row exceeds `cutoff`, no further cell can
4073−/// be <= `cutoff`, so we return `cutoff + 1` immediately. Used for typo-suggestion
4074−/// hot paths where we only care about distances <= 2.
4075−fn levenshtein_bounded(a: &[char], b: &[char], cutoff: usize) -> usize {
4076− let (a, b) = if a.len() > b.len() { (b, a) } else { (a, b) };
4077− if b.len() - a.len() > cutoff {
4078− return cutoff + 1;
4079− }
4080− if a.is_empty() {
4081− return b.len();
4082− }
4083−
4084− let mut prev: Vec<usize> = (0..=a.len()).collect();
4085− let mut curr: Vec<usize> = vec![0; a.len() + 1];
4086−
4087− for (j, &bc) in b.iter().enumerate() {
4088− curr[0] = j + 1;
4089− let mut row_min = curr[0];
4090− for (i, &ac) in a.iter().enumerate() {
4091− let cost = if ac == bc { 0 } else { 1 };
4092− curr[i + 1] = (prev[i + 1] + 1).min(curr[i] + 1).min(prev[i] + cost);
4093− row_min = row_min.min(curr[i + 1]);
4094− }
4095− if row_min > cutoff {
4096− return cutoff + 1;
4097− }
4098− std::mem::swap(&mut prev, &mut curr);
4099− }
4100− prev[a.len()]
4101−}
4102−
4103−fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
4104− let input_chars: Vec<char> = input.chars().collect();
4105− let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
4106− let mut best = None;
4107− let mut best_dist = usize::MAX;
4108− for &candidate in candidates {
4109− // Cheap length-based prune before allocating chars.
4110− let clen = candidate.chars().count();
4111− let diff = clen.abs_diff(input_chars.len());
4112− if diff > max_allowed {
4113− continue;
4114− }
4115− let cand_chars: Vec<char> = candidate.chars().collect();
4116− let dist = levenshtein_bounded(&input_chars, &cand_chars, max_allowed);
4117− if dist < best_dist && dist <= max_allowed {
4118− best_dist = dist;
4119− best = Some(candidate);
4120− }
4121− }
4122− best
4123−}
4124−
4125−/// Suggest the closest user-defined function name for typos, from the
4126−/// functions defined so far and those defined anywhere in the file.
4127−fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
4128− let input_chars: Vec<char> = input.chars().collect();
4129− let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
4130− let mut best: Option<(usize, &String)> = None;
4131− let visible: Vec<&String> = ctx.defined_functions.keys().collect();
4132− for name in visible.into_iter().chain(ctx.namespace.iter()) {
4133− let nlen = name.chars().count();
4134− if nlen.abs_diff(input_chars.len()) > max_allowed {
4135− continue;
4136− }
4137− let name_chars: Vec<char> = name.chars().collect();
4138− let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
4139− if dist <= max_allowed && best.is_none_or(|b| (dist, name) < b) {
4140− best = Some((dist, name));
4141− }
4142− }
4143− best.map(|(_, name)| name.clone())
4144−}
4145−
4146−/// Suggest the closest variable name for undefined `$var` references.
4147−fn suggest_var_name(input: &str, vars: &[&str]) -> Option<String> {
4148− let input_chars: Vec<char> = input.chars().collect();
4149− let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
4150− let mut best: Option<String> = None;
4151− let mut best_dist = usize::MAX;
4152− let mut vars = vars.to_vec();
4153− // The same answer every time, whatever the order of the names
4154− vars.sort_unstable();
4155− for name in vars {
4156− let nlen = name.chars().count();
4157− if nlen.abs_diff(input_chars.len()) > max_allowed {
4158− continue;
4159− }
4160− let name_chars: Vec<char> = name.chars().collect();
4161− let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
4162− if dist < best_dist && dist <= max_allowed {
4163− best_dist = dist;
4164− best = Some(name.to_string());
4165− }
4166− }
4167− best
4168−}
4169−
4170−// ---------------------------------------------------------------------------
4171−// Attribute parsing
4172−// ---------------------------------------------------------------------------
4173−
4174−/// The quoted strings of a CSS value, without their quotes.
4175−fn css_strings(value: &str) -> Vec<&str> {
4176− let mut strings = Vec::new();
4177− let mut start = None;
4178− let mut escaped = false;
4179− for (i, c) in value.char_indices() {
4180− match (c, start) {
4181− _ if escaped => escaped = false,
4182− ('\\', Some(_)) => escaped = true,
4183− ('"', None) => start = Some(i + 1),
4184− ('"', Some(from)) => {
4185− strings.push(&value[from..i]);
4186− start = None;
4187− }
4188− _ => {}
4189− }
4190− }
4191− strings
4192−}
4193−
4194−/// An `if(...)` in a CSS value that isn't CSS's if(): CSS writes each
4195−/// branch `CONDITION: VALUE`, so an if() without a `:` is something else.
4196−/// Quoted CSS strings are text, not calls.
4197−fn not_css_if(value: &str) -> Option<&str> {
4198− let mut quote = None;
4199− let mut prev = None;
4200− let mut chars = value.char_indices();
4201− while let Some((at, c)) = chars.next() {
4202− match quote {
4203− Some(_) if c == '\\' => {
4204− chars.next();
4205− }
4206− Some(q) if c == q => quote = None,
4207− Some(_) => {}
4208− None if matches!(c, '"' | '\'') => quote = Some(c),
4209− None => {
4210− let word_before =
4211− prev.is_some_and(|p: char| p.is_alphanumeric() || matches!(p, '-' | '_'));
4212− if !word_before && value[at..].starts_with("if(") {
4213− let mut depth = 0;
4214− let close = value[at + 2..].char_indices().find_map(|(i, c)| {
4215− match c {
4216− '(' => depth += 1,
4217− ')' => depth -= 1,
4218− _ => {}
4219− }
4220− (depth == 0).then_some(at + 2 + i)
4221− });
4222− let end = close.map_or(value.len(), |c| c + 1);
4223− if !value[at + 3..end].contains(':') {
4224− return Some(&value[at..end]);
4225− }
4226− }
4227− }
4228− }
4229− prev = Some(c);
4230− }
4231− None
4232−}
4233−
4234−/// Why a CSS value can't be written into a rule as it is, whatever it
4235−/// means: a `;` (outside parentheses, where CSS's own `if()` uses it), a
4236−/// `{` or a `}` would end the declaration or the rule, and an unclosed
4237−/// quote or parenthesis would run on into the rules after it. Quoted
4238−/// strings are text, and a backslash escapes the character after it, as
4239−/// in CSS. A `</style` anywhere, even in a string, would end the page's
4240−/// `<style>` element and start HTML.
4241−fn css_breakout(value: &str) -> Option<&'static str> {
4242− if value.to_ascii_lowercase().contains("</style") {
4243− return Some("a `</style`, which would end the page's style element");
4244− }
4245− let mut quote = None;
4246− let mut depth = 0usize;
4247− let mut chars = value.chars();
4248− while let Some(c) = chars.next() {
4249− match (quote, c) {
4250− (_, '\\') => {
4251− chars.next();
4252− }
4253− (Some(q), c) if c == q => quote = None,
4254− (Some(_), _) => {}
4255− (None, '"' | '\'') => quote = Some(c),
4256− (None, '(') => depth += 1,
4257− (None, ')') if depth == 0 => return Some("a `)` that closes nothing"),
4258− (None, ')') => depth -= 1,
4259− (None, ';') if depth == 0 => return Some("a `;`, which would end the declaration"),
4260− (None, '{' | '}') => return Some("a `{` or `}`, which would end the CSS rule"),
4261− _ => {}
4262− }
4263− }
4264− match (quote, depth) {
4265− (Some('"'), _) => Some("a `\"` that isn't closed"),
4266− (Some(_), _) => Some("a `'` that isn't closed"),
4267− (None, 0) => None,
4268− (None, _) => Some("a `(` that isn't closed"),
4269− }
4270−}
4271−
4272−/// The hex colors of a CSS value that don't have 3, 4, 6 or 8 hex
4273−/// digits. A `#` word in a CSS value is a color, except inside `url(...)`
4274−/// and quoted strings.
4275−fn bad_hex_colors(value: &str) -> Vec<&str> {
4276− let mut bad = Vec::new();
4277− let mut quote = None;
4278− let mut in_url = 0usize;
4279− let mut depth = 0usize;
4280− let mut chars = value.char_indices().peekable();
4281− while let Some((at, c)) = chars.next() {
4282− match (quote, c) {
4283− (_, '\\') => {
4284− chars.next();
4285− }
4286− (Some(q), c) if c == q => quote = None,
4287− (Some(_), _) => {}
4288− (None, '"' | '\'') => quote = Some(c),
4289− (None, '(') => {
4290− depth += 1;
4291− if value[..at].ends_with("url") && in_url == 0 {
4292− in_url = depth;
4293− }
4294− }
4295− (None, ')') => {
4296− if in_url == depth {
4297− in_url = 0;
4298− }
4299− depth = depth.saturating_sub(1);
4300− }
4301− (None, '#') if in_url == 0 => {
4302− let end = value[at + 1..]
4303− .find(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_'))
4304− .map_or(value.len(), |n| at + 1 + n);
4305− let hex = &value[at + 1..end];
4306− if !(matches!(hex.len(), 3 | 4 | 6 | 8)
4307− && hex.chars().all(|c| c.is_ascii_hexdigit()))
4308− {
4309− bad.push(&value[at..end]);
4310− }
4311− }
4312− _ => {}
4313− }
4314− }
4315− bad
4316−}
4317−
4318−/// A diagnostic about an attribute, with its column and source line when
4319−/// it is on the line being evaluated.
4320−fn at_attribute(diagnostic: Diagnostic, column: Option<usize>, ctx: &ParseContext) -> Diagnostic {
4321− match (&ctx.current_source, column) {
4322− ((current, Some(text)), Some(column)) if *current == diagnostic.line => {
4323− diagnostic.column(column).source(text.clone())
4324− }
4325− _ => diagnostic,
4326− }
4327−}
4328−
4329−/// Check a style's value. Values go to the CSS as written, so only what
4330−/// can't be wrong in any CSS is checked: a value that would break out of
4331−/// its rule is an error (and it is left out); a hex color without 3, 4, 6
4332−/// or 8 digits, an `if()` that isn't CSS's and a quoted font stack are
4333−/// warnings. Returns whether the value can be written.
4334−fn check_css_value(
4335− attr: &Attribute,
4336− base: &str,
4337− line: usize,
4338− column: Option<usize>,
4339− ctx: &mut ParseContext,
4340−) -> bool {
4341− let Some(value) = &attr.value else {
4342− return true;
4343− };
4344− if let Some(reason) = css_breakout(value) {
4345− let diagnostic = Diagnostic::error(
4346− code::INVALID_VALUE,
4347− line,
4348− format!(
4349− "the value of '{}' has {}: `{}` can't be written into the page's CSS",
4350− attr.key, reason, value
4351− ),
4352− )
4353− .subject(value.as_str());
4354− ctx.diagnostics.push(at_attribute(diagnostic, column, ctx));
4355− return false;
4356− }
4357− if crate::vocab::is_custom_property(base) {
4358− return true;
4359− }
4360−
4361− let warn = |ctx: &mut ParseContext, diagnostic: Diagnostic| {
4362− let diagnostic = at_attribute(diagnostic, column, ctx);
4363− ctx.diagnostics.push(diagnostic);
4364− };
4365− // In CSS, a quoted font-family is one family, commas and all
4366− if base == "font-family"
4367− && let Some(family) = css_strings(value).into_iter().find(|s| s.contains(','))
4368− {
4369− let stack = family
4370− .split(',')
4371− .map(str::trim)
4372− .collect::<Vec<_>>()
4373− .join("\\, ");
4374− warn(
4375− ctx,
4376− Diagnostic::warning(
4377− code::INVALID_VALUE,
4378− line,
4379− format!(
4380− "font-family \"{}\" is one family, whose name has a comma in it: \
4381− for a font stack, write `font-family {}`",
4382− family, stack
4383− ),
4384− )
4385− .subject(format!("\"{}\"", family))
4386− .suggest(Some(stack)),
4387− );
4388− }
4389− if let Some(call) = not_css_if(value) {
4390− warn(
4391− ctx,
4392− Diagnostic::warning(
4393− code::INVALID_VALUE,
4394− line,
4395− format!(
4396− "'{}' is not CSS's if(), whose branches are written \
4397− `if(CONDITION: VALUE; else: VALUE)`",
4398− call
4399− ),
4400− )
4401− .subject(call),
4402− );
4403− }
4404− for color in bad_hex_colors(value) {
4405− warn(
4406− ctx,
4407− Diagnostic::warning(
4408− code::INVALID_COLOR,
4409− line,
4410− format!(
4411− "invalid hex color '{}': a hex color has 3, 4, 6 or 8 hex digits",
4412− color
4413− ),
4414− )
4415− .subject(color),
4416− );
4417− }
4418− true
4419−}
4420−
4421−/// An unknown prefix: the message, the text it is about, and what to write
4422−/// instead, when something is close enough.
4423−struct PrefixProblem {
4424− message: String,
4425− subject: String,
4426− suggestion: Option<String>,
4427−}
4428−
4429−/// What is wrong with the prefix `rest` starts with, when the known
4430−/// prefixes are already split off it and what is left still starts with
4431−/// one: a word and a `:` (`hovr:`), or a word and a parenthesised argument
4432−/// and a `:` (`nth-chld(2):`). Selector prefixes are CSS's pseudo-classes
4433−/// and pseudo-elements under their CSS names, so an unknown one gets the
4434−/// closest name as a suggestion. Only names with the same first letter are
4435−/// suggested: `odd:` is two edits from `md:`, which it doesn't mean.
4436−fn unknown_prefix(rest: &str) -> Option<PrefixProblem> {
4437− use crate::vocab::{self, Pseudo};
4438− let name_len = rest.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))?;
4439− let name = &rest[..name_len];
4440− if name.is_empty() {
4441− return None;
4442− }
4443− if rest[name_len..].starts_with('(') {
4444− // `name(...):`: a pseudo-class with an argument
4445− let close = vocab::closing_paren(rest, name_len)?;
4446− if !rest[close + 1..].starts_with(':') {
4447− return None;
4448− }
4449− let written = &rest[..close + 2];
4450− if matches!(vocab::pseudo(name), Some(Pseudo::Class | Pseudo::Element)) {
4451− let bare = format!("{}:", name);
4452− return Some(PrefixProblem {
4453− message: format!(
4454− "'{}': `{}` takes no argument: write `{}`",
4455− written,
4456− vocab::pseudo_selector(&bare).unwrap_or_default(),
4457− bare
4458− ),
4459− subject: written.to_string(),
4460− suggestion: Some(bare),
4461− });
4462− }
4463− let functional: Vec<&str> = vocab::functional_pseudos()
4464− .into_iter()
4465− .filter(|p| p.chars().next() == name.chars().next())
4466− .collect();
4467− let suggestion = suggest_closest(name, &functional)
4468− .filter(|&closest| closest != name)
4469− .map(|closest| format!("{}(", closest));
4470− let written = format!("{}(", name);
4471− let message = match &suggestion {
4472− Some(closest) => format!("unknown prefix '{}', did you mean '{}'?", written, closest),
4473− None => format!("unknown prefix '{}'", written),
4474− };
4475− return Some(PrefixProblem {
4476− message,
4477− subject: written,
4478− suggestion,
4479− });
4480− }
4481− if !rest[name_len..].starts_with(':') {
4482− return None;
4483− }
4484− let written = &rest[..name_len + 1];
4485− // `has:`: its argument is missing
4486− if vocab::pseudo(name) == Some(Pseudo::Function) {
4487− return Some(PrefixProblem {
4488− message: format!(
4489− "'{}': `:{}()` takes an argument, in parentheses, as in CSS: `{}(...):`",
4490− written, name, name
4491− ),
4492− subject: written.to_string(),
4493− suggestion: None,
4494− });
4495− }
4496− let prefixes = vocab::all_prefixes();
4497− let candidates: Vec<&str> = prefixes
4498− .iter()
4499− .map(String::as_str)
4500− .filter(|p| p.chars().next() == written.chars().next())
4501− .collect();
4502− // The closest name that starts with the same letter (`hvoer:` →
4503− // `hover:`), else the first that starts with the word written and a `-`
4504− // (`first:` → `first-child:`)
4505− let suggestion = suggest_closest(written, &candidates)
4506− .or_else(|| {
4507− let longer = format!("{}-", name);
4508− candidates.iter().copied().find(|p| p.starts_with(&longer))
4509− })
4510− .filter(|&closest| closest != written)
4511− .map(str::to_string);
4512− let message = match &suggestion {
4513− Some(closest) => format!("unknown prefix '{}', did you mean '{}'?", written, closest),
4514− None => format!("unknown prefix '{}'", written),
4515− };
4516− Some(PrefixProblem {
4517− message,
4518− subject: written.to_string(),
4519− suggestion,
4520− })
4521−}
4522−
4523−/// Check an attribute's element prefix (`@td:padding 8`), when it has one:
4524−/// it names a built-in element that has an HTML tag of its own (not a
4525−/// function, and not `@el`, whose `<div>` other elements write too), there
4526−/// is one, not with `children:`, and what it styles is a CSS property, not
4527−/// one of htmlang's words, which compile against each element's layout and
4528−/// its parent's. Returns false, after reporting, when the attribute can't
4529−/// be written.
4530−fn check_element_prefixes(
4531− attr: &Attribute,
4532− prefixes: &[&str],
4533− base: &str,
4534− line: usize,
4535− column: Option<usize>,
4536− ctx: &mut ParseContext,
4537−) -> bool {
4538− use crate::ast::ElementKind;
4539− use crate::vocab;
4540− let elements: Vec<&str> = prefixes
4541− .iter()
4542− .copied()
4543− .filter(|p| vocab::element_prefix(p).is_some())
4544− .collect();
4545− let Some(&prefix) = elements.first() else {
4546− return true;
4547− };
4548− let fail = |ctx: &mut ParseContext,
4549− code: &'static str,
4550− message: String,
4551− subject: &str,
4552− suggestion: Option<String>| {
4553− let diagnostic = Diagnostic::error(code, line, message)
4554− .subject(subject)
4555− .suggest(suggestion);
4556− let diagnostic = at_attribute(diagnostic, column, ctx);
4557− ctx.diagnostics.push(diagnostic);
4558− false
4559− };
4560− let name = vocab::element_prefix(prefix).unwrap_or_default();
4561− if is_function(name, ctx) {
4562− return fail(
4563− ctx,
4564− code::INVALID_PREFIX,
4565− format!(
4566− "'{}': @{} is a function, and `{}` would pick elements out by their HTML tag, \
4567− which a function doesn't have: style what @{} writes in its body, or pass it \
4568− attributes",
4569− attr.key, name, prefix, name
4570− ),
4571− prefix,
4572− None,
4573− );
4574− }
4575− match ElementKind::from_name(name) {
4576− None => {
4577− let own: Vec<&str> = ElementKind::all_names()
4578− .filter(|n| ElementKind::from_name(n).is_some_and(|k| k.own_tag().is_some()))
4579− .collect();
4580− // An HTML name htmlang writes under another (`@a:` is `@link:`)
4581− let (written_otherwise, shared) = match crate::ast::HTML_NAMES_WRITTEN_OTHERWISE
4582− .iter()
4583− .find(|(html, _)| *html == name)
4584− {
4585− Some((_, htmlang)) if own.contains(htmlang) => (Some(*htmlang), false),
4586− Some(_) => (None, true),
4587− None => (None, false),
4588− };
4589− // `@each:`, `@markdown:`: a directive, which is no element
4590− if crate::ast::directive(name).is_some() {
4591− return fail(
4592− ctx,
4593− code::UNKNOWN_PREFIX,
4594− format!(
4595− "unknown element @{} in '{}': @{} is a directive, and an element \
4596− prefix names an element with an HTML tag of its own, such as `@td:`",
4597− name, prefix, name
4598− ),
4599− prefix,
4600− None,
4601− );
4602− }
4603− // `@div:`, `@span:`: several elements write it
4604− if shared {
4605− return fail(
4606− ctx,
4607− code::UNKNOWN_PREFIX,
4608− format!(
4609− "unknown element @{} in '{}': htmlang writes <{}> for several \
4610− elements, so no element prefix picks it out",
4611− name, prefix, name
4612− ),
4613− prefix,
4614− None,
4615− );
4616− }
4617− // A name with the same first letter first (`@tdd:` is `@td:`)
4618− let same_letter: Vec<&str> = own
4619− .iter()
4620− .copied()
4621− .filter(|n| n.chars().next() == name.chars().next())
4622− .collect();
4623− let closest = written_otherwise
4624− .or_else(|| suggest_closest(name, &same_letter))
4625− .or_else(|| suggest_closest(name, &own));
4626− let message = match (closest, written_otherwise) {
4627− (Some(closest), Some(_)) => format!(
4628− "unknown element @{} in '{}', did you mean '@{}:'? (@{} writes <{}>)",
4629− name, prefix, closest, closest, name
4630− ),
4631− (Some(closest), None) => format!(
4632− "unknown element @{} in '{}', did you mean '@{}:'?",
4633− name, prefix, closest
4634− ),
4635− (None, _) => format!("unknown element @{} in '{}'", name, prefix),
4636− };
4637− let suggestion = closest.map(|c| format!("@{}:", c));
4638− fail(ctx, code::UNKNOWN_PREFIX, message, prefix, suggestion)
4639− }
4640− Some(kind) if kind.own_tag().is_none() => {
4641− let reason = match kind.tag() {
4642− None => format!(
4643− "@{} writes no element of its own, so there is nothing for `{}` to style",
4644− name, prefix
4645− ),
4646− Some(_) if kind.is_verbatim() => {
4647− format!("@{} isn't shown, so there is nothing to style", name)
4648− }
4649− Some("span") => format!(
4650− "@{} writes a <span>, as every line of text in a row or column does, so \
4651− `{}` can't pick it out",
4652− name, prefix
4653− ),
4654− Some(tag) => {
4655− let others: Vec<String> = ElementKind::all_names()
4656− .filter(|n| {
4657− *n != name
4658− && ElementKind::from_name(n).and_then(|k| k.tag()) == Some(tag)
4659− })
4660− .map(|n| format!("@{}", n))
4661− .collect();
4662− let others = match others.split_last() {
4663− Some((last, [])) => last.clone(),
4664− Some((last, rest)) => format!("{} and {}", rest.join(", "), last),
4665− None => String::new(),
4666− };
4667− format!(
4668− "@{} writes a <{}>, as {} do, so `{}` can't pick it out",
4669− name, tag, others, prefix
4670− )
4671− }
4672− };
4673− fail(
4674− ctx,
4675− code::INVALID_PREFIX,
4676− format!(
4677− "'{}': {}. Give those elements their styles themselves, or a bundle \
4678− (`@let name [...]`, used as `[$name]`)",
4679− attr.key, reason
4680− ),
4681− prefix,
4682− None,
4683− )
4684− }
4685− Some(_) if elements.len() > 1 => fail(
4686− ctx,
4687− code::INVALID_PREFIX,
4688− format!(
4689− "'{}': a style takes one element prefix, which styles every such element \
4690− inside this one, at any depth",
4691− attr.key
4692− ),
4693− &attr.key,
4694− None,
4695− ),
4696− Some(_) if prefixes.contains(&vocab::CHILDREN) => fail(
4697− ctx,
4698− code::INVALID_PREFIX,
4699− format!(
4700− "'{}': `children:` and `{}` each say which elements the style is for: write \
4701− one of them",
4702− attr.key, prefix
4703− ),
4704− &attr.key,
4705− None,
4706− ),
4707− Some(_) if vocab::is_htmlang_word(base, attr.value.as_deref()) => {
4708− let value = attr.value.as_deref().map(str::trim);
4709− let css = match (base, value) {
4710− ("spacing", _) => " (`gap`)",
4711− ("wrap", _) => " (`flex-wrap wrap`)",
4712− ("grid-cols", _) => " (`grid-template-columns`)",
4713− ("grid-rows", _) => " (`grid-template-rows`)",
4714− ("col-span", _) => " (`grid-column span N`)",
4715− ("row-span", _) => " (`grid-row span N`)",
4716− ("width", Some("fill")) => " (`flex 1` in a row, `width 100%` otherwise)",
4717− ("height", Some("fill")) => " (`flex 1` in a column, `height 100%` otherwise)",
4718− ("width", _) => " (`flex-shrink 0` in a row, `width fit-content` otherwise)",
4719− ("height", _) => " (`flex-shrink 0` in a column, `height fit-content` otherwise)",
4720− ("center-x", _) => " (`margin-inline auto`)",
4721− ("center-y", _) => " (`margin-block auto`)",
4722− _ => "",
4723− };
4724− let word = match value {
4725− Some(v) if matches!(base, "width" | "height") => format!("{} {}", base, v),
4726− _ => base.to_string(),
4727− };
4728− fail(
4729− ctx,
4730− code::INVALID_PREFIX,
4731− format!(
4732− "'{}': `{}` is one of htmlang's layout words, which compile against each \
4733− element's layout and its parent's, and `{}` styles elements whose \
4734− parents it doesn't know: write `{}` on the elements themselves, or the \
4735− CSS you mean{}",
4736− attr.key, word, prefix, word, css
4737− ),
4738− &attr.key,
4739− None,
4740− )
4741− }
4742− Some(_) => true,
4743− }
4744−}
4745−
4746−/// Check an attribute's prefixes: each is known, their selector prefixes
4747−/// read left to right with a pseudo-element (`before:`) last, the argument
4748−/// of `has(...)` or `nth-child(...)` can be written into the CSS, and a word that
4749−/// places an element in its parent (`width fill`, `center-x`, `align-*`)
4750−/// isn't under `children:`, whose styles go on the children. Media,
4751−/// container and width prefixes go anywhere. Returns false, after
4752−/// reporting, when the attribute can't be written.
4753−fn check_prefixes(
4754− attr: &Attribute,
4755− line: usize,
4756− column: Option<usize>,
4757− ctx: &mut ParseContext,
4758−) -> bool {
4759− use crate::vocab;
4760− let (prefixes, base) = vocab::split_prefixes(&attr.key);
4761− let fail =
4762− |ctx: &mut ParseContext, message: String, subject: &str, suggestion: Option<String>| {
4763− let diagnostic = Diagnostic::error(code::INVALID_PREFIX, line, message)
4764− .subject(subject)
4765− .suggest(suggestion);
4766− let diagnostic = at_attribute(diagnostic, column, ctx);
4767− ctx.diagnostics.push(diagnostic);
4768− false
4769− };
4770−
4771− if let Some(problem) = unknown_prefix(base) {
4772− let diagnostic = Diagnostic::error(code::UNKNOWN_PREFIX, line, problem.message)
4773− .subject(problem.subject)
4774− .suggest(problem.suggestion);
4775− let diagnostic = at_attribute(diagnostic, column, ctx);
4776− ctx.diagnostics.push(diagnostic);
4777− return false;
4778− }
4779− if !check_element_prefixes(attr, &prefixes, base, line, column, ctx) {
4780− return false;
4781− }
4782− // The argument of `has(...)` or `nth-child(...)` goes into the CSS as
4783− // written
4784− for prefix in &prefixes {
4785− if let Some(reason) = css_breakout(prefix.trim_end_matches(':')) {
4786− return fail(
4787− ctx,
4788− format!(
4789− "the prefix '{}' has {}: it can't be written into the page's CSS",
4790− prefix, reason
4791− ),
4792− prefix,
4793− None,
4794− );
4795− }
4796− }
4797− // `nth-child():` or `has( ):`: CSS has no pseudo-class with an empty
4798− // argument
4799− let empty_argument = |prefix: &str| {
4800− prefix
4801− .strip_suffix("):")
4802− .and_then(|p| p.split_once('('))
4803− .is_some_and(|(_, argument)| argument.trim().is_empty())
4804− };
4805− if let Some(empty) = prefixes.iter().find(|p| empty_argument(p)) {
4806− return fail(
4807− ctx,
4808− format!(
4809− "'{}': `:{}()` takes an argument between its parentheses, as in CSS",
4810− attr.key,
4811− vocab::pseudo_name(empty)
4812− ),
4813− empty,
4814− None,
4815− );
4816− }
4817− // A pseudo-element is the last selector prefix: CSS has no
4818− // `::before:hover`
4819− let selectors: Vec<&str> = prefixes
4820− .iter()
4821− .copied()
4822− .filter(|p| vocab::at_rule_rank(p).is_none())
4823− .collect();
4824− // Only one: an element has one `::before`, which has no `::after`
4825− let elements: Vec<&str> = selectors
4826− .iter()
4827− .copied()
4828− .filter(|p| vocab::is_pseudo_element(p))
4829− .collect();
4830− if elements.len() > 1 {
4831− return fail(
4832− ctx,
4833− format!(
4834− "'{}': `{}` and `{}` each select a pseudo-element, and a style applies to one \
4835− of them",
4836− attr.key, elements[0], elements[1]
4837− ),
4838− &attr.key,
4839− None,
4840− );
4841− }
4842− if let Some(at) = selectors.iter().position(|p| vocab::is_pseudo_element(p))
4843− && at + 1 < selectors.len()
4844− {
4845− let element = selectors[at];
4846− // The same prefixes, with the pseudo-element moved after the other
4847− // selector prefixes
4848− let mut order: Vec<&str> = prefixes.iter().copied().filter(|p| *p != element).collect();
4849− let last_selector = order
4850− .iter()
4851− .rposition(|p| vocab::at_rule_rank(p).is_none())
4852− .map_or(order.len(), |i| i + 1);
4853− order.insert(last_selector, element);
4854− let fixed = format!("{}{}", order.concat(), base);
4855− return fail(
4856− ctx,
4857− format!(
4858− "'{}': `{}` selects a pseudo-element, which comes after the other selector \
4859− prefixes, as in CSS: write `{}`",
4860− attr.key, element, fixed
4861− ),
4862− &attr.key,
4863− Some(fixed),
4864− );
4865− }
4866− // Under `children:` the styles go on the children, whose parent is this
4867− // element: a word that places an element in its parent can't say where
4868− if prefixes.contains(&"children:") && vocab::places_in_parent(base, attr.value.as_deref()) {
4869− // The CSS of `fill` and `shrink` depends on the direction: along
4870− // it, they are flex properties
4871− let (word, css) = match (base, attr.value.as_deref().map(str::trim)) {
4872− ("width", Some("fill")) => (
4873− "width fill",
4874− " (`children:flex 1` in a row, `children:width 100%` otherwise)",
4875− ),
4876− ("height", Some("fill")) => (
4877− "height fill",
4878− " (`children:flex 1` in a column, `children:height 100%` otherwise)",
4879− ),
4880− ("width", Some(_)) => (
4881− "width shrink",
4882− " (`children:flex-shrink 0` in a row, `children:width fit-content` otherwise)",
4883− ),
4884− ("height", Some(_)) => (
4885− "height shrink",
4886− " (`children:flex-shrink 0` in a column, `children:height fit-content` \
4887− otherwise)",
4888− ),
4889− _ => (base, ""),
4890− };
4891− return fail(
4892− ctx,
4893− format!(
4894− "'{}{}': `{}` places an element in its parent, and under `children:` that \
4895− parent is this element: write `{}` on the children themselves, or the CSS \
4896− you mean{}",
4897− attr.key,
4898− attr.value
4899− .as_deref()
4900− .map_or(String::new(), |v| format!(" {}", v.trim())),
4901− word,
4902− word,
4903− css
4904− ),
4905− &attr.key,
4906− None,
4907− );
4908− }
4909− true
4910−}
4911−
4912−/// Check an attribute written as a style or a flag (not `key=value`): its
4913−/// prefix, its name, and, for a style, its value. A name htmlang doesn't
4914−/// know but CSS could have (`corner-shape`) is passed to the CSS as written,
4915−/// with a warning; custom properties (`--brand`) and vendor-prefixed ones
4916−/// (`-webkit-...`) without one. Anything that can't be written into the
4917−/// page is an error; returns false then, and the attribute is left out.
4918−fn check_attribute(
4919− attr: &Attribute,
4920− line: usize,
4921− column: Option<usize>,
4922− before: Option<&Attribute>,
4923− ctx: &mut ParseContext,
4924−) -> bool {
4925− use crate::vocab;
4926− let (prefixes, base) = vocab::split_prefixes(&attr.key);
4927− let fail = |ctx: &mut ParseContext, code: &'static str, message: String, subject: &str| {
4928− let diagnostic = Diagnostic::error(code, line, message).subject(subject);
4929− let diagnostic = at_attribute(diagnostic, column, ctx);
4930− ctx.diagnostics.push(diagnostic);
4931− false
4932− };
4933−
4934− if !check_prefixes(attr, line, column, ctx) {
4935− return false;
4936− }
4937− let prefixed = !prefixes.is_empty();
4938− // `md:id=x`: `=` makes an HTML attribute, which has no states
4939− if prefixed && let Some((name, _)) = base.split_once('=') {
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, name
4946− ),
4947− &attr.key,
4948− );
4949− }
4950− let Some(value) = &attr.value else {
4951− // `inline` puts a file into the page as it compiles, which no
4952− // state or media condition can undo
4953− if base == "inline" && prefixed {
4954− return fail(
4955− ctx,
4956− code::INVALID_PREFIX,
4957− format!(
4958− "'{}': `inline` puts the image's file into the page, which can't depend \
4959− on a prefix",
4960− attr.key
4961− ),
4962− &attr.key,
4963− );
4964− }
4965− if vocab::HTMLANG_FLAGS.contains(&base) {
4966− return true;
4967− }
4968− if vocab::is_html_flag(base) {
4969− if !prefixed {
4970− return true;
4971− }
4972− return fail(
4973− ctx,
4974− code::INVALID_PREFIX,
4975− format!(
4976− "'{}': a prefix applies to a style, and '{}' is an HTML attribute",
4977− attr.key, base
4978− ),
4979− &attr.key,
4980− );
4981− }
4982− if vocab::is_style_attribute(base)
4983− || vocab::is_custom_property(base)
4984− || vocab::is_vendor_property(base)
4985− {
4986− return fail(
4987− ctx,
4988− code::MISSING_VALUE,
4989− format!("'{}' needs a value: `{} VALUE`", attr.key, attr.key),
4990− &attr.key,
4991− );
4992− }
4993− if vocab::is_html_attribute(base) && !prefixed {
4994− return fail(
4995− ctx,
4996− code::HTML_ATTRIBUTE_FORM,
4997− format!(
4998− "'{}' is an HTML attribute that isn't a flag: write `{}=VALUE`",
4999− base, base
5000− ),
5001− base,
5002− );
5003− }
5004− return unknown_attribute(attr, base, line, column, before, true, ctx);
5005− };
5006−
5007− if vocab::HTMLANG_FLAGS.contains(&base) {
5008− return fail(
5009− ctx,
5010− code::INVALID_VALUE,
5011− format!(
5012− "'{}' is a flag and takes no value: write `{}` alone",
5013− base, attr.key
5014− ),
5015− &attr.key,
5016− );
5017− }
5018− if vocab::is_style_attribute(base)
5019− || vocab::is_custom_property(base)
5020− || vocab::is_vendor_property(base)
5021− {
5022− return check_css_value(attr, base, line, column, ctx);
5023− }
5024− if vocab::is_html_attribute(base) && !prefixed {
5025− let diagnostic = Diagnostic::error(
5026− code::HTML_ATTRIBUTE_FORM,
5027− line,
5028− format!(
5029− "'{}' is an HTML attribute: write `{}={}`",
5030− attr.key, attr.key, value
5031− ),
5032− )
5033− .subject(attr.key.as_str());
5034− let diagnostic = at_attribute(diagnostic, column, ctx);
5035− ctx.diagnostics.push(diagnostic);
5036− return false;
5037− }
5038− let passes = vocab::is_property_name(base);
5039− unknown_attribute(attr, base, line, column, before, !passes, ctx)
5040− && check_css_value(attr, base, line, column, ctx)
5041−}
5042−
5043−/// Whether an attribute of a bundle could pass a parameter to a function
5044−/// it is spliced into (`@let t [title Hello]`): a name without a prefix
5045−/// that isn't a style or a flag. Such a name is checked where the bundle is
5046−/// used.
5047−fn could_be_parameter(attr: &Attribute) -> bool {
5048− use crate::vocab;
5049− let key = attr.key.as_str();
5050− let flag =
5051− attr.value.is_none() && (vocab::HTMLANG_FLAGS.contains(&key) || vocab::is_html_flag(key));
5052− !attr.html
5053− && !flag
5054− && !vocab::is_style_attribute(key)
5055− && key.starts_with(|c: char| c.is_alphabetic())
5056− && key
5057− .chars()
5058− .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_'))
5059−}
5060−
5061−/// At a call, a name close to one of the function's parameters is a
5062−/// misspelled parameter, not an attribute for its root: report it, and
5063−/// return true (it is left out).
5064−fn misspelled_parameter(
5065− attr: &Attribute,
5066− text_keys: &[String],
5067− line: usize,
5068− column: Option<usize>,
5069− ctx: &mut ParseContext,
5070−) -> bool {
5071− if text_keys.is_empty() || !could_be_parameter(attr) {
5072− return false;
5073− }
5074− let params: Vec<&str> = text_keys.iter().map(String::as_str).collect();
5075− let Some(closest) = suggest_closest(&attr.key, &params) else {
5076− return false;
5077− };
5078− let what = if ctx.in_page {
5079− "@page word"
5080− } else {
5081− "parameter"
5082− };
5083− let diagnostic = Diagnostic::error(
5084− code::UNKNOWN_ATTRIBUTE,
5085− line,
5086− format!(
5087− "unknown {} '{}', did you mean '{}'?",
5088− what, attr.key, closest
5089− ),
5090− )
5091− .subject(attr.key.as_str())
5092− .suggest(Some(closest));
5093− let diagnostic = at_attribute(diagnostic, column, ctx);
5094− ctx.diagnostics.push(diagnostic);
5095− true
5096−}
5097−
5098−/// An attribute name htmlang doesn't know. A style whose name CSS could
5099−/// have goes to the CSS as written, with a warning; anything else (a flag,
5100−/// a name that isn't a word) is an error. Returns whether it is kept.
5101−fn unknown_attribute(
5102− attr: &Attribute,
5103− base: &str,
5104− line: usize,
5105− column: Option<usize>,
5106− before: Option<&Attribute>,
5107− error: bool,
5108− ctx: &mut ParseContext,
5109−) -> bool {
5110− // `@td padding 8`: an element prefix without its colon
5111− if let Some(name) = base.strip_prefix('@')
5112− && crate::ast::ElementKind::from_name(name).is_some()
5113− {
5114− let example = match attr.value.as_deref() {
5115− Some(value) => format!("{}:{}", attr.key, value.trim()),
5116− None => format!("{}:padding 8", attr.key),
5117− };
5118− let diagnostic = Diagnostic::error(
5119− code::UNKNOWN_ATTRIBUTE,
5120− line,
5121− format!(
5122− "unknown attribute '{}': an element prefix ends in a colon, as in `{}`",
5123− attr.key, example
5124− ),
5125− )
5126− .subject(base);
5127− let diagnostic = at_attribute(diagnostic, column, ctx);
5128− ctx.diagnostics.push(diagnostic);
5129− return false;
5130− }
5131− // `[card]` where `card` is a bundle: it is used as `$card`
5132− if attr.value.is_none() && ctx.env.bundle(base).is_some() && base == attr.key {
5133− ctx.used_defines.insert(base.to_string());
5134− let diagnostic = Diagnostic::error(
5135− code::UNKNOWN_ATTRIBUTE,
5136− line,
5137− format!(
5138− "unknown attribute '{}': '{}' is an attribute bundle, used as `${}`",
5139− base, base, base
5140− ),
5141− )
5142− .subject(base)
5143− .suggest(Some(format!("${}", base)));
5144− let diagnostic = at_attribute(diagnostic, column, ctx);
5145− ctx.diagnostics.push(diagnostic);
5146− return false;
5147− }
5148− // `hover:title x`: an HTML attribute's name is not a suggestion for
5149− // itself
5150− let suggestion =
5151− suggest_closest(base, &crate::vocab::all_attributes()).filter(|&closest| closest != base);
5152− // `hx-trigger=load, every 2s`: right after a `key=value`, a name that
5153− // isn't close to one htmlang knows is the rest of that value, which
5154− // the comma ended. It is left out.
5155− let cut = before.filter(|b| b.html && b.value.is_some());
5156− let rest = match &attr.value {
5157− Some(value) => format!("{} {}", attr.key, value),
5158− None => attr.key.clone(),
5159− };
5160− let joined = cut.map(|cut| {
5161− let value = cut.value.as_deref().unwrap_or("");
5162− (
5163− format!("{}={}", cut.key, value),
5164− format!("{}={}\\, {}", cut.key, value, rest),
5165− )
5166− });
5167− if let (Some((cut, joined)), None) = (&joined, suggestion) {
5168− let diagnostic = Diagnostic::error(
5169− code::SPLIT_VALUE,
5170− line,
5171− format!(
5172− "unknown attribute '{}' after `{}`: if '{}' is the rest of its value, the comma \
5173− ended it. To keep a comma in a value, write `\\,`: `{}`",
5174− base, cut, rest, joined
5175− ),
5176− )
5177− .subject(base);
5178− let diagnostic = at_attribute(diagnostic, column, ctx);
5179− ctx.diagnostics.push(diagnostic);
5180− return false;
5181− }
5182− let mut message = match (&suggestion, error) {
5183− (Some(closest), true) => {
5184− format!(
5185− "unknown attribute '{}', did you mean '{}'?",
5186− attr.key, closest
5187− )
5188− }
5189− (None, true) => format!("unknown attribute '{}'", attr.key),
5190− (Some(closest), false) => format!(
5191− "unknown CSS property '{}', did you mean '{}'? It is written to the CSS as it is",
5192− base, closest
5193− ),
5194− (None, false) => format!(
5195− "unknown CSS property '{}': it is written to the CSS as it is",
5196− base
5197− ),
5198− };
5199− // `box-shadow 0 1px red, 0 2px blue` or `font-family Inter,
5200− // sans-serif`: the comma ended the attribute, and the rest of the
5201− // value became one
5202− let rest_of_a_value = !base.starts_with(|c: char| c.is_ascii_alphabetic() || c == '-')
5203− || (suggestion.is_none() && attr.value.is_none() && before.is_some());
5204− if joined.is_some() || (rest_of_a_value && error) {
5205− if !message.ends_with('?') {
5206− message.push('.');
5207− }
5208− match &joined {
5209− Some((cut, joined)) => message.push_str(&format!(
5210− " If it is the rest of `{}`, the comma ended it: write `{}`",
5211− cut, joined
5212− )),
5213− None => message
5214− .push_str(" A comma separates attributes: to keep one in a value, write `\\,`"),
5215− }
5216− }
5217− let severity = if error {
5218− Severity::Error
5219− } else {
5220− Severity::Warning
5221− };
5222− let diagnostic = Diagnostic::new(code::UNKNOWN_ATTRIBUTE, severity, line, message)
5223− .subject(base)
5224− .suggest(suggestion);
5225− let diagnostic = at_attribute(diagnostic, column, ctx);
5226− ctx.diagnostics.push(diagnostic);
5227− !error
5228−}
5229−
5230−/// Evaluate the attributes of a list: bundles are spliced in, `if()`
5231−/// chooses, and variables fill values. A variable fills only the value it
5232−/// is written in: attributes come from bundles, never from text. With
5233−/// `validate`, unknown names and invalid values are reported. `text_keys`
5234−/// are a called function's parameters: their values are text rather than
5235−/// CSS, and they aren't checked as attributes.
5236−fn parse_attr_list(
5237− tokens: &[syntax::Attr],
5238 line_num: usize,
2899 line_num: usize,
5239 ctx: &mut ParseContext,
2900 ctx: &mut ParseContext,
5240 validate: bool,
2901 validate: bool,
5241− text_keys: &[String],
2902+) -> Result<(Vec<Attribute>, String), ParseError> {
5242−) -> Vec<Attribute> {
2903+ let mut depth = 0;
5243− parse_attrs(tokens, line_num, ctx, validate, text_keys)
2904+ let mut end_pos = 0;
5244− .into_iter()
5245− .map(|(attr, _)| attr)
5246− .collect()
5247−}
5248−
5249−/// [`parse_attr_list`], with the value of each attribute whose key is one
5250−/// of `text_keys` (a call's parameters) as a value with its type (see
5251−/// [`ParseContext::slot_value`]), so a parameter can get a list or a
5252−/// record.
5253−fn parse_attrs(
5254− tokens: &[syntax::Attr],
5255− line_num: usize,
5256− ctx: &mut ParseContext,
5257− validate: bool,
5258− text_keys: &[String],
5259−) -> Vec<(Attribute, Option<Value>)> {
5260− let mut read = ReadAttrs::default();
5261− read_attrs(tokens, "", line_num, ctx, validate, text_keys, &mut read);
5262− read.attrs
5263−}
5264−
5265−/// The attributes of a list read so far, and the keys written (for the
5266−/// duplicate check).
5267−#[derive(Default)]
5268−struct ReadAttrs {
5269− attrs: Vec<(Attribute, Option<Value>)>,
5270− seen_keys: Vec<(String, bool)>,
5271−}
5272−
5273−/// [`parse_attrs`] for the attributes of `tokens`, each under the prefixes
5274−/// `prefix` (`md:` for those of `md:[...]`), which distribute over a
5275−/// group, a bundle and the branch an `if()` takes. Only styles take a
5276−/// prefix, so an HTML attribute there is an error.
5277−fn read_attrs(
5278− tokens: &[syntax::Attr],
5279− prefix: &str,
5280− line_num: usize,
5281− ctx: &mut ParseContext,
5282− validate: bool,
5283− text_keys: &[String],
5284− out: &mut ReadAttrs,
5285−) {
5286− let mut chosen = Vec::new();
5287− choose_attrs(
5288− tokens,
5289− prefix,
5290− line_num,
5291− ctx,
5292− validate,
5293− text_keys,
5294− &mut chosen,
5295− );
5296−
5297− for token in &chosen {
5298− let line = if token.span.line == 0 {
5299− line_num
5300− } else {
5301− token.span.line
5302− };
5303− let (key, value, html) = (token.key.clone(), token.value.clone(), token.html);
5304− let column = |at: usize| Some(token.span.column + at);
5305−
5306− // `md:[...]`, `md:$card`, `md:if(...)`: the prefixes apply to each
5307− // attribute there, and a prefix a parameter can't have
5308− if let Some(prefixed) = &token.prefixed {
5309− if let Some(inner) = group_prefix(token, prefixed, prefix, line, ctx) {
5310− read_attrs(
5311− prefixed.target.attrs(),
5312− &inner,
5313− line_num,
5314− ctx,
5315− validate,
5316− text_keys,
5317− out,
5318− );
5319− }
5320− continue;
5321− }
5322
2905
5323− // `$name` (or `${name}`) alone: an attribute bundle, spliced in
2906+ for (i, c) in input.char_indices() {
5324− let bundle = key.strip_prefix('$').filter(|_| value.is_none() && !html);
2907+ match c {
5325− let bundle = bundle.and_then(|after| match interp::reference(after, &ctx.env)? {
2908+ '[' => depth += 1,
5326− (interp::Reference::Var(name), len) if len == after.len() => Some(name),
2909+ ']' => {
5327− _ => None,
2910+ depth -= 1;
5328− });
2911+ if depth == 0 {
5329− if let Some(name) = bundle {
2912+ end_pos = i;
5330− if let Some(define_attrs) = ctx.env.bundle(&name).cloned() {
2913+ break;
5331− ctx.used_defines.insert(name.clone());
5332− for attr in define_attrs.iter() {
5333− if !prefix.is_empty() {
5334− let prefixed = Attribute {
5335− key: format!("{}{}", prefix, attr.key),
5336− ..attr.clone()
5337− };
5338− if prefixed_parameter(&prefixed, text_keys, line, column(0), validate, ctx)
5339− {
5340− continue;
5341− }
5342− if let Some(attr) =
5343− prefixed_member(attr, prefix, &name, line, column(0), validate, ctx)
5344− {
5345− out.attrs.push((attr, None));
5346− }
5347− continue;
5348− }
5349− let attrs = &out.attrs;
5350− // Checked here, where it is either a parameter or not
5351− let checked_here = validate
5352− && !ctx.in_bundle
5353− && could_be_parameter(attr)
5354− && !text_keys.contains(&attr.key);
5355− if checked_here
5356− && (misspelled_parameter(attr, text_keys, line, column(0), ctx)
5357− || !check_attribute(
5358− attr,
5359− line,
5360− column(0),
5361− attrs.last().map(|(a, _)| a),
5362− ctx,
5363− ))
5364− {
5365− continue;
5366− }
5367− out.attrs.push((attr.clone(), None));
5368 }
2914 }
5369− } else {
5370− not_a_bundle(&name, line, column(0), ctx);
5371 }
2915 }
5372− continue;
2916+ _ => {}
5373 }
2917 }
5374− // A name never comes from a variable
2918+ }
5375− if key.contains('$') {
2919+
5376− track_var_refs(&key, &mut ctx.used_variables);
2920+ if depth != 0 {
5377− ctx.diagnostics.push(
2921+ return Err(ParseError {
5378− Diagnostic::error(
2922+ line: line_num,
5379− code::ATTRIBUTE_FROM_VARIABLE,
2923+ message: "unclosed '[' in attribute list".to_string(),
5380− line,
2924+ });
5381− format!(
2925+ }
5382− "'{}': an attribute's name can't come from a variable. A variable \
2926+
5383− fills a value (`padding $size`), and whole attributes come from a \
2927+ let attrs_inner = &input[1..end_pos];
5384− bundle (`@let name [padding 8]`, used as `[$name]`)",
2928+ let remaining = input[end_pos + 1..].trim().to_string();
5385− key
2929+ let attrs = parse_attr_list(attrs_inner, line_num, ctx, validate);
5386− ),
2930+
5387− )
2931+ Ok((attrs, remaining))
5388− .subject(key.as_str()),
2932+}
5389− );
2933+
2934+fn is_valid_hex_color(s: &str) -> bool {
2935+ if !s.starts_with('#') {
2936+ return true; // Not a hex color, skip
2937+ }
2938+ let hex = &s[1..];
2939+ matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
2940+}
2941+
2942+fn parse_attr_list(
2943+ input: &str,
2944+ line_num: usize,
2945+ ctx: &mut ParseContext,
2946+ validate: bool,
2947+) -> Vec<Attribute> {
2948+ let mut attrs = Vec::new();
2949+ let mut seen_keys: Vec<String> = Vec::new();
2950+
2951+ for part in split_commas(input) {
2952+ let part = part.trim();
2953+ if part.is_empty() {
5390 continue;
2954 continue;
5391 }
2955 }
5392
2956
5393− // Only a style has states: `md:[id=x]`, `md:id=x`
2957+ // $define reference — expand attribute bundle
5394− if html && (!prefix.is_empty() || !crate::vocab::split_prefixes(&key).0.is_empty()) {
2958+ if let Some(name) = part.strip_prefix('$')
5395− let written = format!("{}{}", prefix, key);
2959+ && let Some(define_attrs) = ctx.defines.get(name)
5396− let (name, _) = crate::vocab::split_prefixes(&written);
2960+ {
5397− let diagnostic = Diagnostic::error(
2961+ ctx.used_defines.insert(name.to_string());
5398− code::INVALID_PREFIX,
2962+ attrs.extend(define_attrs.clone());
5399− line,
5400− format!(
5401− "'{}=': a prefix applies to a style, and `{}=` is an HTML attribute, \
5402− which has no states",
5403− written,
5404− &written[name.concat().len()..]
5405− ),
5406− )
5407− .subject(token.key.as_str());
5408− let diagnostic = at_attribute(diagnostic, column(0), ctx);
5409− ctx.diagnostics.push(diagnostic);
5410 continue;
2963 continue;
5411 }
2964 }
5412− let key = if prefix.is_empty() {
5413− key
5414− } else {
5415− format!("{}{}", prefix, key)
5416− };
5417
2965
5418− // A value's variables are filled in. A style's value is CSS, where
2966+ track_var_refs(part, &mut ctx.used_variables);
5419− // quoted text keeps its quotes; an HTML attribute's (and a
2967+
5420− // parameter's) is text, where it loses them.
2968+ // Conditional attribute: `key if condition` or `key value if condition`.
5421− let sink = if html || text_keys.contains(&key) {
2969+ // Conditions are evaluated on the source text, before substitution.
5422− Sink::Text
2970+ let part = match split_trailing_if(part) {
5423− } else {
2971+ (attr, Some(condition)) => {
5424− Sink::Css
2972+ if !ctx.condition(condition, line_num) {
5425− };
2973+ continue;
5426− let mut filled = true;
5427− let mut quoted = None;
5428− let mut typed = None;
5429− let value = match value {
5430− None => None,
5431− // A parameter's value keeps its type
5432− Some(value) if !html && text_keys.contains(&key) => {
5433− let at = column(token.raw.len() - value.len());
5434− let (value, ok) = ctx.slot_value(&value, line, at);
5435− filled = ok;
5436− if let Value::Quoted(q) = &value {
5437− quoted = Some(q.clone());
5438 }
2974 }
5439− let text = value.to_string();
2975+ attr
5440− typed = Some(value);
5441− Some(text)
5442− }
5443− Some(value) => {
5444− let at = column(token.raw.len() - value.len());
5445− let (text, is_quoted, ok) = ctx.fill_value(&value, line, at, sink);
5446− filled = ok;
5447− quoted = is_quoted;
5448− Some(text)
5449 }
2976 }
2977+ (attr, None) => attr,
5450 };
2978 };
5451− let attr = Attribute {
2979+ // A value `if(cond, a, b)` picks `a` or `b` (free text) by `cond`.
5452− key,
2980+ let part = choose_if_value(part, ctx, line_num);
5453− value,
2981+ let part = substitute_vars(&part, &ctx.variables);
5454− html,
2982+
5455− quoted,
2983+ let attr = if let Some((key, value)) = split_html_attribute(&part) {
2984+ Attribute {
2985+ key: key.to_string(),
2986+ value: Some(value.to_string()),
2987+ html: true,
2988+ }
2989+ } else if let Some((key, value)) = part.split_once(' ') {
2990+ let value = value.trim().to_string();
2991+ Attribute {
2992+ key: key.trim().to_string(),
2993+ value: Some(value),
2994+ html: false,
2995+ }
2996+ } else {
2997+ Attribute {
2998+ key: part.to_string(),
2999+ value: None,
3000+ html: false,
3001+ }
5456 };
3002 };
5457− // A value that couldn't be filled in is already reported, and a
5458− // parameter's value is text for the function
5459− let validate = validate && filled && !text_keys.contains(&attr.key);
5460
3003
5461− // A duplicate is reported (compare the full key, so `border` and
3004+ // Warn on duplicate attributes (compare full key so pseudo-class
5462− // `hover:border` differ, and the form, so `width=800` and
3005+ // variants like `border` and `hover:border` are not conflated)
5463− // `width 200` do too), and the later one wins
5464 if validate {
3006 if validate {
5465− let seen = (normal_key(&attr.key), attr.html);
3007+ if seen_keys.contains(&attr.key) {
5466− if out.seen_keys.contains(&seen) {
3008+ ctx.diagnostics.push(Diagnostic {
5467− let written = if attr.html { "=" } else { "" };
3009+ line: line_num,
5468− let message = format!(
3010+ column: None,
5469− "duplicate attribute '{}{}': the later one wins",
3011+ message: format!("duplicate attribute '{}'", attr.key),
5470− attr.key, written
3012+ severity: Severity::Warning,
5471− );
3013+ source_line: None,
5472− let diagnostic = Diagnostic::warning(code::DUPLICATE_ATTRIBUTE, line, message)
3014+ });
5473− .subject(attr.key.as_str());
5474− let diagnostic = at_attribute(diagnostic, column(0), ctx);
5475− ctx.diagnostics.push(diagnostic);
5476 } else {
3015 } else {
5477− out.seen_keys.push(seen);
3016+ seen_keys.push(attr.key.clone());
5478 }
3017 }
5479− }
5480
3018
5481− if validate && misspelled_parameter(&attr, text_keys, line, column(0), ctx) {
3019+ // Color validation for hex colors
5482− continue;
3020+ if !attr.html
5483− }
3021+ && matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
5484− if prefixed_parameter(&attr, text_keys, line, column(0), validate, ctx) {
3022+ && let Some(ref val) = attr.value
5485− continue;
3023+ && val.starts_with('#')
5486− }
3024+ && !is_valid_hex_color(val)
5487− if validate && attr.html && attr.key == "class" {
3025+ {
5488− reserved_class(&attr, line, column(0), ctx);
3026+ ctx.diagnostics.push(Diagnostic {
5489− }
3027+ line: line_num,
5490− // `padding=4`: a style written as an HTML attribute, which the
3028+ column: None,
5491− // browser ignores (`width=` and `height=` are real on some
3029+ message: format!("invalid hex color '{}'", val),
5492− // elements, see `validate_tree`)
3030+ severity: Severity::Warning,
5493− if validate
3031+ source_line: None,
5494− && attr.html
3032+ });
5495− && crate::vocab::is_style_attribute(&attr.key)
3033+ }
5496− && !crate::vocab::HTML_ATTRIBUTES.contains(&attr.key.as_str())
5497− {
5498− let value = token.value.as_deref().unwrap_or("").trim();
5499− let diagnostic = Diagnostic::warning(
5500− code::HTML_ATTRIBUTE_FORM,
5501− line,
5502− format!(
5503− "'{0}={1}' is an HTML attribute, but {0} is a style: write `{0} {1}`",
5504− attr.key, value
5505− ),
5506− )
5507− .subject(token.raw.as_str())
5508− .suggest(Some(format!("{} {}", attr.key, value)));
5509− let diagnostic = at_attribute(diagnostic, column(0), ctx);
5510− ctx.diagnostics.push(diagnostic);
5511 }
3034 }
5512
3035
5513− // A style or a flag: its name is checked, and what can't be
3036+ // Warn on unknown attributes. `key=value` HTML attributes may use any
5514− // written into the page is an error and left out
3037+ // name; everything else must be a known style.
5515− let checked = validate && !attr.html && !(ctx.in_bundle && could_be_parameter(&attr));
3038+ if validate && !attr.html {
5516− let before = out.attrs.last().map(|(a, _)| a);
3039+ let base_key = crate::vocab::base_attribute(attr.key.as_str());
5517− if checked && !check_attribute(&attr, line, column(0), before, ctx) {
3040+ let is_boolean_html =
5518− continue;
3041+ attr.value.is_none() && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&base_key);
3042+ // A removed attribute that shares its name with a CSS property
3043+ // (bare `grid`) still gets its hint.
3044+ let removed = removed_attribute_hint(base_key)
3045+ .filter(|_| attr.value.is_none() || !crate::vocab::is_css_property(base_key));
3046+ if let Some(hint) = removed {
3047+ ctx.diagnostics.push(Diagnostic {
3048+ line: line_num,
3049+ column: None,
3050+ message: format!(
3051+ "attribute '{}' was removed: {} (run `htmlang upgrade`)",
3052+ base_key, hint
3053+ ),
3054+ severity: Severity::Warning,
3055+ source_line: None,
3056+ });
3057+ } else if is_boolean_html || crate::vocab::is_style_attribute(base_key) {
3058+ validate_attr_value(&attr, line_num, ctx);
3059+ } else if crate::vocab::HTML_ATTRIBUTES.contains(&base_key)
3060+ || base_key.starts_with("aria-")
3061+ || base_key.starts_with("data-")
3062+ {
3063+ ctx.diagnostics.push(Diagnostic {
3064+ line: line_num,
3065+ column: None,
3066+ message: format!(
3067+ "'{}' is an HTML attribute: write `{}={}` (run `htmlang upgrade`)",
3068+ attr.key,
3069+ attr.key,
3070+ attr.value.as_deref().unwrap_or("")
3071+ ),
3072+ severity: Severity::Warning,
3073+ source_line: None,
3074+ });
3075+ } else {
3076+ let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
3077+ let msg = match suggestion {
3078+ Some(closest) => {
3079+ format!(
3080+ "unknown attribute '{}', did you mean '{}'?",
3081+ attr.key, closest
3082+ )
3083+ }
3084+ None => format!("unknown attribute '{}'", attr.key),
3085+ };
3086+ ctx.diagnostics.push(Diagnostic {
3087+ line: line_num,
3088+ column: None,
3089+ message: msg,
3090+ severity: Severity::Warning,
3091+ source_line: None,
3092+ });
3093+ }
5519 }
3094 }
5520
3095
5521− out.attrs.push((attr, typed));
3096+ attrs.push(attr);
5522 }
3097 }
3098+
3099+ attrs
5523}
3100}
5524
3101
5525−/// At a call, a parameter under prefixes (`md:title x`, `md:[title x]`):
3102+/// Split an HTML attribute written `key=value` (`alt=`, `type=email`,
5526−/// a parameter has one value, with no states, so it is reported (with
3103+/// `aria-label=Close menu`). Returns `None` for style attributes.
5527−/// `validate`) and left out; returns true then. A parameter named like a
3104+fn split_html_attribute(part: &str) -> Option<(&str, &str)> {
5528−/// style (`color`) is a style under a prefix, for the root element.
3105+ let first_token = part.split(char::is_whitespace).next()?;
5529−fn prefixed_parameter(
3106+ let eq = first_token.find('=')?;
5530− attr: &Attribute,
3107+ let key = &part[..eq];
5531− text_keys: &[String],
3108+ let valid_key = key.starts_with(|c: char| c.is_ascii_alphabetic())
5532− line: usize,
3109+ && key
5533− column: Option<usize>,
3110+ .chars()
5534− validate: bool,
3111+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
5535− ctx: &mut ParseContext,
3112+ valid_key.then(|| (key, part[eq + 1..].trim()))
5536−) -> bool {
5537− let (prefixes, name) = crate::vocab::split_prefixes(&attr.key);
5538− if attr.html
5539− || prefixes.is_empty()
5540− || !text_keys.iter().any(|k| k == name)
5541− || crate::vocab::is_style_attribute(name)
5542− {
5543− return false;
5544− }
5545− if validate {
5546− let what = if ctx.in_page {
5547− "@page's own word"
5548− } else {
5549− "a parameter"
5550− };
5551− let diagnostic = Diagnostic::error(
5552− code::INVALID_PREFIX,
5553− line,
5554− format!(
5555− "'{}': `{}` is {}, which has one value, and a prefix applies to a style, \
5556− which has states",
5557− attr.key, name, what
5558− ),
5559− )
5560− .subject(attr.key.as_str());
5561− let diagnostic = at_attribute(diagnostic, column, ctx);
5562− ctx.diagnostics.push(diagnostic);
5563− }
5564− true
5565}
3113}
5566
3114
5567−/// The prefixes of a prefixed group, bundle or `if()` inside `outer` (the
3115+fn split_commas(input: &str) -> Vec<&str> {
5568−/// prefixes of the groups it is in), when they are all known; else the
3116+ let mut parts = Vec::new();
5569−/// unknown one is reported. Text after a group's `]` is an error too.
3117+ let mut start = 0;
5570−fn group_prefix(
3118+ let mut depth = 0;
5571− token: &syntax::Attr,
3119+ let mut in_quotes = false;
5572− prefixed: &syntax::Prefixed,
3120+
5573− outer: &str,
3121+ for (i, c) in input.char_indices() {
5574− line: usize,
3122+ match c {
5575− ctx: &mut ParseContext,
3123+ '"' => in_quotes = !in_quotes,
5576−) -> Option<String> {
3124+ _ if in_quotes => {}
5577− let at = |ctx: &ParseContext, diagnostic: Diagnostic, span: syntax::Span| match &ctx
3125+ '[' | '(' => depth += 1,
5578− .current_source
3126+ ']' | ')' => depth -= 1,
5579− {
3127+ ',' if depth == 0 => {
5580− (current, Some(text)) if span.line != 0 && *current == span.line => {
3128+ parts.push(&input[start..i]);
5581− diagnostic.column(span.column).source(text.clone())
3129+ start = i + 1;
5582− }
5583− _ => diagnostic,
5584− };
5585− if let syntax::Branch::Group {
5586− trailing: Some(trailing),
5587− ..
5588− } = &prefixed.target
5589− {
5590− let diagnostic = Diagnostic::error(
5591− code::UNEXPECTED_ARGUMENT,
5592− line,
5593− format!(
5594− "'{}' after the `]` of `{}[...]`: the prefixes apply to the attributes in the \
5595− group, so it goes inside it",
5596− trailing.raw, prefixed.prefix
5597− ),
5598− )
5599− .subject(trailing.raw.as_str());
5600− let diagnostic = at(ctx, diagnostic, trailing.span);
5601− ctx.push_once(diagnostic);
5602− }
5603− let (known, rest) = crate::vocab::split_prefixes(&prefixed.prefix);
5604− if !rest.is_empty() {
5605− let problem = unknown_prefix(rest).unwrap_or_else(|| {
5606− let written = rest.split_inclusive(':').next().unwrap_or(rest);
5607− PrefixProblem {
5608− message: format!("unknown prefix '{}'", written),
5609− subject: written.to_string(),
5610− suggestion: None,
5611 }
3130 }
5612− });
3131+ _ => {}
5613− let diagnostic = Diagnostic::error(code::UNKNOWN_PREFIX, line, problem.message)
3132+ }
5614− .subject(problem.subject)
5615− .suggest(problem.suggestion);
5616− let diagnostic = at(ctx, diagnostic, token.span);
5617− ctx.push_once(diagnostic);
5618− return None;
5619 }
3133 }
5620− Some(format!("{}{}", outer, known.concat()))
3134+ parts.push(&input[start..]);
3135+ parts
5621}
3136}
5622
3137
5623−/// An attribute of the bundle `bundle`, spliced in under `prefix`
3138+// ---------------------------------------------------------------------------
5624−/// (`md:$card`): the same attribute with the prefixes in front, when it can
3139+// Chain splitting (the > operator)
5625−/// take them. An HTML attribute can't; a style's prefixes are checked, as
3140+// ---------------------------------------------------------------------------
5626−/// is a flag or a name that isn't a style (a bundle's parameter), which
3141+
5627−/// under a prefix are attributes like any other.
3142+fn split_chain(content: &str) -> Vec<String> {
5628−fn prefixed_member(
3143+ let mut segments = Vec::new();
5629− attr: &Attribute,
3144+ let mut current = String::new();
5630− prefix: &str,
3145+ let chars: Vec<char> = content.chars().collect();
5631− bundle: &str,
3146+ let mut i = 0;
5632− line: usize,
3147+ let mut bracket_depth = 0;
5633− column: Option<usize>,
3148+
5634− validate: bool,
3149+ while i < chars.len() {
5635− ctx: &mut ParseContext,
3150+ if chars[i] == '[' {
5636−) -> Option<Attribute> {
3151+ bracket_depth += 1;
5637− let key = format!("{}{}", prefix, attr.key);
5638− if attr.html {
5639− if validate {
5640− let diagnostic = Diagnostic::error(
5641− code::INVALID_PREFIX,
5642− line,
5643− format!(
5644− "'{}${}': the bundle has `{}=`, an HTML attribute, and a prefix applies to \
5645− styles, which have states",
5646− prefix, bundle, attr.key
5647− ),
5648− )
5649− .subject(format!("{}${}", prefix, bundle));
5650− let diagnostic = at_attribute(diagnostic, column, ctx);
5651− ctx.diagnostics.push(diagnostic);
5652 }
3152 }
5653− return None;
3153+ if chars[i] == ']' {
5654− }
3154+ bracket_depth -= 1;
5655− let prefixed = Attribute {
5656− key,
5657− ..attr.clone()
5658− };
5659− if validate {
5660− let fine = if attr.value.is_none() || could_be_parameter(attr) {
5661− check_attribute(&prefixed, line, column, None, ctx)
5662− } else {
5663− check_prefixes(&prefixed, line, column, ctx)
5664− };
5665− if !fine {
5666− return None;
5667 }
3155 }
5668− }
5669− Some(prefixed)
5670−}
5671
3156
5672−/// `class=hl-x`: the `hl-` prefix is htmlang's, for its generated classes,
3157+ // Match " > @" outside brackets
5673−/// whose rules would then apply to the element too. It goes into the page
3158+ if bracket_depth == 0
5674−/// as written, so this is a warning.
3159+ && i + 3 < chars.len()
5675−fn reserved_class(attr: &Attribute, line: usize, column: Option<usize>, ctx: &mut ParseContext) {
3160+ && chars[i] == ' '
5676− let classes = attr.value.as_deref().unwrap_or_default();
3161+ && chars[i + 1] == '>'
5677− for class in classes.split_whitespace() {
3162+ && chars[i + 2] == ' '
5678− if class.starts_with(crate::codegen::CLASS_PREFIX) {
3163+ && chars[i + 3] == '@'
5679− let diagnostic = Diagnostic::warning(
3164+ {
5680− code::INVALID_VALUE,
3165+ segments.push(current.trim().to_string());
5681− line,
3166+ current = String::new();
5682− format!(
3167+ i += 3; // skip " > ", keep the "@"
5683− "class '{}' starts with `{}`, which htmlang keeps for the classes it \
3168+ continue;
5684− generates, so their styles could apply to this element: give it \
5685− another name",
5686− class,
5687− crate::codegen::CLASS_PREFIX
5688− ),
5689− )
5690− .subject(class);
5691− let diagnostic = at_attribute(diagnostic, column, ctx);
5692− ctx.diagnostics.push(diagnostic);
5693 }
3169 }
3170+
3171+ current.push(chars[i]);
3172+ i += 1;
5694 }
3173 }
5695−}
5696
3174
5697−/// A style's key as the CSS reads it, for finding duplicates: its at-rule
3175+ if !current.trim().is_empty() {
5698−/// prefixes in one order (`dark:md:` is `md:dark:`), then its selector
3176+ segments.push(current.trim().to_string());
5699−/// prefixes as written, whose order is the selector's.
5700−fn normal_key(key: &str) -> String {
5701− let (prefixes, name) = crate::vocab::split_prefixes(key);
5702− let mut at: Vec<(usize, &str)> = prefixes
5703− .iter()
5704− .filter_map(|p| crate::vocab::at_rule_rank(p).map(|rank| (rank, *p)))
5705− .collect();
5706− at.sort_unstable();
5707− at.dedup();
5708− let mut normal: String = at.iter().map(|(_, p)| *p).collect();
5709− for prefix in prefixes
5710− .iter()
5711− .filter(|p| crate::vocab::at_rule_rank(p).is_none())
5712− {
5713− normal.push_str(prefix);
5714 }
3177 }
5715− normal.push_str(name);
5716− normal
5717−}
5718
3178
5719−/// `[$name]` where `$name` isn't an attribute bundle.
3179+ segments
5720−fn not_a_bundle(name: &str, line: usize, column: Option<usize>, ctx: &mut ParseContext) {
5721− track_var_refs(&format!("${}", name), &mut ctx.used_variables);
5722− let is_value = interp::name_len(name) > 0 && interp::resolve(name, &ctx.env).is_some();
5723− let diagnostic = if is_value {
5724− Diagnostic::error(
5725− code::ATTRIBUTE_FROM_VARIABLE,
5726− line,
5727− format!(
5728− "'${}' is a value, not an attribute: attributes come from a bundle \
5729− (`@let name [padding 8]`, used as `[$name]`), and a value fills an \
5730− attribute's value (`padding ${}`)",
5731− name, name
5732− ),
5733− )
5734− .subject(name)
5735− } else if ctx.env.function(name).is_some() {
5736− ctx.used_functions.insert(name.to_string());
5737− Diagnostic::error(
5738− code::UNDEFINED_VARIABLE,
5739− line,
5740− format!(
5741− "'${}' is a function, not an attribute bundle: it is called as an element, \
5742− `@{}`",
5743− name, name
5744− ),
5745− )
5746− .subject(name)
5747− } else {
5748− let mut bundles = ctx.env.bundle_names();
5749− bundles.sort_unstable();
5750− let suggestion = suggest_closest(name, &bundles);
5751− let message = match suggestion {
5752− Some(closest) => format!(
5753− "undefined attribute bundle '${}', did you mean '${}'?",
5754− name, closest
5755− ),
5756− None => format!("undefined attribute bundle '${}'", name),
5757− };
5758− Diagnostic::error(code::UNDEFINED_VARIABLE, line, message)
5759− .subject(name)
5760− .suggest(suggestion)
5761− };
5762− let diagnostic = match (&ctx.current_source, column) {
5763− ((current, Some(text)), Some(column)) if *current == line => {
5764− diagnostic.column(column).source(text.clone())
5765− }
5766− _ => diagnostic,
5767− };
5768− ctx.diagnostics.push(diagnostic);
5769}
3180}
5770
3181
5771// ---------------------------------------------------------------------------
3182// ---------------------------------------------------------------------------
5772// Text segment parsing (inline {...} elements)
3183// Text segment parsing (inline {...} elements)
5773// ---------------------------------------------------------------------------
3184// ---------------------------------------------------------------------------
5774
3185
5775−/// While a slot is filled in, each escape (see [`syntax::ESCAPES`]) is a
3186+fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment> {
5776−/// private-use placeholder, U+E000 plus its place in the table, so it
3187+ let mut segments = Vec::new();
5777−/// can't start a variable, a quote or an inline element. Afterwards it
3188+ let mut current_text = String::new();
5778−/// becomes what it stands for.
3189+ let chars: Vec<char> = input.chars().collect();
5779−fn protect_escapes(text: &str) -> String {
5780− if !text.contains('\\') {
5781− return text.to_string();
5782− }
5783− let mut out = String::with_capacity(text.len());
5784 let mut i = 0;
3190 let mut i = 0;
5785− while let Some(c) = text[i..].chars().next() {
5786− let len = syntax::escape_len(&text[i..]);
5787− if len == 0 {
5788− out.push(c);
5789− i += c.len_utf8();
5790− continue;
5791− }
5792− let escape = &text[i..i + len];
5793− let index = syntax::ESCAPES.iter().position(|e| *e == escape);
5794− out.extend(index.and_then(|n| char::from_u32(0xE000 + n as u32)));
5795− i += len;
5796− }
5797− out
5798−}
5799−
5800−/// The escape a placeholder stands for, as written (`\,`).
5801−fn escape_of(c: char) -> Option<&'static str> {
5802− let index = (c as u32).checked_sub(0xE000)?;
5803− syntax::ESCAPES.get(index as usize).copied()
5804−}
5805−
5806−fn restore_escapes(text: &str) -> String {
5807− text.chars()
5808− .fold(String::with_capacity(text.len()), |mut out, c| {
5809− match escape_of(c) {
5810− // What the escape stands for: what follows the backslash
5811− Some(escape) => out.push_str(&escape[1..]),
5812− None => out.push(c),
5813− }
5814− out
5815− })
5816−}
5817
3191
5818−/// Inside the quoted strings of a CSS value, `\"` and `\\` mean in CSS what
3192+ while i < chars.len() {
5819−/// they mean in htmlang, so they stay as written there.
3193+ if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '@' {
5820−fn keep_css_string_escapes(protected: &str) -> String {
3194+ // Flush accumulated plain text
5821− let mut out = String::with_capacity(protected.len());
3195+ if !current_text.is_empty() {
5822− let mut inside = false;
3196+ segments.push(TextSegment::Plain(substitute_vars(
5823− for c in protected.chars() {
3197+ &current_text,
5824− match escape_of(c) {
3198+ &ctx.variables,
5825− Some(escape @ ("\\\"" | "\\\\")) if inside => out.push_str(escape),
3199+ )));
5826− _ => {
3200+ current_text.clear();
5827− if c == '"' {
3201+ }
5828− inside = !inside;
3202+
3203+ // Find matching }
3204+ let mut depth = 0;
3205+ let start = i + 1; // after {
3206+ i += 1;
3207+ loop {
3208+ if i >= chars.len() {
3209+ break;
3210+ }
3211+ if chars[i] == '{' {
3212+ depth += 1;
3213+ } else if chars[i] == '}' {
3214+ if depth == 0 {
3215+ break;
3216+ }
3217+ depth -= 1;
5829 }
3218 }
5830− out.push(c);
3219+ i += 1;
5831 }
3220 }
5832− }
5833− }
5834− out
5835−}
5836
3221
5837−/// Evaluate text: fill in the variables of its plain runs and build its
3222+ let inner: String = chars[start..i].iter().collect();
5838−/// inline elements. Each run is one slot, so a value can't make markup.
3223+ i += 1; // skip }
5839−fn text_segments(text: &syntax::Text, ctx: &mut ParseContext) -> Vec<TextSegment> {
3224+
5840− let mut segments = Vec::new();
3225+ match parse_single_element(&inner, ctx.current_line, ctx) {
5841− // The plain text so far, and where it starts
3226+ Ok(elem) => segments.push(TextSegment::Inline(elem)),
5842− let mut plain = String::new();
3227+ Err(e) => {
5843− let mut start: Option<syntax::Span> = None;
3228+ // Keep the braces as literal text, but flag what looks
5844− let flush = |plain: &mut String,
3229+ // like a mistyped inline element.
5845− start: &mut Option<syntax::Span>,
3230+ if inner.trim_start().starts_with('@') {
5846− segments: &mut Vec<TextSegment>,
3231+ ctx.diagnostics.push(Diagnostic {
5847− ctx: &mut ParseContext| {
3232+ line: ctx.current_line,
5848− if !plain.is_empty() {
3233+ column: None,
5849− let at = start.unwrap_or(text.span);
3234+ message: e.message,
5850− let filled = ctx.interpolate_text(plain, at.line, Some(at.column));
3235+ severity: Severity::Warning,
5851− segments.push(TextSegment::Plain(filled));
3236+ source_line: Some(input.to_string()),
5852− plain.clear();
3237+ });
5853− }
5854− *start = None;
5855− };
5856− for segment in &text.segments {
5857− match segment {
5858− Segment::Plain { raw, span } => {
5859− start.get_or_insert(*span);
5860− plain.push_str(raw);
5861− }
5862− Segment::Inline(inline) => {
5863− let line = ctx.current_line;
5864− match resolve(&inline.head, inline.text.as_ref(), line, &text.raw, ctx) {
5865− Ok(resolved) => {
5866− if !inline.closed {
5867− let d = Diagnostic::warning(
5868− code::UNCLOSED_BRACKET,
5869− inline.span.line,
5870− format!(
5871− "unclosed `{{`: the inline @{} runs to the end of the line. \
5872− An inline element ends with `}}` (a brace that is text is \
5873− written `\\{{`)",
5874− inline.head.name
5875− ),
5876− );
5877− let d = at_attribute(d, Some(inline.span.column), ctx);
5878− ctx.push_once(d);
5879− }
5880− flush(&mut plain, &mut start, &mut segments, ctx);
5881− // A function called inline gets no children: its
5882− // content is the text after its attributes
5883− match Evaluator.complete(resolved, Vec::new(), ctx) {
5884− Ok(nodes) => segments.extend(inline_segments(nodes, line)),
5885− Err(mut e) => {
5886− e.source_line
5887− .get_or_insert_with(|| text.raw.as_str().into());
5888− ctx.diagnostics.push(e);
5889− }
5890− }
5891− }
5892− Err(mut e) => {
5893− // A misspelled element is an error inline too (a
5894− // brace that is text is written `\{`); the braces
5895− // stay as written
5896− e.source_line = Some(text.raw.as_str().into());
5897− ctx.diagnostics.push(e);
5898− start.get_or_insert(inline.span);
5899− plain.push('{');
5900− plain.push_str(&inline.raw);
5901− if inline.closed {
5902− plain.push('}');
5903− }
5904 }
3238 }
3239+ current_text.push('{');
3240+ current_text.push_str(&inner);
3241+ current_text.push('}');
5905 }
3242 }
5906 }
3243 }
3244+ } else {
3245+ current_text.push(chars[i]);
3246+ i += 1;
5907 }
3247 }
5908 }
3248 }
5909− flush(&mut plain, &mut start, &mut segments, ctx);
5910− segments
5911−}
5912
3249
5913−/// The variable `raw` is, when it is exactly one `$name` or `${name}`.
3250+ if !current_text.is_empty() {
5914−fn whole_reference(raw: &str, env: &Env) -> Option<String> {
3251+ segments.push(TextSegment::Plain(substitute_vars(
5915− let after = raw.trim().strip_prefix('$')?;
3252+ &current_text,
5916− match interp::reference(after, env)? {
3253+ &ctx.variables,
5917− (interp::Reference::Var(path), len) if len == after.len() => Some(path),
3254+ )));
5918− _ => None,
5919 }
3255 }
3256+
3257+ segments
5920}
3258}
5921
3259
5922−/// The attributes of `tokens` after each whole-attribute `if()` has
3260+// ---------------------------------------------------------------------------
5923−/// chosen (see [`syntax::Choice`]), in order, into `out`. The branch not
3261+// Variable substitution
5924−/// taken isn't evaluated; with `validate`, its attribute names are checked
3262+// ---------------------------------------------------------------------------
5925−/// like code that doesn't run, under the prefixes `prefix` the `if()` is
3263+
5926−/// under (`hover:if(...)`).
3264+// ---------------------------------------------------------------------------
5927−fn choose_attrs(
3265+// Condition evaluation for @if
5928− tokens: &[syntax::Attr],
3266+// ---------------------------------------------------------------------------
5929− prefix: &str,
3267+
5930− line_num: usize,
3268+/// Inclusive integer range from `start` to `end` (counting down when
5931− ctx: &mut ParseContext,
3269+/// `start > end`), stepping by `step`. Stops instead of overflowing.
5932− validate: bool,
3270+fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
5933− text_keys: &[String],
3271+ let mut items = Vec::new();
5934− out: &mut Vec<syntax::Attr>,
3272+ let mut n = start;
5935−) {
3273+ if start <= end {
5936− for token in tokens {
3274+ while n <= end {
5937− let Some(choice) = &token.choice else {
3275+ items.push(n.to_string());
5938− out.push(token.clone());
3276+ match n.checked_add(step) {
5939− continue;
3277+ Some(next) => n = next,
5940− };
3278+ None => break,
5941− let Some(condition) = choice_shape(token, choice, line_num, ctx) else {
5942− continue;
5943− };
5944− let taken = match condition.span.line {
5945− 0 => ctx.condition(&condition.raw, line_num, None),
5946− line => ctx.condition(&condition.raw, line, Some(condition.span.column)),
5947− };
5948− let (chosen, other) = match taken {
5949− true => (choice.branches.first(), choice.branches.get(1)),
5950− false => (choice.branches.get(1), choice.branches.first()),
5951− };
5952− if let Some(other) = other {
5953− track_var_refs(&branch_text(other), &mut ctx.used_variables);
5954− if validate {
5955− check_branch(token, other, prefix, line_num, ctx, text_keys);
5956 }
3279 }
5957 }
3280 }
5958− if let Some(branch) = chosen {
5959− choose_attrs(
5960− branch.attrs(),
5961− prefix,
5962− line_num,
5963− ctx,
5964− validate,
5965− text_keys,
5966− out,
5967− );
5968− }
5969− }
5970−}
5971−
5972−/// Report what is wrong with the shape of `if()`: an empty condition, a
5973−/// number of branches other than one or two, text after a group's `]`.
5974−/// Returns the condition when the `if()` can be evaluated.
5975−fn choice_shape<'a>(
5976− token: &syntax::Attr,
5977− choice: &'a syntax::Choice,
5978− line_num: usize,
5979− ctx: &mut ParseContext,
5980−) -> Option<&'a syntax::Arg> {
5981− let line = if token.span.line == 0 {
5982− line_num
5983 } else {
3281 } else {
5984− token.span.line
3282+ while n >= end {
5985− };
3283+ items.push(n.to_string());
5986− // The line the column points into, when it is the one being evaluated
3284+ match n.checked_sub(step) {
5987− let source = |ctx: &ParseContext, diagnostic: Diagnostic, at: usize| match &ctx.current_source {
3285+ Some(next) => n = next,
5988− (current, Some(text)) if *current == at => diagnostic.source(text.clone()),
3286+ None => break,
5989− _ => diagnostic,
3287+ }
5990− };
5991− for branch in &choice.branches {
5992− if let syntax::Branch::Group {
5993− trailing: Some(trailing),
5994− ..
5995− } = branch
5996− {
5997− let diagnostic = source(
5998− ctx,
5999− Diagnostic::error(
6000− code::UNEXPECTED_ARGUMENT,
6001− trailing.span.line,
6002− format!(
6003− "'{}' after the `]` of a group in if(): a branch is one attribute, \
6004− one `[group]` of attributes or one `$bundle`",
6005− trailing.raw
6006− ),
6007− )
6008− .column(trailing.span.column)
6009− .subject(trailing.raw.as_str()),
6010− trailing.span.line,
6011− );
6012− ctx.push_once(diagnostic);
6013− }
6014− }
6015− match &choice.condition {
6016− Some(condition) if (1..=2).contains(&choice.branches.len()) => Some(condition),
6017− _ => {
6018− let diagnostic = source(
6019− ctx,
6020− Diagnostic::error(
6021− code::INVALID_EXPRESSION,
6022− line,
6023− format!(
6024− "`{}`: if() takes a condition and one or two branches, \
6025− `if(CONDITION, A, B)`",
6026− token.raw
6027− ),
6028− )
6029− .column(token.span.column),
6030− line,
6031− );
6032− ctx.push_once(diagnostic);
6033− None
6034 }
3288 }
6035 }
3289 }
3290+ items
6036}
3291}
6037
3292
6038−/// A branch's text, for the names it uses.
6039−fn branch_text(branch: &syntax::Branch) -> String {
6040− let raws: Vec<&str> = branch.attrs().iter().map(|a| a.raw.as_str()).collect();
6041− raws.join(", ")
6042−}
6043
3293
6044−/// Check attributes that are never evaluated (in code that doesn't run,
6045−/// or the branch an `if()` didn't take), as far as they can be checked:
6046−/// names are, and literal values; a value with a variable depends on what
6047−/// the variable holds. Each branch of an `if()` is checked on its own.
6048−fn check_attrs(tokens: &[syntax::Attr], line: usize, ctx: &mut ParseContext, text_keys: &[String]) {
6049− parse_attr_list(&literal_attrs(tokens), line, ctx, true, text_keys);
6050− check_choices(tokens, "", line, ctx, text_keys);
6051−}
6052
3294
6053−/// [`check_attrs`] for a branch of the `if()` `token`, under the prefixes
3295+/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
6054−/// `prefix` it is under: `hover:if($on, color red, [id=x])` checks
3296+/// Split `key value if condition` into the attribute and its condition
6055−/// `hover:[id=x]`, an error whichever branch is taken.
3297+/// (ignoring ` if ` inside parentheses).
6056−fn check_branch(
3298+fn split_trailing_if(part: &str) -> (&str, Option<&str>) {
6057− token: &syntax::Attr,
3299+ let mut depth = 0;
6058− branch: &syntax::Branch,
3300+ for (i, c) in part.char_indices() {
6059− prefix: &str,
3301+ match c {
6060− line: usize,
3302+ '(' => depth += 1,
6061− ctx: &mut ParseContext,
3303+ ')' => depth -= 1,
6062− text_keys: &[String],
3304+ _ if depth == 0 && part[i..].starts_with(" if ") => {
6063−) {
3305+ return (&part[..i], Some(part[i + 4..].trim()));
6064− if prefix.is_empty() {
3306+ }
6065− check_attrs(branch.attrs(), line, ctx, text_keys);
3307+ _ => {}
6066− return;
3308+ }
6067 }
3309 }
6068− let list = syntax::AttrList {
3310+ (part, None)
6069− attrs: branch.attrs().to_vec(),
3311+}
6070− span: token.span,
3312+
6071− closed: true,
3313+/// Resolve a value written `if(cond, a, b)`: evaluate `cond` and keep the
6072− quoted_close: None,
3314+/// chosen branch's text. Other attributes are returned unchanged.
3315+fn choose_if_value(part: &str, ctx: &mut ParseContext, line: usize) -> String {
3316+ let (head, value) = match part.find(['=', ' ']) {
3317+ Some(pos) => part.split_at(pos + 1),
3318+ None => return part.to_string(),
6073 };
3319 };
6074− let grouped = syntax::Attr {
3320+ let value = value.trim();
6075− key: prefix.to_string(),
3321+ let Some(inner) = value.strip_prefix("if(").and_then(|v| v.strip_suffix(')')) else {
6076− value: None,
3322+ return part.to_string();
6077− html: false,
3323+ };
6078− raw: token.raw.clone(),
3324+ let args = split_if_args(inner);
6079− span: token.span,
3325+ if args.len() != 3 {
6080− choice: None,
3326+ return part.to_string();
6081− prefixed: Some(Box::new(syntax::Prefixed {
3327+ }
6082− prefix: prefix.to_string(),
3328+ let branch = if ctx.condition(args[0].trim(), line) {
6083− prefix_span: token.span,
3329+ args[1]
6084− target: syntax::Branch::Group {
3330+ } else {
6085− list,
3331+ args[2]
6086− trailing: None,
6087− },
6088− })),
6089 };
3332 };
6090− check_attrs(std::slice::from_ref(&grouped), line, ctx, text_keys);
3333+ format!("{}{}", head, branch.trim())
6091}
3334}
6092
3335
6093−/// [`check_attrs`] for the branches of each `if()` of `tokens`, which are
3336+fn split_if_args(input: &str) -> Vec<&str> {
6094−/// under the prefixes `prefix`, also inside a prefixed group or as a
3337+ let mut parts = Vec::new();
6095−/// prefixed `if()`.
3338+ let mut start = 0;
6096−fn check_choices(
3339+ let mut depth = 0;
6097− tokens: &[syntax::Attr],
3340+ for (i, c) in input.char_indices() {
6098− prefix: &str,
3341+ match c {
6099− line: usize,
3342+ '(' => depth += 1,
6100− ctx: &mut ParseContext,
3343+ ')' => depth -= 1,
6101− text_keys: &[String],
3344+ ',' if depth == 0 => {
6102−) {
3345+ parts.push(&input[start..i]);
6103− for token in tokens {
3346+ start = i + 1;
6104− if let Some(choice) = &token.choice
6105− && choice_shape(token, choice, line, ctx).is_some()
6106− {
6107− for branch in &choice.branches {
6108− check_branch(token, branch, prefix, line, ctx, text_keys);
6109 }
3347 }
6110− }
3348+ _ => {}
6111− if let Some(prefixed) = &token.prefixed {
6112− let inner = format!("{}{}", prefix, prefixed.prefix);
6113− check_choices(prefixed.target.attrs(), &inner, line, ctx, text_keys);
6114 }
3349 }
6115 }
3350 }
3351+ parts.push(&input[start..]);
3352+ parts
6116}
3353}
6117
3354
6118// ---------------------------------------------------------------------------
3355// ---------------------------------------------------------------------------
6119// Post-parse validation (context-dependent warnings)
3356// Post-parse validation (context-dependent warnings)
6120// ---------------------------------------------------------------------------
3357// ---------------------------------------------------------------------------
6121
3358
3359+/// Attributes that only make sense on container elements (@row, @column, @el).
3360+const CONTAINER_ONLY_ATTRS: &[&str] = &[
3361+ "spacing",
3362+ "gap",
3363+ "wrap",
3364+ "grid-cols",
3365+ "grid-rows",
3366+ "container",
3367+ "container-name",
3368+ "container-type",
3369+];
3370+
6122fn element_kind_name(kind: &ElementKind) -> String {
3371fn element_kind_name(kind: &ElementKind) -> String {
6123 format!("@{}", kind.name())
3372 format!("@{}", kind.name())
6124}
3373}
6125
3374
6126−/// HTML elements whose start tag ends an open `<p>`: written inside a
3375+fn is_container(kind: &ElementKind) -> bool {
6127−/// `@paragraph`, the browser moves them out of it. (`div` isn't here:
3376+ matches!(kind, ElementKind::Row | ElementKind::Column | ElementKind::El)
6128−/// inside text, htmlang writes its `<div>`s as `<span>`s.)
3377+ || kind.spec().is_some_and(|spec| spec.container)
6129−#[rustfmt::skip]
6130−const ENDS_A_PARAGRAPH: &[&str] = &[
6131− "address", "article", "aside", "blockquote", "dd", "details", "dialog", "dl", "dt",
6132− "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6",
6133− "header", "hgroup", "hr", "li", "main", "menu", "nav", "ol", "p", "pre", "search",
6134− "section", "summary", "table", "ul",
6135−];
6136−
6137−/// The HTML element `kind` is written as, if the browser would take it out
6138−/// of a `<p>`.
6139−fn ends_a_paragraph(kind: &ElementKind) -> Option<&'static str> {
6140− let html = match kind {
6141− ElementKind::Paragraph => "p",
6142− ElementKind::Tag(spec) => spec.html,
6143− _ => return None,
6144− };
6145− ENDS_A_PARAGRAPH.contains(&html).then_some(html)
6146}
3378}
6147
3379
6148/// Stricter checks for `htmlang lint`, on top of the diagnostics every
3380/// Stricter checks for `htmlang lint`, on top of the diagnostics every
@@ -6152,171 +3384,147 @@ pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
6152 fn walk(nodes: &[Node], depth: usize, out: &mut Vec<Diagnostic>) {
3384 fn walk(nodes: &[Node], depth: usize, out: &mut Vec<Diagnostic>) {
6153 for node in nodes {
3385 for node in nodes {
6154 let Node::Element(elem) = node else { continue };
3386 let Node::Element(elem) = node else { continue };
6155− let start = out.len();
3387+ let mut warn = |message: String| {
6156− let mut warn = |code: &'static str, message: String| {
3388+ out.push(Diagnostic {
6157− out.push(Diagnostic::warning(code, elem.line_num, message))
3389+ line: elem.line_num,
3390+ column: None,
3391+ message,
3392+ severity: Severity::Warning,
3393+ source_line: None,
3394+ })
6158 };
3395 };
6159 if depth > 10 {
3396 if depth > 10 {
6160− warn(
3397+ warn(format!(
6161− code::DEEP_NESTING,
3398+ "deeply nested element ({} levels): consider simplifying",
6162− format!(
3399+ depth
6163− "deeply nested element ({} levels): consider simplifying",
3400+ ));
6164− depth
6165− ),
6166− );
6167 }
3401 }
6168− // A function's body may draw with an empty element (a spacer,
3402+ if matches!(elem.kind, ElementKind::Row | ElementKind::Column | ElementKind::El)
6169− // a dot): only one written in the page is flagged
6170− if elem.kind.layout().is_container()
6171 && elem.children.is_empty()
3403 && elem.children.is_empty()
6172− && elem.function.is_none()
6173 {
3404 {
6174− warn(
3405+ warn(format!("empty container (@{}) has no children", elem.kind.name()));
6175− code::EMPTY_CONTAINER,
6176− format!("empty container (@{}) has no children", elem.kind.name()),
6177− );
6178 }
3406 }
6179 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
3407 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
6180− warn(
3408+ warn("@button missing 'type' attribute (defaults to submit)".to_string());
6181− code::MISSING_BUTTON_TYPE,
6182− "@button missing 'type' attribute (defaults to submit)".to_string(),
6183− );
6184 }
3409 }
6185− in_function_body(elem, &mut out[start..]);
6186 walk(&elem.children, depth + 1, out);
3410 walk(&elem.children, depth + 1, out);
6187 }
3411 }
6188 }
3412 }
6189 let mut out = Vec::new();
3413 let mut out = Vec::new();
6190 walk(nodes, 0, &mut out);
3414 walk(nodes, 0, &mut out);
6191− dedupe(&mut out);
6192 out
3415 out
6193}
3416}
6194
3417
6195−/// `in_paragraph`: the nodes are inside a `@paragraph` (and not inside a
3418+
6196−/// `@button` in it, which the browser keeps whole).
6197fn validate_tree(
3419fn validate_tree(
6198 nodes: &[Node],
3420 nodes: &[Node],
6199 parent_kind: Option<&ElementKind>,
3421 parent_kind: Option<&ElementKind>,
6200− in_paragraph: bool,
6201 diagnostics: &mut Vec<Diagnostic>,
3422 diagnostics: &mut Vec<Diagnostic>,
6202) {
3423) {
6203 for node in nodes {
3424 for node in nodes {
6204− // An element inside a line of text (`{@kbd ...}`, an inline call)
6205− // is checked like one on a line of its own
6206− if let Node::Text(segments) = node {
6207− let inline: Vec<Node> = segments
6208− .iter()
6209− .filter_map(|segment| match segment {
6210− TextSegment::Inline(elem) => Some(Node::Element(elem.clone())),
6211− _ => None,
6212− })
6213− .collect();
6214− validate_tree(&inline, parent_kind, in_paragraph, diagnostics);
6215− }
6216 if let Node::Element(elem) = node {
3425 if let Node::Element(elem) = node {
6217− let start = diagnostics.len();
6218− dropped_by_the_element(elem, diagnostics);
6219− if in_paragraph && let Some(html) = ends_a_paragraph(&elem.kind) {
6220− diagnostics.push(
6221− Diagnostic::warning(
6222− code::BLOCK_IN_PARAGRAPH,
6223− elem.line_num,
6224− format!(
6225− "{0} inside @paragraph: HTML ends a <p> before a <{1}>, so the \
6226− browser moves the <{1}> out of the paragraph. Put {0} after the \
6227− paragraph, or use @el, @row or @grid, which are laid out inline \
6228− in text",
6229− element_kind_name(&elem.kind),
6230− html
6231− ),
6232− )
6233− .subject(elem.kind.name()),
6234− );
6235− }
6236 for attr in &elem.attrs {
3426 for attr in &elem.attrs {
6237 let base = crate::vocab::base_attribute(&attr.key);
3427 let base = crate::vocab::base_attribute(&attr.key);
3428+ if base == "width"
3429+ && attr.value.as_deref() == Some("fill")
3430+ && !matches!(parent_kind, Some(ElementKind::Row))
3431+ {
3432+ diagnostics.push(Diagnostic {
3433+ line: elem.line_num,
3434+ column: None,
3435+ message: "'width fill' works best inside @row; using 100% as fallback"
3436+ .to_string(),
3437+ severity: Severity::Warning,
3438+ source_line: None,
3439+ });
3440+ }
3441+ if base == "height"
3442+ && attr.value.as_deref() == Some("fill")
3443+ && !matches!(parent_kind, Some(ElementKind::Column))
3444+ {
3445+ diagnostics.push(Diagnostic {
3446+ line: elem.line_num,
3447+ column: None,
3448+ message: "'height fill' works best inside @column; using 100% as fallback"
3449+ .to_string(),
3450+ severity: Severity::Warning,
3451+ source_line: None,
3452+ });
3453+ }
6238
3454
6239− // htmlang's words for laying out children, on an element
3455+ // Container-only attributes on non-container elements
6240− // that doesn't lay out its children: left out (codegen)
3456+ if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
6241− if !attr.html
3457+ diagnostics.push(Diagnostic {
6242− && crate::vocab::CONTAINER_ATTRIBUTES.contains(&base)
3458+ line: elem.line_num,
6243− && !elem.kind.layout().is_container()
3459+ column: None,
6244− && !has_no_element(&elem.kind)
3460+ message: format!(
6245− && !crate::vocab::split_prefixes(&attr.key)
3461+ "'{}' has no effect on {} (only works on @row, @column, @el)",
6246− .0
3462+ base,
6247− .contains(&"children:")
3463+ element_kind_name(&elem.kind)
6248− {
3464+ ),
6249− diagnostics.push(not_a_container(&elem.kind, elem.line_num, base));
3465+ severity: Severity::Warning,
3466+ source_line: None,
3467+ });
6250 }
3468 }
6251
3469
6252 // Form-specific: placeholder only on @input/@textarea
3470 // Form-specific: placeholder only on @input/@textarea
6253 if base == "placeholder"
3471 if base == "placeholder"
6254 && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
3472 && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
6255 {
3473 {
6256− diagnostics.push(Diagnostic::new(
3474+ diagnostics.push(Diagnostic {
6257− code::NO_EFFECT,
3475+ line: elem.line_num,
6258− Severity::Warning,
3476+ column: None,
6259− elem.line_num,
3477+ message: format!(
6260− format!(
6261 "'placeholder' has no effect on {} (only works on @input, @textarea)",
3478 "'placeholder' has no effect on {} (only works on @input, @textarea)",
6262 element_kind_name(&elem.kind)
3479 element_kind_name(&elem.kind)
6263 ),
3480 ),
6264− ));
3481+ severity: Severity::Warning,
3482+ source_line: None,
3483+ });
6265 }
3484 }
6266
3485
6267− // 'for' only on @label and @output
3486+ // 'for' only on @label
6268− if base == "for" && !(elem.kind.is_tag("label") || elem.kind.is_tag("output")) {
3487+ if base == "for" && !elem.kind.is_tag("label") {
6269− diagnostics.push(Diagnostic::new(
3488+ diagnostics.push(Diagnostic {
6270− code::NO_EFFECT,
3489+ line: elem.line_num,
6271− Severity::Warning,
3490+ column: None,
6272− elem.line_num,
3491+ message: format!(
6273− format!(
3492+ "'for' has no effect on {} (only works on @label)",
6274− "'for' has no effect on {} (only works on @label and @output)",
6275 element_kind_name(&elem.kind)
3493 element_kind_name(&elem.kind)
6276 ),
3494 ),
6277− ));
3495+ severity: Severity::Warning,
6278− }
3496+ source_line: None,
6279−
3497+ });
6280− // `width=` and `height=` are HTML attributes of media and
6281− // embedded content only; elsewhere they are styles
6282− if attr.html
6283− && (base == "width" || base == "height")
6284− && !matches!(elem.kind, ElementKind::Image)
6285− && ![
6286− "video", "canvas", "iframe", "embed", "object", "input", "source",
6287− ]
6288− .iter()
6289− .any(|tag| elem.kind.is_tag(tag))
6290− {
6291− let value = attr.value.as_deref().unwrap_or("");
6292− diagnostics.push(
6293− Diagnostic::warning(
6294− code::HTML_ATTRIBUTE_FORM,
6295− elem.line_num,
6296− format!(
6297− "'{0}={1}' has no effect on {2}, which has no {0} attribute: \
6298− the style is written `{0} {1}`",
6299− base,
6300− value,
6301− element_kind_name(&elem.kind)
6302− ),
6303− )
6304− .subject(format!("{}={}", base, value)),
6305− );
6306 }
3498 }
6307
3499
6308 // 'rows'/'cols' only on @textarea
3500 // 'rows'/'cols' only on @textarea
6309− if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea") {
3501+ if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea")
6310− diagnostics.push(Diagnostic::new(
3502+ {
6311− code::NO_EFFECT,
3503+ diagnostics.push(Diagnostic {
6312− Severity::Warning,
3504+ line: elem.line_num,
6313− elem.line_num,
3505+ column: None,
6314− format!(
3506+ message: format!(
6315 "'{}' has no effect on {} (only works on @textarea)",
3507 "'{}' has no effect on {} (only works on @textarea)",
6316 base,
3508 base,
6317 element_kind_name(&elem.kind)
3509 element_kind_name(&elem.kind)
6318 ),
3510 ),
6319− ));
3511+ severity: Severity::Warning,
3512+ source_line: None,
3513+ });
3514+ }
3515+
3516+ // 'ordered' only on @list
3517+ if base == "ordered" && !elem.kind.is_tag("list") {
3518+ diagnostics.push(Diagnostic {
3519+ line: elem.line_num,
3520+ column: None,
3521+ message: format!(
3522+ "'ordered' has no effect on {} (only works on @list)",
3523+ element_kind_name(&elem.kind)
3524+ ),
3525+ severity: Severity::Warning,
3526+ source_line: None,
3527+ });
6320 }
3528 }
6321
3529
6322 // Media-specific attributes only on @video/@audio
3530 // Media-specific attributes only on @video/@audio
@@ -6331,35 +3539,40 @@ fn validate_tree(
6331 | "preload"
3539 | "preload"
6332 ) && !matches!(elem.kind.name(), "video" | "audio")
3540 ) && !matches!(elem.kind.name(), "video" | "audio")
6333 {
3541 {
6334− diagnostics.push(Diagnostic::new(
3542+ diagnostics.push(Diagnostic {
6335− code::NO_EFFECT,
3543+ line: elem.line_num,
6336− Severity::Warning,
3544+ column: None,
6337− elem.line_num,
3545+ message: format!(
6338− format!(
6339 "'{}' has no effect on {} (only works on @video, @audio)",
3546 "'{}' has no effect on {} (only works on @video, @audio)",
6340 base,
3547 base,
6341 element_kind_name(&elem.kind)
3548 element_kind_name(&elem.kind)
6342 ),
3549 ),
6343− ));
3550+ severity: Severity::Warning,
3551+ source_line: None,
3552+ });
6344 }
3553 }
6345 }
3554 }
6346 // Missing alt text on @image
3555 // Missing alt text on @image
6347 if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt")
3556 if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt")
6348 {
3557 {
6349− diagnostics.push(Diagnostic::new(
3558+ diagnostics.push(Diagnostic {
6350− code::MISSING_ALT,
3559+ line: elem.line_num,
6351− Severity::Warning,
3560+ column: None,
6352− elem.line_num,
3561+ message: "@image missing 'alt' attribute (accessibility)".to_string(),
6353− "@image missing 'alt' attribute (accessibility)".to_string(),
3562+ severity: Severity::Warning,
6354− ));
3563+ source_line: None,
3564+ });
6355 }
3565 }
6356− if elem.kind.is_tag("input") && !elem.attrs.iter().any(|a| a.key == "type") {
3566+ if elem.kind.is_tag("input")
6357− diagnostics.push(Diagnostic::new(
3567+ && !elem.attrs.iter().any(|a| a.key == "type")
6358− code::MISSING_INPUT_TYPE,
3568+ {
6359− Severity::Warning,
3569+ diagnostics.push(Diagnostic {
6360− elem.line_num,
3570+ line: elem.line_num,
6361− "@input missing 'type' attribute (defaults to 'text')".to_string(),
3571+ column: None,
6362− ));
3572+ message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
3573+ severity: Severity::Warning,
3574+ source_line: None,
3575+ });
6363 }
3576 }
6364 if matches!(elem.kind, ElementKind::Link) {
3577 if matches!(elem.kind, ElementKind::Link) {
6365 // For @link, argument is the URL, not text content
3578 // For @link, argument is the URL, not text content
@@ -6370,50 +3583,44 @@ fn validate_tree(
6370 .iter()
3583 .iter()
6371 .any(|a| a.key == "aria-label" || a.key == "title")
3584 .any(|a| a.key == "aria-label" || a.key == "title")
6372 {
3585 {
6373− diagnostics.push(Diagnostic::new(
3586+ diagnostics.push(Diagnostic {
6374− code::MISSING_LINK_TEXT,
3587+ line: elem.line_num,
6375− Severity::Warning,
3588+ column: None,
6376− elem.line_num,
3589+ message: "@link has no visible text or aria-label (accessibility)"
6377− "@link has no visible text or aria-label (accessibility)".to_string(),
3590+ .to_string(),
6378− ));
3591+ severity: Severity::Warning,
3592+ source_line: None,
3593+ });
6379 }
3594 }
6380 }
3595 }
6381
3596
6382− // Contrast ratio check for hex color pairs, each pair on the
3597+ // Contrast ratio check for hex color pairs
6383− // same elements: the element's own, or those an element prefix
3598+ {
6384− // (`@td:`) picks out inside it
3599+ let bg_color = elem
6385− let inside = |key: &str| {
3600+ .attrs
6386− crate::vocab::split_prefixes(key)
3601+ .iter()
6387− .0
3602+ .find(|a| crate::vocab::base_attribute(&a.key) == "background")
6388− .into_iter()
3603+ .and_then(|a| a.value.as_deref());
6389− .find_map(crate::vocab::element_prefix)
3604+ let fg_color = elem
6390− .map(str::to_string)
3605+ .attrs
6391− };
3606+ .iter()
6392− let mut targets: Vec<Option<String>> = Vec::new();
3607+ .find(|a| crate::vocab::base_attribute(&a.key) == "color")
6393− for attr in elem.attrs.iter().filter(|a| !a.html) {
3608+ .and_then(|a| a.value.as_deref());
6394− let target = inside(&attr.key);
6395− if !targets.contains(&target) {
6396− targets.push(target);
6397− }
6398− }
6399− for target in targets {
6400− let color = |property: &str| {
6401− elem.attrs
6402− .iter()
6403− .filter(|a| !a.html && inside(&a.key) == target)
6404− .find(|a| crate::vocab::base_attribute(&a.key) == property)
6405− .and_then(|a| a.value.as_deref())
6406− };
6407− let (bg_color, fg_color) = (color("background"), color("color"));
6408 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
3609 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
6409− && let (Some(bg_rgb), Some(fg_rgb)) = (parse_hex_rgb(bg), parse_hex_rgb(fg))
3610+ && let (Some(bg_rgb), Some(fg_rgb)) = (crate::expr::parse_hex_rgb(bg), crate::expr::parse_hex_rgb(fg))
6410 {
3611 {
6411 let ratio = contrast_ratio(bg_rgb, fg_rgb);
3612 let ratio = contrast_ratio(bg_rgb, fg_rgb);
6412 if ratio < 4.5 {
3613 if ratio < 4.5 {
6413− diagnostics.push(Diagnostic::new(code::LOW_CONTRAST, Severity::Warning, elem.line_num, format!(
3614+ diagnostics.push(Diagnostic {
3615+ line: elem.line_num,
3616+ column: None,
3617+ message: format!(
6414 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
3618 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
6415 ratio, fg, bg
3619 ratio, fg, bg
6416− )));
3620+ ),
3621+ severity: Severity::Warning,
3622+ source_line: None,
3623+ });
6417 }
3624 }
6418 }
3625 }
6419 }
3626 }
@@ -6428,34 +3635,45 @@ fn validate_tree(
6428 let has_title = elem.attrs.iter().any(|a| a.key == "title");
3635 let has_title = elem.attrs.iter().any(|a| a.key == "title");
6429 let in_label = parent_kind.is_some_and(|k| k.is_tag("label"));
3636 let in_label = parent_kind.is_some_and(|k| k.is_tag("label"));
6430 if !has_id && !has_aria_label && !has_title && !in_label {
3637 if !has_id && !has_aria_label && !has_title && !in_label {
6431− diagnostics.push(Diagnostic::new(code::MISSING_LABEL, Severity::Warning, elem.line_num, format!(
3638+ diagnostics.push(Diagnostic {
3639+ line: elem.line_num,
3640+ column: None,
3641+ message: format!(
6432 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
3642 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
6433 element_kind_name(&elem.kind)
3643 element_kind_name(&elem.kind)
6434− )));
3644+ ),
3645+ severity: Severity::Warning,
3646+ source_line: None,
3647+ });
6435 }
3648 }
6436 }
3649 }
6437
3650
6438 // @iframe should have title attribute
3651 // @iframe should have title attribute
6439− if elem.kind.is_tag("iframe") && !elem.attrs.iter().any(|a| a.key == "title") {
3652+ if elem.kind.is_tag("iframe")
6440− diagnostics.push(Diagnostic::new(
3653+ && !elem.attrs.iter().any(|a| a.key == "title")
6441− code::MISSING_TITLE,
3654+ {
6442− Severity::Warning,
3655+ diagnostics.push(Diagnostic {
6443− elem.line_num,
3656+ line: elem.line_num,
6444− "@iframe missing 'title' attribute (accessibility)".to_string(),
3657+ column: None,
6445− ));
3658+ message: "@iframe missing 'title' attribute (accessibility)".to_string(),
3659+ severity: Severity::Warning,
3660+ source_line: None,
3661+ });
6446 }
3662 }
6447
3663
6448 // @button should have accessible text
3664 // @button should have accessible text
6449 if elem.kind.is_tag("button") {
3665 if elem.kind.is_tag("button") {
6450− let has_text = !elem.children.is_empty();
3666+ let has_text = elem.argument.is_some() || !elem.children.is_empty();
6451 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
3667 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
6452 if !has_text && !has_aria {
3668 if !has_text && !has_aria {
6453− diagnostics.push(Diagnostic::new(
3669+ diagnostics.push(Diagnostic {
6454− code::MISSING_BUTTON_TEXT,
3670+ line: elem.line_num,
6455− Severity::Warning,
3671+ column: None,
6456− elem.line_num,
3672+ message: "@button has no text content or aria-label (accessibility)"
6457− "@button has no text content or aria-label (accessibility)".to_string(),
3673+ .to_string(),
6458− ));
3674+ severity: Severity::Warning,
3675+ source_line: None,
3676+ });
6459 }
3677 }
6460 }
3678 }
6461
3679
@@ -6468,15 +3686,16 @@ fn validate_tree(
6468 let has_track = elem
3686 let has_track = elem
6469 .children
3687 .children
6470 .iter()
3688 .iter()
6471− .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("track")));
3689+ .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("source")));
6472 if !has_aria && !has_track {
3690 if !has_aria && !has_track {
6473− diagnostics.push(Diagnostic::new(
3691+ diagnostics.push(Diagnostic {
6474− code::MISSING_CAPTIONS,
3692+ line: elem.line_num,
6475− Severity::Warning,
3693+ column: None,
6476− elem.line_num,
3694+ message: "@video should have aria-label or captions for accessibility"
6477− "@video should have aria-label or a captions @track for accessibility"
6478 .to_string(),
3695 .to_string(),
6479− ));
3696+ severity: Severity::Warning,
3697+ source_line: None,
3698+ });
6480 }
3699 }
6481 }
3700 }
6482
3701
@@ -6486,204 +3705,20 @@ fn validate_tree(
6486 && let Ok(n) = val.parse::<i32>()
3705 && let Ok(n) = val.parse::<i32>()
6487 && n > 0
3706 && n > 0
6488 {
3707 {
6489− diagnostics.push(Diagnostic::new(code::POSITIVE_TABINDEX, Severity::Warning, elem.line_num, format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n)));
3708+ diagnostics.push(Diagnostic {
3709+ line: elem.line_num,
3710+ column: None,
3711+ message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n),
3712+ severity: Severity::Warning,
3713+ source_line: None,
3714+ });
6490 }
3715 }
6491− in_function_body(elem, &mut diagnostics[start..]);
6492−
6493− // An element that ends the <p> takes what is inside it out of
6494− // the paragraph too, so only it is reported
6495− let in_paragraph = elem.kind == ElementKind::Paragraph
6496− || (in_paragraph
6497− && ends_a_paragraph(&elem.kind).is_none()
6498− && !elem.kind.is_tag("button"));
6499− validate_tree(&elem.children, Some(&elem.kind), in_paragraph, diagnostics);
6500− }
6501− }
6502−}
6503−
6504−/// What an element has no place for in HTML, which would otherwise be
6505−/// left out of the page: attributes on `@fragment` (which has no element
6506−/// of its own), styles on `@script` (which isn't shown), a body under a
6507−/// `@script` that has a src (which the browser doesn't run), an element
6508−/// under a `@script` (whose body is code), and content in a void element
6509−/// such as `@input`.
6510−fn dropped_by_the_element(elem: &Element, diagnostics: &mut Vec<Diagnostic>) {
6511− let name = elem.kind.name();
6512− let keys = |attrs: Vec<&Attribute>| {
6513− attrs
6514− .iter()
6515− .map(|a| format!("'{}'", a.key))
6516− .collect::<Vec<_>>()
6517− .join(", ")
6518− };
6519− let mut error = |code: &'static str, message: String| {
6520− diagnostics.push(Diagnostic::error(code, elem.line_num, message).subject(name))
6521− };
6522− if elem.kind == ElementKind::Fragment && !elem.attrs.is_empty() {
6523− error(
6524− code::UNEXPECTED_ARGUMENT,
6525− format!(
6526− "@fragment has no element of its own to put attributes on, so {} would go \
6527− nowhere: put them on its children, or use @el",
6528− keys(elem.attrs.iter().collect())
6529− ),
6530− );
6531− }
6532− // The leading argument given twice: an attribute passed to the root
6533− // of a function whose body gives the argument (written on one line,
6534− // it is reported where it is read, and the attribute wins there)
6535− if let Some(argument) = &elem.argument
6536− && let Some(attr) = elem
6537− .attrs
6538− .iter()
6539− .find(|a| a.html && elem.kind.arg().attributes().contains(&a.key.as_str()))
6540− {
6541− error(
6542− code::DUPLICATE_ATTRIBUTE,
6543− format!(
6544− "@{} takes '{}' as its {}, and it also has {}={}: give it once",
6545− name,
6546− argument,
6547− leading_name(&elem.kind),
6548− attr.key,
6549− attr.value.as_deref().unwrap_or("")
6550− ),
6551− );
6552− }
6553− if elem.kind.is_verbatim() {
6554− let styles: Vec<&Attribute> = elem
6555− .attrs
6556− .iter()
6557− .filter(|a| !a.html && !crate::vocab::is_html_flag(&a.key))
6558− .collect();
6559− if !styles.is_empty() {
6560− error(
6561− code::UNEXPECTED_ARGUMENT,
6562− format!(
6563− "@{} isn't shown on the page, so its styles {} would go nowhere",
6564− name,
6565− keys(styles)
6566− ),
6567− );
6568− }
6569− let src = elem.argument.clone().or_else(|| {
6570− elem.attrs
6571− .iter()
6572− .find(|a| a.html && a.key == "src")
6573− .map(|a| a.value.clone().unwrap_or_default())
6574− });
6575− if let Some(src) = src
6576− && !elem.children.is_empty()
6577− {
6578− error(
6579− code::UNEXPECTED_CONTENT,
6580− format!(
6581− "@{0} has both a src ('{1}') and a body: the browser runs only the file, \
6582− so the body would go nowhere. Put the code in {1}, or leave out the src",
6583− name, src
6584− ),
6585− );
6586− } else if let Some(Node::Element(child)) = elem
6587− .children
6588− .iter()
6589− .find(|child| matches!(child, Node::Element(_)))
6590− {
6591− // `@script > @b x`: its body is code, not elements
6592− error(
6593− code::UNEXPECTED_CONTENT,
6594− format!(
6595− "@{} holds its code as the lines under it, kept as written, so @{} would \
6596− go nowhere",
6597− name,
6598− child.kind.name()
6599− ),
6600− );
6601− }
6602− }
6603− if elem.kind != ElementKind::Image && elem.attrs.iter().any(|a| !a.html && a.key == "inline") {
6604− error(
6605− code::UNEXPECTED_ARGUMENT,
6606− format!(
6607− "`inline` puts an image's file into the page, and @{} has none: it only \
6608− works on @image",
6609− name
6610− ),
6611− );
6612− }
6613− if elem.kind.layout() == Layout::Void && !elem.children.is_empty() {
6614− error(
6615− code::UNEXPECTED_CONTENT,
6616− format!(
6617− "@{} takes no content: it is an element without a closing tag, so its text \
6618− and indented lines would go nowhere",
6619− name
6620− ),
6621− );
6622− }
6623−}
6624−
6625−/// `@fragment`, `@script`, `@children` and `@slot`, whose attributes are
6626−/// reported on their own (see `dropped_by_the_element`).
6627−fn has_no_element(kind: &ElementKind) -> bool {
6628− matches!(
6629− kind,
6630− ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_)
6631− ) || kind.is_verbatim()
6632−}
6633−
6634−/// htmlang's word `word` for laying out children (`spacing`, `wrap`,
6635−/// `grid-cols`, `grid-rows`) on an element that doesn't lay out its
6636−/// children: an error, and the word is left out.
6637−fn not_a_container(kind: &ElementKind, line: usize, word: &str) -> Diagnostic {
6638− let name = element_kind_name(kind);
6639− let (what, instead) = match kind.layout() {
6640− Layout::Text => (
6641− "is text, whose lines and children flow together with no gap between them",
6642− "; to space the children out, put them in an @el or @row",
6643− ),
6644− Layout::Void => ("has no content to lay out", ""),
6645− _ => (
6646− "keeps HTML's own layout",
6647− "; for a flex layout of your own, write CSS, such as `display flex, gap 8`",
6648− ),
6649− };
6650− Diagnostic::error(
6651− code::NO_EFFECT,
6652− line,
6653− format!(
6654− "'{0}' can't go on {1}: {1} {2}. `{0}` works on a row, column or grid \
6655− (@el, @row, @grid, @section, ...){3}",
6656− word, name, what, instead
6657− ),
6658− )
6659− .subject(word)
6660−}
6661
3716
6662−/// Diagnostics about an element a function's body wrote are reported at
3717+ validate_tree(&elem.children, Some(&elem.kind), diagnostics);
6663−/// the call (see `belongs_to_call`); say which body the element is in.
6664−fn in_function_body(elem: &Element, diagnostics: &mut [Diagnostic]) {
6665− if let Some(function) = &elem.function {
6666− for d in diagnostics {
6667− d.message = format!("{}\n in the body of @{}", d.message, function);
6668 }
3718 }
6669 }
3719 }
6670}
3720}
6671
3721
6672−/// Report each diagnostic once: a line that runs many times (in a loop, in
6673−/// a function called from one place many times) reports its problems once.
6674−fn dedupe(diagnostics: &mut Vec<Diagnostic>) {
6675− let mut seen = HashSet::new();
6676− diagnostics.retain(|d| {
6677− seen.insert((
6678− d.code,
6679− d.line,
6680− d.column,
6681− d.message.clone(),
6682− d.severity as u8,
6683− ))
6684− });
6685−}
6686−
6687fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
3722fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
6688 fn linearize(c: u8) -> f64 {
3723 fn linearize(c: u8) -> f64 {
6689 let s = c as f64 / 255.0;
3724 let s = c as f64 / 255.0;
@@ -6696,24 +3731,6 @@ fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
6696 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
3731 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
6697}
3732}
6698
3733
6699−/// The red, green and blue of a `#rgb` or `#rrggbb` colour.
6700−fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
6701− let s = s.strip_prefix('#')?;
6702− if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
6703− return None;
6704− }
6705− let channel = |i: usize, len: usize| u8::from_str_radix(&s[i..i + len], 16).ok();
6706− match s.len() {
6707− 3 => Some((
6708− channel(0, 1)? * 17,
6709− channel(1, 1)? * 17,
6710− channel(2, 1)? * 17,
6711− )),
6712− 6 => Some((channel(0, 2)?, channel(2, 2)?, channel(4, 2)?)),
6713− _ => None,
6714− }
6715−}
6716−
6717fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
3734fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
6718 let l1 = relative_luminance(c1.0, c1.1, c1.2);
3735 let l1 = relative_luminance(c1.0, c1.1, c1.2);
6719 let l2 = relative_luminance(c2.0, c2.1, c2.2);
3736 let l2 = relative_luminance(c2.0, c2.1, c2.2);
@@ -6721,48 +3738,66 @@ fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
6721 (lighter + 0.05) / (darker + 0.05)
3738 (lighter + 0.05) / (darker + 0.05)
6722}
3739}
6723
3740
3741+// ---------------------------------------------------------------------------
3742+// Variable substitution
3743+// ---------------------------------------------------------------------------
3744+
3745+/// Interpolate `$name` variables and `${expr}` expressions into text.
3746+/// Undefined variables, and expressions that don't evaluate, are left as
3747+/// written so they show up in the output.
3748+fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
3749+ if !input.contains('$') {
3750+ return input.to_string();
3751+ }
3752+ let is_name_char = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
3753+ let mut result = String::with_capacity(input.len());
3754+ let mut rest = input;
3755+ while let Some(pos) = rest.find('$') {
3756+ result.push_str(&rest[..pos]);
3757+ let after = &rest[pos + 1..];
3758+ if after.starts_with('{')
3759+ && let Some(close) = matching_brace(after)
3760+ {
3761+ let source = &after[1..close];
3762+ let resolve = |name: &str| vars.get(name).cloned();
3763+ match crate::expr::eval(source, &resolve) {
3764+ Ok(value) => result.push_str(&value.to_string()),
3765+ Err(_) => result.push_str(&rest[pos..pos + 1 + close + 1]),
3766+ }
3767+ rest = &after[close + 1..];
3768+ continue;
3769+ }
3770+ let mut end = after.find(|c: char| !is_name_char(c)).unwrap_or(after.len());
3771+ while end > 0 && after[..end].ends_with('.') {
3772+ end -= 1;
3773+ }
3774+ let name = &after[..end];
3775+ match vars.get(name) {
3776+ Some(value) if !name.is_empty() => result.push_str(value),
3777+ _ => {
3778+ result.push('$');
3779+ result.push_str(name);
3780+ }
3781+ }
3782+ rest = &after[end..];
3783+ }
3784+ result.push_str(rest);
3785+ result
3786+}
3787+
6724/// `@image [inline] icon.svg` becomes the SVG's markup, with `width`,
3788/// `@image [inline] icon.svg` becomes the SVG's markup, with `width`,
6725−/// `height`, `color` / `fill` and the HTML attributes (`class=`, `id=`,
3789+/// `height`, `color` / `fill`, `class=` and `id=` applied to the `<svg>`
6726−/// `aria-label=`, ...) applied to the `<svg>` tag; any other attribute is
3790+/// tag. Other nodes are returned unchanged.
6727−/// an error. Other nodes are returned unchanged.
6728fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
3791fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
6729 let Node::Element(elem) = &node else {
3792 let Node::Element(elem) = &node else {
6730 return node;
3793 return node;
6731 };
3794 };
6732− let inline = elem.kind == ElementKind::Image
3795+ let is_inline_svg = elem.kind == ElementKind::Image
6733 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
3796 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
6734− && elem.argument.as_deref().is_some_and(|src| !src.is_empty());
3797+ && elem.argument.as_deref().is_some_and(|src| src.ends_with(".svg"));
6735− if !inline {
3798+ if !is_inline_svg {
6736 return node;
3799 return node;
6737 }
3800 }
6738− // A raster image: its file, read relative to this one, as a `data:` URI
6739− if !elem
6740− .argument
6741− .as_deref()
6742− .is_some_and(|src| src.ends_with(".svg"))
6743− {
6744− let Node::Element(mut elem) = node else {
6745− return node;
6746− };
6747− let filename = elem.argument.clone().unwrap_or_default();
6748− let resolved = ctx.resolve(&filename);
6749− match std::fs::read(&resolved) {
6750− Ok(data) => {
6751− elem.argument = Some(crate::codegen::data_uri(&filename, &data));
6752− ctx.included_files.push(resolved);
6753− }
6754− Err(e) => ctx.diagnostics.push(Diagnostic::new(
6755− code::UNREADABLE_FILE,
6756− Severity::Error,
6757− line_num,
6758− format!(
6759− "cannot load image '{}' to put it into the page: {}",
6760− filename, e
6761− ),
6762− )),
6763− }
6764− return Node::Element(elem);
6765− }
6766 let filename = elem.argument.clone().unwrap_or_default();
3801 let filename = elem.argument.clone().unwrap_or_default();
6767 let resolved = match &ctx.base_path {
3802 let resolved = match &ctx.base_path {
6768 Some(base) => base.join(&filename),
3803 Some(base) => base.join(&filename),
@@ -6771,104 +3806,145 @@ fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
6771 let mut svg = match std::fs::read_to_string(&resolved) {
3806 let mut svg = match std::fs::read_to_string(&resolved) {
6772 Ok(text) => text.trim().to_string(),
3807 Ok(text) => text.trim().to_string(),
6773 Err(e) => {
3808 Err(e) => {
6774− ctx.diagnostics.push(Diagnostic::new(
3809+ ctx.diagnostics.push(Diagnostic {
6775− code::UNREADABLE_FILE,
3810+ line: line_num,
6776− Severity::Error,
3811+ column: None,
6777− line_num,
3812+ message: format!("cannot load SVG '{}': {}", filename, e),
6778− format!("cannot load SVG '{}': {}", filename, e),
3813+ severity: Severity::Error,
6779− ));
3814+ source_line: None,
3815+ });
6780 return node;
3816 return node;
6781 }
3817 }
6782 };
3818 };
6783 ctx.included_files.push(resolved);
3819 ctx.included_files.push(resolved);
6784− // The file's markup has no class of its own, so a style reaches it only
6785− // as one of the root's attributes: anything else is reported, never
6786− // left out silently
6787− let mut left_out: Vec<String> = Vec::new();
6788− // The styles go in the root's `style=`, where a length takes its unit
6789− // and `var(--name)` works, as in any CSS
6790− let mut style: Vec<String> = Vec::new();
6791 for attr in &elem.attrs {
3820 for attr in &elem.attrs {
6792− let target = match (attr.key.as_str(), attr.html, &attr.value) {
3821+ let Some(value) = &attr.value else { continue };
6793− ("inline", false, None) | ("src", true, _) => continue,
3822+ let target = match (attr.key.as_str(), attr.html) {
6794− // `fill` and `shrink` are layout words, which need a class
3823+ ("width", false) | ("height", false) => attr.key.as_str(),
6795− ("width" | "height", false, Some(value))
3824+ ("color", false) | ("fill", false) => "fill",
6796− if matches!(value.as_str(), "fill" | "shrink") =>
3825+ ("class", true) | ("id", true) => attr.key.as_str(),
6797− {
3826+ _ => continue,
6798− left_out.push(format!("{} {}", attr.key, value));
6799− continue;
6800− }
6801− ("width" | "height", false, Some(value)) => {
6802− let value = crate::vocab::with_px(&attr.key, value);
6803− style.push(format!("{}:{}", attr.key, value));
6804− continue;
6805− }
6806− ("color" | "fill", false, Some(value)) => {
6807− style.push(format!("fill:{}", value));
6808− continue;
6809− }
6810− ("style", true, Some(value)) => {
6811− style.insert(0, value.trim().trim_end_matches(';').to_string());
6812− continue;
6813− }
6814− ("alt", true, _) => {
6815− left_out.push("alt".to_string());
6816− continue;
6817− }
6818− (key, true, Some(_)) => key,
6819− (key, false, None) if crate::vocab::is_html_flag(key) => key,
6820− _ => {
6821− left_out.push(attr.key.clone());
6822− continue;
6823− }
6824 };
3827 };
6825− let value = attr.value.as_deref().unwrap_or("");
3828+ svg = set_svg_attr(&svg, target, value);
6826− svg = set_svg_attr(&svg, target, &html_escape_md(value));
6827 }
3829 }
6828− if !style.is_empty() {
3830+ Node::Raw(svg)
6829− // After the file's own `style=`, which stays: these come later, so
3831+}
6830− // they win where both set a property
3832+
6831− let mut value = html_escape_md(&style.join(";"));
3833+/// The first `$name|filter` (old filter syntax) in `text`, as (name, filter).
6832− if let Some(own) = svg_attr_value(&svg, "style")
3834+fn old_filter_syntax(text: &str) -> Option<(String, String)> {
6833− .map(|range| svg[range].trim().trim_end_matches(';').to_string())
3835+ const FILTERS: &[&str] = &[
6834− .filter(|own| !own.is_empty())
3836+ "uppercase", "lowercase", "capitalize", "trim", "length", "reverse", "truncate",
6835− {
3837+ "replace", "default", "lighten", "darken", "alpha", "mix",
6836− value = format!("{};{}", own, value);
3838+ ];
3839+ let mut rest = text;
3840+ while let Some(pos) = rest.find('$') {
3841+ let after = &rest[pos + 1..];
3842+ let end = after
3843+ .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.')))
3844+ .unwrap_or(after.len());
3845+ if let Some(filter) = after[end..].strip_prefix('|') {
3846+ let name_end = filter.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(filter.len());
3847+ if end > 0 && FILTERS.contains(&&filter[..name_end]) {
3848+ return Some((after[..end].to_string(), filter[..name_end].to_string()));
3849+ }
6837 }
3850 }
6838− svg = set_svg_attr(&svg, "style", &value);
3851+ rest = &after[end..];
6839 }
3852 }
6840− if !left_out.is_empty() {
3853+ None
6841− let alt = left_out.iter().any(|name| name == "alt");
3854+}
6842− let names = left_out
3855+
6843− .iter()
3856+/// Index of the `}` matching the `{` that `s` starts with (skipping
6844− .map(|name| format!("'{}'", name))
3857+/// braces inside string literals).
6845− .collect::<Vec<_>>()
3858+fn matching_brace(s: &str) -> Option<usize> {
6846− .join(", ");
3859+ let mut depth = 0;
6847− let mut message = format!(
3860+ let mut in_string = false;
6848− "{} can't go on an inline SVG: `@image [inline]` puts the file's own markup into the page, which takes `width` and `height` as lengths, `color` or `fill` (as its fill) and HTML attributes (`class=`, `aria-label=`, ...)",
3861+ for (i, c) in s.char_indices() {
6849− names
3862+ match c {
6850− );
3863+ '"' => in_string = !in_string,
6851− if alt {
3864+ _ if in_string => {}
6852− message.push_str("; an SVG has no alt text: name it with `aria-label=` and `role=img`");
3865+ '{' => depth += 1,
3866+ '}' => {
3867+ depth -= 1;
3868+ if depth == 0 {
3869+ return Some(i);
3870+ }
3871+ }
3872+ _ => {}
6853 }
3873 }
6854− ctx.diagnostics.push(Diagnostic::new(
6855− code::UNEXPECTED_ARGUMENT,
6856− Severity::Error,
6857− line_num,
6858− message,
6859− ));
6860 }
3874 }
6861− Node::Raw(svg)
3875+ None
3876+}
3877+
3878+/// Parse a keyframe line in htmlang syntax: `from [opacity 0]` / `50% [transform scale(1.5)]`
3879+fn parse_keyframe_line(line: &str) -> Option<String> {
3880+ let (selector, rest) = if let Some(rest) = line.strip_prefix("from") {
3881+ ("from", rest.trim())
3882+ } else if let Some(rest) = line.strip_prefix("to") {
3883+ ("to", rest.trim())
3884+ } else if let Some(pct_end) = line.find('%') {
3885+ let rest = line[pct_end + 1..].trim();
3886+ let selector = &line[..pct_end + 1];
3887+ (selector, rest)
3888+ } else {
3889+ return None;
3890+ };
3891+
3892+ if !rest.starts_with('[') || !rest.ends_with(']') {
3893+ return None;
3894+ }
3895+
3896+ let inner = &rest[1..rest.len() - 1];
3897+ // Parse comma-separated key-value pairs into CSS
3898+ let mut css = String::new();
3899+ for part in split_commas(inner) {
3900+ let part = part.trim();
3901+ if part.is_empty() {
3902+ continue;
3903+ }
3904+ if let Some((key, value)) = part.split_once(' ') {
3905+ css.push_str(key.trim());
3906+ css.push(':');
3907+ css.push_str(value.trim());
3908+ css.push(';');
3909+ }
3910+ }
3911+
3912+ if css.is_empty() {
3913+ return None;
3914+ }
3915+
3916+ Some(format!("{}{{{}}}", selector, css))
6862}
3917}
6863
3918
6864// ---------------------------------------------------------------------------
3919// ---------------------------------------------------------------------------
6865// Variable usage tracking
3920// Variable usage tracking
6866// ---------------------------------------------------------------------------
3921// ---------------------------------------------------------------------------
6867
3922
6868−/// Record the names a string refers to (by syntax) as used.
3923+/// Scan a string for $name references and record them in the used set.
6869fn track_var_refs(input: &str, used: &mut HashSet<String>) {
3924fn track_var_refs(input: &str, used: &mut HashSet<String>) {
6870− if input.contains('$') {
3925+ if !input.contains('$') {
6871− used.extend(interp::names(input).into_iter().map(String::from));
3926+ return;
3927+ }
3928+ let chars: Vec<char> = input.chars().collect();
3929+ let mut i = 0;
3930+ while i < chars.len() {
3931+ if chars[i] == '$'
3932+ && i + 1 < chars.len()
3933+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
3934+ {
3935+ let start = i + 1;
3936+ let mut end = start;
3937+ while end < chars.len()
3938+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
3939+ {
3940+ end += 1;
3941+ }
3942+ let name: String = chars[start..end].iter().collect();
3943+ used.insert(name);
3944+ i = end;
3945+ } else {
3946+ i += 1;
3947+ }
6872 }
3948 }
6873}
3949}
6874
3950
@@ -6876,63 +3952,49 @@ fn track_var_refs(input: &str, used: &mut HashSet<String>) {
6876// Unused definition warnings
3952// Unused definition warnings
6877// ---------------------------------------------------------------------------
3953// ---------------------------------------------------------------------------
6878
3954
6879−/// Warn about definitions nothing uses, except `exported` ones (the
3955+fn check_unused(ctx: &mut ParseContext) {
6880−/// definitions of a library, for the files that include it).
6881−fn check_unused(ctx: &mut ParseContext, exported: &HashSet<String>) {
6882 // Check unused @let variables
3956 // Check unused @let variables
6883 for (name, &line) in &ctx.let_lines {
3957 for (name, &line) in &ctx.let_lines {
6884− if exported.contains(name) {
3958+ if name.starts_with("--") {
6885− // A library's names are for other files (a custom property
3959+ continue; // CSS vars are always used
6886− // isn't a variable, so it is never listed here)
6887− continue;
6888 }
3960 }
6889 if !ctx.used_variables.contains(name) {
3961 if !ctx.used_variables.contains(name) {
6890− ctx.diagnostics.push(
3962+ ctx.diagnostics.push(Diagnostic {
6891− Diagnostic::new(
3963+ line,
6892− code::UNUSED_VARIABLE,
3964+ column: None,
6893− Severity::Warning,
3965+ message: format!("unused variable '${}' (defined but never referenced)", name),
6894− line,
3966+ severity: Severity::Warning,
6895− format!("unused variable '${}' (defined but never referenced)", name),
3967+ source_line: None,
6896− )
3968+ });
6897− .subject(name.as_str()),
6898− );
6899 }
3969 }
6900 }
3970 }
6901
3971
6902− // Check unused attribute bundles (@let name [...]). A `$name` in code
3972+ // Check unused attribute bundles (@let name [...])
6903− // that doesn't run (an `if()` branch or `@if` not taken) counts too.
6904 for (name, &line) in &ctx.define_lines {
3973 for (name, &line) in &ctx.define_lines {
6905− if !ctx.used_defines.contains(name)
3974+ if !ctx.used_defines.contains(name) {
6906− && !ctx.used_variables.contains(name)
3975+ ctx.diagnostics.push(Diagnostic {
6907− && !exported.contains(name)
3976+ line,
6908− {
3977+ column: None,
6909− ctx.diagnostics.push(
3978+ message: format!(
6910− Diagnostic::new(
3979+ "unused attribute bundle '${}' (defined but never referenced)",
6911− code::UNUSED_BUNDLE,
3980+ name
6912− Severity::Warning,
3981+ ),
6913− line,
3982+ severity: Severity::Warning,
6914− format!(
3983+ source_line: None,
6915− "unused attribute bundle '${}' (defined but never referenced)",
3984+ });
6916− name
6917− ),
6918− )
6919− .subject(name.as_str()),
6920− );
6921 }
3985 }
6922 }
3986 }
6923
3987
6924 // Check unused functions (@let name ... with body)
3988 // Check unused functions (@let name ... with body)
6925 for (name, &line) in &ctx.fn_lines {
3989 for (name, &line) in &ctx.fn_lines {
6926− if !ctx.used_functions.contains(name) && !exported.contains(name) {
3990+ if !ctx.used_functions.contains(name) {
6927− ctx.diagnostics.push(
3991+ ctx.diagnostics.push(Diagnostic {
6928− Diagnostic::new(
3992+ line,
6929− code::UNUSED_FUNCTION,
3993+ column: None,
6930− Severity::Warning,
3994+ message: format!("unused function '@{}' (defined but never called)", name),
6931− line,
3995+ severity: Severity::Warning,
6932− format!("unused function '@{}' (defined but never called)", name),
3996+ source_line: None,
6933− )
3997+ });
6934− .subject(name.as_str()),
6935− );
6936 }
3998 }
6937 }
3999 }
6938}
4000}
@@ -7018,6 +4080,28 @@ fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
7018 }
4080 }
7019}
4081}
7020
4082
4083+/// Split a function body into its content and an @style block at the
4084+/// body's top level (the block's CSS lines, without the `@style` line).
4085+fn split_style_block(body: Vec<Line>) -> (Vec<Line>, Vec<Line>) {
4086+ let top = body.iter().map(|l| l.indent).min().unwrap_or(0);
4087+ let mut content = Vec::new();
4088+ let mut style = Vec::new();
4089+ let mut in_style = false;
4090+ for line in body {
4091+ let is_style_header =
4092+ line.indent == top && matches!(&line.content, LineContent::Normal(s) if s.trim() == "@style");
4093+ if is_style_header {
4094+ in_style = true;
4095+ } else if in_style && line.indent > top {
4096+ style.push(line);
4097+ } else {
4098+ in_style = false;
4099+ content.push(line);
4100+ }
4101+ }
4102+ (content, style)
4103+}
4104+
7021fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
4105fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
7022 pos = skip_ws(chars, pos);
4106 pos = skip_ws(chars, pos);
7023 if pos >= chars.len() {
4107 if pos >= chars.len() {
@@ -7198,21 +4282,75 @@ fn skip_ws(chars: &[char], mut pos: usize) -> usize {
7198 pos
4282 pos
7199}
4283}
7200
4284
7201−/// A JSON value as an htmlang value: an object is a record, an array a
4285+/// Flatten a JSON value into variable assignments.
7202−/// list, a string text (never quoted text), `null` empty. A number keeps
4286+/// - Top-level object: each key becomes `prefix.key`
7203−/// the text it is written with (`1.50`), and reads as a number.
4287+/// - Top-level array: `prefix` becomes comma-separated, `prefix._count` set
7204−fn json_value(json: JsonValue) -> Value {
4288+/// - Array of objects: each item becomes space-separated values for @each destructuring
7205− match json {
4289+fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, String>) {
7206− JsonValue::Null => Value::empty(),
4290+ match value {
7207− JsonValue::Bool(b) => Value::Bool(b),
4291+ JsonValue::Str(s) => {
7208− JsonValue::Number(n) | JsonValue::Str(n) => Value::Str(n),
4292+ vars.insert(prefix.to_string(), s.clone());
7209− JsonValue::Array(items) => Value::list(items.into_iter().map(json_value).collect()),
4293+ }
7210− JsonValue::Object(pairs) => Value::Record(Rc::new(
4294+ JsonValue::Number(n) => {
7211− pairs
4295+ vars.insert(prefix.to_string(), n.clone());
7212− .into_iter()
4296+ }
7213− .map(|(key, value)| (key, json_value(value)))
4297+ JsonValue::Bool(b) => {
7214− .collect(),
4298+ vars.insert(prefix.to_string(), b.to_string());
7215− )),
4299+ }
4300+ JsonValue::Null => {
4301+ vars.insert(prefix.to_string(), String::new());
4302+ }
4303+ JsonValue::Object(pairs) => {
4304+ for (key, val) in pairs {
4305+ flatten_json(&format!("{}.{}", prefix, key), val, vars);
4306+ }
4307+ }
4308+ JsonValue::Array(items) => {
4309+ vars.insert(format!("{}._count", prefix), items.len().to_string());
4310+ // Check if all items are objects with the same keys
4311+ let all_objects = items.iter().all(|v| matches!(v, JsonValue::Object(_)));
4312+ if all_objects && !items.is_empty() {
4313+ // Collect keys from first object for destructuring
4314+ if let JsonValue::Object(first_pairs) = &items[0] {
4315+ let keys: Vec<String> = first_pairs.iter().map(|(k, _)| k.clone()).collect();
4316+ vars.insert(format!("{}._keys", prefix), keys.join(","));
4317+ }
4318+ // Each item becomes space-separated values, items comma-separated
4319+ let csv: Vec<String> = items
4320+ .iter()
4321+ .map(|item| {
4322+ if let JsonValue::Object(pairs) = item {
4323+ pairs
4324+ .iter()
4325+ .map(|(_, v)| json_value_to_string(v))
4326+ .collect::<Vec<_>>()
4327+ .join(" ")
4328+ } else {
4329+ json_value_to_string(item)
4330+ }
4331+ })
4332+ .collect();
4333+ vars.insert(prefix.to_string(), csv.join(","));
4334+ } else {
4335+ // Primitive array: comma-separated
4336+ let csv: Vec<String> = items.iter().map(json_value_to_string).collect();
4337+ vars.insert(prefix.to_string(), csv.join(","));
4338+ }
4339+ // Also set indexed access: prefix.0, prefix.1, etc.
4340+ for (i, item) in items.iter().enumerate() {
4341+ flatten_json(&format!("{}.{}", prefix, i), item, vars);
4342+ }
4343+ }
4344+ }
4345+}
4346+
4347+fn json_value_to_string(v: &JsonValue) -> String {
4348+ match v {
4349+ JsonValue::Str(s) => s.clone(),
4350+ JsonValue::Number(n) => n.clone(),
4351+ JsonValue::Bool(b) => b.to_string(),
4352+ JsonValue::Null => String::new(),
4353+ JsonValue::Array(_) | JsonValue::Object(_) => String::new(),
7216 }
4354 }
7217}
4355}
7218
4356
@@ -7220,29 +4358,6 @@ fn json_value(json: JsonValue) -> Value {
7220// SVG attribute injection helper
4358// SVG attribute injection helper
7221// ---------------------------------------------------------------------------
4359// ---------------------------------------------------------------------------
7222
4360
7223−/// Where the root `<svg ...>` tag's `attr_name="..."` value is, as a byte
7224−/// range of `svg`. Only the opening tag is read, so a child's attribute is
7225−/// never found, and the name must follow whitespace, so `width` doesn't
7226−/// match `stroke-width`.
7227−fn svg_attr_value(svg: &str, attr_name: &str) -> Option<std::ops::Range<usize>> {
7228− let tag_start = svg.find("<svg")?;
7229− let tag_end = svg[tag_start..]
7230− .find('>')
7231− .map(|p| tag_start + p)
7232− .unwrap_or(svg.len());
7233− let tag = &svg[tag_start..tag_end];
7234− let pattern = format!("{}=\"", attr_name);
7235− let (pos, _) = tag.match_indices(&pattern).find(|(pos, _)| {
7236− tag[..*pos]
7237− .chars()
7238− .next_back()
7239− .is_some_and(char::is_whitespace)
7240− })?;
7241− let value_start = tag_start + pos + pattern.len();
7242− let end = svg[value_start..tag_end].find('"')?;
7243− Some(value_start..value_start + end)
7244−}
7245−
7246fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
4361fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
7247 // Only the opening <svg ...> tag is touched, so child attributes such as
4362 // Only the opening <svg ...> tag is touched, so child attributes such as
7248 // `stroke-width` are left alone.
4363 // `stroke-width` are left alone.
@@ -7253,14 +4368,26 @@ fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
7253 .find('>')
4368 .find('>')
7254 .map(|p| tag_start + p)
4369 .map(|p| tag_start + p)
7255 .unwrap_or(svg.len());
4370 .unwrap_or(svg.len());
4371+ let tag = &svg[tag_start..tag_end];
7256
4372
7257− // Replace an existing attribute
4373+ // Replace an existing attribute (must be preceded by whitespace so that
7258− if let Some(existing) = svg_attr_value(svg, attr_name) {
4374+ // `width` doesn't match `stroke-width`).
7259− let mut result = String::with_capacity(svg.len());
4375+ let pattern = format!("{}=\"", attr_name);
7260− result.push_str(&svg[..existing.start]);
4376+ let existing = tag.match_indices(&pattern).find(|(pos, _)| {
7261− result.push_str(value);
4377+ tag[..*pos]
7262− result.push_str(&svg[existing.end..]);
4378+ .chars()
7263− return result;
4379+ .next_back()
4380+ .is_some_and(char::is_whitespace)
4381+ });
4382+ if let Some((pos, _)) = existing {
4383+ let value_start = tag_start + pos + pattern.len();
4384+ if let Some(end) = svg[value_start..tag_end].find('"') {
4385+ let mut result = String::with_capacity(svg.len());
4386+ result.push_str(&svg[..value_start]);
4387+ result.push_str(value);
4388+ result.push_str(&svg[value_start + end..]);
4389+ return result;
4390+ }
7264 }
4391 }
7265
4392
7266 // Otherwise inject it into the opening tag (before a self-closing `/`).
4393 // Otherwise inject it into the opening tag (before a self-closing `/`).
@@ -7583,10 +4710,7 @@ mod tests {
7583 #[test]
4710 #[test]
7584 fn parses_page_directive() {
4711 fn parses_page_directive() {
7585 let r = parse_ok("@page My Title\n");
4712 let r = parse_ok("@page My Title\n");
7586− assert_eq!(
4713+ assert_eq!(r.document.page_title.as_deref(), Some("My Title"));
7587− r.document.page.map(|p| p.title).as_deref(),
7588− Some("My Title")
7589− );
7590 }
4714 }
7591
4715
7592 #[test]
4716 #[test]
crates/htmlang-core/src/std.hl+46−2
@@ -1,9 +1,53 @@
1-- The htmlang standard library, available in every file. It is ordinary
1-- The htmlang standard library, available in every file. It is ordinary
2−-- htmlang: a file's own @let with the same name takes precedence.
2+-- htmlang: a file's own @let with the same name takes precedence, and
3+-- attributes on a call style the component's root element, so
4+-- `@badge [background red] New` works like a built-in element.
5+
6+-- Small pill for counts and statuses.
7+@let badge
8+ @text [display inline-flex, align-items center, padding 2px 8px, font-size 0.75rem, font-weight 600, justify-content center, border-radius 9999px, line-height 1]
9+ @children
10+
11+-- Label with slightly rounded corners.
12+@let tag
13+ @text [display inline-flex, align-items center, padding 2px 8px, font-size 0.75rem, font-weight 600, border-radius 4px]
14+ @children
15+
16+-- Outlined, fully rounded label.
17+@let chip
18+ @text [display inline-flex, align-items center, column-gap 4px, padding 4px 12px, border-radius 9999px, font-size 0.875rem, border 1 currentColor]
19+ @children
20+
21+-- Circular frame for an image or initials.
22+@let avatar
23+ @el [display inline-flex, flex-direction row, align-items center, justify-content center, border-radius 9999px, overflow hidden, flex-shrink 0]
24+ @children
3
25
4-- Takes up the remaining space in a row or column.
26-- Takes up the remaining space in a row or column.
5−@let @spacer
27+@let spacer
6 @el [flex 1]
28 @el [flex 1]
7
29
30+-- Text that shows `tip` when hovered.
31+@let tooltip $tip
32+ @text [position relative, cursor help, title=$tip]
33+ @children
34+
35+-- Horizontally scrolling row that snaps to each child.
36+@let carousel
37+ @row [overflow-x auto, scroll-snap-type x mandatory, scrollbar-width none, children:scroll-snap-align start, children:flex-shrink 0]
38+ @children
39+
40+-- Breadcrumb trail: each crumb is an @item.
41+@let breadcrumb
42+ @nav [aria-label=breadcrumb]
43+ @list [ordered, display flex, flex-direction row, flex-wrap wrap, gap 8px]
44+ @children
45+
46+-- Loading placeholder: [$skeleton]
47+@let skeleton [background linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%), background-size 200% 100%, animation hl-skeleton 1.5s ease-in-out infinite]
48+
8-- Cut text off at one line with an ellipsis: [$truncate]
49-- Cut text off at one line with an ellipsis: [$truncate]
9@let truncate [overflow hidden, text-overflow ellipsis, white-space nowrap]
50@let truncate [overflow hidden, text-overflow ellipsis, white-space nowrap]
51+
52+-- Hide scrollbars but keep scrolling: [$no-scrollbar]
53+@let no-scrollbar [scrollbar-width none]
crates/htmlang-core/src/syntax.rsdeleted−2943

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

editors/vscode/README.md+27−86

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

examples/blog.hl+48−86

This diff is too big to show here.

examples/blog.html.map+1−1

This diff is too big to show here.

examples/demo.hl+302−273

This diff is too big to show here.

examples/demo.html+3−800

This diff is too big to show here.

examples/demo.html.map+1−1

This diff is too big to show here.

examples/docs.hl+61−132

This diff is too big to show here.

examples/docs.html.map+1−1

This diff is too big to show here.

examples/landing.hl+65−131

This diff is too big to show here.

examples/landing.html.map+1−1

This diff is too big to show here.

examples/posts.jsondeleted−27

This diff is too big to show here.

src/bin/htmlang_lsp/analysis.rs+894−1295

This diff is too big to show here.

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

This diff is too big to show here.

src/bin/htmlang_lsp/docs.rs+119−358

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

src/bin/htmlang_lsp/tree.rsdeleted−222

This diff is too big to show here.

src/cli.rs+6−4

This diff is too big to show here.

src/fmt.rs+408−401

This diff is too big to show here.

src/lib.rs+1−3

This diff is too big to show here.

src/main.rs+106−215

This diff is too big to show here.

src/serve.rs+50−128

This diff is too big to show here.

src/upgrade.rsnew file+1393

This diff is too big to show here.

tests/common/mod.rsdeleted−140

This diff is too big to show here.

tests/docs.rs+20−116

This diff is too big to show here.

tests/editor.rsdeleted−157

This diff is too big to show here.

tests/fmt.rsdeleted−55

This diff is too big to show here.

tests/regressions.rs+62−2121

This diff is too big to show here.

tests/snapshots.rs+932−4418

This diff is too big to show here.

tests/snapshots/accessibility.html+1−1

This diff is too big to show here.

tests/snapshots/alignment.hl+1−1

This diff is too big to show here.

tests/snapshots/alignment.html+1−1

This diff is too big to show here.

tests/snapshots/anchor_positioning.html+1−1

This diff is too big to show here.

tests/snapshots/animate_shorthand.hl+7−11

This diff is too big to show here.

tests/snapshots/animate_shorthand.html+1−1

This diff is too big to show here.

tests/snapshots/animations.hl+5−9

This diff is too big to show here.

tests/snapshots/animations.html+1−1

This diff is too big to show here.

tests/snapshots/arithmetic.html+1−1

This diff is too big to show here.

tests/snapshots/assert_directive.hlnew file+10

This diff is too big to show here.

tests/snapshots/assert_directive.htmlnew file+1

This diff is too big to show here.

tests/snapshots/attributes.hl+3−3

This diff is too big to show here.

tests/snapshots/attributes.html+1−1

This diff is too big to show here.

tests/snapshots/basic_elements.hl+2−2

This diff is too big to show here.

tests/snapshots/basic_elements.html+1−1

This diff is too big to show here.

tests/snapshots/breadcrumb.hlnew file+4

This diff is too big to show here.

tests/snapshots/breadcrumb.htmlnew file+1

This diff is too big to show here.

tests/snapshots/chain_operator.hl+1−1

This diff is too big to show here.

tests/snapshots/chain_operator.html+1−1

This diff is too big to show here.

tests/snapshots/clamp_css.hl+1−1

This diff is too big to show here.

tests/snapshots/clamp_css.html+1−1

This diff is too big to show here.

tests/snapshots/code_samples.hldeleted−28

This diff is too big to show here.

tests/snapshots/code_samples.htmldeleted−5

This diff is too big to show here.

tests/snapshots/color_scheme.hl+1−1

This diff is too big to show here.

tests/snapshots/color_scheme.html+1−1

This diff is too big to show here.

tests/snapshots/colour_variants.hldeleted−12

This diff is too big to show here.

tests/snapshots/colour_variants.htmldeleted−1

This diff is too big to show here.

tests/snapshots/compat_test.hl+1−1

This diff is too big to show here.

tests/snapshots/computed_let.hl+3−3

This diff is too big to show here.

tests/snapshots/computed_let.html+1−1

This diff is too big to show here.

tests/snapshots/conditionals.html+1−1

This diff is too big to show here.

tests/snapshots/container_queries.hl+2−2

This diff is too big to show here.

tests/snapshots/container_queries.html+1−1

This diff is too big to show here.

tests/snapshots/convert_basic.hl+1−1

This diff is too big to show here.

tests/snapshots/css_property.html+1−1

This diff is too big to show here.

tests/snapshots/css_shorthands.hl+7−4

This diff is too big to show here.

tests/snapshots/css_shorthands.html+1−1

This diff is too big to show here.

tests/snapshots/css_units.hl+3−3

This diff is too big to show here.

tests/snapshots/css_units.html+1−1

This diff is too big to show here.

tests/snapshots/css_vars.hl+5−5

This diff is too big to show here.

tests/snapshots/css_vars.html+1−1

This diff is too big to show here.

tests/snapshots/custom_properties.hldeleted−47

This diff is too big to show here.

tests/snapshots/custom_properties.htmldeleted−1

This diff is too big to show here.

tests/snapshots/dark_mode.hl+2−2

This diff is too big to show here.

tests/snapshots/dark_mode.html+1−1

This diff is too big to show here.

tests/snapshots/data_directive.hl+6−7

This diff is too big to show here.

tests/snapshots/data_directive.html+1−1

This diff is too big to show here.

tests/snapshots/direction_attr.html+1−1

This diff is too big to show here.

tests/snapshots/each_destructuring.hl+3−4

This diff is too big to show here.

tests/snapshots/each_destructuring.html+1−1

This diff is too big to show here.

tests/snapshots/each_else.hl+2−2

This diff is too big to show here.

tests/snapshots/each_else.html+1−1

This diff is too big to show here.

tests/snapshots/each_index.html+1−1

This diff is too big to show here.

tests/snapshots/each_range.html+1−1

This diff is too big to show here.

tests/snapshots/each_step.hl+4−4

This diff is too big to show here.

tests/snapshots/each_step.html+1−1

This diff is too big to show here.

tests/snapshots/element_prefixes.hldeleted−42

This diff is too big to show here.

tests/snapshots/element_prefixes.htmldeleted−3

This diff is too big to show here.

tests/snapshots/else_if.html+1−1

This diff is too big to show here.

tests/snapshots/env_directive.html+1−1

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.hldeleted−19

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.htmldeleted−1

This diff is too big to show here.

tests/snapshots/extra_css.hl+2−2

This diff is too big to show here.

tests/snapshots/extra_css.html+1−1

This diff is too big to show here.

tests/snapshots/fn_defaults.hl+2−2

This diff is too big to show here.

tests/snapshots/fn_defaults.html+1−1

This diff is too big to show here.

tests/snapshots/form_elements.hl+1−1

This diff is too big to show here.

tests/snapshots/form_elements.html+1−1

This diff is too big to show here.

tests/snapshots/fragment.hl+1−1

This diff is too big to show here.

tests/snapshots/fragment.html+1−1

This diff is too big to show here.

tests/snapshots/function_calls.hldeleted−66

This diff is too big to show here.

tests/snapshots/function_calls.htmldeleted−1

This diff is too big to show here.

tests/snapshots/function_definitions.hldeleted−35

This diff is too big to show here.

tests/snapshots/function_definitions.htmldeleted−1

This diff is too big to show here.

tests/snapshots/functions.hl+5−5

This diff is too big to show here.

tests/snapshots/functions.html+1−1

This diff is too big to show here.

tests/snapshots/grid_areas.html+1−1

This diff is too big to show here.

tests/snapshots/has_pseudo.hl+2−2

This diff is too big to show here.

tests/snapshots/has_pseudo.html+1−1

This diff is too big to show here.

tests/snapshots/hidden_attr.hl+1−1

This diff is too big to show here.

tests/snapshots/hidden_attr.html+1−1

This diff is too big to show here.

tests/snapshots/html_elements.hldeleted−42

This diff is too big to show here.

tests/snapshots/html_elements.htmldeleted−1

This diff is too big to show here.

tests/snapshots/if_expr.hl+3−21

This diff is too big to show here.

tests/snapshots/if_expr.html+1−1

This diff is too big to show here.

tests/snapshots/implicit_el.hlnew file+7

This diff is too big to show here.

tests/snapshots/implicit_el.htmlnew file+1

This diff is too big to show here.

tests/snapshots/initial_letter.html+1−1

This diff is too big to show here.

tests/snapshots/inline_text.hl+2−2

This diff is too big to show here.

tests/snapshots/inline_text.html+1−1

This diff is too big to show here.

tests/snapshots/lang_directive.hl+1−1

This diff is too big to show here.

tests/snapshots/lang_directive.html+1−1

This diff is too big to show here.

tests/snapshots/layer_wrapping.html+1−1

This diff is too big to show here.

tests/snapshots/layout_direction.hldeleted−33

This diff is too big to show here.

tests/snapshots/layout_direction.htmldeleted−1

This diff is too big to show here.

tests/snapshots/layout_modes.hldeleted−44

This diff is too big to show here.

tests/snapshots/layout_modes.htmldeleted−2

This diff is too big to show here.

tests/snapshots/leading_argument.hldeleted−50

This diff is too big to show here.

tests/snapshots/leading_argument.htmldeleted−1

This diff is too big to show here.

tests/snapshots/list_elements.hl+11−11

This diff is too big to show here.

tests/snapshots/list_elements.html+1−1

This diff is too big to show here.

tests/snapshots/loops.html+1−1

This diff is too big to show here.

tests/snapshots/manifest_directive.hl+1−1

This diff is too big to show here.

tests/snapshots/manifest_directive.html+1−1

This diff is too big to show here.

tests/snapshots/markdown_block.hl+1−1

This diff is too big to show here.

tests/snapshots/markdown_block.html+1−1

This diff is too big to show here.

tests/snapshots/match_directive.html+1−1

This diff is too big to show here.

tests/snapshots/media_elements.html+1−1

This diff is too big to show here.

tests/snapshots/media_extended.html+1−1

This diff is too big to show here.

tests/snapshots/meta_head.hl+1−1

This diff is too big to show here.

tests/snapshots/meta_head.html+1−1

This diff is too big to show here.

tests/snapshots/mixin_spread.hl+3−3

This diff is too big to show here.

tests/snapshots/mixin_spread.html+1−1

This diff is too big to show here.

tests/snapshots/named_slots.hl+2−2

This diff is too big to show here.

tests/snapshots/named_slots.html+1−1

This diff is too big to show here.

tests/snapshots/names_and_values.hldeleted−18

This diff is too big to show here.

tests/snapshots/names_and_values.htmldeleted−1

This diff is too big to show here.

tests/snapshots/new_css.hl+1−1

This diff is too big to show here.

tests/snapshots/new_css.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_attrs.hl+1−1

This diff is too big to show here.

tests/snapshots/new_css_attrs.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties.hl+3−3

This diff is too big to show here.

tests/snapshots/new_css_properties.html+1−1

This diff is too big to show here.

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

This diff is too big to show here.

tests/snapshots/new_css_properties_3.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_4.hl+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_4.html+1−1

This diff is too big to show here.

tests/snapshots/new_directives.hl+3−6

This diff is too big to show here.

tests/snapshots/new_directives.html+7−8

This diff is too big to show here.

tests/snapshots/new_elements.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_2.hl+1−1

This diff is too big to show here.

tests/snapshots/new_elements_2.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_3.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_4.hl+13−4

This diff is too big to show here.

tests/snapshots/new_elements_4.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_5.hlnew file+19

This diff is too big to show here.

tests/snapshots/new_elements_5.htmlnew file+1

This diff is too big to show here.

tests/snapshots/new_elements_6.hl+1−1

This diff is too big to show here.

tests/snapshots/new_elements_6.html+1−1

This diff is too big to show here.

tests/snapshots/new_html_attrs.html+1−1

This diff is too big to show here.

tests/snapshots/new_pseudos.hl+2−2

This diff is too big to show here.

tests/snapshots/new_pseudos.html+1−1

This diff is too big to show here.

tests/snapshots/no_page.hl+1−1

This diff is too big to show here.

tests/snapshots/no_page.html+1−1

This diff is too big to show here.

tests/snapshots/nth_pseudo.hl+3−3

This diff is too big to show here.

tests/snapshots/nth_pseudo.html+1−1

This diff is too big to show here.

tests/snapshots/og_directive.html+1−1

This diff is too big to show here.

tests/snapshots/one_css_path.hldeleted−14

This diff is too big to show here.

tests/snapshots/one_css_path.htmldeleted−4

This diff is too big to show here.

tests/snapshots/page_root.hldeleted−15

This diff is too big to show here.

tests/snapshots/page_root.htmldeleted−1

This diff is too big to show here.

tests/snapshots/pagination_each.hlnew file+3

This diff is too big to show here.

tests/snapshots/pagination_each.htmlnew file+1

This diff is too big to show here.

tests/snapshots/parameters.hldeleted−31

This diff is too big to show here.

tests/snapshots/parameters.htmldeleted−1

This diff is too big to show here.

tests/snapshots/popover_api.hl+1−1

This diff is too big to show here.

tests/snapshots/popover_api.html+1−1

This diff is too big to show here.

tests/snapshots/positional.html+1−1

This diff is too big to show here.

tests/snapshots/prefixes_compose.hldeleted−17

This diff is too big to show here.

tests/snapshots/prefixes_compose.htmldeleted−1

This diff is too big to show here.

tests/snapshots/pseudo_elements.hl+1−1

This diff is too big to show here.

tests/snapshots/pseudo_elements.html+1−1

This diff is too big to show here.

tests/snapshots/pseudo_states.html+1−1

This diff is too big to show here.

tests/snapshots/pseudo_states_extended.hl+3−3

This diff is too big to show here.

tests/snapshots/pseudo_states_extended.html+1−1

This diff is too big to show here.

tests/snapshots/px_rule.hldeleted−20

This diff is too big to show here.

tests/snapshots/px_rule.htmldeleted−1

This diff is too big to show here.

tests/snapshots/raw_html.hl+2−2

This diff is too big to show here.

tests/snapshots/raw_html.html+1−1

This diff is too big to show here.

tests/snapshots/repeat_directive.hl+2−2

This diff is too big to show here.

tests/snapshots/repeat_directive.html+1−1

This diff is too big to show here.

tests/snapshots/responsive.hl+1−1

This diff is too big to show here.

tests/snapshots/responsive.html+1−1

This diff is too big to show here.

tests/snapshots/responsive_images.hl+1−1

This diff is too big to show here.

tests/snapshots/responsive_images.html+1−1

This diff is too big to show here.

tests/snapshots/scope_css.hl+1−1

This diff is too big to show here.

tests/snapshots/scope_css.html+1−1

This diff is too big to show here.

tests/snapshots/script_element.html+1−1

This diff is too big to show here.

tests/snapshots/scroll_driven_animations.hl+4−6

This diff is too big to show here.

tests/snapshots/scroll_driven_animations.html+1−1

This diff is too big to show here.

tests/snapshots/selection_pseudo.hl+1−1

This diff is too big to show here.

tests/snapshots/selection_pseudo.html+1−1

This diff is too big to show here.

tests/snapshots/semantic_elements.hl+4−4

This diff is too big to show here.

tests/snapshots/semantic_elements.html+1−1

This diff is too big to show here.

tests/snapshots/sizing.hl+1−1

This diff is too big to show here.

tests/snapshots/sizing.html+1−1

This diff is too big to show here.

tests/snapshots/slots_and_children.hldeleted−59

This diff is too big to show here.

tests/snapshots/slots_and_children.htmldeleted−1

This diff is too big to show here.

tests/snapshots/starting_style.hl+1−1

This diff is too big to show here.

tests/snapshots/starting_style.html+1−1

This diff is too big to show here.

tests/snapshots/stress_deeply_nested.hl+129−129

This diff is too big to show here.

tests/snapshots/stress_deeply_nested.html+1−1

This diff is too big to show here.

tests/snapshots/stress_large.hl+190−194

This diff is too big to show here.

tests/snapshots/stress_large.html+1−1

This diff is too big to show here.

tests/snapshots/string_interpolation.html+1−1

This diff is too big to show here.

tests/snapshots/style_block.html+1−1

This diff is too big to show here.

tests/snapshots/subgrid_css.html+1−1

This diff is too big to show here.

tests/snapshots/table_elements.html+1−1

This diff is too big to show here.

tests/snapshots/text_everywhere.hldeleted−41

This diff is too big to show here.

tests/snapshots/text_everywhere.htmldeleted−3

This diff is too big to show here.

tests/snapshots/translations_i18n.hl+1−1

This diff is too big to show here.

tests/snapshots/translations_i18n.html+1−1

This diff is too big to show here.

tests/snapshots/unless_directive.html+1−1

This diff is too big to show here.

tests/snapshots/values_and_scope.hldeleted−62

This diff is too big to show here.

tests/snapshots/values_and_scope.htmldeleted−1

This diff is too big to show here.

tests/snapshots/variable_filters.hl+1−1

This diff is too big to show here.

tests/snapshots/variable_filters.html+1−1

This diff is too big to show here.

tests/snapshots/variable_slots.hldeleted−18

This diff is too big to show here.

tests/snapshots/variable_slots.htmldeleted−1

This diff is too big to show here.

tests/snapshots/variables_defines.hl+3−3

This diff is too big to show here.

tests/snapshots/variables_defines.html+1−1

This diff is too big to show here.

tests/snapshots/view_transitions.hl+2−2

This diff is too big to show here.

tests/snapshots/view_transitions.html+1−1

This diff is too big to show here.

tests/snapshots/with_directive.html+1−1

This diff is too big to show here.