on 992bdcd 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+285−1396
@@ -1,242 +1,84 @@
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 border-radius 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-family "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 `@` 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 [font-weight 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 [font-weight bold, font-size 24, color #333] Hello world
120−@h2 Welcome to {@text [color #3b82f6] htmlang}
121−@ul
122− @li Read {@link /docs the docs}
123@section [padding 8] Text after the attributes is content too.
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+`>` chains single-child elements on one line; the last element in the chain
212−the chain gets the indented children. A `>` is a chain only between two
70+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```
220@el [padding 16, background blue, border-radius 8] > @link https://example.com
73@el [padding 16, background blue, border-radius 8] > @link https://example.com
221 @text [color white] Get Started
74 @text [color white] Get Started
222```
75```
223
76
224−### Verbatim bodies
77+### Raw content
225−
226−The bodies of `@raw`, `@style`, `@head`, `@markdown`, `@script`, `@code`
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
78
234−The header's line follows one rule. For `@raw`, `@style` and `@head`, text
79+`@raw` pastes HTML into the output verbatim: the rest of its line, or its
235−on the line is a one-line body; for `@markdown` it is the file and for
80+indented block. The bodies of `@style`, `@head`, `@script` and `@markdown` are
236−`@script` the `src`. Text on the line and an indented body together are an
81+verbatim too (a `--` line in them is not a comment).
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
82
241```
83```
242@raw <hr class="fancy">
84@raw <hr class="fancy">
@@ -244,1020 +86,219 @@ error. Only `@script` takes attributes: an attribute list on `@raw`,
244 <div class="custom-widget">
86 <div class="custom-widget">
245 <span>Hand-written HTML</span>
87 <span>Hand-written HTML</span>
246 </div>
88 </div>
247−@style .note { color: gray; }
248```
89```
249
90
250−### Directives
91+## Elements
251
92
252−A directive is a built-in word that isn't an element. Each one takes a
93+The layout and text elements come from elm-ui; every other element has its
253−fixed kind of argument and a fixed kind of body. `@page` is the one
94+HTML name. Every container lays its children out in a column, except `@row`.
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
95
257−| Directive | Argument | Body |
96+| 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|---|---|---|
97|---|---|---|
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`) |
98+| `@row` | div, flex row | Horizontal layout |
318−| row | `@row` | The same, side by side |
99+| `@el` | div, flex column | The container: children laid out top to bottom |
319−| grid | `@grid` | Each line of text is a cell |
100+| `@grid` | div, grid | Grid container (use `grid-cols`) |
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 |
101+| `@in-front` / `@behind` | div, absolute | Overlay layers filling the parent (see below) |
321−| native | `@table`, `@caption`, `@select`, `@pre`, `@textarea`, `@video`, ... | HTML's own layout, which htmlang leaves alone |
102+| `@text` | span | Styled inline text |
322−| void | `@input`, `@hr`, `@br`, `@image`, `@source` | Nothing: it takes no content |
103+| `@paragraph` | p | Flowing text with inline elements |
323−
104+| `@link url` | a | Link; text after the URL becomes its content |
324−The table under [HTML elements](#html-elements) gives every element's layout. The
105+| `@image src` | img | Image |
325−page itself is a column too: `@page` is the [root element](#page-and-head),
106+| `@h1` … `@h6` | h1 … h6 | Headings |
326−`<body>`, so what is written at the top of a page stacks like the children
327−of an `@el`, `height fill` there takes the rest of the window and
328−`center-y` centres in it.
329−
330−```
331−@el [spacing 4]
332− First line
333− Second line
334−@button [type=button]
335− Save
336− changes
337−```
338−
339−The column shows two lines 4px apart; the button says "Save changes".
340−
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
107
428−```
108+Semantic containers, all laid out as columns: `@nav`, `@header`, `@footer`,
429−@paragraph
109+`@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@form`,
430− Price: {@el [padding 2 6, background #fef3c7, border-radius 4] $9} a month.
110+`@details` / `@summary`, `@dialog`, `@figure` / `@figcaption`, `@blockquote` /
431−@h2
111+`@cite`, `@fieldset` / `@legend`, `@noscript`.
432− Plans
433− @row [spacing 4, font-size 14] > @text New
434−```
435
112
436−Any other element keeps its HTML element. One whose HTML ends a paragraph,
113+Content: `@ul` / `@ol` / `@li`, `@dl` / `@dt` /
437−such as `@section`, `@ul` or `@h2`, is a warning inside `@paragraph`
114+`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
438−(`block-in-paragraph`): the browser would move it, and what follows it, out
115+`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
439−of the `<p>`.
116+`@canvas`, `@iframe src`, `@video src`, `@audio src`, `@picture` / `@source`,
117+`@script`, `@fragment` (children without a wrapper).
440
118
441−`@link` is text too, so a card written inside a link is an inline `<span>`
119+Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
442−that shrinks to its content. A clickable card is the link itself, laid out
120+`@label`, `@datalist`. `@form URL` sets the form's `action`.
443−as a column with CSS:
444
121
445```
122```
446−@link [href=/post, display flex, flex-direction column, gap 8, padding 16, border 1 solid #e5e7eb]
123+@ol
447− @strong A post
124+ @li First
448− @text Its summary.
125+ @li Second
126+@form [method=post] /subscribe
127+ @label [for=email] Email
128+ @input [type=email, name=email, id=email, required]
129+ @button [type=submit] Send
130+@details [open]
131+ @summary Question
132+ @text The answer.
449```
133```
450
134
451−### Overlays: `@in-front` and `@behind`
135+### `@in-front` / `@behind`
452
136
453−These layers work like elm-ui's `inFront` and `behind`. Their children fill
137+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
138+parent's bounds; the parent automatically becomes a positioning context
455−(`position: relative; isolation: isolate`). An explicit `position` on the
139+(`position: relative; isolation: isolate`). An explicit `position` on the parent
456−parent takes precedence.
140+wins.
457
141
458```
142```
459@el [width 200, height 200, background blue]
143@el [width 200, height 200, background blue]
460 @text [color white] Main content
144 @text [color white] Main content
461 @in-front
145 @in-front
462− @text [color yellow, align-right, align-bottom] Painted on top
146+ @text [color yellow] Painted on top
463 @behind
147 @behind
464− @el [background red, height fill]
148+ @el [background red, width fill, height fill]
465```
149```
466
150
467### Standard library
151### Standard library
468
152
469−Two definitions are written in htmlang (`std.hl`) and are available in every
153+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
154+Use them like elements; your own `@let` with the same name takes precedence.
155+
156+| Component | Purpose |
157+|---|---|
158+| `@badge` | Small pill for counts and statuses |
159+| `@tag` | Label with slightly rounded corners |
160+| `@chip` | Outlined, fully rounded label |
161+| `@avatar` | Circular frame for an image or initials |
471−up the remaining space in a row or column. The `$truncate` bundle cuts text
162+| `@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
163+| `@tooltip [tip TEXT]` | Text that shows `TEXT` when hovered |
473−with `@let`.
164+| `@carousel` | Horizontally scrolling row that snaps to each child |
165+| `@breadcrumb` | Breadcrumb trail of `@li`s |
166+| `$skeleton`, `$truncate`, `$no-scrollbar` | Attribute bundles: loading placeholder, one-line ellipsis, hidden scrollbars |
474
167
475```
168```
476@row [spacing 8, align-items center]
169@row [spacing 8, align-items center]
477− @text Logo
170+ @badge [background #ef4444, color white] 3
171+ @chip Rust
478 @spacer
172 @spacer
479− @text [$truncate, max-width 200] A long title that gets cut off
173+ @tooltip [tip Opens in a new tab] Help
174+@breadcrumb
175+ @li > @link / Home
176+ @li Docs
177+@el [$skeleton, height 20, width fill]
480```
178```
481
179
482−## Style
180+## Attributes
483
181
484−### CSS properties
182+### Layout
485
183
486−Any attribute that isn't a layout attribute is a **CSS property**, with the
184+htmlang's own attributes are about layout, in the spirit of elm-ui: how an
487−same name and value as in CSS: `padding 20`, `font-weight bold`,
185+element sits in its row or column.
488−`border 1 solid #e5e7eb`, `border-radius 8`, `background red`,
186+
489−`grid-template-areas "a b"`, `display none`.
187+| Attribute | Effect |
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|---|---|
188|---|---|
514−| Size | `width`, `height`, `min-*` and `max-*` of both, `block-size`, `inline-size` and their `min-`/`max-`, `flex-basis`, `column-width`, `contain-intrinsic-*` |
189+| `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` |
190+| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
516−| Position | `top`, `right`, `bottom`, `left`, `inset` and `inset-*`, `translate`, `transform-origin`, `perspective`, `perspective-origin`, `offset-position`, `offset-anchor`, `offset-distance` |
191+| `height fill` / `height N` / `height shrink` | Same for height |
517−| Border | `border` and every `border-*` shorthand, width and radius (not `border-image-*`), `outline`, `outline-width`, `outline-offset`, `column-rule`, `column-rule-width` |
192+| `center-x`, `center-y` | Center within the parent |
518−| Shadow | `box-shadow`, `text-shadow` |
193+| `align-left`, `align-right`, `align-top`, `align-bottom` | Align within the parent |
519−| Grid | `grid-template-columns`, `grid-template-rows`, `grid-auto-columns`, `grid-auto-rows` |
194+| `wrap` | Let a row wrap |
520−| Background | `background-position` (and `-x`, `-y`), `background-size`, `object-position`, `mask-position`, `mask-size` |
195+| `grid-cols N`, `grid-rows N`, `col-span N`, `row-span N` | Grid layout |
521−| Text | `font-size`, `letter-spacing`, `word-spacing`, `text-indent`, `text-decoration-thickness`, `text-underline-offset`, `vertical-align`, `baseline-shift` |
522−| Other | `shape-margin`, `overflow-clip-margin`, `view-timeline-inset` |
523−
524−Shorthands that mix lengths with numbers of other kinds (`font`,
525−`background`, `mask`, `grid`, `grid-template`, `offset`, `flex`) are not in
526−the table, so the lengths in them are written with their unit:
527−`font 700 16px/1.5 Inter`.
528−
529−Nothing else is added to a value, and a shorthand means what it means in
530−CSS: `outline 2 solid red` is `2px solid red`, while `outline 2 red` names
531−no line style, so CSS draws no outline. `container card / inline-size`
532−and `contain content` are CSS's own shorthands too, and a bare `contain` is
533−an error, like any style without a value. `line-clamp N` alone also adds
534−the `-webkit-box` declarations that browsers still need to cut text off
535−after N lines.
536−
537−A property htmlang doesn't know is written to the CSS as it is, so new CSS
538−(`text-grow per-line`) works before htmlang lists it; the
539−compiler warns and suggests the closest known name, in case it is a typo.
540−Vendor-prefixed properties (`-webkit-tap-highlight-color`) pass without a
541−warning. The value of a property htmlang doesn't know is written exactly as
542−it is, without pixels added. A style needs a value: `[padding]` is an error.
543
196
544−```
197+### Style
545−@el [-webkit-tap-highlight-color transparent, -webkit-text-stroke 1px black]
546− Passed through as written
547−```
548
198
549−A value is checked only for what is wrong in any CSS. A `;`, `{` or `}`
199+Everything else is CSS: **any standard CSS property** is an attribute, with
550−(outside a quoted string, and for `;` outside parentheses, as in CSS's own
200+the same name and value: `padding 20`, `font-weight bold`, `font-size 18`,
551−`if()`) or an unclosed quote or parenthesis would break out of the rule, and
201+`border 1 solid #e5e7eb`, `border-radius 8`, `background red`,
552−a `</style` (even quoted) would end the page's style element, so each is an
202+`display none`, `grid-template-areas "a b"`, and so on. `line-clamp N` also
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
203+adds the `-webkit-box` declarations browsers still need to cut text off after
554−style whose value comes out empty, such as a field that a record doesn't
204+N lines.
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
205
571−```
206+**Units.** For lengths, a bare number is pixels (`padding 20` is `20px`,
572−@el [
207+`border 1 solid red` is `1px solid red`). Values with a unit, keywords and CSS
573− --gap 16px,
208+functions are passed through: `width 50%`, `margin 0 auto`,
574− md:--gap 24px,
209+`max-width min(100%, 800px)`.
575− --hue 200,
576− spacing var(--gap),
577− background hsl(var(--hue) 80% 95%)
578−]
579− @text One
580− @text Two
581−```
582
210
583−`@let --name value` declares a custom property for the whole page, on
211+### HTML attributes
584−`:root` (see [CSS custom properties](#css-custom-properties));
212+
585−`[--name value]` sets it on one element and everything inside it. So a
213+Write HTML attributes as `key=value`: `id=main`, `class=note`, `href=/about`,
586−design token is declared once with `@let`, and a theme redefines it where
214+`type=email`, `alt=Logo`, `target=_blank`, `aria-label=Close menu`,
587−it changes, once, rather than repeating a colour on every element that
215+`data-id=42`. Any name works. Booleans are written bare: `required`,
588−uses it:
216+`disabled`, `checked`, `open`, `sandbox`. A style and an HTML attribute may
217+share a name without clashing:
589
218
590```
219```
591−@let --ink #0f172a
220+@image [width=800, width 200, alt=A photo] photo.jpg
592−@let --surface white
221+@select [size=4, font-size 18]
593−@let --subtle #f8fafc
222+ @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```
223```
599
224
600−### Prefixes
225+### State and media prefixes
601
226
602−A prefix applies a style only in some condition:
227+Prefix a style attribute to apply it conditionally:
603
228
604| Prefix | Applies |
229| Prefix | Applies |
605|---|---|
230|---|---|
606−| `hover:`, `focus:`, `first-child:`, `checked:`, `user-invalid:`, `open:`, ... | When that CSS pseudo-class matches (`:hover`, `:first-child`, ...) |
231+| `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 |
232+| `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 |
233| `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)) |
234+| `before:`, `after:` | On the `::before` / `::after` pseudo-element (with `content`) |
235+| `has(SELECTOR):` | When the element contains a match |
611−| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640, 768, 1024, 1280, 1536px) |
236+| `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`) |
237+| `cq-sm:` … `cq-2xl:` | Container queries |
613−| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Under that media condition |
238+| `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
239
743```
240```
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]]
241+@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
747 @text [color white] Click me
242 @text [color white] Click me
748−@row [spacing 4, children:flex 1, hover:children:opacity 0.8]
243+@row [spacing 4, children:flex 1, nth:2n:background #f3f4f6]
749− @el [nth-child(odd):background #f3f4f6] A
244+ @el A
750− @el [nth-child(odd):background #f3f4f6] B
245+ @el B
751− @el [nth-child(odd):background #f3f4f6] C
752−@el [before:content "→ ", before:color red, hover:before:color blue]
246+@el [before:content "→ ", before:color red]
753 Item with an arrow
247 Item with an arrow
754−@section [spacing 16, md:$card, hover:if($active, [background #eef2ff, color #3730a3])]
755− A card from md up
756```
248```
757
249
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
250### Conditional attributes
771
251
772−`if(CONDITION, A, B)`, written as a whole attribute, is `A` when the
252+`if(CONDITION, A, B)` picks `A` or `B` by the condition. It works as a value or
773−condition holds and `B` otherwise. Each branch is an attribute, a `[group]`
253+as a whole attribute, and an empty choice (the `B` can be left out) leaves the
774−of attributes or a `$bundle`. An empty branch leaves the attribute out, and
254+attribute out.
775−`B` itself can be left out. Several attributes that depend on one condition
776−are one `if()` with a group:
777
255
778```
256```
779@let active true
257@let active true
780−@el [if($active, background blue, background gray), if($active, font-weight bold)]
258+@el [background if($active, blue, gray), if($active, font-weight bold), padding if($active, 12)]
781 Conditionally styled
259 Conditionally styled
782−@link [if($active, [background #eef2ff, font-weight 600, aria-current=page])] /docs Docs
783−```
784−
785−A value that depends on a condition is an [expression](#expressions),
786−`${if(CONDITION, A, B)}`. Its branches are expressions, so a number or a
787−single word is written as it is, and other CSS text is quoted:
788−`padding ${if($active, 24, 0)}`, `color ${if($active, #10b981, "var(--muted)")}`.
789−An `if()` written in a value without `${...}` is CSS's own `if()`, which the
790−browser decides, and it is passed through like any CSS function:
791−`width if(media(width > 40em): 50%; else: 100%)`.
792−
793−## HTML
794−
795−### HTML elements
796−
797−The layout and text elements have elm-ui's names. Every other element has
798−its HTML name: `@strong`, `@em`, `@small`, `@br`, `@sub`, `@caption`,
799−`@tfoot`, `@optgroup`, `@hgroup` and the others in the table of layouts
800−below.
801−
802−| Element | Output | Purpose |
803−|---|---|---|
804−| `@el` | div, flex column | The container: children laid out top to bottom |
805−| `@row` | div, flex row | Children laid out side by side |
806−| `@grid` | div, grid | Grid container (with `grid-cols` or `grid-template-*`) |
807−| `@in-front` / `@behind` | div, absolute | Overlay layers that fill the parent |
808−| `@text` | span | Styled inline text |
809−| `@paragraph` | p | A paragraph of flowing text |
810−| `@link URL` | a | Link: the URL is its `href`, and the text after it is its content |
811−| `@image SRC` | img | Image: the one word after its attributes is its `src` |
812−| `@fragment` | (none) | Its children, without a wrapper element (so it takes no attributes) |
813−
814−Every element has one [layout](#rows-columns-and-text):
815−
816−| Layout | Elements |
817−|---|---|
818−| column | `@el`, `@in-front`, `@behind`, `@nav`, `@header`, `@footer`, `@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@noscript`, `@form`, `@details`, `@dialog`, `@figure`, `@blockquote`, `@fieldset`, `@hgroup`, `@ul`, `@ol`, `@menu`, `@li`, `@dl`, `@dd` |
819−| row | `@row` |
820−| grid | `@grid` |
821−| text | `@text`, `@paragraph`, `@link`, `@h1`, `@h2`, `@h3`, `@h4`, `@h5`, `@h6`, `@button`, `@label`, `@legend`, `@summary`, `@figcaption`, `@cite`, `@dt`, `@td`, `@th`, `@code`, `@kbd`, `@mark`, `@abbr`, `@time`, `@b`, `@i`, `@strong`, `@em`, `@small`, `@s`, `@u`, `@sub`, `@sup`, `@q`, `@var`, `@samp`, `@dfn`, `@bdi`, `@bdo`, `@ins`, `@del` |
822−| native | `@table`, `@caption`, `@colgroup`, `@thead`, `@tbody`, `@tfoot`, `@tr`, `@select`, `@optgroup`, `@option`, `@datalist`, `@textarea`, `@progress`, `@meter`, `@output`, `@pre`, `@ruby`, `@rt`, `@rp`, `@picture`, `@video`, `@audio`, `@iframe`, `@object`, `@map`, `@canvas`, `@script` |
823−| void | `@image`, `@input`, `@hr`, `@br`, `@wbr`, `@col`, `@source`, `@track`, `@embed`, `@area` |
824−
825−`@fragment`, `@children` and `@slot` have no element of their own: what
826−they hold takes the layout of the element they are in. The top of a page is
827−a column, `<body>` (see [Page and head](#page-and-head)). In a native
828−element, and at the top of a fragment, lines of text are separated by a line break,
829−which HTML shows as a space except where whitespace is kept, as in `@pre`
830−and `@textarea`.
831−
832−The text-level elements (`@strong`, `@em`, `@b`, `@i`, `@small`, `@sub`,
833−`@q`, `@del`, ...) are text with the browser's own style and no CSS of
834−htmlang's, so they work inside a line like any inline element. Table parts,
835−`@optgroup` and `@ruby` keep HTML's own layout:
836−
837−```
838−@paragraph
839− {@strong Note:} water is H{@sub 2}O,{@br}
840− and {@em this} is {@small fine print}.
841−@table
842− @caption Team
843− @thead
844− @tr
845− @th Name
846− @tbody
847− @tr
848− @td Ada
849− @tfoot
850− @tr
851− @td One person
852−@select [aria-label=Fruit]
853− @optgroup Citrus
854− @option Lemon
855```
260```
856
261
857−The list of elements is fixed: a name that isn't in it is an unknown
262+## Variables and functions
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
263
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:
264+`@let` defines everything reusable. What it defines depends on its shape:
980
265
981```
266```
982-- A value, used as $primary
267-- A value, used as $primary
983@let primary #3b82f6
268@let primary #3b82f6
984−-- A computed value: `=` makes the rest an expression
269+-- A computed value: `=` makes it an expression
985@let gap = 8 * 2
270@let gap = 8 * 2
986−-- Quoted text, with $variables filled in (see Variables for its quotes)
271+-- A quoted string, with $variables interpolated
987−@let greeting "Hello from $primary"
272+@let greeting "Hello $primary"
988-- An attribute bundle, used as [$card]
273-- An attribute bundle, used as [$card]
989@let card [padding 20, background white, border-radius 8]
274@let card [padding 20, background white, border-radius 8]
990−-- A function: `@` before its name, its parameters in brackets, and a body
275+-- A function: a @let with an indented body
991−@let @panel [title]
276+@let panel $title $tone=neutral
992− @el [$card, spacing $gap]
277+ @el [$card]
993− @text [font-weight bold, color $primary] $title
278+ @text [font-weight bold] $title
994 @children
279 @children
995
280
996@panel [title Welcome]
281@panel [title Welcome]
997− $greeting
282+ Body text goes into @children.
998@el [$card, background #f9fafb]
283@el [$card, background #f9fafb]
999 Attributes after a bundle override it.
284 Attributes after a bundle override it.
1000```
285```
1001
286
1002−The line alone says what a `@let` defines: an `@` before the name makes a
287+A function is called like an element:
1003−function, a `[` after the name an attribute bundle, and anything else a
1004−value. A value or a bundle is named without `$` and used with it; a function
1005−is named with `@` and called with it. Only a function has a body, so an
1006−indented block under a value or a bundle is an error, and so are `@let $x`
1007−(write `@let x`) and a `@let` without a value.
1008−
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
288
1050−A name starts with a letter or `_` and goes on with letters, digits, `_`
289+- Its parameters are passed as attributes; parameters with `=default` may be
1051−and `-`. It ends at the first other character, so after `@let lang fr`,
290+ omitted.
1052−`$lang.json` is `fr.json`. A `.field` continues the name only when the value
291+- Other attributes style its root element, so `@panel [title Hi, padding 40]`
1053−is a record or a list (`$post.title`, `$tags.0`). `${name}` is the same name
292+ works like styling a built-in element.
1054−with explicit ends, for text that follows it directly, and `${EXPR}` inserts
293+- Text after the attributes and indented children replace `@children`, and a
1055−the value of an [expression](#expressions). A `$` followed by anything else
294+ caller's `@slot name` block replaces `@slot name` (the slot's own children are
1056−is text.
295+ the default).
296+- An `@style` block at the top of the body is scoped to the function: its rules
297+ only apply inside the function's output.
1057
298
1058```
299```
1059−@let lang fr
1060−@let size 4
1061−@data $post {"title": "Hello"}
1062−@text locales/$lang.json, ${size}px, $post.title, $5
1063−```
1064−
1065−A name that isn't defined is an error. A field that a record doesn't have
1066−is empty, and so is any field of it, so optional fields of `@data` records
1067−work: `@if $post.draft` is false, `$post.author.name` is empty when there is
1068−no `author`, and `${default($post.tag, none)}` gives `none`.
1069−
1070−### Values
1071−
1072−A value is text, a number, `true` or `false`, a list or a record, and a
1073−name holds a whole value: a `@let`, a parameter, `@each` and `@data` bind
1074−one, and binding the name again replaces it. What a `@let` holds is what it
1075−says:
1076−
1077−```
1078−-- Text, as written
1079−@let size 16px
1080−-- Commas make a list, which prints as it was written
1081−@let fruits apple, banana, cherry
1082−-- Quoted, or with `\,`, text with commas in it is one text
1083−@let tagline "Fast, simple"
1084−@let motto small\, fast
1085−-- A range of whole numbers
1086−@let steps 1..9 step 4
1087−-- One `$name` or `${...}` keeps the type of what it holds
1088−@data $posts [{"title": "Hello"}, {"title": "Again"}]
1089−@let first $posts.0
1090−@let backwards ${reverse($fruits)}
1091−@text [font-size $size] $fruits: ${length($fruits)}. $tagline: ${length($tagline)}. $motto. $steps
1092−@text $first.title, $backwards
1093−```
1094−
1095−The last lines give `apple, banana, cherry: 3. Fast, simple: 12. small,
1096−fast. 1..9 step 4` and `Hello, cherry, banana, apple`. A value that is
1097−exactly one `$name` or `${...}` keeps its type wherever it goes: a `@let`, a
1098−function's parameter or default, the list of `@each`. So a record can be
1099−passed to a function whole (`@post-card [post $p]`), and a list stays a
1100−list.
1101−
1102−- Text written without quotes reads as a number when it is one (`3`,
1103− `0.5`), and as false when it is `false` or `0`: that is how htmlang writes
1104− numbers and flags (`@let gap 8`, `@card [dot false]`). Quoted text is
1105− always text.
1106−- **False** is `false`, empty text, `0` and an empty list (or record).
1107− Everything else is true, including `no` and quoted `"0"`.
1108−- `==` and `!=` compare numbers as numbers (`1.0 == 1`), lists and records
1109− item by item, and anything else as text, exactly (`#FFF` isn't `#fff`).
1110− `<`, `>`, `<=` and `>=` compare numbers as numbers and text by character
1111− order.
1112−- A computed number prints without float noise: whole numbers without
1113− decimals, others with at most four (`${100 / 3}` is `33.3333`).
1114−- A list prints as written when the source wrote it, and otherwise as its
1115− items joined with `, `. A record has no text of its own, so writing one
1116− (or a list of records) where text goes is an error: write one of its
1117− fields, or loop over the list with `@each`.
1118−- A `$list` in a comma list is one item, a list inside the list:
1119− `@let more $items, d` has two items. htmlang has no list concatenation.
1120−
1121−**Quoted text remembers that it was quoted.** A value written `"..."` (in a
1122−`@let`, an attribute or a function's argument) keeps its quotes in a CSS
1123−value, where CSS needs them, and loses them in text, in HTML attribute
1124−values and in expressions (`${length($x)}` counts no quotes). It stays
1125−quoted when it passes through another `@let` or a parameter. Inside a quoted string of a CSS value, it inserts
1126−what it says, without its own quotes:
1127−
1128−```
1129−@let arrow "→ "
1130−@let label "Next, please"
1131−@el [before:content $arrow, after:content " ($label)", aria-label=$label] $label
1132−```
1133−
1134−This gives `content:"→ "`, `content:" (Next, please)"`,
1135−`aria-label="Next, please"` and the text `Next, please`. A value written
1136−without quotes is inserted as written everywhere.
1137−
1138−### Functions
1139−
1140−A function's parameters are listed in brackets after its name and written
1141−the way a call passes them: a name alone (`title`) is a required parameter,
1142−and a name and a value (`tone #f9fafb`) is a parameter with that default.
1143−The body uses them as `$title` and `$tone`. `@let @name` without brackets
1144−takes no parameters.
1145−
1146−A default is filled in at each call that leaves its parameter out, like a
1147−value passed for it: it may hold spaces, quotes, escapes, `${...}` and
1148−`$variables`. It sees what the body sees: the names visible where the
1149−function is defined and the parameters declared before it
1150−(`[title, heading "About $title"]`). A default that uses a parameter
1151−declared after it is an error.
1152−
1153−A function is called like an element, wherever an element can be: on a
1154−line of its own, in a [chain](#chains) (`@el > @card [title Hi]`) or
1155−inline in text (`{@key Ctrl+K}`):
1156−
1157−- Its parameters are passed by name, as attributes: `name value`, or the
1158− name alone for `true`. A parameter with a default can be left out;
1159− leaving out one without a default is an error that names it. `=` writes
1160− an HTML attribute, so `title=Hi` for a parameter is an error too.
1161−- Every other attribute goes to the function's root element: a style, a
1162− flag, or a `key=value` HTML attribute such as `id=intro`. It is checked
1163− like an attribute written on that element, so `@panel [title Hi,
1164− padding 40]` works the same as styling a built-in element, and
1165− `[paddin 40]` gets the same warning. A name close to one of the
1166− parameters (`titel Hi`) is a misspelled parameter, which is an error.
1167− This needs the body to have a single root element: attributes on a call
1168− whose body has several roots, or is text, are an error. A `class=` adds
1169− to the root's own classes.
1170−- A parameter passed twice (in the call's list, or after a bundle that
1171− passes it) takes the later value, as any attribute does.
1172−- Text after the attributes and the indented lines are the call's
1173− content, and replace `@children` in the body. A `@slot NAME` block
1174− directly under the call (or under an `@if`, `@else` or `@each` there)
1175− fills the body's `@slot NAME` instead.
1176−- The lines indented under `@children` or `@slot NAME` in the body are its
1177− fallback: they are shown when a call passes no content, or no block for
1178− that slot.
1179−
1180−```
1181−@let @card [title, tone #f9fafb]
1182− @article [padding 16, spacing 8, background $tone, border-radius 8]
1183− @row [align-items center]
1184− @h3 $title
1185− @spacer
1186− @slot actions
1187− @children
1188− @slot footer
1189− @text [font-size 12, color gray] No footer
1190−
1191−@card [title Plain]
1192− Just children.
1193−@card [title Full, tone #eff6ff, padding 24]
1194− @slot actions
1195− @link /edit Edit
1196− The children go here.
1197− @slot footer
1198− @text Updated today
1199−```
1200−
1201−`@children` takes a fallback the same way:
1202−
1203−```
1204−@let @notice
1205− @el [padding 12, background #fef3c7]
1206− @children
1207− @text Nothing to report.
1208−
1209−@notice
1210−@notice Deploys are paused today.
1211−```
1212−
1213−Content that would go nowhere is an error, so a mistake can't drop it:
1214−
1215−- a `@slot NAME` block for a slot the function doesn't have (the message
1216− lists the slots it has);
1217−- text or lines passed to a function whose body has no `@children`;
1218−- a `@slot NAME` block inside an element at the call, instead of directly
1219− under the call;
1220−- `@slot` or `@children` outside a function's body, or inline in text;
1221−- `@children` or `@slot` after an element's attributes (`@td [padding 8]
1222− @children`), where it would be text: it goes on a line of its own.
1223−
1224−A slot's name is one word of letters, digits, `-` and `_`, starting with a
1225−letter, like a function's. Inside a body, a call can pass on what the
1226−function got: `@children` under the call passes the content, and a
1227−`@slot footer` block holding `@slot footer` passes that slot.
1228−
1229−A parameter that is off unless the call names it is a flag: give it the
1230−default `false`, and name it alone to turn it on.
1231−
1232−```
1233−@let @post-card [post, featured false, label "About $post"]
1234− @article [spacing 8, padding ${if($featured, 24, 0)}]
1235− @if $featured
1236− @text [font-weight bold] Featured
1237− @h3 $label
1238−
1239−@post-card [post htmlang, featured]
1240−@post-card [post CSS]
1241−```
1242−
1243−An `@style` block at the top of a function body is **scoped** to the
1244−function. Its rules apply inside the function's root element, and `&` is the
1245−root itself. This requires the body to have a single root element. The
1246−stylesheet goes into the page once, when the function is first called, so
1247−a function that is never called adds nothing. It is in the layer
1248−`hl-inside`, with the styles of element prefixes (see [CSS](#css)): what an
1249−element says about itself wins over it, so a call's `[padding 20]` wins
1250−over the function's `& { padding: 12px; }`, as it would over the root's own
1251−defaults. Where CSS properties are all it needs, an element prefix on the
1252−root says the same without a stylesheet (`@aside [@h2:font-size 18]`).
1253−Only an `@style` at the top of the body is scoped: one under an `@if` or an
1254−element is the page's, where `&` is the whole page, so the compiler warns
1255−about a `&` there.
1256−
1257−```
1258−@let @note [kind Note]
300+@let note $kind=info
1259 @style
301 @style
1260− & { border-left: 4px solid #3b82f6; }
1261 .title { font-weight: bold; }
302 .title { font-weight: bold; }
1262 @el [padding 12, border-radius 6, background #eff6ff]
303 @el [padding 12, border-radius 6, background #eff6ff]
1263 @text [class=title] $kind
304 @text [class=title] $kind
@@ -1266,94 +307,22 @@ about a `&` there.
1266@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
307@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
1267```
308```
1268
309
1269−A call in text or in a chain whose body has several roots, or is text,
310+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−
1273−```
1274−@let @intro
1275− htmlang is a layout language.
1276− It compiles to {@strong static HTML}.
1277−@let @key
1278− @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4]
1279− @children
1280−
1281−@paragraph
1282− @intro
1283−@paragraph
1284− In short: {@intro} Press {@key Ctrl+K} to search.
1285−```
1286−
1287−A function may call itself, under a condition that stops it, so it can
1288−render nested data such as a menu. A parameter passed one `$name` that
1289−holds a record or a list gets the whole value, fields and items
1290−included. Calls nest at most 64 deep: a function that calls itself with
1291−nothing to stop it is an error. Since a body sees only what is defined
1292−above its function, two functions can't call each other.
1293
311
1294```
312```
1295−@data $menu [
313+@let intro """
1296− {"label": "Guide", "children": [{"label": "Install"}, {"label": "Usage"}]},
314+ First line
1297− {"label": "Reference"}
315+ Second line
1298−]
316+ """
1299−@let @tree [items]
317+@text $intro
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```
318```
1309
319
1310−The elements a function's body writes are the call's: a problem with
1311−one of them, such as low contrast or `spacing` on an element that isn't a
1312−row, column or grid, is reported at the call, once, and names the function. A
1313−problem in the text of a body is reported on its own line, or at the call
1314−when the function comes from `std.hl` or another file.
1315−
1316−Naming a function after a built-in element or a directive is a warning:
1317−`@let @button` replaces `@button` in every call after it, and a function
1318−named `@if` could never be called. Inside its own body `@button` is the
1319−function too, so a function that wraps the element it is named after,
1320−outside an `@if`, is an error: give it another name.
1321−
1322−### CSS custom properties
1323−
1324−`@let --name value` declares the CSS custom property `--name` on `:root`,
320+`@let --name value` also emits a CSS custom property, used as `$--name` and
1325−for the whole page. Its value is written as it is, with no pixels added,
321+as `var(--name)` in the generated CSS:
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
322
1349```
323```
1350@let --brand #3b82f6
324@let --brand #3b82f6
1351−@let --radius 8px
1352−@page [dark:--brand #60a5fa] Themed
1353−@el [background var(--brand), border-radius var(--radius), border 1 solid color-mix(in srgb, var(--brand), black 10%)] Themed
325+@el [background $--brand] Themed
1354−@row
1355− @each $shade in 0, 10, 20, 30
1356− @el [background color-mix(in srgb, var(--brand), black $shade%)] $shade%
1357```
326```
1358
327
1359## Expressions
328## Expressions
@@ -1362,40 +331,24 @@ Conditions and computed values (`@let x = ...`) are expressions:
1362
331
1363| | |
332| | |
1364|---|---|
333|---|---|
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 |
334+| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
1366| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
335| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
1367−| Comparison | `== != < > <= >=`: numbers as numbers, text exactly, lists and records item by item (see [Values](#values)) |
336+| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains` (in text, or as an item of a list), `starts-with`, `ends-with` |
1368−| Logic | `and`, `or`, `not`. `false`, empty text, `0` and an empty list are false |
337+| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
1369−| Choice | `if(CONDITION, A, B)`, where `B` may be left out (empty) |
338+| 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) |
339+| Text functions | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)` (a list's items, or a text's characters), `reverse(s)`, `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` |
1372−
340+| 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
341+
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
342+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
343+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,
344+
1382−instead of a guess: `uppercase()` of `Fast, simple` (a list, because of its
345+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`.
346+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
347
1395```
348```
1396@let name htmlang
349@let name htmlang
1397−@let size 12
350+@let base #3b82f6
1398−@text [font-size ${$size + 4}] ${uppercase($name)} has ${length($name)} letters
351+@text [color ${darken($base, 10)}] ${uppercase($name)} has ${length($name)} letters
1399```
352```
1400
353
1401## Control flow
354## Control flow
@@ -1403,8 +356,8 @@ expression (see [Variables](#variables)):
1403All control flow runs at compile time.
356All control flow runs at compile time.
1404
357
1405```
358```
359+@let items apple, banana, cherry
1406@let count 3
360@let count 3
1407−@let hidden false
1408
361
1409@if $count > 2 and not $hidden
362@if $count > 2 and not $hidden
1410 @text Many
363 @text Many
@@ -1412,75 +365,56 @@ All control flow runs at compile time.
1412 @text None
365 @text None
1413@else
366@else
1414 @text Few
367 @text Few
1415−```
1416−
1417−`@each $item in LIST` repeats its body for each item. Its variables are
1418−written with `$`, like `@data $name`. LIST is a value, read like the value
1419−of a `@let`: items written with commas (a quoted item or one with `\,` is
1420−one text), a range `A..B` (which counts down when its start is greater than
1421−its end, and takes `step N`), or one `$name` or `${...}` that holds a list,
1422−such as one loaded with `@data` or `${reverse(1..5)}`. A text is one item,
1423−and empty text none. Each item is bound whole, so a record keeps its fields.
1424−An optional second variable is the index, starting from 0. `@else` gives
1425−the content to show when the list is empty.
1426
368
1427−```
1428−@let fruits apple, banana, cherry
1429−@each $fruit, $i in $fruits
369+@each $item, $i in $items
1430− @text ${$i + 1}. $fruit
370+ @text $i: $item
1431@else
371@else
1432− @text No fruit.
372+ @text The list is empty.
1433
373
1434−@row [spacing 8]
1435− @each $n in 10..0 step 5
374+@each $n in 1..10 step 3
1436− @text $n
375+ @text $n
1437```
376```
1438
377
1439−## Data and files
378+`@each $item in LIST` loops over a list: a list loaded with `@data`, text
1440−
379+split on commas, or a range (which counts down when its start is greater than
1441−| Directive | Effect |
380+its end). An optional second variable is the index, from 0. An item loaded from
1442−|---|---|
381+JSON can be a record, whose fields are `$item.key`:
1443−| `@data $name file.json` | Load a JSON file. Objects are records (`$name.key`) and arrays are lists |
1444−| `@data $name [...]` / `@data $name {...}` | Inline JSON, which may span several lines |
1445−| `@data $name dir/*.json` | A list with one record per file, in name order. `$item.file` is the file's name |
1446−| `@data $name env:NAME DEFAULT` | An environment variable, with an optional default |
1447−| `@include file.hl` | Insert another file: its content and its definitions. A file that holds only definitions outputs nothing (a [library](#page-layouts)) |
1448−| `@markdown` / `@markdown file.md` | Markdown (an indented body or a file), converted to HTML |
1449−| `@image [inline] file.svg` | Put the file inside the page: SVG as markup, other images as base64. The file is read next to the page, and one that can't be read is an error. The SVG's root gets `width` and `height` (lengths, not `fill` or `shrink`) and `color` or `fill` (as its `fill`) in its `style=`, after the file's own, where `var(--name)` works, and the HTML attributes (`class=`, `aria-label=`, ...); any other style, and `alt=`, is an error, since the markup has no generated class, and an element prefix (`@image:`) doesn't reach it. `inline` goes only on `@image`, without a prefix |
1450−
1451−Records keep their values whole, even when a value contains spaces or
1452−commas:
1453
382
1454```
383```
1455−@data $team [
384+@data $links [
1456− {"name": "Ada", "role": "Engineering, research"},
385+ {"label": "About us", "url": "/about"},
1457− {"name": "Grace", "role": "Design"}
386+ {"label": "Blog", "url": "/blog"}
1458]
387]
1459−@ul [spacing 4]
388+@row [spacing 12]
1460− @each $person in $team
389+ @each $link in $links
1461− @li
390+ @link $link.url $link.label
1462− @text [font-weight bold] $person.name
1463− @text $person.role
1464−@text ${length($team)} people
391+@text ${length($links)} links
1465```
392```
1466
393
1467−### Page layouts
394+## Files and data
1468
395
396+| Directive | Effect |
397+|---|---|
398+| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
399+| `@data $name file.json` | Load JSON values as variables (`$name.key`; arrays are lists) |
400+| `@data $name [...]` / `@data $name {...}` | Inline JSON (an array may span lines) |
401+| `@data $name dir/*.json` | A list with one record per file, in name order; `$item.file` is the file's name |
402+| `@data $name env:NAME [default]` | Read an environment variable |
403+| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
404+| `@image [inline] file.svg` | Inline an SVG file (`width`, `height`, `color`, `class=`, `id=` apply to it) |
405+
1469−A layout is an ordinary function in a file of its own. It marks where content
406+A layout is an ordinary function in its own file. It marks where content goes
1470−goes with `@slot NAME` (named blocks) and `@children` (everything in the call
407+with `@slot name` (named blocks, with default content) and `@children`
1471−that is outside a `@slot` block), each with fallback content indented under
408+(everything in the call outside `@slot` blocks):
1472−it.
1473
409
1474```
410```
1475-- layout.hl
411-- layout.hl
1476−@let @layout
412+@let layout
1477− @page [lang=en, background #f8fafc] My Site
413+ @page My Site
1478− @el [width fill, max-width 800, center-x, spacing 24]
414+ @el [max-width 800, center-x]
1479− @header
1480− @slot header
415+ @slot header
1481− @text Default header
416+ @text Default header
1482− @main
1483− @children
417+ @children
1484```
418```
1485
419
1486```
420```
@@ -1488,69 +422,30 @@ it.
1488@include layout.hl
422@include layout.hl
1489@layout
423@layout
1490 @slot header
424 @slot header
1491− @h1 About us
425+ @text About us
1492 @paragraph This fills @children.
426 @paragraph This fills @children.
1493```
427```
1494
428
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
429## Page and head
1507
430
1508−`@page TITLE` makes the output a full HTML document. Without it, the output
431+`@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
432+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
433+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
434+`@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.
435+link). Translations are a JSON file per locale: `@data $t locales/$lang.json`.
1532
436
1533```
437```
1534−@page [lang=en, favicon favicon.png, color #1f2937, dark:background #0b1220, dark:color #e5e7eb] My Site
438+@page [lang en, favicon /favicon.png] My Site
1535@meta description A small site
439@meta description A small site
1536@meta og:title My Site
440@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```
441```
1545
442
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
443## CSS
1550
444
1551−`@style` holds raw CSS, including at-rules such as `@keyframes`,
445+- `@style` holds raw CSS, including at-rules such as `@keyframes`, `@scope`,
1552−`@font-face`, `@property` and `@starting-style`, in its indented block or,
446+ `@property` and `@starting-style`.
1553−for a single rule, on its line:
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```
1556@style
451@style
@@ -1559,61 +454,55 @@ for a single rule, on its line:
1559 to { opacity: 1; }
454 to { opacity: 1; }
1560 }
455 }
1561 .note { color: gray; }
456 .note { color: gray; }
1562−@style .quiet { opacity: 0.6; }
1563@el [animation fade-in 0.3s ease, class=note] Fades in
457@el [animation fade-in 0.3s ease, class=note] Fades in
1564```
458```
1565
459
1566−Each element with styles gets a generated class, and elements with the
460+## Upgrading older files
1567−same styles share one. A class is named by its style: `hl-` and seven
461+
1568−characters of a hash of what it sets (`hl-2k9xq0m`), so one style has one
462+`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
463+removed form with its replacement.
1570−server returns, or any HTML of one file put into another's page) means
464+
1571−the same there, and its rules restyle nothing of the page's: a class of
465+| 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|---|---|
466|---|---|
1599−| `hl-reset` | The reset |
467+| `@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)` |
468+| `@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` |
469+| `@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 |
470+| `grid` (attribute) | `@grid`, or `display grid` |
1603−
471+| `@switch`, `@match` | `@if $x == a` / `@else if` / `@else` |
1604−So an element's own styles win over everything else htmlang writes for
472+| `@use "file" names`, `@import file` | `@include file` |
1605−it; its parent's `children:` styles and the element prefixes of the
473+| `@collection`, `@env`, `@translations`, `@fetch` | `@data $name SOURCE` (glob, `env:NAME`, or a JSON file per locale) |
1606−elements around it win over its defaults.
474+| `@theme` | `@let --name value` lines |
1607−
475+| `@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
476+| `@breakpoint`, `@deprecated` | A media query in `@style`; nothing |
1609−`<body>` a column that fills the window. A fragment (a file
477+| `@svg [attrs] file.svg` | `@image [inline, attrs] file.svg` |
1610−without `@page`, or `--partial` output) goes into a page it doesn't own, so
478+| `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),
479+| `@with $x as y` | `@let y $x` |
1612−which get `box-sizing: border-box`; no other element on the page is
480+| `@extends layout.hl`, `@layout file` | `@include layout.hl` and a call to the layout, now a function (both files are converted) |
1613−touched. In both, an element
481+| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
1614−htmlang lays out stays hidden while it has `hidden`, while an `@dialog` is
482+| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
1615−closed, and while a popover isn't showing, even though its generated
483+| `@og KEY VALUE` | `@meta og:KEY VALUE` |
1616−`display` would otherwise beat the browser's `display: none`. A page with
484+| `@assert`, `@warn`, `@debug`, `@log` | Nothing (the lines are removed) |
1617−links, buttons or form fields also gives htmlang's ones a focus outline.
485+| `@defer` | Its content, directly |
1618−CSS outside any layer takes precedence over every layer, so rules in
486+| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@el`, `@paragraph`, `@image`, `@li`, `@button`, `@ul`, `@hr`, `@option` |
1619−`@style` or `@raw` override the generated ones.
487+| `type email`, `id main` (HTML attributes written as styles) | `type=email`, `id=main` |
488+| `skeleton`, `no-scrollbar`, `gradient A B` | `$skeleton`, `$no-scrollbar`, `background linear-gradient(A, B)` |
489+| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
490+| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
491+| `COND ? A : B` | `if(COND, A, B)` |
492+| `key if COND` (in attributes) | `if(COND, key)` |
493+| `$a ~ " " ~ $b` | `"$a $b"` |
494+| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
495+| `...$bundle` | `$bundle` |
496+| `bold`, `italic`, `underline`, `hidden` | `font-weight bold`, `font-style italic`, `text-decoration underline`, `display none` |
497+| `size`, `font`, `rounded`, `padding-x/y`, `margin-x/y` | `font-size`, `font-family`, `border-radius`, `padding-inline/block`, `margin-inline/block` |
498+| `border N COLOR` (solid implied) | `border N solid COLOR` |
499+| `@each $a, $b in A x, B y` (items split on spaces) | Records: `@data $rows [{"a": "A", "b": "x"}, ...]` and `@each $row in $rows` with `$row.a` |
500+| `$_index` | `@each $item, $i in ...` |
501+| `$posts._count`, `$posts._keys`, `$posts.STEM.key` | `length($posts)`; nothing; `@each $post in $posts` with `$post.file` |
502+| `@raw """ ... """` | `@raw` with an indented body |
503+| `@keyframes NAME` with `from [opacity 0]` | The `@keyframes` rule in `@style` |
504+| `@include lib.hl as ui` | `@include lib.hl`, without the `ui.` prefix |
505+| `@data file.json` (keys as `$key`) | `@data $name file.json` (keys as `$name.key`) |
506+| `[attrs]` alone on a line | `@el [attrs]` |
507+| `animate`, `inset-area` | `animation`, `position-area` |
508+| `$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, border-radius 8, border 1 solid #e5e7eb, hover:border 1 solid $primary, transition all 0.15s ease]
15− @h3 $title
15+ @text [font-weight bold] $title
16 @children
16 @children
17
17
18−@el [width fill, max-width 800, center-x, padding 40, spacing 24]
18+@el [max-width 800, center-x, padding 40, spacing 20]
19− @h1 [font-size 32] Hello
19+ @text [font-weight bold, font-size 32] Hello
20+
20 @paragraph
21 @paragraph
21− Built with {@text [font-weight bold, color var(--brand)] htmlang}.
22+ Built with {@text [font-weight 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 `@el` (a column) and `@row`; `@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, border-radius 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, font-weight 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+layouts, `@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+107−622
@@ -3,33 +3,27 @@ use std::collections::HashMap;
3#[derive(Debug)]
3#[derive(Debug)]
4#[allow(dead_code)]
4#[allow(dead_code)]
5pub struct Document {
5pub struct Document {
6− /// The page `@page` makes: without one, the output is a fragment.
6+ pub page_title: Option<String>,
7+ pub lang: Option<String>,
7− pub page: Option<Page>,
8+ pub favicon: Option<String>,
8 pub meta_tags: Vec<(String, String)>,
9 pub meta_tags: Vec<(String, String)>,
9 pub head_blocks: Vec<String>,
10 pub head_blocks: Vec<String>,
10 pub variables: HashMap<String, String>,
11 pub variables: HashMap<String, String>,
11 pub defines: HashMap<String, Vec<Attribute>>,
12 pub defines: HashMap<String, Vec<Attribute>>,
12 pub css_vars: Vec<(String, String)>,
13 pub css_vars: Vec<(String, String)>,
13 pub custom_css: Vec<String>,
14 pub custom_css: Vec<String>,
14− /// The scoped `@style` of each function called, nested under its
15− /// `.hl-fn-NAME-HASH` class, for `@layer hl-inside`.
16− pub scoped_css: Vec<String>,
17 pub og_tags: Vec<(String, String)>,
15 pub og_tags: Vec<(String, String)>,
16+ pub canonical: Option<String>,
17+ pub base_url: Option<String>,
18+ pub preload_hints: Vec<PreloadHint>,
18 pub nodes: Vec<Node>,
19 pub nodes: Vec<Node>,
19}
20}
20
21
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)]
22+#[derive(Debug, Clone)]
25−pub struct Page {
23+pub struct PreloadHint {
26− pub title: String,
24+ pub href: String,
27− /// `key=value` attributes, and bare boolean HTML attributes, for `<html>`
25+ pub as_type: String, // "font", "image", "style", "script"
28− pub html_attrs: Vec<Attribute>,
26+ 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}
27}
34
28
35#[derive(Debug, Clone)]
29#[derive(Debug, Clone)]
@@ -49,40 +43,18 @@ pub enum TextSegment {
49pub struct Element {
43pub struct Element {
50 pub kind: ElementKind,
44 pub kind: ElementKind,
51 pub attrs: Vec<Attribute>,
45 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>,
46 pub argument: Option<String>,
57 pub children: Vec<Node>,
47 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,
48 pub line_num: usize,
61− /// The function whose body wrote it, when it comes from a call.
62− pub function: Option<String>,
63}
49}
64
50
51+
65#[derive(Debug, Clone)]
52#[derive(Debug, Clone)]
66pub struct Attribute {
53pub struct Attribute {
67 pub key: String,
54 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>,
55 pub value: Option<String>,
71 /// Written `key=value`: an HTML attribute rather than a style.
56 /// Written `key=value`: an HTML attribute rather than a style.
72 pub html: bool,
57 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}
58}
87
59
88#[derive(Debug, Clone, PartialEq)]
60#[derive(Debug, Clone, PartialEq)]
@@ -95,6 +67,7 @@ pub enum ElementKind {
95 Link,
67 Link,
96 Image,
68 Image,
97 // Elements generated in a special way
69 // Elements generated in a special way
70+ Script,
98 Fragment,
71 Fragment,
99 // Function bodies: placeholders for the caller's content
72 // Function bodies: placeholders for the caller's content
100 Children,
73 Children,
@@ -113,6 +86,7 @@ impl ElementKind {
113 ElementKind::Paragraph => "paragraph",
86 ElementKind::Paragraph => "paragraph",
114 ElementKind::Link => "link",
87 ElementKind::Link => "link",
115 ElementKind::Image => "image",
88 ElementKind::Image => "image",
89+ ElementKind::Script => "script",
116 ElementKind::Fragment => "fragment",
90 ElementKind::Fragment => "fragment",
117 ElementKind::Children => "children",
91 ElementKind::Children => "children",
118 ElementKind::Slot(_) => "slot",
92 ElementKind::Slot(_) => "slot",
@@ -127,84 +101,11 @@ impl ElementKind {
127 }
101 }
128 }
102 }
129
103
130− /// How the element lays out what is inside it. `@fragment`, `@children`
104+ /// Does this element lay its children out in a flex column (`@el` and
131− /// and `@slot` have no element of their own: their content lands in the
105+ /// the semantic containers)?
132− /// element around them and takes its layout.
106+ pub fn is_column(&self) -> bool {
133− pub fn layout(&self) -> Layout {
107+ matches!(self, ElementKind::El)
134− match self {
108+ || self.spec().is_some_and(|spec| spec.css.contains("flex-direction:column"))
135− ElementKind::Row => Layout::Row,
136− ElementKind::El => Layout::Column,
137− ElementKind::Text | ElementKind::Paragraph | ElementKind::Link => Layout::Text,
138− ElementKind::Image => Layout::Void,
139− ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_) => Layout::Native,
140− ElementKind::Tag(spec) => spec.layout,
141− }
142− }
143−
144− /// The CSS every such element starts with, besides its layout's
145− /// `display` (browser margins reset, the list markers of a column, a
146− /// link's colour). It goes into the element's own generated class,
147− /// never onto a global element selector, so HTML from `@markdown` and
148− /// `@raw` keeps the browser's defaults.
149− pub fn css(&self) -> &'static str {
150− match self {
151− ElementKind::Paragraph => "margin:0;",
152− ElementKind::Link => "text-decoration:none;color:inherit;",
153− ElementKind::Image => "display:block;",
154− ElementKind::Tag(spec) => spec.css,
155− _ => "",
156− }
157− }
158−
159− /// What the first token after its attributes is: `@link`'s `href`,
160− /// `@image`'s `src`, or its row's [`TagArg`].
161− pub fn arg(&self) -> TagArg {
162− match self {
163− ElementKind::Link => TagArg::Attr("href"),
164− ElementKind::Image => TagArg::Attr("src"),
165− ElementKind::Tag(spec) => spec.arg,
166− _ => TagArg::Child,
167− }
168− }
169−
170− /// The HTML attribute its leading argument fills, when it has one;
171− /// `in_picture`: the element is directly inside `@picture`.
172− pub fn leading_attribute(&self, in_picture: bool) -> Option<&'static str> {
173− self.arg().attribute(in_picture)
174− }
175−
176− /// Whether its indented body is foreign text written into the page as
177− /// it is (`@script`). (`@code` and `@textarea` also have a verbatim
178− /// body, shown as text: see [`TagSpec::literal`].)
179− pub fn is_verbatim(&self) -> bool {
180− self.spec().is_some_and(|spec| spec.verbatim)
181− }
182−
183− /// The HTML element it writes (outside text, where htmlang's own
184− /// `<div>`s are `<span>`s): `None` for `@fragment`, `@children` and
185− /// `@slot`, which write none of their own.
186− pub fn tag(&self) -> Option<&'static str> {
187− match self {
188− ElementKind::Row | ElementKind::El => Some("div"),
189− ElementKind::Text => Some("span"),
190− ElementKind::Paragraph => Some("p"),
191− ElementKind::Link => Some("a"),
192− ElementKind::Image => Some("img"),
193− ElementKind::Fragment | ElementKind::Children | ElementKind::Slot(_) => None,
194− ElementKind::Tag(spec) => Some(spec.html),
195− }
196− }
197−
198− /// The HTML element only this element writes, which a selector can
199− /// pick out: `@td` is every `<td>`, `@paragraph` every `<p>`. `None`
200− /// for an element that shares its tag: `@el`, `@row`, `@grid`,
201− /// `@in-front` and `@behind` are all `<div>`, and `@text` is a
202− /// `<span>`, as is every line of text in a row or column. `@script`
203− /// isn't shown, so there is nothing to style.
204− pub fn own_tag(&self) -> Option<&'static str> {
205− let tag = self.tag()?;
206− let shared = matches!(tag, "div" | "span") || self.is_verbatim();
207− (!shared).then_some(tag)
208 }
109 }
209
110
210 /// Is this the table element named `name` (e.g. `"main"`)?
111 /// Is this the table element named `name` (e.g. `"main"`)?
@@ -221,6 +122,7 @@ impl ElementKind {
221 "paragraph" => ElementKind::Paragraph,
122 "paragraph" => ElementKind::Paragraph,
222 "link" => ElementKind::Link,
123 "link" => ElementKind::Link,
223 "image" => ElementKind::Image,
124 "image" => ElementKind::Image,
125+ "script" => ElementKind::Script,
224 "fragment" => ElementKind::Fragment,
126 "fragment" => ElementKind::Fragment,
225 "children" => ElementKind::Children,
127 "children" => ElementKind::Children,
226 "slot" => ElementKind::Slot(String::new()),
128 "slot" => ElementKind::Slot(String::new()),
@@ -231,15 +133,8 @@ impl ElementKind {
231 /// Every element name, for suggestions and completions.
133 /// Every element name, for suggestions and completions.
232 pub fn all_names() -> impl Iterator<Item = &'static str> {
134 pub fn all_names() -> impl Iterator<Item = &'static str> {
233 [
135 [
234− "row",
136+ "row", "el", "text", "paragraph", "link", "image", "script", "fragment",
235− "el",
236− "text",
237− "paragraph",
238− "link",
239− "image",
240− "fragment",
241− "children",
137+ "children", "slot",
242− "slot",
243 ]
138 ]
244 .into_iter()
139 .into_iter()
245 .chain(TAGS.iter().map(|spec| spec.name))
140 .chain(TAGS.iter().map(|spec| spec.name))
@@ -249,90 +144,12 @@ impl ElementKind {
249/// What an element does with the text after its name and attributes.
144/// What an element does with the text after its name and attributes.
250#[derive(Debug, Clone, Copy, PartialEq)]
145#[derive(Debug, Clone, Copy, PartialEq)]
251pub enum TagArg {
146pub enum TagArg {
252− /// Its content: the first line of its text, parsed like any text
147+ /// 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,
148 Child,
256− /// Its leading attribute: the first token fills this HTML attribute
149+ /// Printed as the element's text (`@li First`).
257− /// (`@iframe URL` sets `src`), and the rest of the text is content, or
258− /// an error on an element without content. The token ends at a space
259− /// outside `"..."` and `${...}`.
260− Attr(&'static str),
261− /// `@source`'s leading attribute, which depends on where it is:
262− /// `srcset` directly inside `@picture`, `src` elsewhere (`@video`,
263− /// `@audio`).
264− Source,
265−}
266−
267−impl TagArg {
268− /// The HTML attribute the leading argument fills; `in_picture`: the
269− /// element is directly inside `@picture`.
270− pub fn attribute(self, in_picture: bool) -> Option<&'static str> {
271− match self {
272− TagArg::Child => None,
273− TagArg::Attr(attr) => Some(attr),
274− TagArg::Source if in_picture => Some("srcset"),
275− TagArg::Source => Some("src"),
276− }
277− }
278−
279− /// The attributes the leading argument may fill, wherever the element
280− /// is: one, or `src` and `srcset` for `@source`.
281− pub fn attributes(self) -> Vec<&'static str> {
282− let mut out: Vec<&'static str> = [self.attribute(false), self.attribute(true)]
283− .into_iter()
284− .flatten()
285− .collect();
286− out.dedup();
287− out
288− }
289−}
290−
291−/// How an element lays out what is inside it: one value per element,
292−/// after elm-ui's layouts. It decides what its text lines are, whether
293−/// `spacing` works on it, and how its children's `width fill`, `center-x`
294−/// and `align-*` compile.
295−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296−pub enum Layout {
297− /// A flex column (`@el`, `@section`, `@ul`, `@li`): each text line is a
298− /// child of its own, and `spacing` is the gap between the children.
299− Column,
300− /// A flex row (`@row`): like a column, side by side.
301− Row,
302− /// A CSS grid (`@grid`): each text line is a cell.
303− Grid,
304− /// Flowing text (`@paragraph`, `@h1`, `@button`, `@td`): the argument,
305− /// the text lines and the children are one run, joined with spaces. It
306− /// has no gap for `spacing`, and a row, column or grid inside it is laid
307− /// out inline (`@el` becomes an inline-flex `<span>`).
308 Text,
150 Text,
309− /// HTML's own layout, which htmlang leaves alone: tables, `@select`,
151+ /// 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 {
152+ 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}
153}
337
154
338/// How an element compiles: the data behind [`ElementKind::Tag`].
155/// How an element compiles: the data behind [`ElementKind::Tag`].
@@ -342,19 +159,17 @@ pub struct TagSpec {
342 pub name: &'static str,
159 pub name: &'static str,
343 /// HTML element emitted.
160 /// HTML element emitted.
344 pub html: &'static str,
161 pub html: &'static str,
345− /// Default CSS besides the layout's `display` (margin reset, ...).
162+ /// Default CSS (flex column, margin reset, ...).
346 pub css: &'static str,
163 pub css: &'static str,
347 pub arg: TagArg,
164 pub arg: TagArg,
348− /// How it lays out its content.
165+ /// Bare text children are wrapped in `<span>` (flex containers).
349− pub layout: Layout,
166+ pub wraps_text: bool,
350− /// Its text is shown as written (`@code`, `@textarea`). On its line and
167+ /// Accepts layout attributes for its children (`spacing`, ...).
351− /// inline, a `{@...}` is text, not an inline element (escapes and
168+ pub container: bool,
352− /// `$names` still work). Its indented body is verbatim: nothing in it is
169+ /// 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,
170+ pub void: bool,
355− /// Its indented body is foreign text written into the page exactly as
171+ /// 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,
172+ pub inline: bool,
358}
173}
359
174
360impl TagSpec {
175impl TagSpec {
@@ -363,413 +178,83 @@ impl TagSpec {
363 html: "",
178 html: "",
364 css: "",
179 css: "",
365 arg: TagArg::Child,
180 arg: TagArg::Child,
366− layout: Layout::Native,
181+ wraps_text: false,
367− literal: false,
182+ container: false,
183+ void: false,
368− verbatim: false,
184+ inline: false,
369 };
185 };
370}
186}
371
187
372/// Every element besides the core ones in [`ElementKind`].
188/// Every element besides the core ones in [`ElementKind`].
373−#[rustfmt::skip]
374pub static TAGS: &[TagSpec] = &[
189pub static TAGS: &[TagSpec] = &[
375− TagSpec { name: "nav", html: "nav", layout: Layout::Column, ..TagSpec::DEFAULT },
190+ 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 },
191+ 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 },
192+ 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 },
193+ 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 },
194+ 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 },
195+ 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 },
196+ 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 },
197+ 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 },
198+ 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 },
199+ TagSpec { name: "search", html: "search", wraps_text: true, container: true, ..TagSpec::DEFAULT },
385− TagSpec { name: "address", html: "address", layout: Layout::Column, ..TagSpec::DEFAULT },
200+ TagSpec { name: "address", html: "address", wraps_text: true, container: true, ..TagSpec::DEFAULT },
386− TagSpec { name: "noscript", html: "noscript", layout: Layout::Column, ..TagSpec::DEFAULT },
201+ 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 },
202+ 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 },
203+ 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 },
204+ 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 },
205+ TagSpec { name: "fieldset", html: "fieldset", css: "display:flex;flex-direction:column;border:1px solid currentColor;padding:8px;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
391− TagSpec { name: "datalist", html: "datalist", layout: Layout::Native, ..TagSpec::DEFAULT },
206+ 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 },
207+ TagSpec { name: "ul", html: "ul", css: "margin:0;padding-left:0;list-style:none;", container: true, ..TagSpec::DEFAULT },
393− TagSpec { name: "ol", html: "ol", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
208+ TagSpec { name: "ol", html: "ol", css: "margin:0;padding-left:0;list-style:none;", container: true, ..TagSpec::DEFAULT },
394− TagSpec { name: "li", html: "li", layout: Layout::Column, ..TagSpec::DEFAULT },
209+ TagSpec { name: "li", html: "li", css: "display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
395− TagSpec { name: "dl", html: "dl", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
210+ TagSpec { name: "dl", html: "dl", css: "margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
396− TagSpec { name: "dt", html: "dt", layout: Layout::Text, ..TagSpec::DEFAULT },
211+ TagSpec { name: "dt", html: "dt", arg: TagArg::Text, container: true, ..TagSpec::DEFAULT },
397− TagSpec { name: "dd", html: "dd", css: "margin:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
212+ TagSpec { name: "dd", html: "dd", css: "margin:0;display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
398− TagSpec { name: "table", html: "table", layout: Layout::Native, ..TagSpec::DEFAULT },
213+ TagSpec { name: "table", html: "table", ..TagSpec::DEFAULT },
399− TagSpec { name: "thead", html: "thead", layout: Layout::Native, ..TagSpec::DEFAULT },
214+ TagSpec { name: "thead", html: "thead", ..TagSpec::DEFAULT },
400− TagSpec { name: "tbody", html: "tbody", layout: Layout::Native, ..TagSpec::DEFAULT },
215+ TagSpec { name: "tbody", html: "tbody", ..TagSpec::DEFAULT },
401− TagSpec { name: "tr", html: "tr", layout: Layout::Native, ..TagSpec::DEFAULT },
216+ TagSpec { name: "tr", html: "tr", ..TagSpec::DEFAULT },
402− TagSpec { name: "select", html: "select", layout: Layout::Native, ..TagSpec::DEFAULT },
217+ TagSpec { name: "select", html: "select", ..TagSpec::DEFAULT },
403− TagSpec { name: "picture", html: "picture", layout: Layout::Native, ..TagSpec::DEFAULT },
218+ TagSpec { name: "picture", html: "picture", ..TagSpec::DEFAULT },
404− TagSpec { name: "progress", html: "progress", layout: Layout::Native, ..TagSpec::DEFAULT },
219+ TagSpec { name: "progress", html: "progress", ..TagSpec::DEFAULT },
405− TagSpec { name: "meter", html: "meter", layout: Layout::Native, ..TagSpec::DEFAULT },
220+ TagSpec { name: "meter", html: "meter", ..TagSpec::DEFAULT },
406− TagSpec { name: "output", html: "output", layout: Layout::Native, ..TagSpec::DEFAULT },
221+ TagSpec { name: "output", html: "output", ..TagSpec::DEFAULT },
407− TagSpec { name: "canvas", html: "canvas", layout: Layout::Native, ..TagSpec::DEFAULT },
222+ TagSpec { name: "canvas", html: "canvas", container: true, ..TagSpec::DEFAULT },
408− TagSpec { name: "td", html: "td", layout: Layout::Text, ..TagSpec::DEFAULT },
223+ TagSpec { name: "td", html: "td", arg: TagArg::Text, ..TagSpec::DEFAULT },
409− TagSpec { name: "th", html: "th", layout: Layout::Text, ..TagSpec::DEFAULT },
224+ TagSpec { name: "th", html: "th", arg: TagArg::Text, ..TagSpec::DEFAULT },
410− TagSpec { name: "button", html: "button", layout: Layout::Text, ..TagSpec::DEFAULT },
225+ TagSpec { name: "button", html: "button", arg: TagArg::Text, ..TagSpec::DEFAULT },
411− TagSpec { name: "label", html: "label", layout: Layout::Text, ..TagSpec::DEFAULT },
226+ TagSpec { name: "label", html: "label", arg: TagArg::Text, ..TagSpec::DEFAULT },
412− TagSpec { name: "option", html: "option", layout: Layout::Native, ..TagSpec::DEFAULT },
227+ TagSpec { name: "option", html: "option", arg: TagArg::Text, ..TagSpec::DEFAULT },
413− TagSpec { name: "textarea", html: "textarea", layout: Layout::Native, literal: true, ..TagSpec::DEFAULT },
228+ TagSpec { name: "textarea", html: "textarea", arg: TagArg::Text, ..TagSpec::DEFAULT },
414− TagSpec { name: "summary", html: "summary", layout: Layout::Text, ..TagSpec::DEFAULT },
229+ TagSpec { name: "summary", html: "summary", arg: TagArg::Text, ..TagSpec::DEFAULT },
415− TagSpec { name: "cite", html: "cite", layout: Layout::Text, ..TagSpec::DEFAULT },
230+ TagSpec { name: "cite", html: "cite", arg: TagArg::Text, ..TagSpec::DEFAULT },
416− TagSpec { name: "figcaption", html: "figcaption", layout: Layout::Text, ..TagSpec::DEFAULT },
231+ TagSpec { name: "figcaption", html: "figcaption", arg: TagArg::Text, ..TagSpec::DEFAULT },
417− TagSpec { name: "legend", html: "legend", layout: Layout::Text, ..TagSpec::DEFAULT },
232+ TagSpec { name: "legend", html: "legend", arg: TagArg::Text, ..TagSpec::DEFAULT },
418− TagSpec { name: "time", html: "time", layout: Layout::Text, ..TagSpec::DEFAULT },
233+ TagSpec { name: "time", html: "time", arg: TagArg::Text, ..TagSpec::DEFAULT },
419− TagSpec { name: "mark", html: "mark", layout: Layout::Text, ..TagSpec::DEFAULT },
234+ TagSpec { name: "mark", html: "mark", arg: TagArg::Text, ..TagSpec::DEFAULT },
420− TagSpec { name: "abbr", html: "abbr", layout: Layout::Text, ..TagSpec::DEFAULT },
235+ TagSpec { name: "abbr", html: "abbr", arg: TagArg::Text, ..TagSpec::DEFAULT },
421− TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, literal: true, ..TagSpec::DEFAULT },
236+ TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
422− TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", layout: Layout::Text, ..TagSpec::DEFAULT },
237+ TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
423− TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", layout: Layout::Native, ..TagSpec::DEFAULT },
238+ TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", ..TagSpec::DEFAULT },
424− TagSpec { name: "h1", html: "h1", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
239+ TagSpec { name: "h1", html: "h1", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
425− TagSpec { name: "h2", html: "h2", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
240+ TagSpec { name: "h2", html: "h2", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
426− TagSpec { name: "h3", html: "h3", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
241+ TagSpec { name: "h3", html: "h3", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
427− TagSpec { name: "h4", html: "h4", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
242+ TagSpec { name: "h4", html: "h4", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
428− TagSpec { name: "h5", html: "h5", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
243+ TagSpec { name: "h5", html: "h5", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
429− TagSpec { name: "h6", html: "h6", css: "margin:0;", layout: Layout::Text, ..TagSpec::DEFAULT },
244+ TagSpec { name: "h6", html: "h6", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
430− TagSpec { name: "input", html: "input", layout: Layout::Void, ..TagSpec::DEFAULT },
245+ TagSpec { name: "input", html: "input", void: true, ..TagSpec::DEFAULT },
431− TagSpec { name: "hr", html: "hr", layout: Layout::Void, ..TagSpec::DEFAULT },
246+ TagSpec { name: "hr", html: "hr", void: true, ..TagSpec::DEFAULT },
432− TagSpec { name: "source", html: "source", arg: TagArg::Source, layout: Layout::Void, ..TagSpec::DEFAULT },
247+ TagSpec { name: "source", html: "source", arg: TagArg::Attr("src"), void: true, ..TagSpec::DEFAULT },
433− TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
248+ TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
434− TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
249+ TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
435− TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), layout: Layout::Native, ..TagSpec::DEFAULT },
250+ TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), container: true, ..TagSpec::DEFAULT },
436− TagSpec { name: "script", html: "script", arg: TagArg::Attr("src"), layout: Layout::Native, verbatim: true, ..TagSpec::DEFAULT },
251+ TagSpec { name: "grid", html: "div", css: "display:grid;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
437− // Flow containers
438− TagSpec { name: "hgroup", html: "hgroup", layout: Layout::Column, ..TagSpec::DEFAULT },
439− TagSpec { name: "menu", html: "menu", css: "margin:0;padding-left:0;list-style:none;", layout: Layout::Column, ..TagSpec::DEFAULT },
440− // Phrasing: text with the browser's own style, and no CSS of htmlang's
441− TagSpec { name: "b", html: "b", layout: Layout::Text, ..TagSpec::DEFAULT },
442− TagSpec { name: "i", html: "i", layout: Layout::Text, ..TagSpec::DEFAULT },
443− TagSpec { name: "strong", html: "strong", layout: Layout::Text, ..TagSpec::DEFAULT },
444− TagSpec { name: "em", html: "em", layout: Layout::Text, ..TagSpec::DEFAULT },
445− TagSpec { name: "small", html: "small", layout: Layout::Text, ..TagSpec::DEFAULT },
446− TagSpec { name: "s", html: "s", layout: Layout::Text, ..TagSpec::DEFAULT },
447− TagSpec { name: "u", html: "u", layout: Layout::Text, ..TagSpec::DEFAULT },
448− TagSpec { name: "sub", html: "sub", layout: Layout::Text, ..TagSpec::DEFAULT },
449− TagSpec { name: "sup", html: "sup", layout: Layout::Text, ..TagSpec::DEFAULT },
450− TagSpec { name: "q", html: "q", layout: Layout::Text, ..TagSpec::DEFAULT },
451− TagSpec { name: "var", html: "var", layout: Layout::Text, ..TagSpec::DEFAULT },
452− TagSpec { name: "samp", html: "samp", layout: Layout::Text, ..TagSpec::DEFAULT },
453− TagSpec { name: "dfn", html: "dfn", layout: Layout::Text, ..TagSpec::DEFAULT },
454− TagSpec { name: "bdi", html: "bdi", layout: Layout::Text, ..TagSpec::DEFAULT },
455− TagSpec { name: "bdo", html: "bdo", layout: Layout::Text, ..TagSpec::DEFAULT },
456− TagSpec { name: "ins", html: "ins", layout: Layout::Text, ..TagSpec::DEFAULT },
457− TagSpec { name: "del", html: "del", layout: Layout::Text, ..TagSpec::DEFAULT },
458− TagSpec { name: "br", html: "br", layout: Layout::Void, ..TagSpec::DEFAULT },
459− TagSpec { name: "wbr", html: "wbr", layout: Layout::Void, ..TagSpec::DEFAULT },
460− // HTML's own display: table parts, option groups, ruby, embedded content
461− TagSpec { name: "caption", html: "caption", layout: Layout::Native, ..TagSpec::DEFAULT },
462− TagSpec { name: "colgroup", html: "colgroup", layout: Layout::Native, ..TagSpec::DEFAULT },
463− TagSpec { name: "col", html: "col", layout: Layout::Void, ..TagSpec::DEFAULT },
464− TagSpec { name: "tfoot", html: "tfoot", layout: Layout::Native, ..TagSpec::DEFAULT },
465− TagSpec { name: "optgroup", html: "optgroup", arg: TagArg::Attr("label"), layout: Layout::Native, ..TagSpec::DEFAULT },
466− TagSpec { name: "ruby", html: "ruby", layout: Layout::Native, ..TagSpec::DEFAULT },
467− TagSpec { name: "rt", html: "rt", layout: Layout::Native, ..TagSpec::DEFAULT },
468− TagSpec { name: "rp", html: "rp", layout: Layout::Native, ..TagSpec::DEFAULT },
469− TagSpec { name: "track", html: "track", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
470− TagSpec { name: "embed", html: "embed", arg: TagArg::Attr("src"), layout: Layout::Void, ..TagSpec::DEFAULT },
471− TagSpec { name: "object", html: "object", arg: TagArg::Attr("data"), layout: Layout::Native, ..TagSpec::DEFAULT },
472− TagSpec { name: "map", html: "map", layout: Layout::Native, ..TagSpec::DEFAULT },
473− TagSpec { name: "area", html: "area", arg: TagArg::Attr("href"), layout: Layout::Void, ..TagSpec::DEFAULT },
474− // htmlang's own
475− TagSpec { name: "grid", html: "div", layout: Layout::Grid, ..TagSpec::DEFAULT },
476− TagSpec { name: "in-front", html: "div", css: "position:absolute;inset:0;", layout: Layout::Column, ..TagSpec::DEFAULT },
252+ TagSpec { name: "in-front", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;", container: true, ..TagSpec::DEFAULT },
477− TagSpec { name: "behind", html: "div", css: "position:absolute;inset:0;z-index:-1;", layout: Layout::Column, ..TagSpec::DEFAULT },
253+ TagSpec { name: "behind", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;z-index:-1;", container: true, ..TagSpec::DEFAULT },
478];
254];
479
255
480−/// HTML elements htmlang writes under another name. The HTML name isn't an
256+/// Elements that print their argument as their text (`@text Hello`,
481−/// element: the unknown-element error for `@a` suggests `@link`. (Not a
257+/// `@li First`).
482−/// second spelling: only the suggestion reads this.)
258+pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
483−pub const HTML_NAMES_WRITTEN_OTHERWISE: &[(&str, &str)] = &[
259+ *kind == ElementKind::Text || kind.spec().is_some_and(|spec| spec.arg == TagArg::Text)
484− ("a", "link"),
485− ("img", "image"),
486− ("span", "text"),
487− ("p", "paragraph"),
488− ("div", "el"),
489−];
490−
491−/// Whether the element named `name` (without the `@`) shows its text as
492−/// written: `{@code {@link /x y}}` prints the braces, and its indented body
493−/// is verbatim text, HTML-escaped. The syntax tree reads this, so every
494−/// tool agrees on it.
495−pub fn has_literal_text(name: &str) -> bool {
496− ElementKind::from_name(name)
497− .and_then(|kind| kind.spec())
498− .is_some_and(|spec| spec.literal)
499−}
500−
501−/// How a directive reads the rest of its line.
502−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503−pub enum ArgGrammar {
504− /// Free text, taken as written (`@include file.hl`, `@meta name value`).
505− /// For a directive with a verbatim body it is the one-line form of the
506− /// body (`@raw <hr>`, `@style .a { color: red }`) or, for `@markdown`,
507− /// the file.
508− Text,
509− /// An optional `[attributes]` list, then text (`@page [lang=en] Title`).
510− AttrsText,
511− /// An expression (`@if $count > 2`).
512− Expression,
513− /// A name, then a value, `= EXPR`, `"text"`, `[attributes]` or the
514− /// parameters of a function (`@let`).
515− Definition,
516− /// Nothing, or `if EXPR` (`@else`, `@else if`).
517− Else,
518− /// `$item in LIST` or `$item, $index in LIST` (`@each`).
519− Loop,
520− /// `$name SOURCE`; inline JSON may span several lines (`@data`).
521− Data,
522−}
523−
524−/// What the lines indented under a directive or element are.
525−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526−pub enum BodyKind {
527− /// Nothing: indented lines are an error.
528− None,
529− /// htmlang: elements, text and directives.
530− Htmlang,
531− /// Foreign text (CSS, HTML, JavaScript, Markdown, a code sample), kept
532− /// exactly as written: nothing in it is parsed, and `--` is not a
533− /// comment. The text on the header's line is its one-line form (or a
534− /// file or URL: `@markdown notes.md`, `@script app.js`); the line and a
535− /// body together are an error. A verbatim directive takes no
536− /// attributes.
537− Verbatim,
538−}
539−
540−/// A directive: a `@name` that isn't an element. The syntax tree
541−/// (`syntax.rs`), the evaluator, the formatter and the editor tools all
542−/// read the directives' shapes from this table.
543−#[derive(Debug, PartialEq)]
544−pub struct DirectiveSpec {
545− /// Name in htmlang source, without the `@`.
546− pub name: &'static str,
547− pub args: ArgGrammar,
548− pub body: BodyKind,
549−}
550−
551−/// Every directive.
552−#[rustfmt::skip]
553−pub static DIRECTIVES: &[DirectiveSpec] = &[
554− DirectiveSpec { name: "page", args: ArgGrammar::AttrsText, body: BodyKind::None },
555− DirectiveSpec { name: "let", args: ArgGrammar::Definition, body: BodyKind::Htmlang },
556− DirectiveSpec { name: "include", args: ArgGrammar::Text, body: BodyKind::None },
557− DirectiveSpec { name: "data", args: ArgGrammar::Data, body: BodyKind::None },
558− DirectiveSpec { name: "meta", args: ArgGrammar::Text, body: BodyKind::None },
559− DirectiveSpec { name: "if", args: ArgGrammar::Expression, body: BodyKind::Htmlang },
560− DirectiveSpec { name: "else", args: ArgGrammar::Else, body: BodyKind::Htmlang },
561− DirectiveSpec { name: "each", args: ArgGrammar::Loop, body: BodyKind::Htmlang },
562− DirectiveSpec { name: "style", args: ArgGrammar::Text, body: BodyKind::Verbatim },
563− DirectiveSpec { name: "head", args: ArgGrammar::Text, body: BodyKind::Verbatim },
564− DirectiveSpec { name: "raw", args: ArgGrammar::Text, body: BodyKind::Verbatim },
565− DirectiveSpec { name: "markdown", args: ArgGrammar::Text, body: BodyKind::Verbatim },
566−];
567−
568−/// The directive named `name` (without the `@`).
569−pub fn directive(name: &str) -> Option<&'static DirectiveSpec> {
570− DIRECTIVES.iter().find(|d| d.name == name)
571−}
572−
573−/// What the lines indented under `@name` are: the directive's body kind,
574−/// or an element's (verbatim for a row that says so: `@script`, and
575−/// `@code` and `@textarea`, whose text is literal; htmlang for every other
576−/// element).
577−pub fn body_kind(name: &str) -> BodyKind {
578− let verbatim = |spec: &TagSpec| spec.verbatim || spec.literal;
579− match directive(name) {
580− Some(spec) => spec.body,
581− None if ElementKind::from_name(name)
582− .and_then(|kind| kind.spec())
583− .is_some_and(verbatim) =>
584− {
585− BodyKind::Verbatim
586− }
587− None => BodyKind::Htmlang,
588− }
589−}
590−
591−#[cfg(test)]
592−mod tests {
593− use super::{ElementKind, Layout};
594−
595− fn layout(name: &str) -> Layout {
596− ElementKind::from_name(name).unwrap().layout()
597− }
598−
599− #[test]
600− fn every_element_has_the_layout_it_is_documented_with() {
601− let text = [
602− "text",
603− "paragraph",
604− "link",
605− "h1",
606− "h2",
607− "h3",
608− "h4",
609− "h5",
610− "h6",
611− "button",
612− "td",
613− "th",
614− "label",
615− "summary",
616− "legend",
617− "figcaption",
618− "dt",
619− "cite",
620− "code",
621− "kbd",
622− "mark",
623− "abbr",
624− "time",
625− "b",
626− "i",
627− "strong",
628− "em",
629− "small",
630− "sub",
631− "sup",
632− "q",
633− "del",
634− ];
635− for name in text {
636− assert_eq!(layout(name), Layout::Text, "@{}", name);
637− }
638− let columns = [
639− "el", "li", "dd", "ul", "ol", "dl", "search", "address", "noscript", "section", "form",
640− "in-front", "behind", "hgroup", "menu",
641− ];
642− for name in columns {
643− assert_eq!(layout(name), Layout::Column, "@{}", name);
644− }
645− assert_eq!(layout("row"), Layout::Row);
646− assert_eq!(layout("grid"), Layout::Grid);
647− for name in [
648− "table", "tr", "select", "option", "picture", "video", "pre", "canvas", "caption",
649− "colgroup", "tfoot", "optgroup", "ruby", "rt", "object",
650− ] {
651− assert_eq!(layout(name), Layout::Native, "@{}", name);
652− }
653− for name in [
654− "image", "input", "hr", "source", "br", "wbr", "col", "track", "embed", "area",
655− ] {
656− assert_eq!(layout(name), Layout::Void, "@{}", name);
657− }
658− }
659−
660− #[test]
661− fn html_names_written_otherwise_are_not_elements() {
662− for (html, htmlang) in super::HTML_NAMES_WRITTEN_OTHERWISE {
663− assert!(ElementKind::from_name(html).is_none(), "@{}", html);
664− let kind = ElementKind::from_name(htmlang).unwrap();
665− let written = match kind {
666− ElementKind::El => "div",
667− ElementKind::Text => "span",
668− ElementKind::Paragraph => "p",
669− ElementKind::Link => "a",
670− ElementKind::Image => "img",
671− _ => panic!("@{} is not one of htmlang's own", htmlang),
672− };
673− assert_eq!(written, *html);
674− }
675− // No two rows share a name, and none is a directive's
676− let mut names: Vec<&str> = ElementKind::all_names().collect();
677− let count = names.len();
678− names.sort_unstable();
679− names.dedup();
680− assert_eq!(names.len(), count, "an element name is listed twice");
681− for name in names {
682− assert!(super::directive(name).is_none(), "@{}", name);
683− }
684− }
685−
686− #[test]
687− fn each_row_names_at_most_one_leading_attribute() {
688− let leading = |name: &str, in_picture: bool| {
689− ElementKind::from_name(name)
690− .unwrap()
691− .leading_attribute(in_picture)
692− };
693− for (name, attr) in [
694− ("link", "href"),
695− ("area", "href"),
696− ("image", "src"),
697− ("script", "src"),
698− ("iframe", "src"),
699− ("video", "src"),
700− ("audio", "src"),
701− ("track", "src"),
702− ("embed", "src"),
703− ("form", "action"),
704− ("object", "data"),
705− ("optgroup", "label"),
706− ] {
707− assert_eq!(leading(name, false), Some(attr), "@{}", name);
708− assert_eq!(leading(name, true), Some(attr), "@{}", name);
709− }
710− assert_eq!(leading("source", false), Some("src"));
711− assert_eq!(leading("source", true), Some("srcset"));
712− for name in ["el", "text", "h1", "button", "slot", "fragment", "picture"] {
713− assert_eq!(leading(name, false), None, "@{}", name);
714− }
715− // @script is an ordinary row whose body is kept as written
716− let script = ElementKind::from_name("script").unwrap();
717− assert!(script.is_verbatim() && script.spec().is_some());
718− assert_eq!(super::body_kind("script"), super::BodyKind::Verbatim);
719− assert_eq!(super::body_kind("el"), super::BodyKind::Htmlang);
720− // @code and @textarea: a verbatim body, shown as text; @pre is an
721− // ordinary element, so `@pre > @code` and wrapper functions work
722− for name in ["code", "textarea"] {
723− assert_eq!(
724− super::body_kind(name),
725− super::BodyKind::Verbatim,
726− "@{}",
727− name
728− );
729− assert!(super::has_literal_text(name), "@{}", name);
730− }
731− assert_eq!(super::body_kind("pre"), super::BodyKind::Htmlang);
732− // Every verbatim directive takes its line as its one-line body or file
733− for spec in super::DIRECTIVES {
734− if spec.body == super::BodyKind::Verbatim {
735− assert_eq!(spec.args, super::ArgGrammar::Text, "@{}", spec.name);
736− }
737− }
738− }
739−
740− #[test]
741− fn an_element_has_a_tag_of_its_own_when_no_other_element_writes_it() {
742− let own = |name: &str| ElementKind::from_name(name).unwrap().own_tag();
743− for shared in [
744− "el", "row", "grid", "in-front", "behind", "text", "script", "fragment",
745− ] {
746− assert_eq!(own(shared), None, "@{}", shared);
747− }
748− assert_eq!(own("td"), Some("td"));
749− assert_eq!(own("link"), Some("a"));
750− assert_eq!(own("paragraph"), Some("p"));
751− assert_eq!(own("image"), Some("img"));
752− // Every tag of its own is written by that one element
753− let names: Vec<&str> = ElementKind::all_names().collect();
754− for name in &names {
755− let Some(tag) = own(name) else { continue };
756− let writers: Vec<&&str> = names
757− .iter()
758− .filter(|other| ElementKind::from_name(other).unwrap().tag() == Some(tag))
759− .collect();
760− assert_eq!(writers, vec![name], "<{}>", tag);
761− }
762− }
763−
764− #[test]
765− fn a_layout_s_display_is_not_repeated_in_the_row_s_css() {
766− for spec in super::TAGS {
767− assert!(
768− !spec.css.contains("display"),
769− "@{}: {}",
770− spec.name,
771− spec.css
772− );
773− }
774− }
775}
260}
crates/htmlang-core/src/codegen.rs+1714−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,63 @@ 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+
893+ // Keyframes for the standard library's `$skeleton` bundle, when used
894+ if element_css.contains("hl-skeleton") {
895+ if dev {
896+ element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
897+ } else {
898+ element_css.push_str("@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}");
899+ }
900+ }
901+
995 // @style blocks (custom CSS)
902 // @style blocks (custom CSS)
996 for block in &doc.custom_css {
903 for block in &doc.custom_css {
997 if dev {
904 if dev {
998 element_css.push_str(block);
905 element_css.push_str(block);
999 element_css.push('\n');
906 element_css.push('\n');
1000 } else {
907 } else {
908+ let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1001− element_css.push_str(&compact_css(block));
909+ element_css.push_str(&minified);
1002 }
910 }
1003 }
911 }
1004
912
1005 element_css
913 element_css
1006}
914}
1007
915
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
916/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1037−/// every layer, so an unlayered rule here would override the generated
917+/// every layer, so an unlayered `a{color:inherit}` would override
1038−/// ones. A page resets `box-sizing` everywhere and makes `<body>` a column
918+/// `@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 {
919+fn reset_css(dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
1045− let own = OWN_ELEMENTS;
920+ let base = if dev {
1046− let mut rules = Vec::new();
1047− if page {
1048− rules.push(if dev {
1049− "*, *::before, *::after { box-sizing: border-box; }".to_string()
1050− } else {
1051− "*,*::before,*::after{box-sizing:border-box}".to_string()
1052− });
1053− rules.push(if dev {
1054− "body { margin: 0; font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; min-height: 100dvh; }".to_string()
921+ "*, *::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"
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 {
922 } else {
1059− rules.push(if dev {
923+ "*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}"
1060− format!("{own}, {own}::before, {own}::after {{ box-sizing: border-box; }}")
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 {
1070− format!("{own}:where({hidden}){{display:none!important}}")
1071− });
924+ };
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);
925+ let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
1077 if dev {
926 if dev {
1078− format!(
1079− "@layer {};\n@layer hl-reset {{\n{}}}\n",
927+ format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
1080− LAYERS.join(", "),
1081− rules
1082− )
1083 } else {
928 } else {
1084− format!("@layer {};@layer hl-reset{{{}}}", LAYERS.join(","), rules)
929+ format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
1085 }
930 }
1086}
931}
1087
932
1088/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
933/// 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 {
934+fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1090 let mut styles = StyleCollector::new();
935 let mut styles = StyleCollector::new();
1091 let mut ctx = GenContext {
936 let mut ctx = GenContext {
1092 dev,
937 dev,
1093 depth: 0,
938 depth: 0,
939+ image_count: 0,
1094 has_interactive: false,
940 has_interactive: false,
1095− in_text: false,
1096− flow: Flow::default(),
1097− in_picture: false,
1098 };
941 };
1099 let mut body = String::new();
942 let mut body = String::new();
1100
943
1101− generate_children(&doc.nodes, None, &mut body, &mut styles, &mut ctx);
944+ for node in &doc.nodes {
1102−
945+ 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 }
946 }
1109− classes.extend(styles.classes());
947+
948+ let element_css = build_element_css(doc, &styles, dev);
1110
949
1111 if element_css.is_empty() {
950 if element_css.is_empty() {
1112 body
951 body
@@ -1121,20 +960,22 @@ fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -
1121// Node generation
960// Node generation
1122// ---------------------------------------------------------------------------
961// ---------------------------------------------------------------------------
1123
962
1124−/// Write `node`, which is inside an element with the layout `parent`
1125−/// (`None` at the top of the page).
1126fn generate_node(
963fn generate_node(
1127 node: &Node,
964 node: &Node,
1128− parent: Option<Layout>,
965+ parent_kind: Option<&ElementKind>,
1129 out: &mut String,
966 out: &mut String,
1130 styles: &mut StyleCollector,
967 styles: &mut StyleCollector,
1131 ctx: &mut GenContext,
968 ctx: &mut GenContext,
1132) {
969) {
1133 match node {
970 match node {
1134− Node::Element(elem) => generate_element(elem, parent, out, styles, ctx),
971+ Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
1135 Node::Text(segments) => {
972 Node::Text(segments) => {
1136− // In a row, column or grid each line of text is a child
973+ let needs_wrap = matches!(
1137− let needs_wrap = parent.is_some_and(Layout::is_container);
974+ parent_kind,
975+ Some(ElementKind::Row | ElementKind::El)
976+ ) || parent_kind
977+ .and_then(ElementKind::spec)
978+ .is_some_and(|spec| spec.wraps_text);
1138 if needs_wrap {
979 if needs_wrap {
1139 out.push_str(&ctx.indent());
980 out.push_str(&ctx.indent());
1140 out.push_str("<span>");
981 out.push_str("<span>");
@@ -1153,156 +994,73 @@ fn generate_node(
1153 }
994 }
1154}
995}
1155
996
1156−/// What goes between two things written one after the other in an element
1157−/// with this layout (`None`: the top of the page): text flows, so its lines
1158−/// and children are joined with a space; in HTML's own layout, and at the
1159−/// top of the page, two lines of text are separated by a line break (a
1160−/// space, except where whitespace is kept, as in `@pre`).
1161−fn separator(layout: Option<Layout>, previous_is_text: bool, next: &Node) -> Option<char> {
1162− match layout {
1163− Some(Layout::Text) => Some(' '),
1164− Some(Layout::Native) | None if previous_is_text && matches!(next, Node::Text(_)) => {
1165− Some('\n')
1166− }
1167− _ => None,
1168− }
1169−}
1170
997
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` →
998+/// Emit the argument of an element whose argument is an HTML attribute
1222−/// `src="URL"`, `@form /submit` → `action="/submit"`, `@link /a` →
999+/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`).
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) {
1000+fn emit_argument_attr(out: &mut String, elem: &Element) {
1226− let (Some(attr), Some(value)) = (elem.kind.leading_attribute(in_picture), &elem.argument)
1001+ if let Some(TagSpec {
1227− else {
1002+ arg: TagArg::Attr(attr),
1228− return;
1003+ ..
1229− };
1004+ }) = elem.kind.spec()
1230− // `@image [inline]`'s file is already a `data:` URI (see the parser)
1005+ && let Some(value) = &elem.argument
1231− let value = html_escape(value);
1006+ {
1232− out.push(' ');
1007+ out.push(' ');
1233− out.push_str(attr);
1008+ out.push_str(attr);
1234− out.push_str("=\"");
1009+ out.push_str("=\"");
1235− out.push_str(&value);
1010+ out.push_str(&html_escape(value));
1236− out.push('"');
1011+ out.push('"');
1237−}
1012+ }
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}
1013}
1258
1014
1259/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1015/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1260−/// are emitted with the generated class) and flags like `required`.
1016+/// are emitted with the generated class) and bare booleans like `required`.
1261fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1017fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1262− for attr in html_attrs(attrs) {
1018+ for attr in attrs {
1263 let key = attr.key.as_str();
1019 let key = attr.key.as_str();
1264− if key != "id" && key != "class" {
1020+ if attr.html && key != "id" && key != "class" {
1265− push_html_attr(out, attr);
1021+ out.push(' ');
1022+ out.push_str(key);
1023+ out.push_str("=\"");
1024+ out.push_str(&html_escape(strip_string_quotes(
1025+ attr.value.as_deref().unwrap_or(""),
1026+ )));
1027+ out.push('"');
1028+ } else if !attr.html
1029+ && attr.value.is_none()
1030+ && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&key)
1031+ {
1032+ out.push(' ');
1033+ out.push_str(key);
1266 }
1034 }
1267 }
1035 }
1268}
1036}
1269
1037
1270−/// The HTML attributes of `attrs`, `key=value` ones and flags, each name
1038+/// 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
1039+/// `"Avatar"`), return the inner content. Quotes act as source-level
1272−/// name, the browser keeps the first, so a bundle's attribute or a
1040+/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute
1273−/// function's own would win over one written after it.)
1041+/// values. Multi-quoted values like `"h h" "s m"` are left unchanged —
1274−fn html_attrs(attrs: &[Attribute]) -> impl Iterator<Item = &Attribute> {
1042+/// those are real string tokens (used e.g. by CSS `grid-template-areas`).
1275− let is_html =
1043+fn strip_string_quotes(val: &str) -> &str {
1276− |a: &Attribute| a.html || (a.value.is_none() && crate::vocab::is_html_flag(&a.key));
1044+ let bytes = val.as_bytes();
1277− attrs.iter().enumerate().filter_map(move |(i, attr)| {
1045+ if bytes.len() < 2 {
1278− let later = attrs[i + 1..]
1046+ return val;
1279− .iter()
1047+ }
1280− .any(|other| is_html(other) && other.key == attr.key);
1048+ let first = bytes[0];
1281− (is_html(attr) && !later).then_some(attr)
1049+ let last = bytes[bytes.len() - 1];
1282− })
1050+ if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1283−}
1051+ let inner = &val[1..val.len() - 1];
1284−
1052+ if !inner.as_bytes().contains(&first) {
1285−/// ` key="value"`, or ` key` for a flag.
1053+ return inner;
1286−fn push_html_attr(out: &mut String, attr: &Attribute) {
1054+ }
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 }
1055 }
1056+ val
1295}
1057}
1296
1058
1297// True if any direct child is `@in-front` or `@behind`. Such children render
1059// True if any direct child is `@in-front` or `@behind`. Such children render
1298// as absolutely positioned overlays, so the parent automatically becomes a
1060// as absolutely positioned overlays, so the parent automatically becomes a
1299// positioning context (position:relative + isolation:isolate).
1061// positioning context (position:relative + isolation:isolate).
1300fn has_overlay_children(elem: &Element) -> bool {
1062fn 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| {
1063+ elem.children.iter().any(|child| {
1306 matches!(
1064 matches!(
1307 child,
1065 child,
1308 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
1066 Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
@@ -1312,32 +1070,48 @@ fn holds_overlays(children: &[Node]) -> bool {
1312
1070
1313fn generate_element(
1071fn generate_element(
1314 elem: &Element,
1072 elem: &Element,
1315− parent: Option<Layout>,
1073+ parent_kind: Option<&ElementKind>,
1316 out: &mut String,
1074 out: &mut String,
1317 styles: &mut StyleCollector,
1075 styles: &mut StyleCollector,
1318 ctx: &mut GenContext,
1076 ctx: &mut GenContext,
1319) {
1077) {
1320 // Self-closing elements
1078 // Self-closing elements
1321− if elem.kind.layout() == Layout::Void {
1079+ if elem.kind == ElementKind::Image || elem.kind.spec().is_some_and(|spec| spec.void) {
1322− generate_self_closing(elem, parent, out, styles, ctx);
1080+ generate_self_closing(elem, parent_kind, out, styles, ctx);
1323 return;
1081 return;
1324 }
1082 }
1325− // A verbatim element (@script) writes its body as it is, with no HTML
1083+ // @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() {
1084+ if elem.kind == ElementKind::Script {
1328− let tag = elem.kind.spec().map_or("script", |spec| spec.html);
1329 out.push_str(&ctx.indent());
1085 out.push_str(&ctx.indent());
1330− out.push('<');
1086+ out.push_str("<script");
1331− out.push_str(tag);
1087+ // Pass through src, type, defer, async, etc.
1332− emit_argument_attr(out, elem, false);
1088+ for attr in &elem.attrs {
1333− let (id, class) = extract_id_class(&elem.attrs);
1089+ let key = attr.key.as_str();
1334− for (key, value) in [("id", id), ("class", class)] {
1090+ if matches!(
1335− if let Some(value) = value {
1091+ key,
1336− out.push_str(&format!(" {}=\"{}\"", key, html_escape(&value)));
1092+ "src"
1093+ | "type"
1094+ | "defer"
1095+ | "async"
1096+ | "crossorigin"
1097+ | "integrity"
1098+ | "nomodule"
1099+ | "id"
1100+ ) {
1101+ if let Some(val) = &attr.value {
1102+ out.push(' ');
1103+ out.push_str(key);
1104+ out.push_str("=\"");
1105+ out.push_str(&html_escape(val));
1106+ out.push('"');
1107+ } else {
1108+ out.push(' ');
1109+ out.push_str(key);
1110+ }
1337 }
1111 }
1338 }
1112 }
1339− emit_html_attrs(out, &elem.attrs);
1340 out.push('>');
1113 out.push('>');
1114+ // Children are raw JS code, not HTML
1341 for child in &elem.children {
1115 for child in &elem.children {
1342 match child {
1116 match child {
1343 Node::Text(segments) => {
1117 Node::Text(segments) => {
@@ -1351,9 +1125,7 @@ fn generate_element(
1351 _ => {}
1125 _ => {}
1352 }
1126 }
1353 }
1127 }
1354− out.push_str("</");
1128+ out.push_str("</script>");
1355− out.push_str(tag);
1356− out.push('>');
1357 out.push_str(ctx.nl());
1129 out.push_str(ctx.nl());
1358 return;
1130 return;
1359 }
1131 }
@@ -1364,13 +1136,13 @@ fn generate_element(
1364 return;
1136 return;
1365 }
1137 }
1366 if elem.kind == ElementKind::Fragment {
1138 if elem.kind == ElementKind::Fragment {
1367− // Render children without a wrapper element, as if they were
1139+ // Render children without a wrapper element
1368− // written where the fragment is
1140+ for child in &elem.children {
1369− generate_children(&elem.children, parent, out, styles, ctx);
1141+ generate_node(child, parent_kind, out, styles, ctx);
1142+ }
1370 return;
1143 return;
1371 }
1144 }
1372
1145
1373− let mut own = Flow::of(elem);
1374 let tag = match &elem.kind {
1146 let tag = match &elem.kind {
1375 ElementKind::Row | ElementKind::El => "div",
1147 ElementKind::Row | ElementKind::El => "div",
1376 ElementKind::Text => "span",
1148 ElementKind::Text => "span",
@@ -1379,11 +1151,6 @@ fn generate_element(
1379 ElementKind::Tag(spec) => spec.html,
1151 ElementKind::Tag(spec) => spec.html,
1380 _ => "",
1152 _ => "",
1381 };
1153 };
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();
1154 let kind_label = elem.kind.name();
1388
1155
1389 // Track interactive elements for focus-visible CSS
1156 // Track interactive elements for focus-visible CSS
@@ -1394,8 +1161,14 @@ fn generate_element(
1394 }
1161 }
1395
1162
1396 // Compute CSS for each state and get a class name
1163 // Compute CSS for each state and get a class name
1397− let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1164+ let overlay_children = has_overlay_children(elem);
1398− own.class = gen_class.clone();
1165+ let gen_class = compute_class(
1166+ &elem.attrs,
1167+ &elem.kind,
1168+ parent_kind,
1169+ styles,
1170+ overlay_children,
1171+ );
1399 let (id, user_class) = extract_id_class(&elem.attrs);
1172 let (id, user_class) = extract_id_class(&elem.attrs);
1400
1173
1401 if ctx.dev && elem.line_num > 0 {
1174 if ctx.dev && elem.line_num > 0 {
@@ -1409,7 +1182,32 @@ fn generate_element(
1409 out.push('<');
1182 out.push('<');
1410 out.push_str(tag);
1183 out.push_str(tag);
1411
1184
1185+ // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1186+ if elem.kind.is_tag("main") && id.is_none() {
1187+ out.push_str(" id=\"hl-main\"");
1188+ }
1189+
1190+ if elem.kind == ElementKind::Link
1191+ && let Some(url) = &elem.argument
1192+ {
1193+ out.push_str(" href=\"");
1194+ out.push_str(&html_escape(url));
1195+ out.push('"');
1196+ // Auto rel="noopener noreferrer" and target="_blank" for external links
1197+ let is_external = url.starts_with("http://") || url.starts_with("https://");
1198+ if is_external {
1199+ let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1200+ let has_target = elem.attrs.iter().any(|a| a.key == "target");
1201+ if !has_rel {
1202+ out.push_str(" rel=\"noopener noreferrer\"");
1203+ }
1204+ if !has_target {
1205+ out.push_str(" target=\"_blank\"");
1206+ }
1207+ }
1208+ }
1209+
1412− emit_argument_attr(out, elem, ctx.in_picture);
1210+ emit_argument_attr(out, elem);
1413
1211
1414 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1212 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1415
1213
@@ -1430,27 +1228,28 @@ fn generate_element(
1430 }
1228 }
1431
1229
1432 out.push('>');
1230 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());
1231 out.push_str(ctx.nl());
1439
1232
1440− let layout = elem.kind.layout();
1233+ // Inline text argument
1234+ if renders_argument_as_text(&elem.kind)
1235+ && let Some(text) = &elem.argument
1236+ {
1237+ out.push_str(&html_escape(text));
1238+ }
1239+
1240+ // Children
1441 ctx.depth += 1;
1241 ctx.depth += 1;
1442− let outer_in_text = ctx.in_text;
1242+ let is_paragraph = elem.kind == ElementKind::Paragraph
1443− ctx.in_text = in_text || layout == Layout::Text;
1243+ || elem.kind.spec().is_some_and(|spec| spec.inline);
1444− let outer_flow = std::mem::replace(&mut ctx.flow, own);
1244+ for (i, child) in elem.children.iter().enumerate() {
1445− let outer_picture = std::mem::replace(&mut ctx.in_picture, elem.kind.is_tag("picture"));
1245+ generate_node(child, Some(&elem.kind), out, styles, ctx);
1446− generate_children(&elem.children, Some(layout), out, styles, ctx);
1246+ if is_paragraph && i < elem.children.len() - 1 {
1447− ctx.in_picture = outer_picture;
1247+ out.push(' ');
1448− ctx.flow = outer_flow;
1248+ }
1449− ctx.in_text = outer_in_text;
1249+ }
1450 ctx.depth -= 1;
1250 ctx.depth -= 1;
1451
1251
1452 out.push_str(&ctx.indent());
1252 out.push_str(&ctx.indent());
1453− ctx.dev = outer_dev;
1454 out.push_str("</");
1253 out.push_str("</");
1455 out.push_str(tag);
1254 out.push_str(tag);
1456 out.push('>');
1255 out.push('>');
@@ -1459,12 +1258,12 @@ fn generate_element(
1459
1258
1460fn generate_self_closing(
1259fn generate_self_closing(
1461 elem: &Element,
1260 elem: &Element,
1462− parent: Option<Layout>,
1261+ parent_kind: Option<&ElementKind>,
1463 out: &mut String,
1262 out: &mut String,
1464 styles: &mut StyleCollector,
1263 styles: &mut StyleCollector,
1465 ctx: &mut GenContext,
1264 ctx: &mut GenContext,
1466) {
1265) {
1467− let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles);
1266+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1468 let (id, user_class) = extract_id_class(&elem.attrs);
1267 let (id, user_class) = extract_id_class(&elem.attrs);
1469
1268
1470 let (tag, kind_label) = match &elem.kind {
1269 let (tag, kind_label) = match &elem.kind {
@@ -1483,7 +1282,44 @@ fn generate_self_closing(
1483 out.push_str(&ctx.indent());
1282 out.push_str(&ctx.indent());
1484 out.push('<');
1283 out.push('<');
1485 out.push_str(tag);
1284 out.push_str(tag);
1486− emit_argument_attr(out, elem, ctx.in_picture);
1285+ emit_argument_attr(out, elem);
1286+
1287+ // Image src (with optional non-SVG base64 inlining)
1288+ if elem.kind == ElementKind::Image {
1289+ let src = elem.argument.as_deref().unwrap_or("");
1290+ let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
1291+ if is_inline && !src.is_empty() && !src.ends_with(".svg") {
1292+ let mime = if src.ends_with(".png") {
1293+ "image/png"
1294+ } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
1295+ "image/jpeg"
1296+ } else if src.ends_with(".gif") {
1297+ "image/gif"
1298+ } else if src.ends_with(".webp") {
1299+ "image/webp"
1300+ } else if src.ends_with(".avif") {
1301+ "image/avif"
1302+ } else {
1303+ "application/octet-stream"
1304+ };
1305+ if let Ok(data) = std::fs::read(src) {
1306+ let b64 = base64_encode(&data);
1307+ out.push_str(" src=\"data:");
1308+ out.push_str(mime);
1309+ out.push_str(";base64,");
1310+ out.push_str(&b64);
1311+ out.push('"');
1312+ } else {
1313+ out.push_str(" src=\"");
1314+ out.push_str(&html_escape(src));
1315+ out.push('"');
1316+ }
1317+ } else {
1318+ out.push_str(" src=\"");
1319+ out.push_str(&html_escape(src));
1320+ out.push('"');
1321+ }
1322+ }
1487
1323
1488 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1324 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1489
1325
@@ -1503,6 +1339,80 @@ fn generate_self_closing(
1503 ));
1339 ));
1504 }
1340 }
1505
1341
1342+ // Image optimization: auto-add loading="lazy" and decoding="async"
1343+ if elem.kind == ElementKind::Image {
1344+ // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
1345+ let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
1346+ if let Some(resp) = responsive_attr
1347+ && let Some(ref sizes_str) = resp.value
1348+ {
1349+ let widths: Vec<&str> = sizes_str.split_whitespace().collect();
1350+ if !widths.is_empty() {
1351+ let src = elem.argument.as_deref().unwrap_or("");
1352+ if !src.is_empty() {
1353+ // Generate srcset with width descriptors
1354+ // Convention: file-{width}.ext (e.g., photo-400.jpg)
1355+ let dot_pos = src.rfind('.').unwrap_or(src.len());
1356+ let base = &src[..dot_pos];
1357+ let ext = &src[dot_pos..];
1358+ let mut srcset_parts = Vec::new();
1359+ for w in &widths {
1360+ srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
1361+ }
1362+ out.push_str(" srcset=\"");
1363+ out.push_str(&srcset_parts.join(", "));
1364+ out.push('"');
1365+ // Generate sizes attribute
1366+ let max_width = widths.last().unwrap_or(&"100vw");
1367+ out.push_str(&format!(
1368+ " sizes=\"(max-width: {}px) 100vw, {}px\"",
1369+ max_width, max_width
1370+ ));
1371+ }
1372+ }
1373+ }
1374+
1375+ // Auto image dimensions: read local image file to inject width/height + aspect-ratio
1376+ let has_width = elem.attrs.iter().any(|a| a.key == "width");
1377+ let has_height = elem.attrs.iter().any(|a| a.key == "height");
1378+ if (!has_width || !has_height)
1379+ && let Some(ref src) = elem.argument
1380+ && !src.starts_with("http://")
1381+ && !src.starts_with("https://")
1382+ && !src.starts_with("data:")
1383+ && let Some((w, h)) = read_image_dimensions(src)
1384+ {
1385+ // Intrinsic size attributes only when neither dimension is set in
1386+ // CSS; with one CSS dimension, a leftover intrinsic attribute for
1387+ // the other would distort the image (200 wide but 1000 tall).
1388+ if !has_width && !has_height {
1389+ out.push_str(&format!(" width=\"{}\" height=\"{}\"", w, h));
1390+ }
1391+ // Auto aspect-ratio to prevent CLS
1392+ if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
1393+ out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
1394+ }
1395+ }
1396+
1397+ // Smart image loading: first 3 images get fetchpriority="high" (above the fold),
1398+ // subsequent images get loading="lazy" + decoding="async"
1399+ ctx.image_count += 1;
1400+ if ctx.image_count <= 3 {
1401+ // Above-the-fold: eager loading with high priority
1402+ if !elem.attrs.iter().any(|a| a.key == "fetchpriority") {
1403+ out.push_str(" fetchpriority=\"high\"");
1404+ }
1405+ } else {
1406+ // Below-the-fold: lazy loading
1407+ if !elem.attrs.iter().any(|a| a.key == "loading") {
1408+ out.push_str(" loading=\"lazy\"");
1409+ }
1410+ if !elem.attrs.iter().any(|a| a.key == "decoding") {
1411+ out.push_str(" decoding=\"async\"");
1412+ }
1413+ }
1414+ }
1415+
1506 out.push('>');
1416 out.push('>');
1507 out.push_str(ctx.nl());
1417 out.push_str(ctx.nl());
1508}
1418}
@@ -1518,10 +1428,7 @@ fn generate_text_segments(
1518 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1428 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
1519 TextSegment::Inline(elem) => {
1429 TextSegment::Inline(elem) => {
1520 let mut buf = String::new();
1430 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);
1431+ generate_element(elem, None, &mut buf, styles, ctx);
1524− ctx.flow = outer_flow;
1525 out.push_str(buf.trim_end());
1432 out.push_str(buf.trim_end());
1526 }
1433 }
1527 }
1434 }
@@ -1534,97 +1441,135 @@ fn generate_text_segments(
1534
1441
1535fn compute_class(
1442fn compute_class(
1536 attrs: &[Attribute],
1443 attrs: &[Attribute],
1537− site: &Site,
1444+ kind: &ElementKind,
1445+ parent_kind: Option<&ElementKind>,
1538 styles: &mut StyleCollector,
1446 styles: &mut StyleCollector,
1539− distinct: String,
1447+ has_overlay_children: bool,
1540) -> Option<String> {
1448) -> Option<String> {
1541− // Every condition an attribute is under, and none
1449+ let base = attrs_to_css(attrs, "", kind, parent_kind, has_overlay_children);
1542− let mut conditions = vec![Condition::default()];
1450+
1543− for attr in attrs.iter().filter(|a| !a.html) {
1451+ // Collect pseudo-state overrides
1544− let (condition, _) = Condition::of(&attr.key);
1452+ let mut pseudo = Vec::new();
1545− if !conditions.contains(&condition) {
1453+ for &(prefix, selector) in crate::vocab::PSEUDO_PREFIXES {
1546− conditions.push(condition);
1454+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1455+ if !css.is_empty() {
1456+ pseudo.push((selector.to_string(), css));
1457+ }
1458+ }
1459+
1460+ // Collect nth:EXPR: dynamic pseudo selectors
1461+ let mut nth_prefixes: Vec<String> = Vec::new();
1462+ for attr in attrs {
1463+ if attr.key.starts_with("nth:") {
1464+ let rest = &attr.key[4..];
1465+ if let Some(colon_pos) = rest.find(':') {
1466+ let prefix = format!("nth:{}:", &rest[..colon_pos]);
1467+ if !nth_prefixes.contains(&prefix) {
1468+ nth_prefixes.push(prefix);
1469+ }
1470+ }
1471+ }
1472+ }
1473+ for prefix in &nth_prefixes {
1474+ let expr = &prefix[4..prefix.len() - 1];
1475+ let selector = format!(":nth-child({})", expr);
1476+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1477+ if !css.is_empty() {
1478+ pseudo.push((selector, css));
1547 }
1479 }
1548 }
1480 }
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
1481
1573−/// The defaults of an element's kind where it is: an overlay's positioning
1482+ // Collect has(...): dynamic pseudo selectors
1574−/// context, its layout, and its kind's own CSS (a heading's `margin:0`).
1483+ let mut has_prefixes: Vec<String> = Vec::new();
1575−fn default_css(site: &Site) -> String {
1484+ for attr in attrs {
1576− let mut css = String::new();
1485+ if attr.key.starts_with("has(")
1577− // Elements with @in-front / @behind children become positioning
1486+ && let Some(close) = attr.key.find("):")
1578− // contexts
1487+ {
1579− if site.has_overlay_children {
1488+ let prefix = format!("{}:", &attr.key[..close + 1]);
1580− css.push_str("position:relative;isolation:isolate;");
1489+ if !has_prefixes.contains(&prefix) {
1490+ has_prefixes.push(prefix);
1491+ }
1492+ }
1581 }
1493 }
1582− if !site.root {
1494+ for prefix in &has_prefixes {
1583− css.push_str(layout_css(site.kind.layout(), site.inline));
1495+ let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
1496+ let selector = format!(":has({})", inner);
1497+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1498+ if !css.is_empty() {
1499+ pseudo.push((selector, css));
1500+ }
1584 }
1501 }
1585− css.push_str(site.kind.css());
1586− css
1587−}
1588
1502
1589−/// The declarations of a rule body, as (property, `property:value;`), for
1503+ // Collect responsive overrides
1590−/// bodies htmlang wrote: every declaration ends in `;`, and a `;` inside
1504+ let mut responsive = Vec::new();
1591−/// quotes or parentheses doesn't end one.
1505+ for &(bp_name, _) in BREAKPOINTS {
1592−fn declarations(css: &str) -> impl Iterator<Item = (&str, &str)> {
1506+ let prefix = format!("{}:", bp_name);
1593− let mut rest = css;
1507+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1594− std::iter::from_fn(move || {
1595− if rest.is_empty() {
1508+ if !css.is_empty() {
1596− return None;
1509+ responsive.push((bp_name.to_string(), css));
1597 }
1510 }
1598− let (mut depth, mut quote, mut escaped) = (0i32, None, false);
1511+ }
1599− let mut end = rest.len();
1512+
1600− for (i, c) in rest.char_indices() {
1513+ // Collect container query overrides
1601− match c {
1514+ let mut container = Vec::new();
1602− _ if escaped => escaped = false,
1515+ for &(bp_name, _) in BREAKPOINTS {
1603− '\\' => escaped = true,
1516+ let prefix = format!("cq-{}:", bp_name);
1604− c if Some(c) == quote => quote = None,
1517+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
1605− _ if quote.is_some() => {}
1518+ if !css.is_empty() {
1606− '"' | '\'' => quote = Some(c),
1519+ 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 }
1520 }
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− })
1521+ }
1522+
1523+ let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
1524+ let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
1525+ let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind, has_overlay_children);
1526+ let motion_reduce =
1527+ attrs_to_css(attrs, "motion-reduce:", kind, parent_kind, has_overlay_children);
1528+ let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
1529+ let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
1530+
1531+ // Dedupe: if a property is declared twice within a single rule, keep only
1532+ // the last occurrence (element-kind defaults are written before
1533+ // user attributes, so a user [list-style disc] correctly overrides the
1534+ // default list-style:none, and we don't need to ship both).
1535+ let base = dedupe_declarations(&base);
1536+ let pseudo: Vec<(String, String)> = pseudo
1537+ .into_iter()
1538+ .map(|(sel, css)| (sel, dedupe_declarations(&css)))
1539+ .collect();
1540+ let responsive: Vec<(String, String)> = responsive
1541+ .into_iter()
1542+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1543+ .collect();
1544+ let dark = dedupe_declarations(&dark);
1545+ let print = dedupe_declarations(&print);
1546+ let motion_safe = dedupe_declarations(&motion_safe);
1547+ let motion_reduce = dedupe_declarations(&motion_reduce);
1548+ let landscape = dedupe_declarations(&landscape);
1549+ let portrait = dedupe_declarations(&portrait);
1550+ let container: Vec<(String, String)> = container
1551+ .into_iter()
1552+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
1553+ .collect();
1554+
1555+ styles.get_class(
1556+ base,
1557+ pseudo,
1558+ responsive,
1559+ dark,
1560+ print,
1561+ motion_safe,
1562+ motion_reduce,
1563+ landscape,
1564+ portrait,
1565+ container,
1566+ )
1621}
1567}
1622
1568
1623/// Dedupe CSS declarations within a single rule body: for any property
1569/// Dedupe CSS declarations within a single rule body: for any property
1624/// declared more than once, keep only the last occurrence. Unparseable
1570/// declared more than once, keep only the last occurrence. Unparseable
1625−/// segments (no `:`) are preserved as-is. Semicolons inside parentheses or
1571+/// segments (no `:`) are preserved as-is. Semicolons inside parentheses are
1626−/// quoted strings are treated as part of a value, not as declaration
1572+/// treated as part of a value, not as declaration separators.
1627−/// separators.
1628fn dedupe_declarations(css: &str) -> String {
1573fn dedupe_declarations(css: &str) -> String {
1629 if css.is_empty() {
1574 if css.is_empty() {
1630 return String::new();
1575 return String::new();
@@ -1639,26 +1584,8 @@ fn dedupe_declarations(css: &str) -> String {
1639 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1584 let mut decls: Vec<(Option<String>, String)> = Vec::new();
1640 let mut current = String::new();
1585 let mut current = String::new();
1641 let mut depth: i32 = 0;
1586 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() {
1587 for ch in css.chars() {
1647− if escaped {
1588+ 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;
1589 depth += 1;
1663 current.push(ch);
1590 current.push(ch);
1664 } else if ch == ')' {
1591 } else if ch == ')' {
@@ -1740,553 +1667,487 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
1740// Attribute → CSS mapping
1667// Attribute → CSS mapping
1741// ---------------------------------------------------------------------------
1668// ---------------------------------------------------------------------------
1742
1669
1743−/// Where an element is written, which its CSS depends on.
1670+/// (htmlang prefix, CSS selector suffix)
1744−struct Site<'a> {
1671+/// `display:flex;flex-direction:column;` — base layout for `@el` and every
1745− kind: &'a ElementKind,
1672+/// semantic wrapper that behaves like a column.
1746− /// How its parent lays out its children: its `width fill`, `height
1673+const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
1747− /// fill` and `shrink` compile against it.
1748− parent: &'a Flow,
1749− /// It has `@in-front` / `@behind` children.
1750− has_overlay_children: bool,
1751− /// A row, column or grid inside text, laid out inline.
1752− inline: bool,
1753− /// The page's `<body>`, which the reset already makes a column.
1754− root: bool,
1755−}
1756
1674
1757−impl Site<'_> {
1758− /// Where an element in a parent with the layout `layout` and the flow
1759− /// `parent` is.
1760− fn new<'a>(elem: &'a Element, layout: Option<Layout>, parent: &'a Flow) -> Site<'a> {
1761− Site {
1762− kind: &elem.kind,
1763− parent,
1764− has_overlay_children: has_overlay_children(elem),
1765− inline: layout == Some(Layout::Text) && elem.kind.layout().is_container(),
1766− root: false,
1767− }
1768− }
1769−}
1770
1675
1771−// ---------------------------------------------------------------------------
1772−// Direction: what a child's `fill` and `shrink` compile against
1773−// ---------------------------------------------------------------------------
1774
1676
1775−/// The way a flex row or column lays out its children.
1677+fn attrs_to_css(
1776−#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1678+ attrs: &[Attribute],
1777−enum Axis {
1679+ state_prefix: &str,
1778− Row,
1680+ kind: &ElementKind,
1779− Column,
1681+ parent_kind: Option<&ElementKind>,
1780−}
1682+ has_overlay_children: bool,
1683+) -> String {
1684+ let mut css = String::new();
1781
1685
1782−impl Axis {
1686+ // Base element styles only for the default (non-state) pass
1783− /// The direction a `flex-direction` or `flex-flow` value sets, if it
1687+ if state_prefix.is_empty() {
1784− /// names one (not `var(--dir)`, which only the browser knows).
1688+ // Elements with @in-front / @behind children become positioning
1785− fn of_value(value: &str) -> Option<Axis> {
1689+ // contexts. Pushed before user attrs so an explicit `position` wins
1786− value.split_whitespace().find_map(|word| match word {
1690+ // via dedupe (only the last declaration of a property is kept).
1787− "row" | "row-reverse" => Some(Axis::Row),
1691+ if has_overlay_children {
1788− "column" | "column-reverse" => Some(Axis::Column),
1692+ css.push_str("position:relative;isolation:isolate;");
1789− _ => None,
1693+ }
1694+ match kind {
1695+ ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
1696+ ElementKind::El => css.push_str(FLEX_COLUMN),
1697+ ElementKind::Paragraph => css.push_str("margin:0;"),
1698+ ElementKind::Tag(spec) => css.push_str(spec.css),
1699+ _ => {}
1790− })
1700+ }
1791 }
1701 }
1792−}
1793
1702
1794−/// How an element lays out its children, as its CSS sets it: the
1795−/// direction its children's `width`/`height` `fill` and `shrink` compile
1796−/// against. That is the direction of `@row` or of a column, or the one its
1797−/// own `flex-direction` (or `flex-flow`) sets, with or without media or
1798−/// container prefixes. Like elm-ui's `.r > .wf`, the rules for a direction
1799−/// set under a prefix are keyed on the element's class: `@media (...) {
1800−/// :where(.a)>.b {...} }`.
1801−#[derive(Clone, Default)]
1802−struct Flow {
1803− /// Without a prefix; `None` when the element isn't a flex row or column
1804− /// (a grid, text, HTML's own layout, the top of the page)
1805− base: Option<Axis>,
1806− /// The direction under each chain of at-rule prefixes that sets one, in
1807− /// the order their blocks are written
1808− changes: Vec<(Vec<usize>, Axis)>,
1809− /// The element's generated class, which the rules for the changes are
1810− /// keyed on
1811− class: Option<String>,
1812−}
1813−
1814−impl Flow {
1815− fn of(elem: &Element) -> Flow {
1816− let mut base = match elem.kind.layout() {
1817− Layout::Row => Axis::Row,
1818− Layout::Column => Axis::Column,
1819− _ => return Flow::default(),
1820− };
1821− let mut changes: Vec<(Vec<usize>, Axis)> = Vec::new();
1822− for attr in elem.attrs.iter().filter(|a| !a.html) {
1703+ for attr in attrs {
1823− let (condition, name) = Condition::of(&attr.key);
1704+ if attr.html {
1824− if !matches!(name, "flex-direction" | "flex-flow") || !condition.selector.is_empty() {
1705+ continue;
1706+ }
1707+ // Determine the effective key for this pass
1708+ let effective_key = if state_prefix.is_empty() {
1709+ if crate::vocab::is_prefixed(&attr.key) {
1825 continue;
1710 continue;
1826 }
1711 }
1827− let Some(axis) = attr.value.as_deref().and_then(Axis::of_value) else {
1712+ attr.key.as_str()
1713+ } else {
1714+ match attr.key.strip_prefix(state_prefix) {
1715+ Some(k) => k,
1828− continue;
1716+ None => continue,
1829− };
1830− if condition.at.is_empty() {
1831− base = axis;
1832− } else {
1833− // The later one wins, as in the CSS
1834− changes.retain(|(at, _)| *at != condition.at);
1835− changes.push((condition.at, axis));
1836 }
1717 }
1837− }
1718+ };
1838− changes.sort_by_key(|(at, _)| at_order(at));
1839− Flow {
1840− base: Some(base),
1841− changes,
1842− class: None,
1843− }
1844− }
1845
1719
1846− /// The direction under the at-rules `at`: the latest one set among
1720+ let val = attr.value.as_deref();
1847− /// the conditions that hold there (`md:` holds at `lg:`), else the one
1848− /// without a prefix.
1849− fn at(&self, at: &[usize]) -> Option<Axis> {
1850− let base = self.base?;
1851− let here = Condition::at_only(at);
1852− let changed = self
1853− .changes
1854− .iter()
1855− .rev()
1856− .find(|(chain, _)| Condition::at_only(chain).holds_at(&here))
1857− .map(|&(_, axis)| axis);
1858− Some(changed.unwrap_or(base))
1859− }
1860−}
1861
1721
1862−/// `width` or `height`.
1722+ match effective_key {
1863−#[derive(Clone, Copy, PartialEq, Eq)]
1723+ // Layout
1864−enum Dim {
1724+ "spacing" | "gap" => {
1865− Width,
1725+ if let Some(v) = val {
1866− Height,
1726+ push_css(&mut css, "gap", &css_px(v));
1727+ }
1728+ }
1729+ "padding" => {
1730+ if let Some(v) = val {
1731+ push_css(&mut css, "padding", &css_px_multi(v));
1732+ }
1733+ }
1734+ "padding-top" => {
1735+ if let Some(v) = val {
1736+ push_css(&mut css, "padding-top", &css_px(v));
1737+ }
1738+ }
1739+ "padding-bottom" => {
1740+ if let Some(v) = val {
1741+ push_css(&mut css, "padding-bottom", &css_px(v));
1742+ }
1743+ }
1744+ "padding-left" => {
1745+ if let Some(v) = val {
1746+ push_css(&mut css, "padding-left", &css_px(v));
1747+ }
1748+ }
1749+ "padding-right" => {
1750+ if let Some(v) = val {
1751+ push_css(&mut css, "padding-right", &css_px(v));
1752+ }
1867−}
1753+ }
1868
1754
1755+ // Sizing
1756+ "width" => {
1757+ if let Some(v) = val {
1758+ match v {
1759+ "fill" => match parent_kind {
1760+ Some(ElementKind::Row) => {
1761+ push_css(&mut css, "flex", "1");
1762+ push_css(&mut css, "min-width", "0");
1763+ }
1764+ _ => push_css(&mut css, "width", "100%"),
1765+ },
1766+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
1767+ _ => push_css(&mut css, "width", &css_px(v)),
1768+ }
1769+ }
1770+ }
1869−/// What a `width` or `height` says.
1771+ "height" => {
1870−#[derive(Clone, Copy, PartialEq, Eq)]
1772+ if let Some(v) = val {
1871−enum Size {
1773+ match v {
1872− Fill,
1774+ "fill" => match parent_kind {
1873− Shrink,
1775+ Some(parent) if parent.is_column() => {
1874− /// A size of its own (`width 300`)
1776+ push_css(&mut css, "flex", "1");
1875− Set,
1777+ push_css(&mut css, "min-height", "0");
1778+ }
1779+ _ => push_css(&mut css, "height", "100%"),
1780+ },
1781+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
1782+ _ => push_css(&mut css, "height", &css_px(v)),
1783+ }
1784+ }
1785+ }
1786+ "min-width" => {
1787+ if let Some(v) = val {
1788+ push_css(&mut css, "min-width", &css_px(v));
1789+ }
1790+ }
1791+ "max-width" => {
1792+ if let Some(v) = val {
1793+ push_css(&mut css, "max-width", &css_px(v));
1794+ }
1795+ }
1796+ "min-height" => {
1797+ if let Some(v) = val {
1798+ push_css(&mut css, "min-height", &css_px(v));
1799+ }
1800+ }
1801+ "max-height" => {
1802+ if let Some(v) = val {
1803+ push_css(&mut css, "max-height", &css_px(v));
1804+ }
1876−}
1805+ }
1877
1806
1878−impl Size {
1807+ // Alignment
1879− fn of(value: &str) -> Size {
1808+ "center-x" => match parent_kind {
1880− match value.trim() {
1809+ Some(parent) if parent.is_column() => {
1881− "fill" => Size::Fill,
1810+ push_css(&mut css, "align-self", "center");
1882− "shrink" => Size::Shrink,
1811+ }
1883− _ => Size::Set,
1812+ _ => {
1813+ push_css(&mut css, "margin-left", "auto");
1814+ push_css(&mut css, "margin-right", "auto");
1815+ }
1816+ },
1817+ "center-y" => match parent_kind {
1818+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
1819+ _ => {
1820+ push_css(&mut css, "margin-top", "auto");
1821+ push_css(&mut css, "margin-bottom", "auto");
1884− }
1822+ }
1885− }
1823+ },
1824+ "align-left" => match parent_kind {
1825+ Some(parent) if parent.is_column() => {
1826+ push_css(&mut css, "align-self", "flex-start");
1827+ }
1828+ _ => push_css(&mut css, "margin-right", "auto"),
1829+ },
1830+ "align-right" => match parent_kind {
1831+ Some(parent) if parent.is_column() => {
1832+ push_css(&mut css, "align-self", "flex-end");
1833+ }
1834+ _ => push_css(&mut css, "margin-left", "auto"),
1835+ },
1836+ "align-top" => match parent_kind {
1837+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
1838+ _ => push_css(&mut css, "margin-bottom", "auto"),
1839+ },
1840+ "align-bottom" => match parent_kind {
1841+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
1842+ _ => push_css(&mut css, "margin-top", "auto"),
1843+ },
1844+
1845+ // Typography
1846+ "line-height" => {
1847+ if let Some(v) = val {
1848+ push_css(&mut css, "line-height", &css_line_height(v));
1849+ }
1850+ }
1851+ "letter-spacing" => {
1852+ if let Some(v) = val {
1853+ push_css(&mut css, "letter-spacing", &css_px(v));
1854+ }
1886−}
1855+ }
1887
1856
1888−/// The declarations `fill` and `shrink` make in a parent laid out along
1857+ // Overflow & positioning
1889−/// `axis` (`None`: not a flex row or column). Along the parent's direction,
1858+ "top" => {
1890−/// `fill` takes the remaining space and `shrink` keeps the content's size;
1859+ if let Some(v) = val {
1891−/// across it (or outside flex), `fill` is the full size and `shrink` fits
1860+ push_css(&mut css, "top", &css_px(v));
1892−/// the content.
1861+ }
1893−fn sizing(dim: Dim, size: Size, axis: Option<Axis>) -> &'static [(&'static str, &'static str)] {
1862+ }
1894− match (dim, size, axis) {
1863+ "right" => {
1895− (_, Size::Set, _) => &[],
1864+ if let Some(v) = val {
1896− (Dim::Width, Size::Fill, Some(Axis::Row)) => &[("flex", "1"), ("min-width", "0")],
1865+ push_css(&mut css, "right", &css_px(v));
1897− (Dim::Width, Size::Fill, _) => &[("width", "100%")],
1866+ }
1898− (Dim::Width, Size::Shrink, Some(Axis::Row)) => &[("flex-shrink", "0")],
1867+ }
1899− (Dim::Width, Size::Shrink, _) => &[("width", "fit-content")],
1868+ "bottom" => {
1900− (Dim::Height, Size::Fill, Some(Axis::Column)) => &[("flex", "1"), ("min-height", "0")],
1869+ if let Some(v) = val {
1901− (Dim::Height, Size::Fill, _) => &[("height", "100%")],
1870+ push_css(&mut css, "bottom", &css_px(v));
1902− (Dim::Height, Size::Shrink, Some(Axis::Column)) => &[("flex-shrink", "0")],
1871+ }
1903− (Dim::Height, Size::Shrink, _) => &[("height", "fit-content")],
1872+ }
1873+ "left" => {
1874+ if let Some(v) = val {
1875+ push_css(&mut css, "left", &css_px(v));
1904− }
1876+ }
1905−}
1877+ }
1906
1878
1907−/// Every property `fill` and `shrink` of `dim` can set, with its initial
1879+ // Effects
1908−/// value (`flex` covers `flex-shrink`).
1909−fn sizing_properties(dim: Dim) -> [(&'static str, &'static str); 3] {
1910− match dim {
1911− Dim::Width => [
1912− ("flex", "0 1 auto"),
1913− ("min-width", "auto"),
1914− ("width", "auto"),
1915− ],
1916− Dim::Height => [
1917− ("flex", "0 1 auto"),
1918− ("min-height", "auto"),
1919− ("height", "auto"),
1920− ],
1921− }
1922−}
1923
1880
1924−/// What an element's attributes under some prefixes say about its size.
1881+ // Flow
1925−#[derive(Default)]
1882+ "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
1926−struct Sizes {
1927− /// The last `width` and `height`
1928− width: Option<Size>,
1929− height: Option<Size>,
1930− /// A `fill` or `shrink` for the width / height is among them (a later
1931− /// size may replace it)
1932− width_word: bool,
1933− height_word: bool,
1934− /// It writes `flex`, one of its longhands (`flex-grow`, `flex-shrink`,
1935− /// `flex-basis`), `min-width` or `min-height` itself
1936− flex: bool,
1937− flex_grow: bool,
1938− flex_shrink: bool,
1939− flex_basis: bool,
1940− min_width: bool,
1941− min_height: bool,
1942−}
1943
1883
1944−impl Sizes {
1884+ // Grid
1945− /// Read the attributes under the conditions that hold wherever `at`
1885+ "grid-cols" => {
1946− /// does, a later condition over an earlier one.
1886+ if let Some(v) = val {
1947− fn of(attrs: &[Attribute], at: &Condition) -> Sizes {
1887+ if let Ok(n) = v.parse::<u32>() {
1948− let mut sizes = Sizes::default();
1888+ push_css(
1949− let mut read: Vec<(Condition, &str, &str)> = Vec::new();
1889+ &mut css,
1950− for attr in attrs.iter().filter(|a| !a.html) {
1890+ "grid-template-columns",
1951− let (condition, name) = Condition::of(&attr.key);
1891+ &format!("repeat({},1fr)", n),
1952− let Some(value) = attr.value.as_deref().filter(|v| !v.trim().is_empty()) else {
1892+ );
1953− continue;
1893+ } else {
1954− };
1894+ push_css(&mut css, "grid-template-columns", v);
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− }
1895+ }
1972− "flex" => sizes.flex = true,
1896+ }
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 }
1897 }
1980− }
1898+ "grid-rows" => {
1981− sizes
1899+ if let Some(v) = val {
1982− }
1900+ if let Ok(n) = v.parse::<u32>() {
1983−
1901+ push_css(
1984− /// The element writes `property` itself: a layout word leaves it alone.
1902+ &mut css,
1985− fn writes(&self, property: &str) -> bool {
1903+ "grid-template-rows",
1986− match property {
1904+ &format!("repeat({},1fr)", n),
1987− "flex" => self.flex,
1905+ );
1988− "flex-grow" => self.flex || self.flex_grow,
1906+ } else {
1989− "flex-shrink" => self.flex || self.flex_shrink,
1907+ push_css(&mut css, "grid-template-rows", v);
1990− "flex-basis" => self.flex || self.flex_basis,
1991− "min-width" => self.min_width,
1992− "min-height" => self.min_height,
1993− "width" => self.width == Some(Size::Set),
1994− "height" => self.height == Some(Size::Set),
1995− _ => false,
1996− }
1997− }
1998−
1999− /// Write a layout word's `property: value`, unless the element writes
2000− /// the property itself. When it writes some of `flex`'s longhands, a
2001− /// `flex` is written as the others (`flex-shrink 0, width fill` still
2002− /// grows).
2003− fn push(&self, css: &mut String, property: &str, value: &str) {
2004− if property == "flex" && !self.flex {
2005− let longhands = match value {
2006− "1" => [
2007− ("flex-grow", "1"),
2008− ("flex-shrink", "1"),
2009− ("flex-basis", "0%"),
2010− ],
2011− // The reset, `0 1 auto`
2012− _ => [
2013− ("flex-grow", "0"),
2014− ("flex-shrink", "1"),
2015− ("flex-basis", "auto"),
2016− ],
2017− };
2018− if longhands.iter().any(|&(p, _)| self.writes(p)) {
2019− for (longhand, value) in longhands {
2020− if !self.writes(longhand) {
2021− push_css(css, longhand, value);
2022 }
1908 }
2023 }
1909 }
2024− return;
2025 }
1910 }
2026− }
1911+ "col-span" => {
2027− if !self.writes(property) {
1912+ if let Some(v) = val {
2028− push_css(css, property, value);
1913+ push_css(&mut css, "grid-column", &format!("span {}", v));
2029− }
1914+ }
2030− }
1915+ }
2031−}
1916+ "row-span" => {
1917+ if let Some(v) = val {
1918+ push_css(&mut css, "grid-row", &format!("span {}", v));
1919+ }
1920+ }
2032
1921
2033−/// The rule that gives an element's `fill` and `shrink`, as its attributes
1922+ // CSS containment for rendering performance
2034−/// under the at-rules `at` set them, their meaning in a parent laid out
1923+ "contain" => {
2035−/// along `axis`. Every other property a layout word could have set is put
1924+ if let Some(v) = val {
2036−/// back to its initial value, so the rule is right whichever earlier rule
1925+ push_css(&mut css, "contain", v);
2037−/// it overrides. Properties the element writes itself are left alone.
1926+ } else {
2038−fn sizing_rule(attrs: &[Attribute], at: &[usize], axis: Axis) -> String {
1927+ push_css(&mut css, "contain", "layout style paint");
2039− let sizes = Sizes::of(attrs, &Condition::at_only(at));
1928+ }
2040− let mut sets: Vec<(&str, &str)> = Vec::new();
1929+ }
2041− let mut resets: Vec<(&str, &str)> = Vec::new();
1930+ "content-visibility" => {
2042− for (dim, size, word) in [
1931+ if let Some(v) = val {
2043− (Dim::Width, sizes.width, sizes.width_word),
1932+ push_css(&mut css, "content-visibility", v);
2044− (Dim::Height, sizes.height, sizes.height_word),
1933+ } else {
2045− ] {
1934+ push_css(&mut css, "content-visibility", "auto");
2046− if !word {
1935+ }
2047− continue;
1936+ }
2048− }
2049− if let Some(size) = size {
2050− sets.extend_from_slice(sizing(dim, size, Some(axis)));
2051− }
2052− resets.extend_from_slice(&sizing_properties(dim));
2053− }
2054− let mut css = String::new();
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
1937
2069−/// The rules an element's `fill` and `shrink` need under each chain of
1938+ // Outline (like border but doesn't affect layout)
2070−/// at-rules where the direction of `flow` (its parent's) changes: `(at,
1939+ "outline" => {
2071−/// body)`.
1940+ if let Some(v) = val {
2072−fn sizing_changes(attrs: &[Attribute], flow: &Flow) -> Vec<(Vec<usize>, String)> {
1941+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
2073− flow.changes
1942+ if parts.len() == 2 {
2074− .iter()
1943+ push_css(
2075− .map(|(at, axis)| (at.clone(), sizing_rule(attrs, at, *axis)))
1944+ &mut css,
2076− .filter(|(_, body)| !body.is_empty())
1945+ "outline",
2077− .collect()
1946+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
2078−}
1947+ );
1948+ } else {
1949+ push_css(
1950+ &mut css,
1951+ "outline",
1952+ &format!("{} solid currentColor", css_px(parts[0])),
1953+ );
1954+ }
1955+ }
1956+ }
2079
1957
2080−/// An element's generated class, with the rules keyed on its parent's
1958+ // Logical properties (i18n-aware)
2081−/// class that its `fill` and `shrink` need where the parent's direction
1959+ "padding-inline" => {
2082−/// changes under a prefix (see [`Flow`]): `:where(.a)>.b`, one class of
1960+ if let Some(v) = val {
2083−/// specificity like any class rule.
1961+ push_css(&mut css, "padding-inline", &css_px_multi(v));
2084−fn element_class(elem: &Element, site: &Site, styles: &mut StyleCollector) -> Option<String> {
1962+ }
2085− let as_child = match site.parent.class {
1963+ }
2086− Some(_) => sizing_changes(&elem.attrs, site.parent),
1964+ "padding-block" => {
2087− None => Vec::new(),
1965+ if let Some(v) = val {
2088− };
1966+ push_css(&mut css, "padding-block", &css_px_multi(v));
2089− // Two elements with the same CSS but different rules keyed on them
1967+ }
2090− // need two classes
1968+ }
2091− let mut distinct = String::new();
1969+ "margin-inline" => {
2092− for (at, body) in &as_child {
1970+ if let Some(v) = val {
2093− distinct.push_str(&format!("<{:?}{}", at, body));
1971+ push_css(&mut css, "margin-inline", &css_px_multi(v));
2094− }
1972+ }
2095− let class = compute_class(&elem.attrs, site, styles, distinct)?;
1973+ }
2096− if let Some(parent) = &site.parent.class {
1974+ "margin-block" => {
2097− for (at, body) in as_child {
1975+ if let Some(v) = val {
2098− styles.add_keyed(at, format!(":where(.{})>.{}", parent, class), body);
1976+ push_css(&mut css, "margin-block", &css_px_multi(v));
2099− }
1977+ }
2100− }
1978+ }
2101− Some(class)
2102−}
2103
1979
2104−/// The `display` of a layout (nothing for text, native and void elements,
1980+ // Logical property start/end variants
2105−/// which keep HTML's own).
1981+ "padding-inline-start"
2106−fn layout_css(layout: Layout, inline: bool) -> &'static str {
1982+ | "padding-inline-end"
2107− match (layout, inline) {
1983+ | "padding-block-start"
2108− (Layout::Column, false) => "display:flex;flex-direction:column;",
1984+ | "padding-block-end"
2109− (Layout::Column, true) => "display:inline-flex;flex-direction:column;",
1985+ | "margin-inline-start"
2110− (Layout::Row, false) => "display:flex;flex-direction:row;",
1986+ | "margin-inline-end"
2111− (Layout::Row, true) => "display:inline-flex;flex-direction:row;",
1987+ | "margin-block-start"
2112− (Layout::Grid, false) => "display:grid;",
1988+ | "margin-block-end" => {
2113− (Layout::Grid, true) => "display:inline-grid;",
1989+ if let Some(v) = val {
2114− _ => "",
1990+ push_css(&mut css, effective_key, &css_px(v));
2115− }
1991+ }
2116−}
1992+ }
2117
1993
2118−/// The declarations of the attributes under `condition` (the defaults of
1994+ // Logical inset
2119−/// the element's kind are [`default_css`]'s).
1995+ "inset-inline" | "inset-block" => {
2120−fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> String {
1996+ if let Some(v) = val {
2121− let mut css = String::new();
1997+ push_css(&mut css, effective_key, &css_px_multi(v));
2122− // The auto margins of `center-x`, `align-left`, ..., written at the end
1998+ }
2123− let mut aligned = String::new();
1999+ }
2124− let kind = site.kind;
2000+ "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
2125− // Under `children:` the styles go on the children, whose parent is this
2001+ if let Some(v) = val {
2126− // element: htmlang's words that place an element in its parent are an
2002+ push_css(&mut css, effective_key, &css_px(v));
2127− // error there (the parser reports them), and left out
2003+ }
2128− let on_children = condition.on_children();
2004+ }
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
2005
2160− let val = attr.value.as_deref();
2006+ // Logical border
2161− // A style whose value came out empty (a field a record doesn't
2007+ "border-inline"
2162− // have, `${if()}` without its other branch) is left out
2008+ | "border-block"
2163− if val.is_some_and(|v| v.trim().is_empty()) {
2009+ | "border-inline-start"
2164− continue;
2010+ | "border-inline-end"
2165− }
2011+ | "border-block-start"
2012+ | "border-block-end" => {
2013+ if let Some(v) = val {
2014+ push_css(&mut css, effective_key, v);
2015+ }
2016+ }
2166
2017
2167− match effective_key {
2018+ // Logical border-radius
2168− // htmlang's own words: they lay out children or place the
2019+ "border-start-start-radius"
2169− // element in its parent, and mean more than one CSS property
2020+ | "border-start-end-radius"
2170− "spacing" if !lays_out_children => {}
2021+ | "border-end-start-radius"
2171− "spacing" => {
2022+ | "border-end-end-radius" => {
2172 if let Some(v) = val {
2023 if let Some(v) = val {
2173− push_css(&mut css, "gap", &with_px("gap", v));
2024+ push_css(&mut css, effective_key, &css_px(v));
2174 }
2025 }
2175 }
2026 }
2176
2027
2177− // Sizing: `fill` and `shrink` compile against the parent's
2028+ // Logical scroll margins & padding
2178− // direction. Only the last `width` (`height`) counts, as for
2029+ "scroll-margin-inline"
2179− // any style written twice.
2030+ | "scroll-margin-block"
2180− "width" | "height" if val.is_some_and(|v| Size::of(v) != Size::Set) => {
2031+ | "scroll-padding-inline"
2181− let later = attrs[index + 1..].iter().any(|a| {
2032+ | "scroll-padding-block" => {
2182− !a.html
2033+ if let Some(v) = val {
2183− && under(&a.key, condition) == Some(effective_key)
2034+ push_css(&mut css, effective_key, &css_px_multi(v));
2184− && a.value.as_deref().is_some_and(|v| !v.trim().is_empty())
2185− });
2186− if later {
2187− continue;
2188 }
2035 }
2189− let dim = if effective_key == "width" {
2036+ }
2190− Dim::Width
2037+
2191− } else {
2038+ // Logical sizing
2192− Dim::Height
2039+ "inline-size" | "block-size" | "min-inline-size" | "max-inline-size"
2193− };
2040+ | "min-block-size" | "max-block-size" => {
2194− let size = Size::of(val.unwrap_or_default());
2041+ if let Some(v) = val {
2195− // What the element writes itself, which a layout word
2042+ push_css(&mut css, effective_key, &css_px(v));
2196− // leaves alone
2197− let sizes = Sizes::of(attrs, condition);
2198− for &(property, value) in sizing(dim, size, axis) {
2199− sizes.push(&mut css, property, value);
2200− }
2201− }
2202−
2203− // A size of its own under a prefix, where a `fill` or
2204− // `shrink` that sets `flex` (along a flex parent's direction)
2205− // holds too: the word's `flex` and minimum are put back, so the
2206− // size is the one the element gets (`width fill, lg:width 200`
2207− // in a row)
2208− "width" | "height"
2209− if *condition != Condition::default()
2210− && attrs.iter().any(|a| {
2211− let (c, name) = Condition::of(&a.key);
2212− let along = match site.parent.at(&c.at) {
2213− Some(Axis::Row) => name == "width",
2214− Some(Axis::Column) => name == "height",
2215− None => false,
2216− };
2217− !a.html
2218− && name == effective_key
2219− && along
2220− && c != *condition
2221− && c.holds_at(condition)
2222− && a.value
2223− .as_deref()
2224− .is_some_and(|v| !v.trim().is_empty() && Size::of(v) != Size::Set)
2225− }) =>
2226− {
2227− let dim = if effective_key == "width" {
2228− Dim::Width
2229− } else {
2230− Dim::Height
2231− };
2232− let sizes = Sizes::of(attrs, condition);
2233− for (property, value) in sizing_properties(dim) {
2234− if property != effective_key {
2235− sizes.push(&mut css, property, value);
2236− }
2237 }
2043 }
2044+ }
2045+
2046+ // Margin
2047+ "margin" => {
2238 if let Some(v) = val {
2048 if let Some(v) = val {
2239− push_css(&mut css, effective_key, &with_px(effective_key, v));
2049+ push_css(&mut css, "margin", &css_px_multi(v));
2240 }
2050 }
2241 }
2051 }
2052+ // Container queries
2053+ "container" => {
2054+ push_css(&mut css, "container-type", "inline-size");
2055+ }
2242
2056
2243− // Alignment: auto margins, which work along either direction.
2057+ // Inset (shorthand for top/right/bottom/left)
2244− // They are written last, so a `margin` on the same element
2058+ "inset" => {
2245− // keeps its other sides (`center-x, margin 20`)
2059+ if let Some(v) = val {
2246− "center-x" => {
2060+ push_css(&mut css, "inset", &css_px(v));
2247− push_css(&mut aligned, "margin-left", "auto");
2061+ }
2248− push_css(&mut aligned, "margin-right", "auto");
2249 }
2062 }
2250− "center-y" => {
2063+
2251− push_css(&mut aligned, "margin-top", "auto");
2064+ // Table
2252− push_css(&mut aligned, "margin-bottom", "auto");
2065+ "border-spacing" => {
2066+ if let Some(v) = val {
2067+ push_css(&mut css, "border-spacing", &css_px(v));
2068+ }
2253 }
2069 }
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
2070
2259− "wrap" if lays_out_children => push_css(&mut css, "flex-wrap", "wrap"),
2071+ // Text decoration
2260− "wrap" => {}
2072+ "text-decoration-thickness" => {
2073+ if let Some(v) = val {
2074+ push_css(&mut css, "text-decoration-thickness", &css_px(v));
2075+ }
2076+ }
2077+ "text-underline-offset" => {
2078+ if let Some(v) = val {
2079+ push_css(&mut css, "text-underline-offset", &css_px(v));
2080+ }
2081+ }
2261
2082
2262− // Grid
2083+ // Multi-column
2263− "grid-cols" | "grid-rows" if !lays_out_children => {}
2084+ "column-width" => {
2264− "grid-cols" | "grid-rows" => {
2265 if let Some(v) = val {
2085 if let Some(v) = val {
2266− let property = if effective_key == "grid-cols" {
2086+ push_css(&mut css, "column-width", &css_px(v));
2267− "grid-template-columns"
2087+ }
2268− } else {
2088+ }
2269− "grid-template-rows"
2089+
2270− };
2090+ // New CSS properties
2271− match v.parse::<u32>() {
2091+ "column-gap" => {
2272− Ok(n) => push_css(&mut css, property, &format!("repeat({},1fr)", n)),
2092+ if let Some(v) = val {
2273− Err(_) => push_css(&mut css, property, &with_px(property, v)),
2093+ push_css(&mut css, "column-gap", &css_px(v));
2274− }
2094+ }
2095+ }
2096+ "text-indent" => {
2097+ if let Some(v) = val {
2098+ push_css(&mut css, "text-indent", &css_px(v));
2099+ }
2100+ }
2101+ "flex-basis" => {
2102+ if let Some(v) = val {
2103+ push_css(&mut css, "flex-basis", &css_px(v));
2104+ }
2105+ }
2106+ "scroll-margin" => {
2107+ if let Some(v) = val {
2108+ push_css(&mut css, "scroll-margin", &css_px(v));
2109+ }
2110+ }
2111+ "scroll-margin-top"
2112+ | "scroll-margin-bottom"
2113+ | "scroll-margin-left"
2114+ | "scroll-margin-right" => {
2115+ if let Some(v) = val {
2116+ push_css(&mut css, effective_key, &css_px(v));
2117+ }
2118+ }
2119+ "scroll-padding" => {
2120+ if let Some(v) = val {
2121+ push_css(&mut css, "scroll-padding", &css_px(v));
2122+ }
2123+ }
2124+ "scroll-padding-top"
2125+ | "scroll-padding-bottom"
2126+ | "scroll-padding-left"
2127+ | "scroll-padding-right" => {
2128+ if let Some(v) = val {
2129+ push_css(&mut css, effective_key, &css_px(v));
2275 }
2130 }
2276 }
2131 }
2277− "col-span" | "row-span" => {
2132+
2133+ "content" => {
2278 if let Some(v) = val {
2134 if let Some(v) = val {
2279− let property = if effective_key == "col-span" {
2135+ // Wrap in quotes if not already quoted and not a CSS keyword
2280− "grid-column"
2136+ if v.starts_with('"')
2137+ || v.starts_with('\'')
2138+ || v == "none"
2139+ || v == "normal"
2140+ || v.starts_with("attr(")
2141+ || v.starts_with("counter(")
2142+ {
2143+ push_css(&mut css, "content", v);
2281 } else {
2144 } else {
2282− "grid-row"
2145+ push_css(&mut css, "content", &format!("\"{}\"", v));
2283− };
2146+ }
2284− push_css(&mut css, property, &format!("span {}", v));
2285 }
2147 }
2286 }
2148 }
2287
2149
2288− // CSS's `line-clamp` has no support yet without its
2150+ // --- CSS Shorthands ---
2289− // `-webkit-` fallback, which needs a box to clamp
2290 "line-clamp" => {
2151 "line-clamp" => {
2291 if let Some(v) = val {
2152 if let Some(v) = val {
2292 push_css(&mut css, "display", "-webkit-box");
2153 push_css(&mut css, "display", "-webkit-box");
@@ -2296,14 +2157,27 @@ fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> Stri
2296 }
2157 }
2297 }
2158 }
2298
2159
2299− // Every other name is a CSS property (standard, custom,
2160+ // Any other standard CSS property is copied through, with `px`
2300− // vendor-prefixed, or one htmlang doesn't know, which the
2161+ // added to bare numbers where the property takes a length.
2301− // parser warned about), written under its own name with the
2162+ // `font-family "Inter, sans-serif"`: the quotes only keep the
2302− // value as written, except for `px` after a bare number where
2163+ // stack's commas from splitting the attribute list.
2303− // the property takes a length
2164+ "font-family" => {
2304− key if crate::vocab::is_property_name(key) => {
2305 if let Some(v) = val {
2165 if let Some(v) = val {
2306− push_css(&mut css, key, &with_px(key, v));
2166+ let v = match v.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
2167+ Some(stack) if stack.contains(',') => stack,
2168+ _ => v,
2169+ };
2170+ push_css(&mut css, "font-family", v);
2171+ }
2172+ }
2173+
2174+ key if crate::vocab::is_css_property(key) => {
2175+ if let Some(v) = val {
2176+ if crate::vocab::is_length_property(key) {
2177+ push_css(&mut css, key, &css_px_multi(v));
2178+ } else {
2179+ push_css(&mut css, key, v);
2180+ }
2307 }
2181 }
2308 }
2182 }
2309
2183
@@ -2311,7 +2185,6 @@ fn attrs_to_css(attrs: &[Attribute], condition: &Condition, site: &Site) -> Stri
2311 }
2185 }
2312 }
2186 }
2313
2187
2314− css.push_str(&aligned);
2315 css
2188 css
2316}
2189}
2317
2190
@@ -2322,6 +2195,146 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
2322 css.push(';');
2195 css.push(';');
2323}
2196}
2324
2197
2198+/// Known CSS units — if a value ends with one, skip appending `px`.
2199+/// Format a length value: a bare number gets `px` appended; anything else
2200+/// (values with units, keywords like `auto`, functions like `calc(...)`) is
2201+/// passed through unchanged.
2202+fn css_px(value: &str) -> String {
2203+ let v = value.trim();
2204+ if v == "0" {
2205+ return "0".to_string();
2206+ }
2207+ let is_bare_number = !v.is_empty()
2208+ && v.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
2209+ && v.parse::<f64>().is_ok();
2210+ if is_bare_number {
2211+ format!("{}px", v)
2212+ } else {
2213+ v.to_string()
2214+ }
2215+}
2216+
2217+/// Rewrite literal values that match a declared CSS custom property (from
2218+/// `@theme` or `@let --name value`) to `var(--name)` references. Matches are
2219+/// anchored to CSS value boundaries so e.g. `#3b82f6` inside a longer hex or
2220+/// inside an identifier is not replaced. The `:root` block is emitted before
2221+/// this call runs, so its declarations are not affected.
2222+fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
2223+ if css.is_empty() || vars.is_empty() {
2224+ return css.to_string();
2225+ }
2226+ // Prefer longer values first so that if two vars share a prefix, the
2227+ // longer (more specific) match wins.
2228+ let mut pairs: Vec<(&str, String)> = vars
2229+ .iter()
2230+ .filter(|(name, value)| name.starts_with("--") && !value.is_empty())
2231+ .map(|(name, value)| (value.as_str(), format!("var({})", name)))
2232+ .collect();
2233+ pairs.sort_by_key(|(v, _)| std::cmp::Reverse(v.len()));
2234+ if pairs.is_empty() {
2235+ return css.to_string();
2236+ }
2237+
2238+ let is_boundary_before = |b: Option<u8>| match b {
2239+ None => true,
2240+ Some(c) => matches!(c, b':' | b' ' | b',' | b'(' | b';' | b'{' | b'\n' | b'\t'),
2241+ };
2242+ let is_boundary_after = |b: Option<u8>| match b {
2243+ None => true,
2244+ Some(c) => matches!(c, b';' | b'}' | b',' | b' ' | b')' | b'\n' | b'\t'),
2245+ };
2246+
2247+ let bytes = css.as_bytes();
2248+ let mut out = String::with_capacity(css.len());
2249+ let mut i = 0;
2250+ let mut prev: Option<u8> = None;
2251+ // Only declaration values are rewritten — never selectors or at-rule
2252+ // preludes such as `@media (min-width:768px)`. `blocks` records, for
2253+ // each open `{`, whether it holds declarations (a style rule) or nested
2254+ // rules (an at-rule like `@media` / `@layer`).
2255+ let mut blocks: Vec<bool> = Vec::new();
2256+ let mut prelude_start = 0;
2257+ let mut in_value = false;
2258+ while i < bytes.len() {
2259+ match bytes[i] {
2260+ b'{' => {
2261+ let prelude = css[prelude_start..i].trim_start();
2262+ blocks.push(!prelude.starts_with('@'));
2263+ in_value = false;
2264+ prelude_start = i + 1;
2265+ }
2266+ b'}' => {
2267+ blocks.pop();
2268+ in_value = false;
2269+ prelude_start = i + 1;
2270+ }
2271+ b';' => {
2272+ in_value = false;
2273+ prelude_start = i + 1;
2274+ }
2275+ b':' if blocks.last() == Some(&true) => in_value = true,
2276+ _ => {}
2277+ }
2278+ if in_value && is_boundary_before(prev) {
2279+ let mut matched = false;
2280+ for (val, repl) in &pairs {
2281+ let vb = val.as_bytes();
2282+ if i + vb.len() <= bytes.len() && &bytes[i..i + vb.len()] == vb {
2283+ let next = bytes.get(i + vb.len()).copied();
2284+ if is_boundary_after(next) {
2285+ out.push_str(repl);
2286+ prev = repl.as_bytes().last().copied();
2287+ i += vb.len();
2288+ matched = true;
2289+ break;
2290+ }
2291+ }
2292+ }
2293+ if matched {
2294+ continue;
2295+ }
2296+ }
2297+ // Advance by one UTF-8 code point.
2298+ let start = i;
2299+ i += 1;
2300+ while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
2301+ i += 1;
2302+ }
2303+ out.push_str(&css[start..i]);
2304+ prev = bytes.get(i - 1).copied();
2305+ }
2306+ out
2307+}
2308+
2309+
2310+/// Format a `line-height` value. CSS accepts either a unitless multiplier
2311+/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
2312+/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which
2313+/// CSS interprets as 24× font-size — almost never what anyone wants). Treat
2314+/// integers ≥ 2 as pixel lengths; anything with a decimal, an existing unit,
2315+/// or the value `0`/`1` passes through unchanged.
2316+fn css_line_height(value: &str) -> String {
2317+ let v = value.trim();
2318+ if v == "0" || v == "1" {
2319+ return v.to_string();
2320+ }
2321+ // Plain integer — treat as pixel length. Decimals, units, keywords and
2322+ // functions pass through.
2323+ if !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()) {
2324+ return format!("{}px", v);
2325+ }
2326+ v.to_string()
2327+}
2328+
2329+/// Format multiple space-separated values, each getting px if needed.
2330+fn css_px_multi(value: &str) -> String {
2331+ value
2332+ .split_whitespace()
2333+ .map(css_px)
2334+ .collect::<Vec<_>>()
2335+ .join(" ")
2336+}
2337+
2325fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2338fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2326 let mut id = None;
2339 let mut id = None;
2327 let mut class = None;
2340 let mut class = None;
@@ -2335,6 +2348,153 @@ fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2335 (id, class)
2348 (id, class)
2336}
2349}
2337
2350
2351+/// Read image dimensions from a local file by parsing the header bytes.
2352+/// Supports PNG, JPEG, GIF, WebP, AVIF, and SVG.
2353+fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
2354+ // SVG: parse as text for viewBox/width/height attributes
2355+ if path.ends_with(".svg") || path.ends_with(".SVG") {
2356+ let text = std::fs::read_to_string(path).ok()?;
2357+ return read_svg_dimensions(&text);
2358+ }
2359+
2360+ let data = std::fs::read(path).ok()?;
2361+ if data.len() < 12 {
2362+ return None;
2363+ }
2364+
2365+ // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
2366+ if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") {
2367+ let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
2368+ let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
2369+ return Some((w, h));
2370+ }
2371+
2372+ // GIF: "GIF87a" or "GIF89a", width/height at bytes 6-9 (little-endian)
2373+ if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
2374+ let w = u16::from_le_bytes([data[6], data[7]]) as u32;
2375+ let h = u16::from_le_bytes([data[8], data[9]]) as u32;
2376+ return Some((w, h));
2377+ }
2378+
2379+ // JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2)
2380+ if data.starts_with(b"\xff\xd8") {
2381+ let mut i = 2;
2382+ while i + 9 < data.len() {
2383+ if data[i] != 0xFF {
2384+ i += 1;
2385+ continue;
2386+ }
2387+ let marker = data[i + 1];
2388+ if marker == 0xC0 || marker == 0xC2 {
2389+ let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
2390+ let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
2391+ return Some((w, h));
2392+ }
2393+ if i + 3 < data.len() {
2394+ let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2395+ i += 2 + len;
2396+ } else {
2397+ break;
2398+ }
2399+ }
2400+ }
2401+
2402+ // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
2403+ if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
2404+ if &data[12..16] == b"VP8 " && data.len() >= 30 {
2405+ let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
2406+ let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
2407+ return Some((w, h));
2408+ }
2409+ if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
2410+ let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
2411+ let w = (bits & 0x3FFF) + 1;
2412+ let h = ((bits >> 14) & 0x3FFF) + 1;
2413+ return Some((w, h));
2414+ }
2415+ }
2416+
2417+ // AVIF: ISOBMFF container with "ftyp" box containing "avif"/"avis" brand,
2418+ // then "ispe" box with width/height
2419+ if data.len() >= 12 && &data[4..8] == b"ftyp" {
2420+ let brand = &data[8..12];
2421+ if brand == b"avif" || brand == b"avis" || brand == b"mif1" {
2422+ return read_avif_dimensions(&data);
2423+ }
2424+ }
2425+
2426+ None
2427+}
2428+
2429+/// Parse SVG viewBox or width/height attributes to get dimensions.
2430+fn read_svg_dimensions(text: &str) -> Option<(u32, u32)> {
2431+ // Try viewBox first: viewBox="minX minY width height"
2432+ if let Some(vb_start) = text.find("viewBox=\"") {
2433+ let rest = &text[vb_start + 9..];
2434+ if let Some(end) = rest.find('"') {
2435+ let parts: Vec<&str> = rest[..end].split_whitespace().collect();
2436+ if parts.len() == 4
2437+ && let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
2438+ && w > 0.0
2439+ && h > 0.0
2440+ {
2441+ return Some((w.round() as u32, h.round() as u32));
2442+ }
2443+ }
2444+ }
2445+ // Fall back to width/height attributes on <svg>
2446+ let svg_tag = text.find("<svg")?;
2447+ let tag_end = text[svg_tag..].find('>')? + svg_tag;
2448+ let tag = &text[svg_tag..tag_end];
2449+ let w = extract_svg_attr(tag, "width")?;
2450+ let h = extract_svg_attr(tag, "height")?;
2451+ Some((w, h))
2452+}
2453+
2454+fn extract_svg_attr(tag: &str, attr: &str) -> Option<u32> {
2455+ let needle = format!("{}=\"", attr);
2456+ let start = tag.find(&needle)? + needle.len();
2457+ let rest = &tag[start..];
2458+ let end = rest.find('"')?;
2459+ let val = rest[..end].trim_end_matches("px");
2460+ val.parse::<f64>().ok().map(|v| v.round() as u32)
2461+}
2462+
2463+/// Parse AVIF (ISOBMFF) container to find ispe box with image dimensions.
2464+fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
2465+ // Walk ISOBMFF boxes looking for "ispe" (image spatial extents)
2466+ let mut i = 0;
2467+ while i + 8 <= data.len() {
2468+ let box_size =
2469+ u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
2470+ let box_type = &data[i + 4..i + 8];
2471+ if box_size < 8 {
2472+ break;
2473+ }
2474+ let box_end = (i + box_size).min(data.len());
2475+ // ispe box: 4 bytes version/flags + 4 bytes width + 4 bytes height
2476+ if box_type == b"ispe" && box_end >= i + 20 {
2477+ let w = u32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
2478+ let h = u32::from_be_bytes([data[i + 16], data[i + 17], data[i + 18], data[i + 19]]);
2479+ return Some((w, h));
2480+ }
2481+ // Recurse into container boxes (meta, iprp, ipco)
2482+ if matches!(
2483+ box_type,
2484+ b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia"
2485+ ) {
2486+ let header_size = if box_type == b"meta" { 12 } else { 8 };
2487+ if i + header_size < box_end
2488+ && let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end])
2489+ {
2490+ return Some(dims);
2491+ }
2492+ }
2493+ i = box_end;
2494+ }
2495+ None
2496+}
2497+
2338fn html_escape(s: &str) -> String {
2498fn html_escape(s: &str) -> String {
2339 s.replace('&', "&amp;")
2499 s.replace('&', "&amp;")
2340 .replace('<', "&lt;")
2500 .replace('<', "&lt;")
@@ -2450,30 +2610,13 @@ fn vlq_encode(value: i64, out: &mut String) {
2450 }
2610 }
2451}
2611}
2452
2612
2453−#[cfg(test)]
2613+
2454−mod tests {
2614+/// Does the tree contain the table element `name` (e.g. `"main"`)?
2455− use super::*;
2615+fn has_tag(nodes: &[Node], name: &str) -> bool {
2456−
2616+ nodes.iter().any(|node| match node {
2457− #[test]
2617+ Node::Element(elem) => elem.kind.is_tag(name) || has_tag(&elem.children, name),
2458− fn a_name_is_a_stable_hash_of_the_style() {
2618+ _ => false,
2459− // The same in every build and on every platform
2619+ })
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}
2620}
2621+
2622+
crates/htmlang-core/src/codegen_tests.rs+8−33
@@ -1,8 +1,6 @@
1#[cfg(test)]
1#[cfg(test)]
2mod tests {
2mod tests {
3− use crate::codegen::{
3+ use crate::codegen::{generate, generate_dev, generate_partial, short_class_name};
4− CodegenOptions, generate, generate_dev, generate_partial, generate_with_classes,
5− };
6 use crate::parser::parse;
4 use crate::parser::parse;
7
5
8 fn compile(src: &str) -> String {
6 fn compile(src: &str) -> String {
@@ -49,42 +47,19 @@ mod tests {
49
47
50 #[test]
48 #[test]
51 fn dev_mode_is_deterministic_across_runs() {
49 fn dev_mode_is_deterministic_across_runs() {
52− let src =
50+ let src = "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
53− "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
54 let r1 = parse(src);
51 let r1 = parse(src);
55 let r2 = parse(src);
52 let r2 = parse(src);
56 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
53 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
57 }
54 }
58
55
59− /// The classes of `src`, compiled as a fragment
60− fn classes(src: &str) -> Vec<crate::codegen::Class> {
61− let options = CodegenOptions {
62− partial: true,
63− ..Default::default()
64− };
65− generate_with_classes(&parse(src).document, &options).1
66− }
67−
68 #[test]
56 #[test]
69− fn a_style_has_one_name_in_every_file() {
57+ fn short_class_name_is_stable() {
70− // A fragment's classes mean in a page what they mean in the
58+ assert_eq!(short_class_name(0), "a");
71− // fragment, whatever else either holds
59+ assert_eq!(short_class_name(25), "z");
72− let page = classes("@el [padding 10, background red] a\n@el [gap 4] b\n");
60+ // Higher indexes must produce unique, stable names.
73− let fragment = classes("@row [margin 2] x\n@el [padding 10, background red] y\n");
61+ let names: std::collections::HashSet<_> = (0..50_000).map(short_class_name).collect();
74− assert_eq!(page[0], fragment[1]);
62+ assert_eq!(names.len(), 50_000, "short_class_name collisions");
75− assert_ne!(page[1].style, fragment[0].style);
76− assert_ne!(page[1].name, fragment[0].name);
77− for class in page.iter().chain(&fragment) {
78− let digits = class.name.strip_prefix("hl-").expect("hl-");
79− assert_eq!(digits.len(), crate::codegen::CLASS_DIGITS, "{}", class.name);
80− assert!(
81− digits
82− .bytes()
83− .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase()),
84− "{}",
85− class.name
86− );
87− }
88 }
63 }
89
64
90 #[test]
65 #[test]
crates/htmlang-core/src/diagnostic.rsdeleted−245
@@ -1,245 +0,0 @@
1−//! Diagnostics: what the compiler reports about a source file.
2−//!
3−//! Every diagnostic has a stable `code` (see [`code`]), so tools such as the
4−//! language server key their quick fixes on the code rather than on the
5−//! wording of the message. `subject` and `suggestion` carry the name the
6−//! diagnostic is about and the name to use instead, when there is one.
7−
8−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9−pub enum Severity {
10− Error,
11− Warning,
12− Info,
13− Help,
14−}
15−
16−#[derive(Debug, Clone)]
17−pub struct Diagnostic {
18− /// Stable identifier of the kind of problem, e.g. `unknown-element`.
19− pub code: &'static str,
20− pub line: usize,
21− pub column: Option<usize>,
22− pub message: String,
23− pub severity: Severity,
24− pub source_line: Option<Box<str>>,
25− /// The name the diagnostic is about, as written (`@nosuch` without the
26− /// `@`, an attribute key, a definition's name).
27− pub subject: Option<Box<str>>,
28− /// The name to write instead of `subject` ("did you mean ...?").
29− pub suggestion: Option<Box<str>>,
30−}
31−
32−impl Diagnostic {
33− pub fn new(code: &'static str, severity: Severity, line: usize, message: String) -> Self {
34− Diagnostic {
35− code,
36− line,
37− column: None,
38− message,
39− severity,
40− source_line: None,
41− subject: None,
42− suggestion: None,
43− }
44− }
45−
46− pub fn error(code: &'static str, line: usize, message: String) -> Self {
47− Self::new(code, Severity::Error, line, message)
48− }
49−
50− pub fn warning(code: &'static str, line: usize, message: String) -> Self {
51− Self::new(code, Severity::Warning, line, message)
52− }
53−
54− pub fn column(mut self, column: usize) -> Self {
55− self.column = Some(column);
56− self
57− }
58−
59− pub fn source(mut self, source: impl Into<String>) -> Self {
60− self.source_line = Some(source.into().into_boxed_str());
61− self
62− }
63−
64− pub fn subject(mut self, subject: impl Into<String>) -> Self {
65− self.subject = Some(subject.into().into_boxed_str());
66− self
67− }
68−
69− pub fn suggest(mut self, suggestion: Option<impl Into<String>>) -> Self {
70− self.suggestion = suggestion.map(|s| s.into().into_boxed_str());
71− self
72− }
73−}
74−
75−/// The diagnostic codes. A code names a kind of problem and never changes,
76−/// even when a message is reworded.
77−pub mod code {
78− // Syntax: found while reading the file, before anything runs
79− pub const UNCLOSED_BRACKET: &str = "unclosed-bracket";
80− /// A `]` that ends an attribute list inside what reads as a `'...'`
81− /// string in a value (`hx-on:click=alert(']')`).
82− pub const UNBALANCED_QUOTE: &str = "unbalanced-quote";
83− pub const UNEXPECTED_BODY: &str = "unexpected-body";
84− pub const UNEXPECTED_ARGUMENT: &str = "unexpected-argument";
85− pub const MISSING_ARGUMENT: &str = "missing-argument";
86− pub const INVALID_LOOP: &str = "invalid-loop";
87− /// A `@let` whose name, parameters or body don't fit its kind.
88− pub const INVALID_DEFINITION: &str = "invalid-definition";
89− pub const STRAY_ELSE: &str = "stray-else";
90− /// Indentation with tabs and spaces mixed, which nests lines in ways
91− /// the eye doesn't see.
92− pub const MIXED_INDENTATION: &str = "mixed-indentation";
93−
94− // Names
95− pub const UNKNOWN_ELEMENT: &str = "unknown-element";
96− /// An attribute name htmlang doesn't know: a warning when CSS could
97− /// have the name (it is written to the CSS as it is), an error otherwise.
98− pub const UNKNOWN_ATTRIBUTE: &str = "unknown-attribute";
99− /// A misspelled prefix (`hovr:color red`).
100− pub const UNKNOWN_PREFIX: &str = "unknown-prefix";
101− /// A prefix the attribute can't take: a second one (`md:hover:`), or
102− /// one on an HTML attribute (`hover:required`).
103− pub const INVALID_PREFIX: &str = "invalid-prefix";
104− /// A style written without its value (`[padding]`).
105− pub const MISSING_VALUE: &str = "missing-value";
106− pub const HTML_ATTRIBUTE_FORM: &str = "html-attribute-form";
107− /// A second `@page`: a page has one root, so one `@page`.
108− pub const DUPLICATE_PAGE: &str = "duplicate-page";
109− pub const UNDEFINED_VARIABLE: &str = "undefined-variable";
110− /// An attribute, or an attribute's name, made from a variable.
111− pub const ATTRIBUTE_FROM_VARIABLE: &str = "attribute-from-variable";
112− pub const DUPLICATE_ATTRIBUTE: &str = "duplicate-attribute";
113−
114− // Values
115− /// A value that can't be written into the page's CSS (an error), or
116− /// one that can't mean what it says (a warning).
117− pub const INVALID_VALUE: &str = "invalid-value";
118− pub const INVALID_COLOR: &str = "invalid-color";
119− pub const INVALID_EXPRESSION: &str = "invalid-expression";
120− pub const INVALID_JSON: &str = "invalid-json";
121− /// A name htmlang doesn't know right after a `key=value`: the rest of
122− /// the value, which a comma ended (`hx-trigger=load, every 2s`).
123− pub const SPLIT_VALUE: &str = "split-value";
124− /// A leading argument taken as a URL that reads as text (`@link
125− /// [hx-get=/x] Open panel`, whose href is `Open`).
126− pub const TEXT_AS_URL: &str = "text-as-url";
127−
128− // Definitions and functions
129− pub const UNUSED_VARIABLE: &str = "unused-variable";
130− pub const UNUSED_BUNDLE: &str = "unused-bundle";
131− pub const UNUSED_FUNCTION: &str = "unused-function";
132− pub const RECURSIVE_CALL: &str = "recursive-call";
133− pub const NO_SINGLE_ROOT: &str = "no-single-root";
134− /// A call that leaves out a parameter without a default.
135− pub const MISSING_PARAMETER: &str = "missing-parameter";
136− /// A parameter passed `name=value`: parameters are written `name value`.
137− pub const PARAMETER_FORM: &str = "parameter-form";
138− /// A function named like a built-in element (which it replaces) or a
139− /// directive (which it can't replace).
140− pub const SHADOWS_BUILT_IN: &str = "shadows-built-in";
141− /// A `@slot NAME` block at a call whose function has no such slot.
142− pub const UNKNOWN_SLOT: &str = "unknown-slot";
143− /// Content passed to a function whose body has no `@children`.
144− pub const UNEXPECTED_CONTENT: &str = "unexpected-content";
145− /// A `@slot` or `@children` where it marks or fills nothing: outside
146− /// a function's body, inside an element at a call, or inline in text.
147− pub const MISPLACED_SLOT: &str = "misplaced-slot";
148− /// A slot name that isn't one name (`@slot my footer`).
149− pub const INVALID_SLOT_NAME: &str = "invalid-slot-name";
150−
151− // Files
152− pub const UNREADABLE_FILE: &str = "unreadable-file";
153− pub const CIRCULAR_INCLUDE: &str = "circular-include";
154− /// Two files of one build whose different styles got one class name:
155− /// a name is a hash of its style (`htmlang build` checks).
156− pub const CLASS_COLLISION: &str = "class-collision";
157− pub const UNSET_ENVIRONMENT: &str = "unset-environment";
158−
159− // Context: an attribute or element where it has no effect
160− /// An attribute the element ignores (a warning), or a layout word such
161− /// as `spacing` on an element that doesn't lay out its children (an
162− /// error: it is left out).
163− pub const NO_EFFECT: &str = "no-effect";
164− /// An element whose HTML ends a `<p>`, written inside a `@paragraph`.
165− pub const BLOCK_IN_PARAGRAPH: &str = "block-in-paragraph";
166−
167− // Accessibility
168− pub const MISSING_ALT: &str = "missing-alt";
169− pub const MISSING_INPUT_TYPE: &str = "missing-input-type";
170− pub const MISSING_LINK_TEXT: &str = "missing-link-text";
171− pub const MISSING_LABEL: &str = "missing-label";
172− pub const MISSING_TITLE: &str = "missing-title";
173− pub const MISSING_BUTTON_TEXT: &str = "missing-button-text";
174− pub const MISSING_CAPTIONS: &str = "missing-captions";
175− pub const LOW_CONTRAST: &str = "low-contrast";
176− pub const POSITIVE_TABINDEX: &str = "positive-tabindex";
177−
178− // `htmlang lint`
179− pub const DEEP_NESTING: &str = "deep-nesting";
180− pub const EMPTY_CONTAINER: &str = "empty-container";
181− pub const MISSING_BUTTON_TYPE: &str = "missing-button-type";
182−
183− /// A bug in the compiler itself.
184− pub const INTERNAL: &str = "internal";
185−
186− /// Every code, for tools and tests.
187− pub const ALL: &[&str] = &[
188− UNCLOSED_BRACKET,
189− UNBALANCED_QUOTE,
190− UNEXPECTED_BODY,
191− UNEXPECTED_ARGUMENT,
192− MISSING_ARGUMENT,
193− INVALID_LOOP,
194− INVALID_DEFINITION,
195− STRAY_ELSE,
196− MIXED_INDENTATION,
197− UNKNOWN_ELEMENT,
198− UNKNOWN_ATTRIBUTE,
199− UNKNOWN_PREFIX,
200− INVALID_PREFIX,
201− MISSING_VALUE,
202− HTML_ATTRIBUTE_FORM,
203− DUPLICATE_PAGE,
204− UNDEFINED_VARIABLE,
205− ATTRIBUTE_FROM_VARIABLE,
206− DUPLICATE_ATTRIBUTE,
207− INVALID_VALUE,
208− INVALID_COLOR,
209− INVALID_EXPRESSION,
210− INVALID_JSON,
211− SPLIT_VALUE,
212− TEXT_AS_URL,
213− UNUSED_VARIABLE,
214− UNUSED_BUNDLE,
215− UNUSED_FUNCTION,
216− RECURSIVE_CALL,
217− NO_SINGLE_ROOT,
218− MISSING_PARAMETER,
219− PARAMETER_FORM,
220− SHADOWS_BUILT_IN,
221− UNKNOWN_SLOT,
222− UNEXPECTED_CONTENT,
223− MISPLACED_SLOT,
224− INVALID_SLOT_NAME,
225− UNREADABLE_FILE,
226− CIRCULAR_INCLUDE,
227− CLASS_COLLISION,
228− UNSET_ENVIRONMENT,
229− NO_EFFECT,
230− BLOCK_IN_PARAGRAPH,
231− MISSING_ALT,
232− MISSING_INPUT_TYPE,
233− MISSING_LINK_TEXT,
234− MISSING_LABEL,
235− MISSING_TITLE,
236− MISSING_BUTTON_TEXT,
237− MISSING_CAPTIONS,
238− LOW_CONTRAST,
239− POSITIVE_TABINDEX,
240− DEEP_NESTING,
241− EMPTY_CONTAINER,
242− MISSING_BUTTON_TYPE,
243− INTERNAL,
244− ];
245−}
crates/htmlang-core/src/expr.rs+291−478
@@ -1,23 +1,17 @@
1//! Expressions: conditions (`@if $count > 2 and not $hidden`), computed
1//! Expressions: conditions (`@if $count > 2 and not $hidden`), computed
2−//! values (`@let gap = $base * 2`) and `${...}` in text and values
2+//! values (`@let gap = $base * 2`) and `if(cond, a, b)`.
3−//! (`${if($featured, 24, 0)}`).
4//!
3//!
5//! Expressions are tokenized and evaluated as a whole. Variables are looked
4//! Expressions are tokenized and evaluated as a whole. Variables are looked
6//! up while evaluating, never pasted into the text first, so a value that
5//! up while evaluating, never pasted into the text first, so a value that
7//! happens to contain `==` or spaces can't change how an expression parses.
6//! happens to contain `==` or spaces can't change how an expression parses.
8//!
7//!
9−//! Only what decides the result is evaluated: `if()` evaluates the branch
10−//! it takes, `and` and `or` stop at the first side that decides. The rest
11−//! is still parsed (and its function names and argument counts checked),
12−//! so `$n != 0 and 10 / $n > 1` is false for `$n` = 0, not an error.
13−//!
14//! ```text
8//! ```text
15//! expr := or
9//! expr := or
16//! or := and ("or" and)*
10//! or := and ("or" and)*
17//! and := not ("and" not)*
11//! and := not ("and" not)*
18//! not := "not" not | compare
12//! not := "not" not | compare
19−//! compare := range (("==" | "!=" | "<" | ">" | "<=" | ">=") range)?
13+//! compare := sum (("==" | "!=" | "<" | ">" | "<=" | ">=" | "contains"
20−//! range := sum (".." sum ("step" sum)?)?
14+//! | "starts-with" | "ends-with") sum)?
21//! sum := product (("+" | "-") product)*
15//! sum := product (("+" | "-") product)*
22//! product := unary (("*" | "/" | "%") unary)*
16//! product := unary (("*" | "/" | "%") unary)*
23//! unary := "-" unary | primary
17//! unary := "-" unary | primary
@@ -25,89 +19,74 @@
25//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
19//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
26//! ```
20//! ```
27//!
21//!
28−//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is text. Values are
22+//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string. A variable
29−//! typed (see `value.rs`, which also defines truthiness and comparison):
23+//! loaded from a JSON array is a list: `length` counts its items and
30−//! `$name` keeps whatever it holds, a list or a record included, and
24+//! `contains` tests membership. Functions:
31−//! `A..B` (with `step N`) is a list of whole numbers. Functions:
25+//! `if(cond, a, b)`, text (`uppercase`, `lowercase`, `capitalize`, `trim`,
32−//! `if(cond, a, b)` (`b` may be left out: empty), tests (`contains(s, x)`,
26+//! `length`, `reverse`, `truncate(s, n)`, `replace(s, old, new)`,
33−//! `starts-with(s, x)`, `ends-with(s, x)`), text (`uppercase`, `lowercase`,
27+//! `default(s, fallback)`) and colors (`lighten(c, pct)`, `darken(c, pct)`,
34−//! `capitalize`, `trim`, `length`, `reverse`, `truncate(s, n)`,
28+//! `alpha(c, a)`, `mix(c1, c2, pct)`). In text, `${expr}` interpolates an
35−//! `replace(s, old, new)`, `default(s, fallback)`). A colour variant is
29+//! expression.
36−//! CSS, not a function here: `color-mix(in srgb, var(--brand), black 8%)`
37−//! is an attribute's value, written as it is.
38−//! `length`, `contains` and `reverse` work on the items of a list; the other
39−//! text functions take text, and a list is an error (a text with commas in
40−//! it is written in quotes or with `\,`, so it isn't a list).
41−//!
42−//! A `$name` ends as it does in text (see `interp.rs`), so `$post.title`
43−//! reads a field and `$a..$b` is two names. An undefined name is an error.
44
30
45use std::fmt;
31use std::fmt;
46
32
47−use crate::interp::{self, Problem, Reference, Scope};
48−
49−pub use crate::value::Value;
50−
51−/// Why an expression has no value.
52#[derive(Debug, Clone, PartialEq)]
33#[derive(Debug, Clone, PartialEq)]
53−pub enum Error {
34+pub enum Value {
54− /// `$name` has no definition; `offset` is where the `$` is.
35+ Num(f64),
55− Undefined { name: String, offset: usize },
36+ Str(String),
56− /// Anything else: a syntax error, a type error, division by zero.
37+ Bool(bool),
57− Invalid(String),
38+ /// A list's items as text (from a JSON array).
39+ List(Vec<String>),
58}
40}
59
41
60−impl Error {
42+impl Value {
61− /// This error as a problem of a text in which the expression starts
43+ /// Empty strings, `false`, `0` and `"false"` / `"0"` are false.
62− /// at `base`.
44+ pub fn truthy(&self) -> bool {
63− pub(crate) fn at(self, base: usize) -> Problem {
64 match self {
45 match self {
65− Error::Undefined { name, offset } => Problem::Undefined {
46+ Value::Bool(b) => *b,
66− name,
47+ Value::Num(n) => *n != 0.0,
67− offset: base + offset,
48+ Value::Str(s) => !s.is_empty() && s != "false" && s != "0",
68− },
49+ Value::List(items) => !items.is_empty(),
69− Error::Invalid(message) => Problem::Invalid {
70− message,
71− offset: base,
72− },
73 }
50 }
74 }
51 }
75−}
76
52
77−impl From<String> for Error {
53+ fn as_num(&self) -> Option<f64> {
78− fn from(message: String) -> Self {
54+ match self {
79− Error::Invalid(message)
55+ Value::Num(n) => Some(*n),
56+ Value::Str(s) => s.trim().parse().ok(),
57+ Value::Bool(_) | Value::List(_) => None,
58+ }
80 }
59 }
81}
60}
82
61
83−impl fmt::Display for Error {
62+impl fmt::Display for Value {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
64 match self {
86− Error::Undefined { name, .. } => write!(f, "undefined variable '${}'", name),
65+ Value::Num(n) if n.fract() == 0.0 && n.abs() < 1e15 => write!(f, "{}", *n as i64),
87− Error::Invalid(message) => f.write_str(message),
66+ Value::Num(n) => write!(f, "{}", n),
67+ Value::Str(s) => f.write_str(s),
68+ Value::Bool(b) => write!(f, "{}", b),
69+ Value::List(items) => f.write_str(&items.join(", ")),
88 }
70 }
89 }
71 }
90}
72}
91
73
92−/// Evaluate `src` with the variables of `scope`. An undefined variable is
74+/// Resolves a variable name (the text after `$`). Returns `None` for
93−/// an error; a field that a record doesn't have is empty.
75+/// undefined variables.
94−pub fn eval(src: &str, scope: &dyn Scope) -> Result<Value, Error> {
76+pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<Value>;
95− let mut tokens = Vec::new();
77+
96− tokenize(src, scope, 0, &mut tokens)?;
78+/// Evaluate `src`. Undefined variables are empty strings.
79+pub fn eval(src: &str, resolve: Resolver) -> Result<Value, String> {
80+ let tokens = tokenize(src)?;
97 let mut parser = Parser {
81 let mut parser = Parser {
98 tokens: &tokens,
82 tokens: &tokens,
99 pos: 0,
83 pos: 0,
100− scope,
84+ resolve,
101− skip: false,
102 };
85 };
103 let value = parser.expr()?;
86 let value = parser.expr()?;
104 match parser.tokens.get(parser.pos) {
87 match parser.tokens.get(parser.pos) {
105 None => Ok(value),
88 None => Ok(value),
106− Some((tok, _)) => Err(Error::Invalid(format!(
89+ Some(tok) => Err(format!("unexpected {} in `{}`", tok, src.trim())),
107− "unexpected {} in `{}`",
108− tok,
109− src.trim()
110− ))),
111 }
90 }
112}
91}
113
92
@@ -138,116 +117,84 @@ impl fmt::Display for Token {
138 }
117 }
139}
118}
140
119
141−const OPERATORS: &[&str] = &[
120+const OPERATORS: &[&str] = &["==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%"];
142− "..", "==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%",
121+
143−];
122+fn tokenize(src: &str) -> Result<Vec<Token>, String> {
144−
123+ let chars: Vec<char> = src.chars().collect();
145−/// Read `src` into tokens, each with its byte offset (plus `base`).
124+ let mut tokens = Vec::new();
146−/// Variable names end as in text (see `interp.rs`), and `${...}` is a
147−/// variable (`${name}`) or a parenthesized expression.
148−fn tokenize(
149− src: &str,
150− scope: &dyn Scope,
151− base: usize,
152− tokens: &mut Vec<(Token, usize)>,
153−) -> Result<(), Error> {
154− let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
155 let mut i = 0;
125 let mut i = 0;
156− while let Some(c) = src[i..].chars().next() {
126+ let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
157− let at = base + i;
127+ while i < chars.len() {
128+ let c = chars[i];
158 if c.is_whitespace() {
129 if c.is_whitespace() {
159− i += c.len_utf8();
130+ i += 1;
160 } else if c == '(' {
131 } else if c == '(' {
161− tokens.push((Token::LParen, at));
132+ tokens.push(Token::LParen);
162 i += 1;
133 i += 1;
163 } else if c == ')' {
134 } else if c == ')' {
164− tokens.push((Token::RParen, at));
135+ tokens.push(Token::RParen);
165 i += 1;
136 i += 1;
166 } else if c == ',' {
137 } else if c == ',' {
167− tokens.push((Token::Comma, at));
138+ tokens.push(Token::Comma);
168 i += 1;
139 i += 1;
169 } else if c == '"' {
140 } else if c == '"' {
170 let start = i + 1;
141 let start = i + 1;
171− let end = src[start..]
142+ let end = chars[start..]
172− .find('"')
143+ .iter()
144+ .position(|&c| c == '"')
173 .map(|p| start + p)
145 .map(|p| start + p)
174 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
146 .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
175− tokens.push((Token::Str(src[start..end].to_string()), at));
147+ tokens.push(Token::Str(chars[start..end].iter().collect()));
176 i = end + 1;
148 i = end + 1;
177 } else if c == '$' {
149 } else if c == '$' {
178− match interp::reference(&src[i + 1..], scope) {
150+ let start = i + 1;
179− Some((Reference::Var(path), len)) => {
151+ let mut end = start;
180− tokens.push((Token::Var(path), at));
152+ while end < chars.len() && is_var_char(chars[end]) {
181− i += 1 + len;
153+ end += 1;
182− }
154+ }
183− Some((Reference::Expr(inner), len)) => {
155+ while end > start && chars[end - 1] == '.' {
184− tokens.push((Token::LParen, at));
156+ end -= 1;
185− tokenize(inner, scope, at + 2, tokens)?;
186− tokens.push((Token::RParen, at + len));
187− i += 1 + len;
188− }
189− None => {
190− let n = interp::custom_property_len(&src[i + 1..]);
191− return Err(Error::Invalid(match n {
192− 0 => format!("`$` without a variable name in `{}`", src.trim()),
193− n => interp::custom_property_message(&src[i + 1..i + 1 + n]),
194− }));
195− }
196 }
157 }
158+ if end == start {
159+ return Err(format!("`$` without a variable name in `{}`", src.trim()));
160+ }
161+ tokens.push(Token::Var(chars[start..end].iter().collect()));
162+ i = end;
197 } else if let Some(op) = OPERATORS
163 } else if let Some(op) = OPERATORS
198 .iter()
164 .iter()
199− .find(|op| src[i..].starts_with(**op))
165+ .find(|op| chars[i..].iter().take(op.len()).copied().eq(op.chars()))
200 // A `-` followed by a letter starts a word (`-webkit-box`);
166 // A `-` followed by a letter starts a word (`-webkit-box`);
201 // inside words (`ease-in`) it is consumed by the word itself.
167 // inside words (`ease-in`) it is consumed by the word itself.
202− .filter(|op| **op != "-" || !src[i + 1..].starts_with(char::is_alphabetic))
168+ .filter(|op| **op != "-" || !chars.get(i + 1).is_some_and(|c| c.is_alphabetic()))
203 {
169 {
204− tokens.push((Token::Op(op), at));
170+ tokens.push(Token::Op(op));
205 i += op.len();
171 i += op.len();
206 } else if is_word_char(c) {
172 } else if is_word_char(c) {
207 let start = i;
173 let start = i;
208− // A word ends at `..`, so `1..5` is a range
174+ while i < chars.len() && is_word_char(chars[i]) {
209− while let Some(c) = src[i..]
175+ i += 1;
210− .chars()
211− .next()
212− .filter(|&c| is_word_char(c) && !src[i..].starts_with(".."))
213− {
214− i += c.len_utf8();
215 }
176 }
216− let word = &src[start..i];
177+ let word: String = chars[start..i].iter().collect();
217− tokens.push((
178+ tokens.push(match word.parse::<f64>() {
218− match word.parse::<f64>() {
179+ Ok(n) if !word.ends_with('.') => Token::Num(n),
219− Ok(n) if !word.ends_with('.') => Token::Num(n),
180+ _ => Token::Word(word),
220− _ => Token::Word(word.to_string()),
181+ });
221− },
222− at,
223− ));
224 } else {
182 } else {
225− return Err(Error::Invalid(format!(
183+ return Err(format!("unexpected `{}` in `{}`", c, src.trim()));
226− "unexpected `{}` in `{}`",
227− c,
228− src.trim()
229− )));
230 }
184 }
231 }
185 }
232− Ok(())
186+ Ok(tokens)
233}
187}
234
188
235struct Parser<'a> {
189struct Parser<'a> {
236− tokens: &'a [(Token, usize)],
190+ tokens: &'a [Token],
237 pos: usize,
191 pos: usize,
238− scope: &'a dyn Scope,
192+ resolve: Resolver<'a>,
239− /// Parsing a part whose value isn't needed (the branch `if()` doesn't
240− /// take, the side of `and`/`or` that doesn't decide): it is read and
241− /// checked for syntax, not evaluated.
242− skip: bool,
243}
193}
244
194
245−/// The value a skipped part stands for; nothing reads it.
246−const SKIPPED: Value = Value::Bool(false);
247−
248impl Parser<'_> {
195impl Parser<'_> {
249 fn peek(&self) -> Option<&Token> {
196 fn peek(&self) -> Option<&Token> {
250− self.tokens.get(self.pos).map(|(token, _)| token)
197+ self.tokens.get(self.pos)
251 }
198 }
252
199
253 fn eat_word(&mut self, word: &str) -> bool {
200 fn eat_word(&mut self, word: &str) -> bool {
@@ -270,167 +217,122 @@ impl Parser<'_> {
270 }
217 }
271 }
218 }
272
219
273− fn expect(&mut self, token: Token) -> Result<(), Error> {
220+ fn expect(&mut self, token: Token) -> Result<(), String> {
274 match self.peek() {
221 match self.peek() {
275 Some(t) if *t == token => {
222 Some(t) if *t == token => {
276 self.pos += 1;
223 self.pos += 1;
277 Ok(())
224 Ok(())
278 }
225 }
279− Some(t) => Err(format!("expected {} but found {}", token, t).into()),
226+ Some(t) => Err(format!("expected {} but found {}", token, t)),
280− None => Err(format!("expected {} at the end", token).into()),
227+ None => Err(format!("expected {} at the end", token)),
281 }
228 }
282 }
229 }
283
230
284− /// Parse with `f`, skipping evaluation when `skip` (or when already
231+ fn expr(&mut self) -> Result<Value, String> {
285− /// skipping).
286− fn skipping(
287− &mut self,
288− skip: bool,
289− f: impl FnOnce(&mut Self) -> Result<Value, Error>,
290− ) -> Result<Value, Error> {
291− let outer = self.skip;
292− self.skip |= skip;
293− let value = f(self);
294− self.skip = outer;
295− value
296− }
297−
298− fn expr(&mut self) -> Result<Value, Error> {
299 let mut left = self.and()?;
232 let mut left = self.and()?;
300 while self.eat_word("or") {
233 while self.eat_word("or") {
301− let decided = left.truthy();
234+ let right = self.and()?;
302− let right = self.skipping(decided, Self::and)?;
235+ left = Value::Bool(left.truthy() || right.truthy());
303− left = Value::Bool(decided || right.truthy());
304 }
236 }
305 Ok(left)
237 Ok(left)
306 }
238 }
307
239
308− fn and(&mut self) -> Result<Value, Error> {
240+ fn and(&mut self) -> Result<Value, String> {
309 let mut left = self.not()?;
241 let mut left = self.not()?;
310 while self.eat_word("and") {
242 while self.eat_word("and") {
311− let decided = !left.truthy();
243+ let right = self.not()?;
312− let right = self.skipping(decided, Self::not)?;
244+ left = Value::Bool(left.truthy() && right.truthy());
313− left = Value::Bool(!decided && right.truthy());
314 }
245 }
315 Ok(left)
246 Ok(left)
316 }
247 }
317
248
318− fn not(&mut self) -> Result<Value, Error> {
249+ fn not(&mut self) -> Result<Value, String> {
319 if self.eat_word("not") {
250 if self.eat_word("not") {
320 return Ok(Value::Bool(!self.not()?.truthy()));
251 return Ok(Value::Bool(!self.not()?.truthy()));
321 }
252 }
322 self.compare()
253 self.compare()
323 }
254 }
324
255
325− fn compare(&mut self) -> Result<Value, Error> {
256+ fn compare(&mut self) -> Result<Value, String> {
326− let left = self.range()?;
257+ let left = self.sum()?;
327− let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
258+ let op = if let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) {
259+ op
260+ } else if self.eat_word("contains") {
261+ "contains"
262+ } else if self.eat_word("starts-with") {
263+ "starts-with"
264+ } else if self.eat_word("ends-with") {
265+ "ends-with"
266+ } else {
328 return Ok(left);
267 return Ok(left);
329 };
268 };
330− let right = self.range()?;
269+ let right = self.sum()?;
331− if self.skip {
270+ let numbers = left.as_num().zip(right.as_num());
332− return Ok(SKIPPED);
271+ let (l, r) = (left.to_string(), right.to_string());
333− }
272+ Ok(Value::Bool(match (op, numbers) {
334− Ok(Value::Bool(left.compare(op, &right)?))
273+ ("==", Some((a, b))) => a == b,
335− }
274+ ("!=", Some((a, b))) => a != b,
336−
275+ ("<", Some((a, b))) => a < b,
337− /// `A..B` or `A..B step N`: the whole numbers from A to B.
276+ (">", Some((a, b))) => a > b,
338− fn range(&mut self) -> Result<Value, Error> {
277+ ("<=", Some((a, b))) => a <= b,
339− let start = self.sum()?;
278+ (">=", Some((a, b))) => a >= b,
340− if self.eat_op(&[".."]).is_none() {
279+ ("==", None) => l == r,
341− return Ok(start);
280+ ("!=", None) => l != r,
342− }
281+ ("<", None) => l < r,
343− let end = self.sum()?;
282+ (">", None) => l > r,
344− let step = match self.eat_word("step") {
283+ ("<=", None) => l <= r,
345− true => Some(self.sum()?),
284+ (">=", None) => l >= r,
346− false => None,
285+ ("contains", _) => match &left {
347− };
286+ Value::List(items) => items.contains(&r),
348− if self.skip {
287+ _ => l.contains(&r),
349− return Ok(SKIPPED);
288+ },
350− }
289+ ("starts-with", _) => l.starts_with(&r),
351− let number = |value: &Value| {
290+ ("ends-with", _) => l.ends_with(&r),
352− value.as_num().ok_or_else(|| {
291+ _ => unreachable!(),
353− Error::Invalid(format!(
292+ }))
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 }
293 }
366
294
367− fn sum(&mut self) -> Result<Value, Error> {
295+ fn sum(&mut self) -> Result<Value, String> {
368 let mut left = self.product()?;
296 let mut left = self.product()?;
369 while let Some(op) = self.eat_op(&["+", "-"]) {
297 while let Some(op) = self.eat_op(&["+", "-"]) {
370 let right = self.product()?;
298 let right = self.product()?;
371− left = self.arithmetic(op, &left, &right)?;
299+ left = arithmetic(op, &left, &right)?;
372 }
300 }
373 Ok(left)
301 Ok(left)
374 }
302 }
375
303
376− fn product(&mut self) -> Result<Value, Error> {
304+ fn product(&mut self) -> Result<Value, String> {
377 let mut left = self.unary()?;
305 let mut left = self.unary()?;
378 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
306 while let Some(op) = self.eat_op(&["*", "/", "%"]) {
379 let right = self.unary()?;
307 let right = self.unary()?;
380− left = self.arithmetic(op, &left, &right)?;
308+ left = arithmetic(op, &left, &right)?;
381 }
309 }
382 Ok(left)
310 Ok(left)
383 }
311 }
384
312
385− fn unary(&mut self) -> Result<Value, Error> {
313+ fn unary(&mut self) -> Result<Value, String> {
386 if self.eat_op(&["-"]).is_some() {
314 if self.eat_op(&["-"]).is_some() {
387 let value = self.unary()?;
315 let value = self.unary()?;
388− if self.skip {
389− return Ok(SKIPPED);
390− }
391 return value
316 return value
392 .as_num()
317 .as_num()
393 .map(|n| Value::Num(-n))
318 .map(|n| Value::Num(-n))
394− .ok_or_else(|| Error::Invalid(format!("cannot negate `{}`", value)));
319+ .ok_or_else(|| format!("cannot negate `{}`", value));
395 }
320 }
396 self.primary()
321 self.primary()
397 }
322 }
398
323
399− fn primary(&mut self) -> Result<Value, Error> {
324+ fn primary(&mut self) -> Result<Value, String> {
400− let (token, at) = self
325+ let token = self
401− .tokens
326+ .peek()
402− .get(self.pos)
403 .cloned()
327 .cloned()
404 .ok_or_else(|| "expected a value at the end".to_string())?;
328 .ok_or_else(|| "expected a value at the end".to_string())?;
405 self.pos += 1;
329 self.pos += 1;
406 match token {
330 match token {
407 Token::Num(n) => Ok(Value::Num(n)),
331 Token::Num(n) => Ok(Value::Num(n)),
408− Token::Str(_) | Token::Var(_) if self.skip => Ok(SKIPPED),
332+ Token::Str(s) => Ok(Value::Str(interpolate(&s, self.resolve))),
409− Token::Str(s) => {
333+ Token::Var(name) => Ok((self.resolve)(&name).unwrap_or(Value::Str(String::new()))),
410− let (text, problems) = interp::interpolate(&s, self.scope);
411− match problems.into_iter().next() {
412− None => Ok(Value::Str(text)),
413− Some(Problem::Undefined { name, offset }) => Err(Error::Undefined {
414− name,
415− offset: at + 1 + offset,
416− }),
417− Some(Problem::Invalid { message, .. } | Problem::Record { message, .. }) => {
418− Err(Error::Invalid(message))
419− }
420− Some(Problem::CustomProperty { name, .. }) => {
421− Err(Error::Invalid(interp::custom_property_message(&name)))
422− }
423− }
424− }
425− Token::Var(name) => {
426− interp::resolve(&name, self.scope).ok_or(Error::Undefined { name, offset: at })
427− }
428 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
334 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
429 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
335 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) => {
336 Token::Word(name) if self.peek() == Some(&Token::LParen) => {
435 self.pos += 1;
337 self.pos += 1;
436 let mut args = Vec::new();
338 let mut args = Vec::new();
@@ -442,11 +344,7 @@ impl Parser<'_> {
442 }
344 }
443 }
345 }
444 self.expect(Token::RParen)?;
346 self.expect(Token::RParen)?;
445− check_call(&name, args.len())?;
347+ call(&name, args)
446− if self.skip {
447− return Ok(SKIPPED);
448− }
449− Ok(call(&name, args)?)
450 }
348 }
451 Token::Word(w) => Ok(Value::Str(w)),
349 Token::Word(w) => Ok(Value::Str(w)),
452 Token::LParen => {
350 Token::LParen => {
@@ -454,46 +352,11 @@ impl Parser<'_> {
454 self.expect(Token::RParen)?;
352 self.expect(Token::RParen)?;
455 Ok(value)
353 Ok(value)
456 }
354 }
457− other => Err(format!("unexpected {}", other).into()),
355+ other => Err(format!("unexpected {}", other)),
458 }
356 }
459 }
357 }
460}
358}
461
359
462−impl Parser<'_> {
463− /// `if(cond, a, b)` after its `(`: the branch that `cond` picks is
464− /// evaluated, the other only read. Without `b`, a false `cond` gives
465− /// an empty value.
466− fn choice(&mut self) -> Result<Value, Error> {
467− let arguments =
468− || Error::Invalid("if() takes 2 or 3 arguments: if(CONDITION, A, B)".into());
469− let condition = self.expr()?;
470− match self.peek() {
471− Some(Token::RParen) | None => return Err(arguments()),
472− _ => self.expect(Token::Comma)?,
473− }
474− let taken = condition.truthy();
475− let then = self.skipping(!taken, Self::expr)?;
476− let otherwise = if self.peek() == Some(&Token::Comma) {
477− self.pos += 1;
478− self.skipping(taken, Self::expr)?
479− } else {
480− Value::Str(String::new())
481− };
482− if self.peek() == Some(&Token::Comma) {
483− return Err(arguments());
484− }
485− self.expect(Token::RParen)?;
486− Ok(if taken { then } else { otherwise })
487− }
488−
489− fn arithmetic(&self, op: &str, left: &Value, right: &Value) -> Result<Value, Error> {
490− if self.skip {
491− return Ok(SKIPPED);
492− }
493− Ok(arithmetic(op, left, right)?)
494− }
495−}
496−
497fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
360fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
498 let (Some(a), Some(b)) = (left.as_num(), right.as_num()) else {
361 let (Some(a), Some(b)) = (left.as_num(), right.as_num()) else {
499 return Err(format!("`{} {} {}` needs numbers", left, op, right));
362 return Err(format!("`{} {} {}` needs numbers", left, op, right));
@@ -510,137 +373,177 @@ fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
510 }))
373 }))
511}
374}
512
375
513−/// The built-in functions (other than `if`) and how many arguments each
376+fn is_var_char(c: char) -> bool {
514−/// takes.
377+ 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}
378}
546
379
547−/// Call a built-in function with the right number of arguments (see
380+/// 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> {
381fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
553− let text = |i: usize| -> Result<String, String> {
382+ 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| {
383 let num = |i: usize| {
571 args.get(i)
384 args.get(i)
572 .and_then(Value::as_num)
385 .and_then(Value::as_num)
573 .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
386 .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
574 };
387 };
388+ let arity = |n: usize| {
389+ if args.len() == n {
390+ Ok(())
391+ } else {
392+ Err(format!("{}() takes {} argument{}", name, n, if n == 1 { "" } else { "s" }))
393+ }
394+ };
395+ let color = |rgb: (u8, u8, u8)| Value::Str(format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2));
575 Ok(match name {
396 Ok(match name {
576− "contains" => Value::Bool(match &args[0] {
397+ "if" => {
577− Value::List(list) => list.items.iter().any(|item| item.equals(&args[1])),
398+ arity(3)?;
578− _ => text(0)?.contains(&text(1)?),
399+ let mut args = args;
579− }),
400+ let otherwise = args.pop().unwrap();
580− "starts-with" => Value::Bool(text(0)?.starts_with(&text(1)?)),
401+ let then = args.pop().unwrap();
581− "ends-with" => Value::Bool(text(0)?.ends_with(&text(1)?)),
402+ if args[0].truthy() { then } else { otherwise }
582− "uppercase" => Value::Str(text(0)?.to_uppercase()),
403+ }
583− "lowercase" => Value::Str(text(0)?.to_lowercase()),
404+ "uppercase" => { arity(1)?; Value::Str(text(0).to_uppercase()) }
405+ "lowercase" => { arity(1)?; Value::Str(text(0).to_lowercase()) }
584 "capitalize" => {
406 "capitalize" => {
585− let s = text(0)?;
407+ arity(1)?;
408+ let s = text(0);
586 let mut chars = s.chars();
409 let mut chars = s.chars();
587 Value::Str(match chars.next() {
410 Value::Str(match chars.next() {
588 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
411 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
589 None => String::new(),
412 None => String::new(),
590 })
413 })
591 }
414 }
592− "trim" => Value::Str(text(0)?.trim().to_string()),
415+ "trim" => { arity(1)?; Value::Str(text(0).trim().to_string()) }
593− "length" => Value::Num(match &args[0] {
416+ "length" => {
594− Value::List(list) => list.items.len(),
417+ arity(1)?;
595− Value::Record(fields) => fields.len(),
418+ Value::Num(match &args[0] {
596− _ => text(0)?.chars().count(),
419+ Value::List(items) => items.len(),
597− } as f64),
420+ other => other.to_string().chars().count(),
598− "reverse" => match &args[0] {
421+ } as f64)
599− Value::List(list) => Value::list(list.items.iter().rev().cloned().collect()),
422+ }
600− _ => Value::Str(text(0)?.chars().rev().collect()),
423+ "reverse" => { arity(1)?; Value::Str(text(0).chars().rev().collect()) }
601− },
602 "truncate" => {
424 "truncate" => {
603− let (s, n) = (text(0)?, num(1)? as usize);
425+ arity(2)?;
426+ let (s, n) = (text(0), num(1)? as usize);
604 Value::Str(if s.chars().count() > n {
427 Value::Str(if s.chars().count() > n {
605 format!("{}...", s.chars().take(n).collect::<String>())
428 format!("{}...", s.chars().take(n).collect::<String>())
606 } else {
429 } else {
607 s
430 s
608 })
431 })
609 }
432 }
610− "replace" => Value::Str(text(0)?.replace(&text(1)?, &text(2)?)),
433+ "replace" => { arity(3)?; Value::Str(text(0).replace(&text(1), &text(2))) }
611 "default" => {
434 "default" => {
612− let empty = match &args[0] {
435+ arity(2)?;
613− Value::List(list) => list.items.is_empty(),
436+ let s = text(0);
614− Value::Record(_) => false,
437+ Value::Str(if s.is_empty() { text(1) } else { s })
615− value => value.to_string().is_empty(),
438+ }
439+ "lighten" | "darken" | "alpha" | "mix" => {
440+ let expected = if name == "mix" { 3 } else { 2 };
441+ arity(expected)?;
442+ let Some(rgb) = parse_hex_rgb(&text(0)) else {
443+ return Err(format!("{}() needs a hex color, got `{}`", name, text(0)));
616 };
444 };
617− let mut args = args;
445+ match name {
618− args.swap_remove(usize::from(empty))
446+ "lighten" => color(lighten_color(rgb, num(1)? / 100.0)),
447+ "darken" => color(darken_color(rgb, num(1)? / 100.0)),
448+ "alpha" => {
449+ let a = (num(1)?.clamp(0.0, 1.0) * 255.0) as u8;
450+ Value::Str(format!("#{:02x}{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2, a))
451+ }
452+ _ => {
453+ let Some(other) = parse_hex_rgb(&text(1)) else {
454+ return Err(format!("mix() needs a hex color, got `{}`", text(1)));
455+ };
456+ color(mix_colors(rgb, other, num(2)? / 100.0))
457+ }
458+ }
619 }
459 }
620 _ => return Err(format!("unknown function `{}()`", name)),
460 _ => return Err(format!("unknown function `{}()`", name)),
621 })
461 })
622}
462}
623
463
464+pub(crate) fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
465+ let s = s.strip_prefix('#')?;
466+ if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
467+ return None;
468+ }
469+ match s.len() {
470+ 3 => {
471+ let r = u8::from_str_radix(&s[0..1], 16).ok()?;
472+ let g = u8::from_str_radix(&s[1..2], 16).ok()?;
473+ let b = u8::from_str_radix(&s[2..3], 16).ok()?;
474+ Some((r * 17, g * 17, b * 17))
475+ }
476+ 6 => {
477+ let r = u8::from_str_radix(&s[0..2], 16).ok()?;
478+ let g = u8::from_str_radix(&s[2..4], 16).ok()?;
479+ let b = u8::from_str_radix(&s[4..6], 16).ok()?;
480+ Some((r, g, b))
481+ }
482+ _ => None,
483+ }
484+}
485+
486+fn lighten_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
487+ let r = rgb.0 as f64 + (255.0 - rgb.0 as f64) * amount.clamp(0.0, 1.0);
488+ let g = rgb.1 as f64 + (255.0 - rgb.1 as f64) * amount.clamp(0.0, 1.0);
489+ let b = rgb.2 as f64 + (255.0 - rgb.2 as f64) * amount.clamp(0.0, 1.0);
490+ (r.round() as u8, g.round() as u8, b.round() as u8)
491+}
492+
493+fn darken_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
494+ let factor = 1.0 - amount.clamp(0.0, 1.0);
495+ let r = (rgb.0 as f64 * factor).round() as u8;
496+ let g = (rgb.1 as f64 * factor).round() as u8;
497+ let b = (rgb.2 as f64 * factor).round() as u8;
498+ (r, g, b)
499+}
500+
501+fn mix_colors(c1: (u8, u8, u8), c2: (u8, u8, u8), weight: f64) -> (u8, u8, u8) {
502+ let w = weight.clamp(0.0, 1.0);
503+ let r = (c1.0 as f64 * (1.0 - w) + c2.0 as f64 * w).round() as u8;
504+ let g = (c1.1 as f64 * (1.0 - w) + c2.1 as f64 * w).round() as u8;
505+ let b = (c1.2 as f64 * (1.0 - w) + c2.2 as f64 * w).round() as u8;
506+ (r, g, b)
507+}
508+
509+
510+/// Replace `$name` references inside a string literal.
511+fn interpolate(s: &str, resolve: Resolver) -> String {
512+ let mut out = String::with_capacity(s.len());
513+ let mut rest = s;
514+ while let Some(pos) = rest.find('$') {
515+ out.push_str(&rest[..pos]);
516+ let after = &rest[pos + 1..];
517+ let end = after.find(|c: char| !is_var_char(c)).unwrap_or(after.len());
518+ if end == 0 {
519+ out.push('$');
520+ } else {
521+ out.push_str(&resolve(&after[..end]).map(|v| v.to_string()).unwrap_or_default());
522+ }
523+ rest = &after[end..];
524+ }
525+ out.push_str(rest);
526+ out
527+}
528+
624#[cfg(test)]
529#[cfg(test)]
625mod tests {
530mod tests {
626 use super::*;
531 use super::*;
627
532
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 {
533 fn ev(src: &str) -> Value {
643− eval(src, &scope()).unwrap()
534+ let resolve = |name: &str| {
535+ let text = |s: &str| Some(Value::Str(s.to_string()));
536+ match name {
537+ "tags" => Some(Value::List(vec!["rust".into(), "web dev".into()])),
538+ "count" => text("3"),
539+ "name" => text("World"),
540+ "theme" => text("dark"),
541+ "tricky" => text("a == b"),
542+ "empty" => text(""),
543+ _ => None,
544+ }
545+ };
546+ eval(src, &resolve).unwrap()
644 }
547 }
645
548
646 #[test]
549 #[test]
@@ -658,8 +561,7 @@ mod tests {
658 assert!(ev("$theme == dark").truthy());
561 assert!(ev("$theme == dark").truthy());
659 assert!(ev("$theme != light and not $empty").truthy());
562 assert!(ev("$theme != light and not $empty").truthy());
660 assert!(!ev("$count < 2 or $empty").truthy());
563 assert!(!ev("$count < 2 or $empty").truthy());
661− assert!(ev("contains($name, or)").truthy());
564+ 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());
565 assert!(ev("uppercase($name) == WORLD").truthy());
664 assert!(ev("#3b82f6 != red").truthy());
566 assert!(ev("#3b82f6 != red").truthy());
665 }
567 }
@@ -675,62 +577,7 @@ mod tests {
675 fn strings_and_if() {
577 fn strings_and_if() {
676 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
578 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
677 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
579 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
678− assert_eq!(ev("${name}").to_string(), "World");
580+ 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 }
581 }
735
582
736 #[test]
583 #[test]
@@ -740,62 +587,28 @@ mod tests {
740 assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
587 assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
741 assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
588 assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
742 assert_eq!(ev("default($empty, none)").to_string(), "none");
589 assert_eq!(ev("default($empty, none)").to_string(), "none");
590+ assert_eq!(ev("darken(#ffffff, 50)").to_string(), "#808080");
591+ assert_eq!(ev("mix(#000000, #ffffff, 50)").to_string(), "#808080");
592+ assert_eq!(ev("alpha(#3b82f6, 0.5)").to_string(), "#3b82f67f");
743 }
593 }
744
594
745 #[test]
595 #[test]
746 fn lists() {
596 fn lists() {
747 assert_eq!(ev("length($tags)").to_string(), "2");
597 assert_eq!(ev("length($tags)").to_string(), "2");
748− assert!(ev("contains($tags, \"web dev\")").truthy());
598+ assert!(ev("$tags contains \"web dev\"").truthy());
749− assert!(!ev("contains($tags, web)").truthy());
599+ assert!(!ev("$tags contains web").truthy());
750 assert_eq!(ev("$tags").to_string(), "rust, web dev");
600 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 }
601 }
780
602
781 #[test]
603 #[test]
782 fn errors_are_reported() {
604 fn errors_are_reported() {
783− let none = Map::new(&[]);
605+ let none = |_: &str| None;
784 assert!(eval("dark * 2", &none).is_err());
606 assert!(eval("dark * 2", &none).is_err());
785 assert!(eval("1 +", &none).is_err());
607 assert!(eval("1 +", &none).is_err());
786 assert!(eval("(1", &none).is_err());
608 assert!(eval("(1", &none).is_err());
787 assert!(eval("1 / 0", &none).is_err());
609 assert!(eval("1 / 0", &none).is_err());
788 assert!(eval("nope(1)", &none).is_err());
610 assert!(eval("nope(1)", &none).is_err());
789 assert!(eval("uppercase(a, b)", &none).is_err());
611 assert!(eval("uppercase(a, b)", &none).is_err());
790− // Colour maths is CSS's (`color-mix()`), and a custom property is
612+ 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 }
613 }
801}
614}
crates/htmlang-core/src/interp.rsdeleted−651
@@ -1,651 +0,0 @@
1−//! Variables in htmlang strings: `$name`, `$record.field` and `${...}`.
2−//!
3−//! The syntax tree finds every slot first: a run of text, an attribute's
4−//! value, an element's argument, a `@let` value, a file path, the title of
5−//! `@page`, the value of `@meta`. Each slot is then interpolated here, once.
6−//! A reference fills its own slot, and what it inserts is never read again
7−//! as htmlang: a value can't become an attribute name, a whole attribute, a
8−//! comma between attributes, markup or another `$name`.
9−//!
10−//! - `$` followed by a letter or `_` starts a name, which goes on with
11−//! letters, digits, `_` and `-` (a `-` only when a name character follows
12−//! it).
13−//! - `$--name` is an error: a custom property is read one way, with
14−//! `var(--name)`, in the browser, so it follows a token redefined under
15−//! `dark:`. See [`custom_property_len`].
16−//! - `.field` continues the name only when the value is a record or a list
17−//! (`$post.title`, `$tags.0`). After `@let lang fr`, `$lang.json` is
18−//! `fr.json`, and `$a..$b` is two names.
19−//! - `${name}` is the same name, delimited (`${size}px`), and `${EXPR}`
20−//! inserts the value of an expression (see `expr.rs`).
21−//! - A `$` before anything else is text: `$5`, `$$`, a `$` at the end.
22−//! - An undefined name is an error. A field that a record doesn't have is
23−//! empty, and so are its fields (`$post.author.name` when `$post` has no
24−//! `author`), so optional fields of `@data` records work.
25−//! - Quoted text (`@let arrow "→ "`, `@card [quote "→ "]`) remembers its
26−//! quotes: a CSS value gets them (`content $arrow` is `content:"→ "`),
27−//! text and HTML attribute values don't. See [`Sink`].
28−
29−use crate::expr;
30−use crate::value::Value;
31−
32−/// The variables in scope, as the evaluator stores them.
33−pub trait Scope {
34− /// The value of a variable, by its name (without fields).
35− fn get(&self, name: &str) -> Option<Value>;
36−}
37−
38−/// Where a slot's value goes, which decides what quoted text inserts.
39−#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40−pub enum Sink {
41− /// Text, an HTML attribute value, a path, a parameter: quoted text
42− /// inserts what it says, without the quotes.
43− Text,
44− /// A CSS value: quoted text inserts itself with its quotes, or, inside a
45− /// quoted string of the value, what it says with `"` escaped as `\"`.
46− Css,
47−}
48−
49−/// Whether a CSS value is inside a quoted string after `text`, when it
50−/// was (`inside`) before it. A backslash inside a string escapes the next
51−/// character.
52−fn css_string_after(text: &str, mut inside: bool) -> bool {
53− let mut chars = text.chars();
54− while let Some(c) = chars.next() {
55− match c {
56− '\\' if inside => {
57− chars.next();
58− }
59− '"' => inside = !inside,
60− _ => {}
61− }
62− }
63− inside
64−}
65−
66−/// `text` as it goes inside a CSS string: `"` escaped as `\"`. A backslash
67−/// and the character after it are a CSS escape and stay as written
68−/// (`\201C`), but a backslash at the end is doubled, so it can't escape
69−/// the rest of the string (`a\` gives `a\\`).
70−pub fn css_string_body(text: &str) -> String {
71− let mut out = String::with_capacity(text.len());
72− let mut chars = text.chars();
73− while let Some(c) = chars.next() {
74− match c {
75− '\\' => {
76− out.push('\\');
77− out.push(chars.next().unwrap_or('\\'));
78− }
79− '"' => out.push_str("\\\""),
80− _ => out.push(c),
81− }
82− }
83− out
84−}
85−
86−/// A problem found while interpolating, at a byte offset into the text.
87−#[derive(Debug, Clone, PartialEq)]
88−pub enum Problem {
89− /// A name with no definition.
90− Undefined { name: String, offset: usize },
91− /// `$--name`: a custom property, which is read with `var(--name)`.
92− CustomProperty { name: String, offset: usize },
93− /// A `${...}` whose expression doesn't evaluate.
94− Invalid { message: String, offset: usize },
95− /// A record put where text goes, which it has none of: it would print
96− /// nothing.
97− Record { message: String, offset: usize },
98−}
99−
100−/// The problem with putting `value`, written `written`, where text goes:
101−/// a record has no text.
102−fn no_text(written: &str, value: &Value, offset: usize) -> Option<Problem> {
103− let name = written.strip_prefix('$').map(|name| {
104− name.strip_prefix('{')
105− .and_then(|n| n.strip_suffix('}'))
106− .map_or(name, str::trim)
107− });
108− let fields = match value {
109− Value::Record(fields) => fields,
110− // A list of records, which would print only what isn't a record
111− Value::List(list)
112− if list.written.is_none()
113− && list
114− .items
115− .iter()
116− .any(|item| matches!(item, Value::Record(_))) =>
117− {
118− let example = match (name, list.items.first()) {
119− (Some(name), Some(Value::Record(fields))) if is_path(name) => {
120− match fields.first() {
121− Some((field, _)) => format!(", such as `${}.0.{}`", name, field),
122− None => String::new(),
123− }
124− }
125− _ => String::new(),
126− };
127− return Some(Problem::Record {
128− message: format!(
129− "'{}' is a list of records, which has no text of its own: loop over it \
130− with @each, or write a field of one of its items{}",
131− written, example
132− ),
133− offset,
134− });
135− }
136− _ => return None,
137− };
138− let example = match (name, fields.first()) {
139− (Some(name), Some((field, _))) if is_path(name) => {
140− format!(", such as `${}.{}`", name, field)
141− }
142− _ => String::new(),
143− };
144− Some(Problem::Record {
145− message: format!(
146− "'{}' is a record, which has no text of its own: write one of its fields{}",
147− written, example
148− ),
149− offset,
150− })
151−}
152−
153−fn is_name_char(c: char) -> bool {
154− c.is_alphanumeric() || c == '_'
155−}
156−
157−/// The length of a word at the start of `s` whose first character passes
158−/// `first`: name characters, with `-` between them.
159−fn word_len(s: &str, first: impl Fn(char) -> bool) -> usize {
160− let Some(c) = s.chars().next().filter(|&c| first(c)) else {
161− return 0;
162− };
163− let mut end = c.len_utf8();
164− let mut i = end;
165− while let Some(c) = s[i..].chars().next() {
166− if is_name_char(c) {
167− i += c.len_utf8();
168− end = i;
169− } else if c == '-' {
170− let rest = &s[i..];
171− let dashes = rest.len() - rest.trim_start_matches('-').len();
172− if !rest[dashes..].starts_with(is_name_char) {
173− break;
174− }
175− i += dashes;
176− } else {
177− break;
178− }
179− }
180− end
181−}
182−
183−/// The length of the variable name at the start of `s` (the text after a
184−/// `$`), by syntax alone, or 0 when `s` doesn't start one.
185−pub fn name_len(s: &str) -> usize {
186− word_len(s, |c| c.is_alphabetic() || c == '_')
187−}
188−
189−/// The length of the custom property's name `--name` at the start of `s`
190−/// (the text after a `$`), or 0. `$--name` is not a variable and not text
191−/// either: it is an error that says to write `var(--name)`.
192−pub fn custom_property_len(s: &str) -> usize {
193− match s.strip_prefix("--") {
194− Some(rest) => match word_len(rest, |c| c.is_alphanumeric() || c == '_') {
195− 0 => 0,
196− n => n + 2,
197− },
198− None => 0,
199− }
200−}
201−
202−/// What is wrong with `$NAME`, where `name` is a custom property's name
203−/// (`--brand`).
204−pub fn custom_property_message(name: &str) -> String {
205− format!(
206− "`${}` is not a variable: a custom property is read with `var({})`",
207− name, name
208− )
209−}
210−
211−/// Whether `s` is a whole name path, `name` or `name.field.field`.
212−fn is_path(s: &str) -> bool {
213− let n = name_len(s);
214− if n == 0 || matches!(&s[..n], "true" | "false" | "not" | "and" | "or") {
215− return false;
216− }
217− let mut rest = &s[n..];
218− while let Some(field) = rest.strip_prefix('.') {
219− let f = word_len(field, is_name_char);
220− if f == 0 {
221− return false;
222− }
223− rest = &field[f..];
224− }
225− rest.is_empty()
226−}
227−
228−/// What a name path leads to so far: a value, or a field that a record
229−/// (or an item that a list) doesn't have, which is empty, and so is every
230−/// field of it.
231−#[derive(Clone)]
232−enum Step {
233− Value(Value),
234− Missing,
235−}
236−
237−impl Step {
238− /// The field `name` of this step, or `None` when it has no fields
239− /// (text, a number, true or false).
240− fn field(&self, name: &str) -> Option<Step> {
241− let Step::Value(value) = self else {
242− return Some(Step::Missing);
243− };
244− match value {
245− Value::Record(fields) => Some(
246− fields
247− .iter()
248− .find(|(key, _)| key == name)
249− .map_or(Step::Missing, |(_, value)| Step::Value(value.clone())),
250− ),
251− Value::List(list) => Some(
252− name.parse::<usize>()
253− .ok()
254− .and_then(|i| list.items.get(i).cloned())
255− .map_or(Step::Missing, Step::Value),
256− ),
257− _ => None,
258− }
259− }
260−
261− fn value(self) -> Value {
262− match self {
263− Step::Value(value) => value,
264− Step::Missing => Value::empty(),
265− }
266− }
267−}
268−
269−/// The name path at the start of `s` (after a `$`) and its length: the
270−/// name, and each `.field` while the value so far is a record or a list,
271−/// or a field one of them doesn't have.
272−fn path_at(s: &str, scope: &dyn Scope) -> Option<(String, usize)> {
273− let n = name_len(s);
274− if n == 0 {
275− return None;
276− }
277− let mut path = s[..n].to_string();
278− let mut len = n;
279− let mut step = scope.get(&path).map(Step::Value);
280− while let Some(rest) = s[len..].strip_prefix('.') {
281− let f = word_len(rest, is_name_char);
282− let Some(field) = step
283− .as_ref()
284− .filter(|_| f > 0)
285− .and_then(|v| v.field(&rest[..f]))
286− else {
287− break;
288− };
289− path = format!("{}.{}", path, &rest[..f]);
290− len += 1 + f;
291− step = Some(field);
292− }
293− Some((path, len))
294−}
295−
296−/// A reference, after its `$`.
297−#[derive(Debug, Clone, PartialEq)]
298−pub enum Reference<'a> {
299− /// `$name`, `$name.field` or `${name}`: the name path.
300− Var(String),
301− /// `${EXPR}`: the expression.
302− Expr(&'a str),
303−}
304−
305−/// The reference that `s` (the text after a `$`) starts with, and its
306−/// length. `None` when the `$` is text.
307−pub fn reference<'a>(s: &'a str, scope: &dyn Scope) -> Option<(Reference<'a>, usize)> {
308− if s.starts_with('{') {
309− let close = matching_brace(s)?;
310− let inner = &s[1..close];
311− let reference = if is_path(inner.trim()) {
312− Reference::Var(inner.trim().to_string())
313− } else {
314− Reference::Expr(inner)
315− };
316− return Some((reference, close + 1));
317− }
318− let (path, len) = path_at(s, scope)?;
319− Some((Reference::Var(path), len))
320−}
321−
322−/// The value of a name path, or `None` when it is undefined. A field that
323−/// a record doesn't have is empty, and so is any field of it.
324−pub fn resolve(path: &str, scope: &dyn Scope) -> Option<Value> {
325− let mut parts = path.split('.');
326− let mut step = Step::Value(scope.get(parts.next()?)?);
327− for field in parts {
328− step = step.field(field)?;
329− }
330− Some(step.value())
331−}
332−
333−/// Fill in the `$name`s and `${...}`s of one slot's text, for text (see
334−/// [`Sink::Text`]). Each one that can't be filled is left as written and
335−/// reported.
336−pub fn interpolate(text: &str, scope: &dyn Scope) -> (String, Vec<Problem>) {
337− interpolate_for(text, scope, Sink::Text)
338−}
339−
340−/// [`interpolate`] for a slot whose value goes to `sink`.
341−pub fn interpolate_for(text: &str, scope: &dyn Scope, sink: Sink) -> (String, Vec<Problem>) {
342− let mut problems = Vec::new();
343− if !text.contains('$') {
344− return (text.to_string(), problems);
345− }
346− let mut out = String::with_capacity(text.len());
347− let mut pos = 0;
348− // Whether the CSS value is inside a quoted string at `pos`
349− let mut in_string = false;
350− while let Some(found) = text[pos..].find('$') {
351− let dollar = pos + found;
352− out.push_str(&text[pos..dollar]);
353− if sink == Sink::Css {
354− in_string = css_string_after(&text[pos..dollar], in_string);
355− }
356− let after = &text[dollar + 1..];
357− let Some((reference, len)) = reference(after, scope) else {
358− let n = custom_property_len(after);
359− if n > 0 {
360− problems.push(Problem::CustomProperty {
361− name: after[..n].to_string(),
362− offset: dollar,
363− });
364− }
365− out.push_str(&text[dollar..dollar + 1 + n]);
366− pos = dollar + 1 + n;
367− continue;
368− };
369− let written = &text[dollar..dollar + 1 + len];
370− let insert = |value: String, quoted: Option<String>| match (sink, quoted) {
371− (Sink::Css, Some(css)) if in_string => {
372− // The string's body, without its quotes
373− let body = css.strip_prefix('"').unwrap_or(&css);
374− body.strip_suffix('"').unwrap_or(body).to_string()
375− }
376− (Sink::Css, Some(css)) => css,
377− (Sink::Css, None) if in_string => css_string_body(&value),
378− _ => value,
379− };
380− match reference {
381− Reference::Var(path) => match resolve(&path, scope) {
382− Some(value) => match no_text(written, &value, dollar) {
383− Some(problem) => problems.push(problem),
384− None => out.push_str(&insert(
385− value.to_string(),
386− value.quoted_css().map(str::to_string),
387− )),
388− },
389− None => {
390− problems.push(Problem::Undefined {
391− name: path,
392− offset: dollar,
393− });
394− out.push_str(written);
395− }
396− },
397− Reference::Expr(source) => match expr::eval(source, scope) {
398− Ok(value) => match no_text(written, &value, dollar) {
399− Some(problem) => problems.push(problem),
400− None => out.push_str(&insert(value.to_string(), None)),
401− },
402− Err(error) => {
403− problems.push(match error {
404− // At the `${`
405− expr::Error::Invalid(message) => Problem::Invalid {
406− message,
407− offset: dollar,
408− },
409− // At the name, inside the braces
410− error => error.at(dollar + 2),
411− });
412− out.push_str(written);
413− }
414− },
415− }
416− pos = dollar + 1 + len;
417− }
418− out.push_str(&text[pos..]);
419− (out, problems)
420−}
421−
422−/// The names `text` refers to, by syntax alone: the first name of each
423−/// path, including those inside `${...}`.
424−pub fn names(text: &str) -> Vec<&str> {
425− name_spans(text)
426− .into_iter()
427− .map(|span| &text[span])
428− .collect()
429−}
430−
431−/// Where the names of [`names`] are in `text`, as byte ranges of the name
432−/// alone: after the `$` of `$name`, inside the braces of `${name}`.
433−pub fn name_spans(text: &str) -> Vec<std::ops::Range<usize>> {
434− let mut found = Vec::new();
435− collect_name_spans(text, 0, &mut found);
436− found
437−}
438−
439−fn collect_name_spans(text: &str, base: usize, found: &mut Vec<std::ops::Range<usize>>) {
440− let mut pos = 0;
441− while let Some(i) = text[pos..].find('$') {
442− let after = pos + i + 1;
443− let rest = &text[after..];
444− pos = after;
445− // `\$` is a dollar sign (and `\\$` a backslash before a name)
446− let backslashes = text[..after - 1].len() - text[..after - 1].trim_end_matches('\\').len();
447− if backslashes % 2 == 1 {
448− continue;
449− }
450− if rest.starts_with('{')
451− && let Some(close) = matching_brace(rest)
452− {
453− let inner = &rest[1..close];
454− let trimmed = inner.trim_start();
455− if is_path(trimmed.trim_end()) {
456− let start = base + after + 1 + inner.len() - trimmed.len();
457− found.push(start..start + name_len(trimmed));
458− } else {
459− collect_name_spans(inner, base + after + 1, found);
460− }
461− pos = after + close + 1;
462− continue;
463− }
464− let n = name_len(rest);
465− if n > 0 {
466− found.push(base + after..base + after + n);
467− pos = after + n;
468− }
469− }
470−}
471−
472−/// Index of the `}` matching the `{` that `s` starts with (skipping
473−/// braces inside string literals).
474−pub(crate) fn matching_brace(s: &str) -> Option<usize> {
475− let mut depth = 0;
476− let mut in_string = false;
477− for (i, c) in s.char_indices() {
478− match c {
479− '"' => in_string = !in_string,
480− _ if in_string => {}
481− '{' => depth += 1,
482− '}' => {
483− depth -= 1;
484− if depth == 0 {
485− return Some(i);
486− }
487− }
488− _ => {}
489− }
490− }
491− None
492−}
493−
494−#[cfg(test)]
495−pub(crate) mod tests {
496− use super::*;
497− use std::collections::HashMap;
498−
499− /// Variables by name: text from `new`, any value from `with`.
500− pub(crate) struct Map(pub HashMap<String, Value>);
501−
502− impl Map {
503− pub(crate) fn new(pairs: &[(&str, &str)]) -> Self {
504− Map(pairs
505− .iter()
506− .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
507− .collect())
508− }
509−
510− pub(crate) fn with(mut self, name: &str, value: Value) -> Self {
511− self.0.insert(name.to_string(), value);
512− self
513− }
514− }
515−
516− impl Scope for Map {
517− fn get(&self, name: &str) -> Option<Value> {
518− self.0.get(name).cloned()
519− }
520− }
521−
522− pub(crate) fn text_list(items: &[&str]) -> Value {
523− Value::list(items.iter().map(|s| Value::Str(s.to_string())).collect())
524− }
525−
526− pub(crate) fn record(fields: &[(&str, &str)]) -> Value {
527− Value::Record(std::rc::Rc::new(
528− fields
529− .iter()
530− .map(|(k, v)| (k.to_string(), Value::Str(v.to_string())))
531− .collect(),
532− ))
533− }
534−
535− fn vars() -> Map {
536− Map::new(&[("lang", "fr"), ("n", "3"), ("n-1", "two")])
537− .with("post", record(&[("title", "Hello")]))
538− .with("tags", text_list(&["a", "b"]))
539− }
540−
541− fn fill(text: &str) -> String {
542− interpolate(text, &vars()).0
543− }
544−
545− #[test]
546− fn names_end_where_they_must() {
547− assert_eq!(fill("locales/$lang.json"), "locales/fr.json");
548− assert_eq!(fill("$post.title!"), "Hello!");
549− assert_eq!(fill("$tags.1 and $tags"), "b and a, b");
550− assert_eq!(fill("${lang}uage"), "fruage");
551− assert_eq!(fill("$lang- $lang:"), "fr- fr:");
552− assert_eq!(fill("$n-1"), "two");
553− assert_eq!(fill("1..$n"), "1..3");
554− // A name ends at `..`
555− assert_eq!(fill("$n..$n $post.title..x"), "3..3 Hello..x");
556− }
557−
558− #[test]
559− fn a_dollar_before_anything_else_is_text() {
560− assert_eq!(fill("$5 and $$ and $"), "$5 and $$ and $");
561− assert_eq!(fill("a $-x ${"), "a $-x ${");
562− }
563−
564− #[test]
565− fn a_custom_property_is_not_a_variable() {
566− let (out, problems) = interpolate("x $--brand-dark $-- $--1", &vars());
567− assert_eq!(out, "x $--brand-dark $-- $--1");
568− assert_eq!(
569− problems,
570− [
571− Problem::CustomProperty {
572− name: "--brand-dark".into(),
573− offset: 2
574− },
575− Problem::CustomProperty {
576− name: "--1".into(),
577− offset: 20
578− }
579− ]
580− );
581− assert!(custom_property_message("--brand").contains("`var(--brand)`"));
582− assert_eq!(names("$--e $f"), ["f"]);
583− }
584−
585− #[test]
586− fn missing_fields_are_empty_and_undefined_names_are_errors() {
587− assert_eq!(fill("[$post.summary]"), "[]");
588− let (out, problems) = interpolate("x $nope y", &vars());
589− assert_eq!(out, "x $nope y");
590− assert_eq!(
591− problems,
592− [Problem::Undefined {
593− name: "nope".into(),
594− offset: 2
595− }]
596− );
597− }
598−
599− #[test]
600− fn expressions() {
601− assert_eq!(fill("${$n * 2}px"), "6px");
602− let (_, problems) = interpolate("a ${$n + $m}", &vars());
603− assert_eq!(
604− problems,
605− [Problem::Undefined {
606− name: "m".into(),
607− offset: 9
608− }]
609− );
610− }
611−
612− #[test]
613− fn quoted_text_keeps_its_quotes_only_in_css() {
614− let quoted = Value::Quoted(crate::ast::Quoted {
615− text: r#"a"b"#.into(),
616− css: r#""a\"b""#.into(),
617− });
618− let scope = Map::new(&[("n", r#"x"y"#)]).with("q", quoted);
619− let fill = |text: &str, sink| interpolate_for(text, &scope, sink).0;
620− assert_eq!(fill("$q", Sink::Text), r#"a"b"#);
621− assert_eq!(fill("$q", Sink::Css), r#""a\"b""#);
622− // Inside a CSS string, what it says, escaped as the string needs
623− assert_eq!(
624− fill(r#""($q) $n" $q"#, Sink::Css),
625− r#""(a\"b) x\"y" "a\"b""#
626− );
627− assert_eq!(fill(r#""\" $q""#, Sink::Css), r#""\" a\"b""#);
628− // What an unquoted value holds goes into a string that stays valid
629− let scope = Map::new(&[("end", r"a\"), ("esc", r"\201C"), ("q", r#"x\"y"#)]);
630− let fill = |text: &str| interpolate_for(text, &scope, Sink::Css).0;
631− assert_eq!(fill(r#""$end""#), r#""a\\""#);
632− assert_eq!(fill(r#""$esc""#), r#""\201C""#);
633− assert_eq!(fill(r#""$q""#), r#""x\"y""#);
634− }
635−
636− #[test]
637− fn inserted_values_are_never_read_again() {
638− let scope = Map::new(&[("a", "$b"), ("b", "no")]);
639− assert_eq!(interpolate("$a", &scope).0, "$b");
640− }
641−
642− #[test]
643− fn names_by_syntax() {
644− assert_eq!(names("$a.b ${c} ${upper($d)} $5 $--e"), ["a", "c", "d"]);
645− assert_eq!(names(r"\$a \\$b \\\$c"), ["b"]);
646− assert_eq!(
647− name_spans("x ${ size }px ${$n + ${m}} $a- b"),
648− [5..9, 17..18, 23..24, 28..29]
649− );
650− }
651−}
crates/htmlang-core/src/lib.rs+1−4
@@ -1,11 +1,8 @@
1pub mod ast;
1pub mod ast;
2pub mod codegen;
2pub mod codegen;
3−pub mod diagnostic;
4pub mod expr;
3pub mod expr;
5−pub mod interp;
6pub mod parser;
4pub mod parser;
7−pub mod syntax;
5+mod syntax;
8−pub mod value;
9pub mod vocab;
6pub mod vocab;
10
7
11#[cfg(test)]
8#[cfg(test)]
crates/htmlang-core/src/parser.rs+2499−6129
@@ -1,193 +1,76 @@
1use std::collections::{HashMap, HashSet};
1use std::collections::{HashMap, HashSet};
2+use std::fmt;
2use std::path::{Path, PathBuf};
3use std::path::{Path, PathBuf};
3−use std::rc::Rc;
4
4
5use crate::ast::*;
5use crate::ast::*;
6−use crate::diagnostic::code;
6+use crate::syntax::{self, Syntax};
7−pub use crate::diagnostic::{Diagnostic, Severity};
8−use crate::interp::{self, Sink};
9−use crate::syntax::{self, DirectiveArgs, LetForm, NodeKind, Segment, Tree};
10−use crate::value::{self, Value};
11
7
12// ---------------------------------------------------------------------------
8// ---------------------------------------------------------------------------
13// Public types
9// Public types
14// ---------------------------------------------------------------------------
10// ---------------------------------------------------------------------------
15
11
12+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13+pub enum Severity {
14+ Error,
15+ Warning,
16+ Info,
17+ Help,
18+}
19+
20+#[derive(Debug, Clone)]
21+pub struct Diagnostic {
22+ pub line: usize,
23+ pub column: Option<usize>,
24+ pub message: String,
25+ pub severity: Severity,
26+ pub source_line: Option<String>,
27+}
28+
16pub struct ParseResult {
29pub struct ParseResult {
17 pub document: Document,
30 pub document: Document,
18 pub diagnostics: Vec<Diagnostic>,
31 pub diagnostics: Vec<Diagnostic>,
19 pub included_files: Vec<PathBuf>,
32 pub included_files: Vec<PathBuf>,
20}
33}
21
34
22−/// An error that stops one line from being evaluated.
35+#[derive(Debug)]
23−type ParseError = Diagnostic;
36+struct ParseError {
24−
25−// ---------------------------------------------------------------------------
26−// Internal types
27−// ---------------------------------------------------------------------------
28−
29−#[derive(Clone)]
30−struct FnDef {
31− /// The parameters in the order they are declared; one without a
32− /// default is required.
33− params: Vec<syntax::Param>,
34− /// The definition's line and its text (when it is one physical line),
35− /// and its node, for diagnostics about a default.
36 line: usize,
37 line: usize,
37− source: Option<String>,
38+ message: String,
38− node: usize,
39− body: Vec<syntax::Node>,
40− /// The file it is defined in, when that isn't the file being compiled
41− /// (see `ParseContext::current_file`).
42− file: Option<String>,
43− /// The slots its body declares (`@slot NAME`), in order.
44− slots: Vec<String>,
45− /// Whether its body has a `@children` for the content of a call.
46− has_children: bool,
47− /// Its scoped `@style`: its class (`hl-fn-NAME-HASH`), and the
48− /// stylesheet nested under it, written into the page once the function
49− /// is called (see `Document::scoped_css`).
50− scoped: Option<(String, String)>,
51− /// What was visible where it was defined: its body and its defaults
52− /// see this, plus its parameters.
53− env: Env,
54− /// It is named like a built-in element and its body calls that name
55− /// outside any `@if`, meaning the element (`@let @nav` around `@nav`):
56− /// it would call itself forever, so a call writes nothing (the
57− /// definition's error says so).
58− wraps_itself: bool,
59}
39}
60
40
61−impl FnDef {
41+impl fmt::Display for ParseError {
62− fn is_param(&self, name: &str) -> bool {
42+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
63− self.params.iter().any(|p| p.name == name)
43+ write!(f, "line {}: {}", self.line, self.message)
64 }
44 }
65}
45}
66
46
67−/// What a name means: every `@let`, parameter, loop variable and `@data`
47+// ---------------------------------------------------------------------------
68−/// binds one, and they share one namespace.
48+// Internal types
69−#[derive(Clone)]
49+// ---------------------------------------------------------------------------
70−enum Binding {
71− Value(Value),
72− Bundle(Rc<Vec<Attribute>>),
73− Function(Rc<FnDef>),
74−}
75−
76−/// The names visible at a line: one frame per block it is in (the
77−/// standard library, the file, an element's children, an `@if` branch, an
78−/// iteration of `@each`, a function's body), the innermost last. A
79−/// definition goes into the innermost frame, so it is visible from its
80−/// line to the end of its block. A function keeps the frames visible
81−/// where it is defined; a frame is shared until one side changes it.
82−#[derive(Clone, Default)]
83−struct Env {
84− frames: Vec<Rc<HashMap<String, Binding>>>,
85−}
86−
87−impl Env {
88− fn push(&mut self) {
89− self.frames.push(Rc::default());
90− }
91−
92− fn pop(&mut self) {
93− self.frames.pop();
94− }
95−
96− /// Give `name` a meaning in the innermost frame, replacing what it
97− /// meant there and hiding what it means outside it.
98− fn define(&mut self, name: &str, binding: Binding) {
99− if self.frames.is_empty() {
100− self.push();
101− }
102− if let Some(frame) = self.frames.last_mut() {
103− Rc::make_mut(frame).insert(name.to_string(), binding);
104− }
105− }
106−
107− /// What `name` means here: the innermost definition.
108− fn get(&self, name: &str) -> Option<&Binding> {
109− self.frames.iter().rev().find_map(|frame| frame.get(name))
110− }
111−
112− fn value(&self, name: &str) -> Option<&Value> {
113− match self.get(name)? {
114− Binding::Value(value) => Some(value),
115− _ => None,
116− }
117− }
118−
119− fn bundle(&self, name: &str) -> Option<&Rc<Vec<Attribute>>> {
120− match self.get(name)? {
121− Binding::Bundle(attrs) => Some(attrs),
122− _ => None,
123− }
124− }
125−
126− fn function(&self, name: &str) -> Option<&Rc<FnDef>> {
127− match self.get(name)? {
128− Binding::Function(function) => Some(function),
129− _ => None,
130− }
131− }
132−
133− /// Every name visible here, with what it means.
134− fn visible(&self) -> HashMap<&str, &Binding> {
135− let mut names = HashMap::new();
136− for frame in &self.frames {
137− for (name, binding) in frame.iter() {
138− names.insert(name.as_str(), binding);
139− }
140− }
141− names
142− }
143−
144− /// The names of the values visible here.
145− fn value_names(&self) -> Vec<&str> {
146− self.visible()
147− .into_iter()
148− .filter(|(_, binding)| matches!(binding, Binding::Value(_)))
149− .map(|(name, _)| name)
150− .collect()
151− }
152−
153− /// The names of the bundles visible here.
154− fn bundle_names(&self) -> Vec<&str> {
155− self.visible()
156− .into_iter()
157− .filter(|(_, binding)| matches!(binding, Binding::Bundle(_)))
158− .map(|(name, _)| name)
159− .collect()
160− }
161−}
162
50
163−impl interp::Scope for Env {
51+#[derive(Clone)]
164− fn get(&self, name: &str) -> Option<Value> {
52+struct FnDef {
165− self.value(name).cloned()
53+ params: Vec<String>,
166− }
54+ defaults: HashMap<String, String>,
55+ body: Vec<Syntax>,
167}
56}
168
57
169struct ParseContext {
58struct ParseContext {
170 /// Source line currently being parsed, for diagnostics raised deep
59 /// Source line currently being parsed, for diagnostics raised deep
171 /// inside helpers that don't take a line number.
60 /// inside helpers that don't take a line number.
172 current_line: usize,
61 current_line: usize,
173− /// What the `@page` said, once one has run
62+ /// Functions whose body has a scoped @style block.
174− page: Option<Page>,
63+ scoped_functions: std::collections::HashSet<String>,
175− /// Where that `@page` is, for the error a second one gets
64+ page_title: Option<String>,
176− page_at: Option<String>,
65+ lang: Option<String>,
66+ favicon: Option<String>,
177 meta_tags: Vec<(String, String)>,
67 meta_tags: Vec<(String, String)>,
178 head_blocks: Vec<String>,
68 head_blocks: Vec<String>,
179− /// The first `@meta` or `@head` that ran, for the warning a file
69+ variables: HashMap<String, String>,
180− /// without `@page` gets: a fragment has no `<head>` for them.
70+ defines: HashMap<String, Vec<Attribute>>,
181− head_at: Option<usize>,
71+ functions: HashMap<String, FnDef>,
182− /// What each name means at the line being evaluated.
183− env: Env,
184− /// Every function whose definition ran, by name (the last one), for
185− /// the checks of code that never runs.
186− defined_functions: HashMap<String, Rc<FnDef>>,
187 css_vars: Vec<(String, String)>,
72 css_vars: Vec<(String, String)>,
188 custom_css: Vec<String>,
73 custom_css: Vec<String>,
189− /// The scoped `@style` of each function called so far
190− scoped_css: Vec<String>,
191 og_tags: Vec<(String, String)>,
74 og_tags: Vec<(String, String)>,
192 diagnostics: Vec<Diagnostic>,
75 diagnostics: Vec<Diagnostic>,
193 base_path: Option<PathBuf>,
76 base_path: Option<PathBuf>,
@@ -207,550 +90,39 @@ struct ParseContext {
207 fn_lines: HashMap<String, usize>,
90 fn_lines: HashMap<String, usize>,
208 /// Line numbers of attribute bundle definitions (name -> line)
91 /// Line numbers of attribute bundle definitions (name -> line)
209 define_lines: HashMap<String, usize>,
92 define_lines: HashMap<String, usize>,
210− /// Syntax nodes evaluated at least once, by id.
93+ /// Canonical URL
211− visited: HashSet<usize>,
94+ canonical: Option<String>,
212− /// The id the next parsed tree starts at, so ids are unique across
95+ /// Base URL for relative links
213− /// the file, its includes and the standard library.
96+ base_url: Option<String>,
214− next_id: usize,
215− /// The file's tree and those of the files it includes, with the
216− /// include chain that leads to each, for the checks of code that never
217− /// runs.
218− trees: Vec<(Rc<Tree>, Option<String>)>,
219− /// The files each `@include` line brought in (by the line's node id),
220− /// with the include chain that leads to each, so the checks of
221− /// `@slot` and `@children` see an included file's lines where the
222− /// `@include` is written.
223− included_by: Rc<IncludedBy>,
224− /// Every function defined anywhere in the file or the files it
225− /// includes, whether or not its definition runs.
226− namespace: HashSet<String>,
227− /// The line being evaluated, and its text as written when it is one
228− /// physical line, for diagnostics that point at a column.
229− current_source: (usize, Option<String>),
230− /// Variable diagnostics already reported, so a line evaluated many
231− /// times (in a loop, in a function) reports each problem once.
232− /// Keyed by the syntax node (ids are unique across files), line,
233− /// column and message.
234− reported: HashSet<(usize, usize, Option<usize>, String)>,
235− /// The id of the syntax node being evaluated.
236− current_node: usize,
237− /// The file whose lines are being evaluated, when it isn't the file
238− /// being compiled: the standard library, or an included file (by its
239− /// include chain).
240− current_file: Option<String>,
241− /// A call went deeper than [`MAX_CALL_DEPTH`]: the calls it is nested
242− /// in expand to nothing, until the outermost one returns.
243− too_deep: bool,
244− /// A bundle's attributes are being read: a name that could be a
245− /// parameter of the function the bundle is passed to is checked where
246− /// the bundle is used instead.
247− in_bundle: bool,
248− /// `@page`'s attributes are being read: a name close to `favicon` is
249− /// a misspelled word of `@page`'s.
250− in_page: bool,
251−}
252−
253−/// How deeply function calls may nest, so that a function that calls
254−/// itself without an `@if` that stops it is an error instead of a hang.
255−const MAX_CALL_DEPTH: usize = 64;
256−
257−/// A CSS rule at the top level of a stylesheet that starts with `&`
258−/// (outside any `{}`, strings and comments).
259−fn has_top_level_ampersand(css: &str) -> bool {
260− let mut depth = 0usize;
261− let mut chars = css.chars().peekable();
262− let mut at_rule_start = true;
263− while let Some(c) = chars.next() {
264− match c {
265− '/' if chars.peek() == Some(&'*') => {
266− chars.next();
267− let mut last = ' ';
268− for c in chars.by_ref() {
269− if last == '*' && c == '/' {
270− break;
271− }
272− last = c;
273− }
274− continue;
275− }
276− '"' | '\'' => {
277− for d in chars.by_ref() {
278− if d == c {
279− break;
280− }
281− }
282− }
283− '{' => depth += 1,
284− '}' => depth = depth.saturating_sub(1),
285− '&' if depth == 0 && at_rule_start => return true,
286− _ => {}
287− }
288− at_rule_start = match c {
289− '{' | '}' | ';' => true,
290− c if c.is_whitespace() => at_rule_start,
291− _ => false,
292− };
293− }
294− false
295−}
296−
297−/// Whether a file is a library (see [`Tree::is_library`]), with the files
298−/// its `@include` lines name read relative to `base`.
299−fn is_library_file(
300− tree: &Tree,
301− base: Option<&Path>,
302− ctx: &mut ParseContext,
303− seen: &mut Vec<PathBuf>,
304−) -> bool {
305− tree.is_library_with(&mut |path| {
306− if path.contains('$') {
307− return false;
308− }
309− let resolved = base.map_or_else(|| PathBuf::from(path), |base| base.join(path));
310− if seen.contains(&resolved) {
311− return false;
312− }
313− seen.push(resolved.clone());
314− let Ok(text) = ctx.read_file(&resolved) else {
315− return false;
316− };
317− let included = syntax::parse(&text);
318− is_library_file(&included, resolved.parent(), ctx, seen)
319− })
320}
97}
321
98
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.
99/// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes.
327struct Evaluator;
100struct Evaluator;
328
101
329impl ParseContext {
102impl ParseContext {
330− /// Bind a value to `name` in the innermost block.
103+ /// Evaluate an expression (see `expr.rs`), reporting errors at `line`.
331− fn bind(&mut self, name: &str, value: Value) {
104+ 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);
105 track_var_refs(src, &mut self.used_variables);
344− match crate::expr::eval(src, &self.env) {
106+ let vars = &self.variables;
107+ let result = crate::expr::eval(src, &|name: &str| lookup(vars, name));
108+ match result {
345 Ok(value) => Some(value),
109 Ok(value) => Some(value),
346− Err(error) => {
110+ Err(message) => {
347− // Without the line to point into, show the expression
111+ self.diagnostics.push(Diagnostic {
348− let shown = matches!(&self.current_source, (current, Some(_)) if *current == line);
112+ line,
349− let before = self.diagnostics.len();
113+ column: None,
350− self.report(error.at(0), line, column);
114+ message: format!("invalid expression: {}", message),
351− if !shown && let Some(diagnostic) = self.diagnostics.get_mut(before) {
115+ severity: Severity::Error,
352− diagnostic.source_line = Some(src.into());
116+ source_line: Some(src.to_string()),
353− }
117+ });
354 None
118 None
355 }
119 }
356 }
120 }
357 }
121 }
358
122
359 /// Evaluate a condition; an invalid one is reported and counts as false.
123 /// 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 {
124+ fn condition(&mut self, src: &str, line: usize) -> bool {
361− self.eval(src, line, column)
125+ 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 }
126 }
755}
127}
756
128
@@ -759,19 +131,11 @@ impl ParseContext {
759const PRELUDE: &str = include_str!("std.hl");
131const PRELUDE: &str = include_str!("std.hl");
760
132
761fn load_prelude(ctx: &mut ParseContext) {
133fn load_prelude(ctx: &mut ParseContext) {
762− // The library's definitions are in a block of their own, around the
134+ let _ = Evaluator.eval_block(&syntax::parse(PRELUDE), ctx);
763− // file's, so the file's own `@let` of the same name hides them
764− ctx.env.push();
765− let tree = ctx.parse_tree(PRELUDE);
766− collect_namespace(&tree.nodes, None, &mut HashSet::new(), ctx);
767− ctx.current_file = Some("std.hl".to_string());
768− let _ = Evaluator.eval_block(&tree.nodes, ctx);
769− ctx.current_file = None;
770 // Library definitions aren't the file's own: never report them unused.
135 // Library definitions aren't the file's own: never report them unused.
771 ctx.fn_lines.clear();
136 ctx.fn_lines.clear();
772 ctx.define_lines.clear();
137 ctx.define_lines.clear();
773 ctx.let_lines.clear();
138 ctx.let_lines.clear();
774− ctx.env.push();
775}
139}
776
140
777pub fn parse(input: &str) -> ParseResult {
141pub fn parse(input: &str) -> ParseResult {
@@ -779,18 +143,20 @@ pub fn parse(input: &str) -> ParseResult {
779}
143}
780
144
781pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
145pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
146+ let tree = syntax::parse(input);
782 let mut ctx = ParseContext {
147 let mut ctx = ParseContext {
783 current_line: 0,
148 current_line: 0,
784− page: None,
149+ scoped_functions: std::collections::HashSet::new(),
785− page_at: None,
150+ page_title: None,
151+ lang: None,
152+ favicon: None,
786 meta_tags: Vec::new(),
153 meta_tags: Vec::new(),
787 head_blocks: Vec::new(),
154 head_blocks: Vec::new(),
788− head_at: None,
155+ variables: HashMap::new(),
789− env: Env::default(),
156+ defines: HashMap::new(),
790− defined_functions: HashMap::new(),
157+ functions: HashMap::new(),
791 css_vars: Vec::new(),
158 css_vars: Vec::new(),
792 custom_css: Vec::new(),
159 custom_css: Vec::new(),
793− scoped_css: Vec::new(),
794 og_tags: Vec::new(),
160 og_tags: Vec::new(),
795 diagnostics: Vec::new(),
161 diagnostics: Vec::new(),
796 base_path: base_path.map(|p| p.to_path_buf()),
162 base_path: base_path.map(|p| p.to_path_buf()),
@@ -804,85 +170,28 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
804 let_lines: HashMap::new(),
170 let_lines: HashMap::new(),
805 fn_lines: HashMap::new(),
171 fn_lines: HashMap::new(),
806 define_lines: HashMap::new(),
172 define_lines: HashMap::new(),
807− visited: HashSet::new(),
173+ canonical: None,
808− next_id: 0,
174+ base_url: None,
809− trees: Vec::new(),
810− included_by: Rc::default(),
811− namespace: HashSet::new(),
812− current_source: (0, None),
813− reported: HashSet::new(),
814− current_node: 0,
815− current_file: None,
816− too_deep: false,
817− in_bundle: false,
818− in_page: false,
819 };
175 };
820 load_prelude(&mut ctx);
176 load_prelude(&mut ctx);
821− let tree = ctx.parse_tree(input);
177+ let nodes = Evaluator.eval_block(&tree, &mut ctx);
822− ctx.diagnostics.extend(tree.diagnostics.iter().cloned());
178+ validate_tree(&nodes, None, &mut ctx.diagnostics);
823− ctx.trees.push((tree.clone(), None));
179+ 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 {
180 ParseResult {
876 document: Document {
181 document: Document {
877− page: ctx.page,
182+ page_title: ctx.page_title,
183+ lang: ctx.lang,
184+ favicon: ctx.favicon,
878 meta_tags: ctx.meta_tags,
185 meta_tags: ctx.meta_tags,
879 head_blocks: ctx.head_blocks,
186 head_blocks: ctx.head_blocks,
880− variables,
187+ variables: ctx.variables,
881− defines,
188+ defines: ctx.defines,
882 css_vars: ctx.css_vars,
189 css_vars: ctx.css_vars,
883 custom_css: ctx.custom_css,
190 custom_css: ctx.custom_css,
884− scoped_css: ctx.scoped_css,
885 og_tags: ctx.og_tags,
191 og_tags: ctx.og_tags,
192+ canonical: ctx.canonical,
193+ base_url: ctx.base_url,
194+ preload_hints: collect_image_preload_hints(&nodes),
886 nodes,
195 nodes,
887 },
196 },
888 diagnostics: ctx.diagnostics,
197 diagnostics: ctx.diagnostics,
@@ -890,882 +199,619 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
890 }
199 }
891}
200}
892
201
893−// ---------------------------------------------------------------------------
202+/// 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
203+fn collect_image_preload_hints(nodes: &[Node]) -> Vec<crate::ast::PreloadHint> {
895−// an empty list
204+ let mut hints = Vec::new();
896−// ---------------------------------------------------------------------------
205+ // Only preload the first few images (above-the-fold heuristic)
206+ collect_images_recursive(nodes, &mut hints, 3);
207+ hints
208+}
897
209
898−/// Collect the functions defined anywhere in `nodes` and in the files they
210+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 {
211 for node in nodes {
908− node.walk(&mut |node| match node.directive().map(|d| &d.args) {
212+ if hints.len() >= max {
909− Some(DirectiveArgs::Let(def)) if matches!(def.form, LetForm::Function(_)) => {
213+ 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 }
214 }
923− let resolved = match &base {
215+ if let Node::Element(elem) = node {
924− Some(base) => base.join(path),
216+ if elem.kind == ElementKind::Image
925− None => PathBuf::from(path),
217+ && let Some(ref src) = elem.argument
926− };
218+ && !src.is_empty()
927− if !seen.insert(resolved.clone()) {
219+ && !src.starts_with("data:")
928− continue;
220+ && !src.starts_with('#')
221+ {
222+ hints.push(crate::ast::PreloadHint {
223+ href: src.clone(),
224+ as_type: "image".to_string(),
225+ crossorigin: false,
226+ });
227+ }
228+ collect_images_recursive(&elem.children, hints, max);
929 }
229 }
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 }
230 }
937}
231}
938
232
939−/// Check the lines that were never evaluated: element and function names,
233+// ---------------------------------------------------------------------------
940−/// and the attributes of built-in elements (those that don't depend on a
234+// Preprocessing: strip comments/blanks, collapse @raw blocks
941−/// variable). Names that come from data are only checked when evaluated.
235+// ---------------------------------------------------------------------------
942−fn check_unevaluated(ctx: &mut ParseContext) {
236+
943− let trees = std::mem::take(&mut ctx.trees);
237+// ---------------------------------------------------------------------------
944− let mut seen = HashSet::new();
238+// Evaluation
945− for (tree, chain) in &trees {
239+// ---------------------------------------------------------------------------
946− let before = ctx.diagnostics.len();
240+
947− for node in &tree.nodes {
241+impl Evaluator {
948− check_unevaluated_node(node, ctx);
242+ fn eval_block(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec<Node> {
949− }
243+ let mut nodes = Vec::new();
950− let mut found = ctx.diagnostics.split_off(before);
244+ for node in block {
951− if let Some(chain) = chain {
245+ match self.eval(node, ctx) {
952− for d in &mut found {
246+ Ok(Some(new_nodes)) => nodes.extend(new_nodes),
953− d.message = format!("{}\n in {}", d.message, chain);
247+ Ok(None) => {}
248+ // Record the error and go on, to report several in one pass
249+ Err(e) => ctx.diagnostics.push(Diagnostic {
250+ line: e.line,
251+ column: None,
252+ message: e.message,
253+ severity: Severity::Error,
254+ source_line: Some(node.source()),
255+ }),
954 }
256 }
955 }
257 }
956− // A file included twice is checked twice: report each problem once
258+ nodes
957− found.retain(|d| seen.insert((d.line, d.message.clone())));
958− ctx.diagnostics.extend(found);
959 }
259 }
960− ctx.trees = trees;
961−}
962
260
963−fn check_unevaluated_node(node: &syntax::Node, ctx: &mut ParseContext) {
261+ /// Evaluate a block in its own scope: `@let` inside doesn't leak out.
964− if !ctx.visited.contains(&node.id) {
262+ fn eval_scoped(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec<Node> {
965− let line = node.span.line;
263+ let saved_vars = ctx.variables.clone();
966− // A reference from code that doesn't run still counts as a use
264+ let nodes = self.eval_block(block, ctx);
967− track_var_refs(&node.source, &mut ctx.used_variables);
265+ ctx.variables = saved_vars;
968− let before = ctx.diagnostics.len();
266+ nodes
969− match &node.kind {
267+ }
970− NodeKind::Element(element) => {
268+
971− for head in &element.chain {
269+ fn eval(&mut self, node: &Syntax, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
972− check_head(head, line, ctx);
270+ let line_num = node.line();
973− }
271+ ctx.current_line = line_num;
974− check_unevaluated_content(node, element, ctx);
272+ if !matches!(node, Syntax::Raw { .. })
975− if let Some(text) = &element.text {
273+ && let Some(filter) = old_filter_syntax(&node.source())
976− check_inline_heads(text, line, ctx);
274+ {
275+ ctx.diagnostics.push(Diagnostic {
276+ line: line_num,
277+ column: None,
278+ message: format!(
279+ "`${}|{}` filters are functions now: write `${{{}(${})}}` (run `htmlang upgrade`)",
280+ filter.0, filter.1, filter.1, filter.0
281+ ),
282+ severity: Severity::Warning,
283+ source_line: Some(node.source()),
284+ });
285+ }
286+ let (content, current_indent, children) = match node {
287+ Syntax::Raw { text, .. } => return Ok(Some(vec![Node::Raw(text.clone())])),
288+ Syntax::Function { name, params, defaults, body, .. } => {
289+ self.define_function(name, params, defaults, body, line_num, ctx);
290+ return Ok(None);
291+ }
292+ Syntax::If { branches } => {
293+ // Every condition is checked (and its errors reported);
294+ // the first that holds picks the branch.
295+ let mut chosen = None;
296+ for branch in branches {
297+ let holds = match &branch.condition {
298+ Some(condition) => ctx.condition(condition, branch.line),
299+ None => true,
300+ };
301+ if holds && chosen.is_none() {
302+ chosen = Some(&branch.body);
303+ }
977 }
304 }
305+ return Ok(chosen.map(|body| self.eval_scoped(body, ctx)));
978 }
306 }
979− NodeKind::Text(text) => check_inline_heads(text, line, ctx),
307+ Syntax::Each { header, body, empty, .. } => {
980− // `@page`'s attributes are checked like an element's (a layout's
308+ return self.eval_each(header, body, empty, line_num, ctx).map(Some);
981− // `@page` is in a function that may not run in this file)
982− NodeKind::Directive(syntax::Directive {
983− args:
984− DirectiveArgs::Page {
985− attrs: Some(list), ..
986− },
987− ..
988− }) => {
989− let words: Vec<String> = crate::vocab::PAGE_WORDS
990− .iter()
991− .map(|w| w.to_string())
992− .collect();
993− ctx.in_page = true;
994− check_attrs(&list.attrs, line, ctx, &words);
995− ctx.in_page = false;
996− check_page_tokens(&list.attrs, line, ctx);
997 }
309 }
998− _ => {}
310+ Syntax::Line { text, indent, children, .. } => (text.clone(), *indent, children),
311+ };
312+
313+ // --- Directives ---
314+
315+ // Removed syntax gets a pointer to its replacement — unless the
316+ // name is a user function (e.g. `@let divider`).
317+ if content.starts_with('@')
318+ && !ctx.functions.contains_key(extract_element_name(&content))
319+ && let Some(hint) = removed_syntax_hint(&content)
320+ {
321+ return Err(ParseError {
322+ line: line_num,
323+ message: hint,
324+ });
999 }
325 }
1000− let text = written_line(node);
326+
1001− for d in &mut ctx.diagnostics[before..] {
327+ // @page [lang en, favicon /f.png, canonical URL, base URL] Title
1002− d.source_line.get_or_insert_with(|| text.as_str().into());
328+ if let Some(rest) = content.strip_prefix("@page ") {
329+ let rest = rest.trim_start();
330+ let title = if rest.starts_with('[') {
331+ let (attrs, title) = parse_attr_brackets_no_validate(rest, line_num, ctx)?;
332+ for attr in attrs {
333+ let value = attr.value.unwrap_or_default();
334+ match attr.key.as_str() {
335+ "lang" => ctx.lang = Some(value),
336+ "favicon" => ctx.favicon = Some(value),
337+ "canonical" => ctx.canonical = Some(value),
338+ "base" => ctx.base_url = Some(value),
339+ other => ctx.diagnostics.push(Diagnostic {
340+ line: line_num,
341+ column: None,
342+ message: format!(
343+ "unknown @page attribute '{}' (expected lang, favicon, canonical or base)",
344+ other
345+ ),
346+ severity: Severity::Warning,
347+ source_line: Some(content.clone()),
348+ }),
349+ }
350+ }
351+ title
352+ } else {
353+ rest.to_string()
354+ };
355+ ctx.page_title = Some(substitute_vars(title.trim(), &ctx.variables));
356+ return Ok(None);
1003 }
357 }
1004− }
1005− for child in &node.children {
1006− check_unevaluated_node(child, ctx);
1007− }
1008−}
1009
358
1010−/// Check the content passed to the calls of a line that was never
1011−/// evaluated: its `@slot NAME` blocks, and, for a function without
1012−/// `@children`, any other content.
1013−fn check_unevaluated_content(
1014− node: &syntax::Node,
1015− element: &syntax::ElementLine,
1016− ctx: &mut ParseContext,
1017−) {
1018− let calls: Vec<Option<(String, Rc<FnDef>)>> = element
1019− .chain
1020− .iter()
1021− .map(|head| {
1022− let function = ctx.defined_functions.get(&head.name)?;
1023− Some((head.name.clone(), function.clone()))
1024− })
1025− .collect();
1026− check_fillers(node, element, &calls, ctx);
1027− let last = calls.len() - 1;
1028− for (i, call) in calls.iter().enumerate() {
1029− let Some((name, function)) = call else {
1030− continue;
1031− };
1032− if function.has_children || function.wraps_itself {
1033− continue;
1034− }
1035− let content = if i == last {
1036− element.text.is_some() || holds_content(&node.children, ctx)
1037− } else {
1038− element.chain[i + 1].name != "slot" || !slot_is_built_in(ctx)
1039− };
1040− if content {
1041− let head = &element.chain[i];
1042− ctx.push_once(no_children(
1043− name,
1044− node.span.line,
1045− name_column(head, node.span.line),
1046− ));
1047− }
1048− }
1049−}
1050
359
1051−/// Check a head that was never evaluated: its name, and its attributes
360+ if let Some(rest) = content.strip_prefix("@let ") {
1052−/// (for a call, see [`check_call`]).
361+ let rest = rest.trim();
1053−fn check_head(head: &syntax::Head, line: usize, ctx: &mut ParseContext) {
362+
1054− let is_function =
363+ if !children.is_empty() {
1055− ctx.namespace.contains(&head.name) || ctx.defined_functions.contains_key(&head.name);
364+ return Err(ParseError {
1056− if is_function {
365+ line: line_num,
1057− ctx.used_functions.insert(head.name.clone());
366+ message: "@let with body requires a name".to_string(),
1058− if let Some(function) = ctx.defined_functions.get(&head.name).cloned()
367+ });
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− }
1089 }
368 }
1090− }
1091− }
1092−}
1093
369
1094−/// The attributes of a list that is never evaluated that can be checked
370+ if let Some((name, value)) = rest.split_once(' ') {
1095−/// together: not bundles or `if()`s. Names are checked; a value with a
371+ let value = value.trim();
1096−/// variable in it depends on what the variable holds, so only a literal
372+ // `@let name = EXPR` computes its value (see expr.rs); any
1097−/// value is (the other is checked as an empty value).
373+ // other value is literal text with `$var` interpolation.
1098−/// `@page`'s own rules (see `check_page_attr`) for a list that isn't
374+ if let Some(expression) = value.strip_prefix('=') {
1099−/// evaluated, as written: a value made from a variable counts as given.
375+ let value = ctx
1100−fn check_page_tokens(tokens: &[syntax::Attr], line: usize, ctx: &mut ParseContext) {
376+ .eval(expression.trim(), line_num)
1101− for token in tokens {
377+ .map(|v| v.to_string())
1102− match &token.choice {
378+ .unwrap_or_default();
1103− Some(choice) => {
379+ if name.starts_with("--") {
1104− for branch in &choice.branches {
380+ ctx.css_vars.push((name.to_string(), value.clone()));
1105− check_page_tokens(branch.attrs(), line, ctx);
381+ }
382+ ctx.variables.insert(name.to_string(), value);
383+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
384+ return Ok(None);
385+ }
386+
387+ // Attribute bundle: @let name [attr1, attr2, ...]
388+ if value.starts_with('[') {
389+ let (attrs, _) = parse_attr_brackets(value, line_num, ctx)?;
390+ ctx.defines.insert(name.to_string(), attrs);
391+ ctx.define_lines.entry(name.to_string()).or_insert(line_num);
392+ return Ok(None);
1106 }
393 }
1107− }
394+
1108− // Under prefixes there are only styles
395+ // Triple quotes: @let name """...""" (syntax.rs joins the
1109− None if token.key.starts_with('$') || token.prefixed.is_some() => {}
396+ // lines of a multi-line string)
1110− None => {
397+ let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
1111− let value = token.value.as_deref().map(|v| v.trim_matches('"'));
398+ after_open.strip_suffix("\"\"\"").unwrap_or(after_open)
1112− let value = match value {
399+ } else if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
1113− Some(v) if v.contains('$') => Some("$"),
400+ // Support quoted string interpolation: @let greeting "Hello $name"
1114− other => other,
401+ &value[1..value.len() - 1]
402+ } else {
403+ value
1115 };
404 };
1116− check_page_attr(
405+ track_var_refs(value, &mut ctx.used_variables);
1117− &token.key,
406+ let value = substitute_vars(value, &ctx.variables);
1118− token.html,
407+ if name.starts_with("--") {
1119− value,
408+ // CSS custom property
1120− line,
409+ ctx.css_vars.push((name.to_string(), value.clone()));
1121− Some(token.span.column),
410+ }
1122− ctx,
411+ ctx.variables.insert(name.to_string(), value);
1123− );
412+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
1124 }
413 }
414+ return Ok(None);
1125 }
415 }
1126− }
1127−}
1128
416
1129−fn literal_attrs(tokens: &[syntax::Attr]) -> Vec<syntax::Attr> {
417+ // @meta NAME VALUE; `og:` names become Open Graph property tags
1130− // A prefixed group is checked with its prefixes, as far as its own
418+ if let Some(rest) = content.strip_prefix("@meta ") {
1131− // attributes can be; a prefixed bundle or `if()` depends on what it holds
419+ let rest = rest.trim();
1132− let group = |a: &syntax::Attr| {
420+ if let Some((name, value)) = rest.split_once(' ') {
1133− let prefixed = a.prefixed.as_ref()?;
421+ let value = substitute_vars(value.trim(), &ctx.variables);
1134− let syntax::Branch::Group { list, trailing } = &prefixed.target else {
422+ match name.trim().strip_prefix("og:") {
1135− return None;
423+ Some(property) => ctx.og_tags.push((property.to_string(), value)),
1136− };
424+ None => ctx.meta_tags.push((name.trim().to_string(), value)),
1137− let mut list = list.clone();
425+ }
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 }
426 }
1163− a
427+ return Ok(None);
1164− })
1165− .chain(groups)
1166− .collect()
1167−}
1168−
1169−/// Check a call that was never evaluated: how it passes its parameters
1170−/// (none written `name=value`, and every one without a default passed,
1171−/// when no bundle or `if()` could pass it), and its other attributes, like
1172−/// those of an element.
1173−fn check_call(head: &syntax::Head, function: &FnDef, line: usize, ctx: &mut ParseContext) {
1174− let name = head.name.as_str();
1175− let attrs = head.attrs.as_ref().map_or(&[][..], |list| &list.attrs[..]);
1176− written_parameter_forms(name, function, attrs, line, ctx);
1177− if let Some(list) = &head.attrs {
1178− let params: Vec<String> = function.params.iter().map(|p| p.name.clone()).collect();
1179− check_attrs(&list.attrs, line, ctx, &params);
1180− }
1181− let unknown = attrs
1182− .iter()
1183− .any(|a| a.choice.is_some() || (a.key.starts_with('$') && a.value.is_none()));
1184− if unknown {
1185− return;
1186− }
1187− for param in function.params.iter().filter(|p| p.default.is_none()) {
1188− if !attrs.iter().any(|a| a.key == param.name) {
1189− let column = name_column(head, line);
1190− ctx.push_once(missing_parameter(name, &param.name, line, column));
1191 }
428 }
1192− }
1193−}
1194
429
1195−/// Report each parameter a call's list writes `name=value`, with the value
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− }
1223− reported
1224−}
1225
430
1226−fn check_inline_heads(text: &syntax::Text, line: usize, ctx: &mut ParseContext) {
431+ if content == "@head" || content.starts_with("@head ") {
1227− for segment in &text.segments {
432+ let trimmed = block_text(children).trim().to_string();
1228− if let Segment::Inline(inline) = segment {
433+ if !trimmed.is_empty() {
1229− check_head(&inline.head, line, ctx);
434+ ctx.head_blocks.push(trimmed);
1230− if let Some(text) = &inline.text {
1231− check_inline_heads(text, line, ctx);
1232 }
435 }
436+ return Ok(None);
1233 }
437 }
1234− }
1235−}
1236−
1237−// ---------------------------------------------------------------------------
1238−// Evaluation
1239−// ---------------------------------------------------------------------------
1240
438
1241−/// The `@else` lines that continue the `@if` or `@each` before `from`, and
439+ // --- @style block (raw CSS) ---
1242−/// the position after them.
440+ if content.trim() == "@style" {
1243−fn else_branches(block: &[syntax::Node], from: usize, is_if: bool) -> (Vec<&syntax::Node>, usize) {
441+ let trimmed = block_text(children).trim().to_string();
1244− let mut branches = Vec::new();
442+ if !trimmed.is_empty() {
1245− let mut next = from;
443+ ctx.custom_css.push(trimmed);
1246− let mut i = from;
444+ }
1247− while i < block.len() {
445+ return Ok(None);
1248− let node = &block[i];
1249− i += 1;
1250− if node.is_trivia() {
1251− continue;
1252− }
1253− let condition = match node.directive().map(|d| &d.args) {
1254− Some(DirectiveArgs::Else { condition }) => condition.is_some(),
1255− _ => break,
1256− };
1257− if condition && !is_if {
1258− break;
1259− }
1260− branches.push(node);
1261− next = i;
1262− if !condition {
1263− break;
1264 }
446 }
1265− }
1266− (branches, next)
1267−}
1268
447
1269−impl Evaluator {
448+
1270− fn eval_block(&mut self, block: &[syntax::Node], ctx: &mut ParseContext) -> Vec<Node> {
449+ // --- @markdown block or file (convert markdown to HTML) ---
1271− let mut nodes = Vec::new();
450+ if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
1272− let mut i = 0;
451+ let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
1273− while i < block.len() {
452+ if arg.is_empty() {
1274− let node = &block[i];
453+ // Inline markdown block: indented children
1275− i += 1;
454+ let md_lines: Vec<String> = block_text(children).lines().map(String::from).collect();
1276− if node.is_trivia() {
455+ let html = markdown_to_html(&md_lines);
1277− continue;
456+ return Ok(Some(vec![Node::Raw(html)]));
1278− }
457+ } else {
1279− let result = match node.directive().map(|d| d.name()) {
458+ // External markdown file: @markdown file.md
1280− Some(name @ ("if" | "each")) => {
459+ let filename = substitute_vars(arg, &ctx.variables);
1281− let (branches, next) = else_branches(block, i, name == "if");
460+ let resolved = match &ctx.base_path {
1282− i = next;
461+ Some(base) => base.join(&filename),
1283− if name == "if" {
462+ None => PathBuf::from(&filename),
1284− Ok(Some(self.eval_if(node, &branches, ctx)))
463+ };
1285− } else {
464+ let md_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1286− self.eval_each(node, branches.first().copied(), ctx)
465+ cached.clone()
466+ } else {
467+ match std::fs::read_to_string(&resolved) {
468+ Ok(text) => {
469+ ctx.file_cache.insert(resolved.clone(), text.clone());
470+ text
471+ }
472+ Err(e) => {
473+ ctx.diagnostics.push(Diagnostic {
474+ line: line_num,
475+ column: None,
476+ message: format!("cannot read markdown '{}': {}", filename, e),
477+ severity: Severity::Error,
478+ source_line: Some(content.clone()),
479+ });
480+ return Ok(None);
481+ }
1287 }
482 }
1288− }
483+ };
1289− // Reported as a syntax error
484+ ctx.included_files.push(resolved);
1290− Some("else") => {
485+ let md_lines: Vec<String> = md_text.lines().map(|l| l.to_string()).collect();
1291− ctx.visited.insert(node.id);
486+ let html = markdown_to_html(&md_lines);
1292− Ok(None)
487+ return Ok(Some(vec![Node::Raw(html)]));
1293− }
1294− _ => self.eval(node, ctx),
1295− };
1296− match result {
1297− Ok(Some(new_nodes)) => nodes.extend(new_nodes),
1298− Ok(None) => {}
1299− // Record the error and go on, to report several in one pass
1300− Err(mut e) => {
1301− e.source_line
1302− .get_or_insert_with(|| node.source.as_str().into());
1303− ctx.diagnostics.push(e);
1304− }
1305 }
488 }
1306 }
489 }
1307− nodes
1308− }
1309
490
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
491
1319− /// `@if` with its `@else if` / `@else` branches. Every condition is
1320− /// checked (and its errors reported); the first that holds picks the
1321− /// branch.
1322− fn eval_if(
1323− &mut self,
1324− node: &syntax::Node,
1325− branches: &[&syntax::Node],
1326− ctx: &mut ParseContext,
1327− ) -> Vec<Node> {
1328− let mut chosen = None;
1329− for branch in std::iter::once(node).chain(branches.iter().copied()) {
1330− ctx.visited.insert(branch.id);
1331− ctx.enter(branch);
1332− let holds = match branch.directive().map(|d| &d.args) {
1333− Some(DirectiveArgs::Condition(condition))
1334− | Some(DirectiveArgs::Else {
1335− condition: Some(condition),
1336− }) => {
1337− !condition.raw.is_empty()
1338− && ctx.condition(
1339− &condition.raw,
1340− branch.span.line,
1341− Some(condition.span.column),
1342− )
1343− }
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
492
1355− fn eval(
493+ if let Some(rest) = content.strip_prefix("@include ") {
1356− &mut self,
494+ let rest = rest.trim();
1357− node: &syntax::Node,
495+ if let Some((file, alias)) = rest.rsplit_once(" as ") {
1358− ctx: &mut ParseContext,
496+ return Err(ParseError {
1359− ) -> Result<Option<Vec<Node>>, ParseError> {
497+ line: line_num,
1360− ctx.visited.insert(node.id);
498+ message: format!(
1361− ctx.enter(node);
499+ "`@include ... as` was removed: write `@include {}` and drop the `{}.` \
1362− match &node.kind {
500+ prefix (run `htmlang upgrade`)",
1363− NodeKind::Blank | NodeKind::Comment => Ok(None),
501+ file.trim(),
1364− // `@code` and `@textarea` show their body as text; `@script`'s
502+ alias.trim()
1365− // goes into the page as it is
503+ ),
1366− NodeKind::Verbatim(body) if body.escaped => {
504+ });
1367− let text = body.text.trim_matches('\n').to_string();
1368− Ok(Some(vec![Node::Text(vec![TextSegment::Plain(text)])]))
1369− }
1370− NodeKind::Verbatim(body) => Ok(Some(vec![Node::Raw(body.text.clone())])),
1371− NodeKind::Directive(directive) => {
1372− let mut nodes = self.eval_directive(node, directive, ctx)?;
1373− // A directive that takes no body: the lines indented under
1374− // it (a syntax error) are evaluated as its siblings. A
1375− // @let's body belongs to it even under a value (also an
1376− // error), so it is never evaluated in place.
1377− let takes_body = directive.spec.body != BodyKind::None;
1378− if !takes_body {
1379− let siblings = self.eval_block(&node.children, ctx);
1380− nodes.get_or_insert_with(Vec::new).extend(siblings);
1381− }
1382− Ok(nodes)
1383− }
1384− NodeKind::Element(element) => self.eval_element(node, element, ctx).map(Some),
1385− NodeKind::Text(text) => {
1386− let mut nodes = vec![Node::Text(text_segments(text, ctx))];
1387− // Text takes no body: lines indented under it are its
1388− // siblings
1389− nodes.extend(self.eval_block(&node.children, ctx));
1390− Ok(Some(nodes))
1391 }
505 }
1392− }
506+ let filename = rest
1393− }
507+ .strip_prefix('"')
508+ .and_then(|f| f.strip_suffix('"'))
509+ .unwrap_or(rest);
510+ let filename = substitute_vars(filename, &ctx.variables);
1394
511
1395− fn eval_directive(
512+ // Glob support: if filename contains *, expand to multiple imports
1396− &mut self,
513+ if filename.contains('*') {
1397− node: &syntax::Node,
514+ let base_dir = match &ctx.base_path {
1398− directive: &syntax::Directive,
515+ Some(base) => base.clone(),
1399− ctx: &mut ParseContext,
516+ None => PathBuf::from("."),
1400− ) -> Result<Option<Vec<Node>>, ParseError> {
1401− let line_num = node.span.line;
1402− let content = &node.source;
1403− match (directive.name(), &directive.args) {
1404− // @page [lang=en, favicon /f.png, background #f8fafc] Title
1405− ("page", DirectiveArgs::Page { attrs, title }) => {
1406− let page = read_page(attrs.as_ref(), title.as_ref(), line_num, ctx);
1407− // A page has one root: a second `@page` (a layout called
1408− // twice, or one called after the page's own) is an error
1409− let here = match &ctx.current_file {
1410− Some(file) => format!("line {} of {}", line_num, file),
1411− None => format!("line {}", line_num),
1412 };
517 };
1413− if let Some(first) = &ctx.page_at {
518+ let pattern_path = Path::new(&filename);
1414− let again = if *first == here {
519+ let (glob_dir, glob_pattern) = match pattern_path.parent() {
1415− "this @page runs a second time (in a loop, or in a function called \
520+ Some(dir) if !dir.as_os_str().is_empty() => (
1416− twice)"
521+ base_dir.join(dir),
1417− .to_string()
522+ pattern_path
1418− } else {
523+ .file_name()
1419− format!("a second @page (this page already has one, on {})", first)
524+ .unwrap_or_default()
1420− };
525+ .to_string_lossy()
1421− let diagnostic = Diagnostic::error(
526+ .to_string(),
1422− code::DUPLICATE_PAGE,
527+ ),
1423− line_num,
528+ _ => (base_dir.clone(), filename.clone()),
1424− format!(
529+ };
1425− "{}: a page has one @page, the root element that styles its <body>",
530+ let matched_files = match std::fs::read_dir(&glob_dir) {
1426− again
531+ Ok(entries) => {
1427− ),
532+ let mut files: Vec<PathBuf> = entries
1428− )
533+ .flatten()
1429− .source(content.clone())
534+ .filter(|e| {
1430− .subject("@page");
535+ let name = e.file_name().to_string_lossy().to_string();
1431− ctx.diagnostics.push(diagnostic);
536+ glob_match(&glob_pattern, &name)
1432− } else {
537+ })
1433− ctx.page_at = Some(here);
538+ .map(|e| e.path())
1434− ctx.page = Some(page);
539+ .collect();
1435− }
540+ files.sort();
1436− Ok(None)
541+ files
1437− }
1438−
1439− ("let", DirectiveArgs::Let(def)) => {
1440− let name = def.name.as_str();
1441− // Values, bundles and functions share one namespace: a
1442− // definition replaces whatever the name meant in its block
1443− match &def.form {
1444− LetForm::Function(function) => {
1445− self.define_function(name, &function.params, node, ctx);
1446− }
1447− // `@let name = EXPR` computes its value (see expr.rs)
1448− LetForm::Computed(expression) => {
1449− let value = ctx
1450− .eval(&expression.raw, line_num, Some(expression.span.column))
1451− .unwrap_or_else(Value::empty);
1452− set_variable(name, value, line_num, ctx);
1453− }
1454− // Attribute bundle: @let name [attr1, attr2, ...]
1455− LetForm::Bundle(list) => {
1456− ctx.in_bundle = true;
1457− let attrs = parse_attr_list(&list.attrs, line_num, ctx, true, &[]);
1458− ctx.in_bundle = false;
1459− ctx.env.define(name, Binding::Bundle(Rc::new(attrs)));
1460− ctx.define_lines.entry(name.to_string()).or_insert(line_num);
1461− }
1462− // `@let size 16px`, `@let fruits apple, banana`,
1463− // `@let post $posts.0`: see `slot_value`
1464− LetForm::Value(value) => {
1465− let (value, _) =
1466− ctx.slot_value(&value.raw, value.span.line, Some(value.span.column));
1467− set_variable(name, value, line_num, ctx);
1468 }
542 }
1469− // Quoted text: `@let arrow "→ "`
543+ Err(e) => {
1470− LetForm::Quoted(value) => {
544+ ctx.diagnostics.push(Diagnostic {
1471− let raw = format!("\"{}\"", value.raw);
545+ line: line_num,
1472− let column = value.span.column.saturating_sub(1);
546+ column: None,
1473− let (text, quoted, _) =
547+ message: format!(
1474− ctx.fill_value(&raw, value.span.line, Some(column), Sink::Text);
548+ "cannot read directory for glob '{}': {}",
1475− let value = quoted.map_or(Value::Str(text), Value::Quoted);
549+ filename, e
1476− set_variable(name, value, line_num, ctx);
550+ ),
551+ severity: Severity::Error,
552+ source_line: Some(content.clone()),
553+ });
554+ return Ok(None);
1477 }
555 }
556+ };
557+ if matched_files.is_empty() {
558+ ctx.diagnostics.push(Diagnostic {
559+ line: line_num,
560+ column: None,
561+ message: format!("no files matched glob pattern '{}'", filename),
562+ severity: Severity::Warning,
563+ source_line: Some(content.clone()),
564+ });
565+ return Ok(None);
1478 }
566 }
1479− Ok(None)
567+ let mut matched_nodes = Vec::new();
568+ for file_path in matched_files {
569+ let rel_name = file_path
570+ .strip_prefix(&base_dir)
571+ .unwrap_or(&file_path)
572+ .to_string_lossy()
573+ .to_string();
574+ let import_line = format!("@include \"{}\"", rel_name);
575+ matched_nodes.extend(self.eval_block(&syntax::parse(&import_line), ctx));
576+ }
577+ return Ok(Some(matched_nodes));
1480 }
578 }
1481
579
1482− // @meta NAME VALUE; `og:` names become Open Graph property tags
580+ let resolved = match &ctx.base_path {
1483− ("meta", DirectiveArgs::Text(Some(arg))) => {
581+ Some(base) => base.join(&filename),
1484− let Some((name, value)) = arg.raw.split_once(char::is_whitespace) else {
582+ None => PathBuf::from(&filename),
1485− ctx.diagnostics.push(
583+ };
1486− Diagnostic::error(
584+
1487− code::MISSING_ARGUMENT,
585+ if ctx.include_stack.contains(&resolved) {
1488− line_num,
586+ let cycle_chain = format_include_chain(&ctx.include_stack);
1489− format!("@meta needs a name and a value: `@meta {} VALUE`", arg.raw),
587+ ctx.diagnostics.push(Diagnostic {
1490− )
588+ line: line_num,
1491− .source(content.clone()),
589+ column: None,
1492− );
590+ message: format!(
1493− return Ok(None);
591+ "circular include '{}' (cycle: {} → {})",
1494− };
592+ filename, cycle_chain, filename
1495− let value_column = arg.span.column + arg.raw.len() - value.trim_start().len();
593+ ),
1496− // An HTML attribute value: quoted text loses its quotes
594+ severity: Severity::Error,
1497− let (value, _, _) =
595+ source_line: Some(content.clone()),
1498− ctx.fill_value(value.trim(), line_num, Some(value_column), Sink::Text);
596+ });
1499− // The same tag twice says nothing more (a layout called
597+ return Ok(None);
1500− // twice): it is written once
1501− let (tags, tag) = match name.trim().strip_prefix("og:") {
1502− Some(property) => (&mut ctx.og_tags, (property.to_string(), value)),
1503− None => (&mut ctx.meta_tags, (name.trim().to_string(), value)),
1504− };
1505− if !tags.contains(&tag) {
1506− tags.push(tag);
1507− }
1508− ctx.head_at.get_or_insert(line_num);
1509− Ok(None)
1510 }
598 }
1511
599
1512− ("head", _) => {
600+ let imported_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1513− let text = verbatim_content(node);
601+ cached.clone()
1514− if !text.trim().is_empty() {
602+ } else {
1515− ctx.head_blocks.push(text.trim().to_string());
603+ match std::fs::read_to_string(&resolved) {
1516− ctx.head_at.get_or_insert(line_num);
604+ Ok(text) => {
605+ ctx.file_cache.insert(resolved.clone(), text.clone());
606+ text
607+ }
608+ Err(e) => {
609+ ctx.diagnostics.push(Diagnostic {
610+ line: line_num,
611+ column: None,
612+ message: format!("cannot include '{}': {}", filename, e),
613+ severity: Severity::Error,
614+ source_line: Some(content.clone()),
615+ });
616+ return Ok(None);
617+ }
1517 }
618 }
1518− Ok(None)
619+ };
620+
621+ ctx.included_files.push(resolved.clone());
622+ ctx.include_stack.push(resolved.clone());
623+ let saved_base = ctx.base_path.clone();
624+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
625+
626+ let diag_count_before = ctx.diagnostics.len();
627+
628+ let included_nodes = self.eval_block(&syntax::parse(&imported_text), ctx);
629+
630+ // Annotate new diagnostics with import chain
631+ let import_chain = format_include_chain(&ctx.include_stack);
632+ for d in &mut ctx.diagnostics[diag_count_before..] {
633+ d.message = format!("{}\n in {}", d.message, import_chain);
1519 }
634 }
1520
635
1521− // @style: raw CSS
636+ ctx.base_path = saved_base;
1522− ("style", _) => {
637+ ctx.include_stack.pop();
1523− let text = verbatim_content(node);
638+ return Ok(Some(included_nodes));
1524− // Only the @style at the top of a function's body is
639+ }
1525− // scoped; anywhere else `&` is `:scope`, the whole page
640+
1526− if has_top_level_ampersand(&text) {
641+
1527− ctx.push_once(Diagnostic::warning(
642+
1528− code::INVALID_VALUE,
643+ // --- @data (load JSON file into variables) ---
1529− node.span.line,
644+
1530− "`&` in this @style is the whole page: only an @style at the top of a \
645+ // @data $name file.json values as $name.key
1531− function's body is scoped to the function, with `&` its root element"
646+ // @data $name dir/*.json a list of the files' records
1532− .to_string(),
647+ // @data $name [...] / {...} inline JSON
1533− ));
648+ // @data $name env:NAME [DEFAULT] an environment variable
1534− }
649+ if let Some(rest) = content.strip_prefix("@data ") {
1535− if !text.trim().is_empty() {
650+ let rest = rest.trim();
1536− ctx.custom_css.push(text.trim().to_string());
651+ let Some((prefix, filename)) = rest
652+ .strip_prefix('$')
653+ .and_then(|named| named.split_once(char::is_whitespace))
654+ .map(|(name, source)| (name.to_string(), source.trim().to_string()))
655+ else {
656+ return Err(ParseError {
657+ line: line_num,
658+ message: format!(
659+ "@data needs a name: write `@data $name {}` and use `$name.key`",
660+ rest.trim_start_matches('$')
661+ ),
662+ });
663+ };
664+
665+ if let Some(env) = filename.strip_prefix("env:") {
666+ let (var, default) = match env.split_once(char::is_whitespace) {
667+ Some((var, default)) => (var, Some(default.trim())),
668+ None => (env, None),
669+ };
670+ let value = std::env::var(var)
671+ .ok()
672+ .or_else(|| default.map(|d| substitute_vars(d, &ctx.variables)));
673+ if value.is_none() {
674+ ctx.diagnostics.push(Diagnostic {
675+ line: line_num,
676+ column: None,
677+ message: format!(
678+ "environment variable '{}' is not set and has no default",
679+ var
680+ ),
681+ severity: Severity::Warning,
682+ source_line: Some(content.clone()),
683+ });
1537 }
684 }
1538− Ok(None)
685+ ctx.variables.insert(prefix, value.unwrap_or_default());
686+ return Ok(None);
1539 }
687 }
1540
688
1541− // @markdown: an indented block, or a file
689+ // Inline JSON: @data $links [{"label": "Home", "url": "/"}]
1542− ("markdown", DirectiveArgs::Text(file)) => {
690+ let (json_text, source) = if filename.starts_with(['[', '{']) {
1543− let Some(file) = file else {
691+ (filename, "the inline data".to_string())
1544− let md_lines: Vec<String> = verbatim_text(&node.children)
692+ } else {
1545− .lines()
693+ let filename = substitute_vars(&filename, &ctx.variables);
1546− .map(String::from)
694+ if filename.contains('*') {
1547− .collect();
695+ self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
1548− return Ok(Some(vec![Node::Raw(markdown_to_html(&md_lines))]));
696+ return Ok(None);
697+ }
698+ let resolved = match &ctx.base_path {
699+ Some(base) => base.join(&filename),
700+ None => PathBuf::from(&filename),
1549 };
701 };
1550− let filename = ctx.interpolate_text(&file.raw, line_num, Some(file.span.column));
702+ match std::fs::read_to_string(&resolved) {
1551− let resolved = ctx.resolve(&filename);
703+ Ok(text) => {
1552− let md_text = match ctx.read_file(&resolved) {
704+ ctx.included_files.push(resolved);
1553− Ok(text) => text,
705+ (text, format!("'{}'", filename))
706+ }
1554 Err(e) => {
707 Err(e) => {
1555− ctx.diagnostics.push(
708+ ctx.diagnostics.push(Diagnostic {
1556− Diagnostic::error(
709+ line: line_num,
1557− code::UNREADABLE_FILE,
710+ column: None,
1558− line_num,
711+ message: format!("cannot load data '{}': {}", filename, e),
1559− format!("cannot read markdown '{}': {}", filename, e),
712+ severity: Severity::Error,
1560− )
713+ source_line: Some(content.clone()),
1561− .source(content.clone()),
714+ });
1562− );
1563 return Ok(None);
715 return Ok(None);
1564 }
716 }
1565− };
717+ }
1566− ctx.included_files.push(resolved);
718+ };
1567− let md_lines: Vec<String> = md_text.lines().map(|l| l.to_string()).collect();
719+ match parse_json_with_error(&json_text) {
1568− Ok(Some(vec![Node::Raw(markdown_to_html(&md_lines))]))
720+ Ok(json) => flatten_json(&prefix, &json, &mut ctx.variables),
721+ Err(detail) => ctx.diagnostics.push(Diagnostic {
722+ line: line_num,
723+ column: None,
724+ message: format!("invalid JSON in {}: {}", source, detail),
725+ severity: Severity::Error,
726+ source_line: Some(content.clone()),
727+ }),
1569 }
728 }
729+ return Ok(None);
730+ }
1570
731
1571− ("include", DirectiveArgs::Text(Some(path))) => {
732+ // --- @if / @else ---
1572− self.eval_include(node.id, path, line_num, content, ctx)
1573− }
1574
733
1575− ("data", DirectiveArgs::Data { name, source, .. }) => {
734+ if content.trim() == "@else" || content.trim().starts_with("@else if ") {
1576− self.eval_data(name, source, line_num, content, ctx);
735+ return Err(ParseError {
1577− Ok(None)
736+ line: line_num,
1578− }
737+ message: "@else without matching @if".to_string(),
738+ });
739+ }
1579
740
1580− // @raw: the rest of its line, or its indented body, verbatim
741+ // --- @each loop ---
1581− ("raw", _) => {
1582− let text = verbatim_content(node);
1583− let text = text.trim_end_matches('\n');
1584− Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())]))
1585− }
1586
742
1587− // A header with a syntax error (reported), or a directive
743+ // @raw: its indented body, or the rest of the line, verbatim
1588− // without its argument
744+ if content == "@raw" {
1589− _ => Ok(None),
745+ let text = block_text(children);
746+ let text = text.trim_end_matches('\n');
747+ return Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())]));
1590 }
748 }
1591− }
749+ if let Some(rest) = content.strip_prefix("@raw ") {
1592−
750+ if rest.starts_with("\"\"\"") {
1593− fn eval_include(
751+ return Err(ParseError {
1594− &mut self,
752+ line: line_num,
1595− id: usize,
753+ message: "`@raw \"\"\"` was removed: put the content in an indented block \
1596− path: &syntax::Arg,
754+ under `@raw` (run `htmlang upgrade`)"
1597− line_num: usize,
755+ .to_string(),
1598− content: &str,
756+ });
1599− ctx: &mut ParseContext,
757+ }
1600− ) -> Result<Option<Vec<Node>>, ParseError> {
758+ return Ok(Some(vec![Node::Raw(rest.to_string())]));
1601− let (filename, column) = match path.raw.strip_prefix('"').and_then(|f| f.strip_suffix('"'))
1602− {
1603− Some(quoted) => (quoted, path.span.column + 1),
1604− None => (path.raw.as_str(), path.span.column),
1605− };
1606− let filename = ctx.interpolate_text(filename, line_num, Some(column));
1607− let resolved = ctx.resolve(&filename);
1608−
1609− if ctx.include_stack.contains(&resolved) {
1610− let cycle_chain = format_include_chain(&ctx.include_stack);
1611− ctx.diagnostics.push(
1612− Diagnostic::error(
1613− code::CIRCULAR_INCLUDE,
1614− line_num,
1615− format!(
1616− "circular include '{}' (cycle: {} → {})",
1617− filename, cycle_chain, filename
1618− ),
1619− )
1620− .source(content),
1621− );
1622− return Ok(None);
1623 }
759 }
1624
760
1625− let imported_text = match ctx.read_file(&resolved) {
761+ // --- Function call ---
1626− Ok(text) => text,
762+
1627− Err(e) => {
763+ if content.starts_with('@') {
1628− ctx.diagnostics.push(
764+ let name = extract_element_name(&content);
1629− Diagnostic::error(
765+ if ctx.functions.contains_key(name) {
1630− code::UNREADABLE_FILE,
766+ ctx.used_functions.insert(name.to_string());
1631− line_num,
767+ let nodes = self.expand_fn_call(name, &content, children, line_num, ctx)?;
1632− format!("cannot include '{}': {}", filename, e),
768+ return Ok(Some(nodes));
1633− )
1634− .source(content),
1635− );
1636− return Ok(None);
1637 }
769 }
1638− };
770+ }
1639
771
1640− ctx.included_files.push(resolved.clone());
772+ // --- Elements ---
1641− ctx.include_stack.push(resolved.clone());
773+
1642− let saved_base = ctx.base_path.clone();
774+ if content.starts_with('@') {
1643− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
775+ let node = self.parse_element_line(&content, children, line_num, ctx)?;
1644−
776+ return Ok(Some(vec![inline_svg(node, line_num, ctx)]));
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 }
777 }
1654− let saved_file = ctx.current_file.replace(import_chain.clone());
778+
1655− let defined_before: HashSet<String> = ctx
779+ // --- Bare text ---
1656− .let_lines
780+
1657− .keys()
781+ // `[attrs]` alone on a line used to be an anonymous @el
1658− .chain(ctx.define_lines.keys())
782+ if let Some(list) = content.strip_prefix('[')
1659− .chain(ctx.fn_lines.keys())
783+ && (list.is_empty()
1660− .cloned()
784+ || list
1661− .collect();
785+ .split([',', ']'])
1662− let included_nodes = self.eval_block(&tree.nodes, ctx);
786+ .next()
1663− ctx.current_file = saved_file;
787+ .and_then(|first| first.split_whitespace().next())
1664− // An included file's definitions aren't reported unused here, where
788+ .is_some_and(crate::vocab::is_style_attribute))
1665− // their lines aren't this file's: a library's are there to be
789+ {
1666− // picked from, and any other file reports its own when checked
790+ ctx.diagnostics.push(Diagnostic {
1667− let keep = |name: &String, _: &mut usize| defined_before.contains(name);
791+ line: line_num,
1668− ctx.let_lines.retain(keep);
792+ column: None,
1669− ctx.define_lines.retain(keep);
793+ message: "a line starting with `[` is text: for an element, write `@el [...]` \
1670− ctx.fn_lines.retain(keep);
794+ (run `htmlang upgrade`)"
1671−
795+ .to_string(),
1672− // Annotate new diagnostics with import chain
796+ severity: Severity::Warning,
1673− for d in &mut ctx.diagnostics[diag_count_before..] {
797+ source_line: Some(content.clone()),
1674− d.message = format!("{}\n in {}", d.message, import_chain);
798+ });
1675 }
799 }
1676
800
1677− ctx.base_path = saved_base;
801+ let var_warnings =
1678− ctx.include_stack.pop();
802+ check_undefined_vars(&content, &ctx.variables, line_num, current_indent);
1679− Ok(Some(included_nodes))
803+ ctx.diagnostics.extend(var_warnings);
804+ track_var_refs(&content, &mut ctx.used_variables);
805+ let segments = parse_text_segments(&content, ctx);
806+ Ok(Some(vec![Node::Text(segments)]))
1680 }
807 }
1681
808
1682− /// @data $name file.json values as $name.key
809+ /// `@data $name dir/*.json`: a list with one record per matching file
1683− /// @data $name dir/*.json a list of the files' records
810+ /// (in name order), whose `file` is the file's name without extension.
1684− /// @data $name [...] / {...} inline JSON
811+ fn load_data_glob(
1685− /// @data $name env:NAME [DEFAULT] an environment variable
1686− fn eval_data(
1687 &mut self,
812 &mut self,
1688− prefix: &str,
813+ name: &str,
1689− source: &syntax::Arg,
814+ pattern: &str,
1690− line_num: usize,
1691− content: &str,
1692− ctx: &mut ParseContext,
1693− ) {
1694− let prefix = prefix.to_string();
1695− let filename = source.raw.as_str();
1696− if let Some(env) = filename.strip_prefix("env:") {
1697− let (var, default) = match env.split_once(char::is_whitespace) {
1698− Some((var, default)) => (var, Some(default.trim())),
1699− None => (env, None),
1700− };
1701− let default_column = default.map(|d| source.span.column + filename.len() - d.len());
1702− let value = std::env::var(var)
1703− .ok()
1704− .or_else(|| default.map(|d| ctx.interpolate_text(d, line_num, default_column)));
1705− if value.is_none() {
1706− ctx.diagnostics.push(
1707− Diagnostic::warning(
1708− code::UNSET_ENVIRONMENT,
1709− line_num,
1710− format!(
1711− "environment variable '{}' is not set and has no default",
1712− var
1713− ),
1714− )
1715− .source(content),
1716− );
1717− }
1718− ctx.bind(&prefix, Value::Str(value.unwrap_or_default()));
1719− return;
1720− }
1721−
1722− // Inline JSON: @data $links [{"label": "Home", "url": "/"}]
1723− let (json_text, source) = if filename.starts_with(['[', '{']) {
1724− (filename.to_string(), "the inline data".to_string())
1725− } else {
1726− let filename = ctx.interpolate_text(filename, line_num, Some(source.span.column));
1727− if filename.contains('*') {
1728− self.load_data_glob(&prefix, &filename, line_num, content, ctx);
1729− return;
1730− }
1731− let resolved = ctx.resolve(&filename);
1732− match std::fs::read_to_string(&resolved) {
1733− Ok(text) => {
1734− ctx.included_files.push(resolved);
1735− (text, format!("'{}'", filename))
1736− }
1737− Err(e) => {
1738− ctx.diagnostics.push(
1739− Diagnostic::error(
1740− code::UNREADABLE_FILE,
1741− line_num,
1742− format!("cannot load data '{}': {}", filename, e),
1743− )
1744− .source(content),
1745− );
1746− return;
1747− }
1748− }
1749− };
1750− match parse_json_with_error(&json_text) {
1751− Ok(json) => ctx.bind(&prefix, json_value(json)),
1752− Err(detail) => ctx.diagnostics.push(
1753− Diagnostic::error(
1754− code::INVALID_JSON,
1755− line_num,
1756− format!("invalid JSON in {}: {}", source, detail),
1757− )
1758− .source(content),
1759− ),
1760− }
1761− }
1762−
1763− /// `@data $name dir/*.json`: a list with one record per matching file
1764− /// (in name order), whose `file` is the file's name without extension.
1765− fn load_data_glob(
1766− &mut self,
1767− name: &str,
1768− pattern: &str,
1769 line_num: usize,
815 line_num: usize,
1770 content: &str,
816 content: &str,
1771 ctx: &mut ParseContext,
817 ctx: &mut ParseContext,
@@ -1791,15 +837,13 @@ impl Evaluator {
1791 })
837 })
1792 .collect(),
838 .collect(),
1793 Err(e) => {
839 Err(e) => {
1794− ctx.diagnostics.push(
840+ ctx.diagnostics.push(Diagnostic {
1795− Diagnostic::new(
841+ line: line_num,
1796− code::UNREADABLE_FILE,
842+ column: None,
1797− Severity::Error,
843+ message: format!("cannot read directory for '{}': {}", pattern, e),
1798− line_num,
844+ severity: Severity::Error,
1799− format!("cannot read directory for '{}': {}", pattern, e),
845+ source_line: Some(content.to_string()),
1800− )
846+ });
1801− .source(content.to_string()),
1802− );
1803 return;
847 return;
1804 }
848 }
1805 };
849 };
@@ -1814,15 +858,13 @@ impl Evaluator {
1814 let text = match std::fs::read_to_string(&file) {
858 let text = match std::fs::read_to_string(&file) {
1815 Ok(text) => text,
859 Ok(text) => text,
1816 Err(e) => {
860 Err(e) => {
1817− ctx.diagnostics.push(
861+ ctx.diagnostics.push(Diagnostic {
1818− Diagnostic::new(
862+ line: line_num,
1819− code::UNREADABLE_FILE,
863+ column: None,
1820− Severity::Error,
864+ message: format!("cannot load data '{}': {}", file.display(), e),
1821− line_num,
865+ severity: Severity::Error,
1822− format!("cannot load data '{}': {}", file.display(), e),
866+ source_line: Some(content.to_string()),
1823− )
867+ });
1824− .source(content.to_string()),
1825− );
1826 continue;
868 continue;
1827 }
869 }
1828 };
870 };
@@ -1833,435 +875,257 @@ impl Evaluator {
1833 }
875 }
1834 records.push(JsonValue::Object(pairs));
876 records.push(JsonValue::Object(pairs));
1835 }
877 }
1836− Ok(_) => ctx.diagnostics.push(
878+ Ok(_) => ctx.diagnostics.push(Diagnostic {
1837− Diagnostic::new(
879+ line: line_num,
1838− code::INVALID_JSON,
880+ column: None,
1839− Severity::Error,
881+ message: format!("'{}' should hold a JSON object", file.display()),
1840− line_num,
882+ severity: Severity::Error,
1841− format!("'{}' should hold a JSON object", file.display()),
883+ source_line: Some(content.to_string()),
1842− )
884+ }),
1843− .source(content.to_string()),
885+ Err(detail) => ctx.diagnostics.push(Diagnostic {
1844− ),
886+ line: line_num,
1845− Err(detail) => ctx.diagnostics.push(
887+ column: None,
1846− Diagnostic::new(
888+ message: format!("invalid JSON in '{}': {}", file.display(), detail),
1847− code::INVALID_JSON,
889+ severity: Severity::Error,
1848− Severity::Error,
890+ source_line: Some(content.to_string()),
1849− line_num,
891+ }),
1850− format!("invalid JSON in '{}': {}", file.display(), detail),
1851− )
1852− .source(content.to_string()),
1853− ),
1854 }
892 }
1855 ctx.included_files.push(file);
893 ctx.included_files.push(file);
1856 }
894 }
1857− ctx.bind(name, json_value(JsonValue::Array(records)));
895+ flatten_json(name, &JsonValue::Array(records), &mut ctx.variables);
1858 }
896 }
1859
897
1860− /// `@each $item in LIST` or `@each $item, $index in LIST`, with the
898+ /// `@each $item in LIST` or `@each $item, $index in LIST`. A LIST from
1861− /// `@else` that follows it. LIST is a value (see
899+ /// JSON binds each record or value to `$item`; any other list is text,
1862− /// [`ParseContext::slot_value`]): a list written with commas, a range,
900+ /// split on commas, or a range `A..B [step N]`.
1863− /// or one `$name` or `${...}` that holds a list. Each item is bound
1864− /// whole to `$item`, and each iteration is a block of its own.
1865 fn eval_each(
901 fn eval_each(
1866 &mut self,
902 &mut self,
1867− node: &syntax::Node,
903+ header: &str,
1868− empty_branch: Option<&syntax::Node>,
904+ body: &[Syntax],
905+ empty: &[Syntax],
906+ line_num: usize,
1869 ctx: &mut ParseContext,
907 ctx: &mut ParseContext,
1870− ) -> Result<Option<Vec<Node>>, ParseError> {
908+ ) -> Result<Vec<Node>, ParseError> {
1871− ctx.visited.insert(node.id);
909+ let Some((names, list_src)) = header.split_once(" in ") else {
1872− if let Some(branch) = empty_branch {
910+ return Err(ParseError {
1873− ctx.visited.insert(branch.id);
911+ line: line_num,
1874− }
912+ message: "@each requires: @each $item in LIST".to_string(),
1875− ctx.enter(node);
913+ });
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 };
914 };
1901− if bracketed {
915+ let names: Vec<&str> = names.split(',').map(|v| v.trim().trim_start_matches('$')).collect();
1902− let inner = list.raw[1..list.raw.len() - 1].trim().to_string();
916+ if names.len() > 2 {
1903− let diagnostic = not_a_list(
917+ return Err(ParseError {
1904− format!(
918+ line: line_num,
1905− "@each takes a list written without brackets, `@each ${} in {}`; \
919+ message: "@each takes `$item` or `$item, $index`: to loop over records, \
1906− JSON data goes in `@data`",
920+ load them with @data and use `$item.key`"
1907− item, inner
921+ .to_string(),
1908− ),
922+ });
1909− ctx,
1910− )
1911− .suggest(Some(inner));
1912− ctx.push_once(diagnostic);
1913 }
923 }
1914− let items: Vec<Value> = match source {
924+ let (item, index) = (names[0], names.get(1).copied());
1915− Value::List(list) => list.items.to_vec(),
925+ let list_src = list_src.trim();
1916− // Reported (an undefined `$name`, brackets): there is nothing
926+ if list_src.ends_with(']') && list_src.contains("[page ") {
1917− // to repeat
927+ return Err(ParseError {
1918− _ if !filled => Vec::new(),
928+ line: line_num,
1919− Value::Record(_) => {
929+ message: "@each pagination (`[page N]`) was removed: split the list or \
1920− let diagnostic = not_a_list(
930+ filter it with @if"
1921− format!(
931+ .to_string(),
1922− "@each repeats for the items of a list, and `{}` is a record: \
932+ });
1923− loop over a list of records, or use its fields",
933+ }
1924− list.raw
934+ track_var_refs(list_src, &mut ctx.used_variables);
1925− ),
935+
1926− ctx,
936+ // A list loaded from JSON, by name
1927− );
937+ let data_list = list_src.strip_prefix('$').and_then(|name| {
1928− ctx.push_once(diagnostic);
938+ let len = ctx.variables.get(&format!("{}#", name))?.parse::<usize>().ok()?;
939+ Some((name.to_string(), len))
940+ });
941+ let undefined = list_src.strip_prefix('$').filter(|name| {
942+ name.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
943+ && !ctx.variables.contains_key(*name)
944+ });
945+ let text_items: Vec<String> = match (&data_list, undefined) {
946+ (Some(_), _) => Vec::new(),
947+ // A missing variable is an empty list: quietly for a record's
948+ // missing field (`$post.tags`), with a warning otherwise
949+ (None, Some(name)) => {
950+ let root = name.split('.').next().unwrap_or(name);
951+ if !ctx.variables.contains_key(root) {
952+ ctx.diagnostics.push(Diagnostic {
953+ line: line_num,
954+ column: None,
955+ message: format!("undefined variable '${}'", name),
956+ severity: Severity::Warning,
957+ source_line: Some(format!("@each {}", header)),
958+ });
959+ }
1929 Vec::new()
960 Vec::new()
1930 }
961 }
1931− // Empty text (a field a record doesn't have) is no items; any
962+ (None, None) => text_list_items(&substitute_vars(list_src, &ctx.variables)),
1932− // other value is one
1933− value if value.to_string().is_empty() => Vec::new(),
1934− value => vec![value],
1935 };
963 };
964+ let count = data_list.as_ref().map_or(text_items.len(), |(_, len)| *len);
1936
965
1937− if body.iter().all(syntax::Node::is_trivia) {
966+ if body.is_empty() {
1938− return Ok(Some(Vec::new()));
967+ return Ok(Vec::new());
1939 }
968 }
1940− if items.is_empty() {
969+ if count == 0 {
1941− return Ok(Some(self.eval_scoped(empty, ctx)));
970+ return Ok(self.eval_scoped(empty, ctx));
1942 }
971 }
972+ let saved_vars = ctx.variables.clone();
1943 let mut nodes = Vec::new();
973 let mut nodes = Vec::new();
1944− for (i, value) in items.into_iter().enumerate() {
974+ for i in 0..count {
1945− ctx.env.push();
975+ match &data_list {
1946− ctx.bind(item, value);
976+ Some((name, _)) => bind_item(&mut ctx.variables, &format!("{}.{}", name, i), item),
977+ None => {
978+ let text = text_items.get(i).cloned().unwrap_or_default();
979+ ctx.variables.insert(item.to_string(), text);
980+ }
981+ }
1947 if let Some(index) = index {
982 if let Some(index) = index {
1948− ctx.bind(index, Value::Num(i as f64));
983+ ctx.variables.insert(index.to_string(), i.to_string());
1949 }
984 }
1950 nodes.extend(self.eval_block(body, ctx));
985 nodes.extend(self.eval_block(body, ctx));
1951− ctx.env.pop();
1952 }
986 }
1953− Ok(Some(nodes))
987+ ctx.variables = saved_vars;
988+ Ok(nodes)
1954 }
989 }
1955
990
1956 fn define_function(
991 fn define_function(
1957 &mut self,
992 &mut self,
1958 name: &str,
993 name: &str,
1959− params: &[syntax::Param],
994+ params: &[String],
1960− node: &syntax::Node,
995+ defaults: &HashMap<String, String>,
996+ body: &[Syntax],
997+ line_num: usize,
1961 ctx: &mut ParseContext,
998 ctx: &mut ParseContext,
1962 ) {
999 ) {
1963− let line_num = node.span.line;
1964− let body = &node.children;
1965 // An @style block at the top of the body is scoped to the
1000 // An @style block at the top of the body is scoped to the
1966− // function: its rules apply inside a `.hl-fn-NAME-HASH` wrapper (a
1001+ // function: its rules apply inside a `.hl-NAME` wrapper.
1967− // generated class never has a second `-`, so the two can't meet).
1002+ let (style, body): (Vec<&Syntax>, Vec<&Syntax>) = body
1968− // The hash is the stylesheet's, so two functions of one name with
1003+ .iter()
1969− // two stylesheets, in two files, don't style each other's elements
1004+ .partition(|node| matches!(node, Syntax::Line { text, .. } if text == "@style"));
1970− // in a page that has both. It is written into the page when the
1005+ if !style.is_empty() {
1971− // function is called.
1972− let (style, body): (Vec<&syntax::Node>, Vec<&syntax::Node>) =
1973− body.iter().partition(|node| node.is_directive("style"));
1974− let scoped = (!style.is_empty()).then(|| {
1975 let css: String = style
1006 let css: String = style
1976 .iter()
1007 .iter()
1977− .map(|node| {
1008+ .filter_map(|node| match node {
1978− ctx.visited.insert(node.id);
1009+ Syntax::Line { children, .. } => Some(block_text(children)),
1979− verbatim_content(node)
1010+ _ => None,
1980 })
1011 })
1981 .collect();
1012 .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
1013 // Nested under the scope class, so any CSS works (multi-line
1986 // rules, at-rules)
1014 // rules, at-rules)
1987− let css = if css.trim().is_empty() {
1015+ if !css.trim().is_empty() {
1988− String::new()
1016+ ctx.custom_css.push(format!(".hl-{} {{\n{}}}", name, css));
1989− } else {
1017+ }
1990− format!(".{} {{\n{}}}", class, css)
1018+ ctx.scoped_functions.insert(name.to_string());
1991− };
1992− (class, css)
1993− });
1994−
1995− // A default is filled in only when a call leaves its parameter
1996− // out, so the names in it count as used here
1997− for default in params.iter().filter_map(|p| p.default.as_ref()) {
1998− track_var_refs(default, &mut ctx.used_variables);
1999 }
1019 }
1020+
2000 ctx.fn_lines.entry(name.to_string()).or_insert(line_num);
1021 ctx.fn_lines.entry(name.to_string()).or_insert(line_num);
2001− let wraps_itself = ElementKind::from_name(name).is_some()
1022+ ctx.functions.insert(
2002− && body.iter().any(|node| calls_unguarded(node, name));
1023+ name.to_string(),
2003− if wraps_itself {
1024+ FnDef {
2004− ctx.push_once(
1025+ params: params.to_vec(),
2005− Diagnostic::error(
1026+ defaults: defaults.clone(),
2006− code::RECURSIVE_CALL,
1027+ body: body.into_iter().cloned().collect(),
2007− line_num,
1028+ },
2008− format!(
1029+ );
2009− "@{0} in the body of `@let @{0}` calls the function itself, not the \
2010− element @{0}, so it would never stop: give the function another name \
2011− (such as @site-{0})",
2012− name
2013− ),
2014− )
2015− .subject(name),
2016− );
2017− }
2018− for node in &body {
2019− node.walk(&mut |node| {
2020− if let Some(d) = marker_in_text(node) {
2021− ctx.push_once(d);
2022− }
2023− });
2024− }
2025− let body: Vec<syntax::Node> = body.into_iter().cloned().collect();
2026− let (slots, has_children) = declared_slots(&body, ctx);
2027− let function = Rc::new(FnDef {
2028− params: params.to_vec(),
2029− line: line_num,
2030− source: ctx.current_source.1.clone(),
2031− node: node.id,
2032− body,
2033− file: ctx.current_file.clone(),
2034− slots,
2035− has_children,
2036− scoped,
2037− env: ctx.env.clone(),
2038− wraps_itself,
2039− });
2040− ctx.defined_functions
2041− .insert(name.to_string(), function.clone());
2042− ctx.env.define(name, Binding::Function(function));
2043 }
1030 }
2044
1031
2045− /// An element line: a chain of elements and function calls, the last of
1032+ fn expand_fn_call(
2046− /// which gets the indented children.
2047− fn eval_element(
2048 &mut self,
1033 &mut self,
2049− node: &syntax::Node,
1034+ name: &str,
2050− element: &syntax::ElementLine,
1035+ content: &str,
1036+ children: &[Syntax],
1037+ line_num: usize,
2051 ctx: &mut ParseContext,
1038 ctx: &mut ParseContext,
2052 ) -> Result<Vec<Node>, ParseError> {
1039 ) -> Result<Vec<Node>, ParseError> {
2053− // An unclosed attribute list is a syntax error, already reported
1040+ // Recursive function cycle detection
2054− if element
1041+ if ctx.fn_call_stack.contains(&name.to_string()) {
2055− .chain
1042+ return Err(ParseError {
2056− .iter()
1043+ line: line_num,
2057− .any(|h| h.attrs.as_ref().is_some_and(|a| !a.closed))
1044+ message: format!(
2058− {
1045+ "recursive function call to @{} (call stack: {})",
2059− return Ok(Vec::new());
1046+ name,
2060− }
1047+ ctx.fn_call_stack.join(" -> ")
2061− let line_num = node.span.line;
1048+ ),
2062− let last = element.chain.len() - 1;
1049+ });
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 }
1050 }
2089− Ok(current)
1051+ ctx.fn_call_stack.push(name.to_string());
2090− }
2091
1052
2092− /// Give a resolved `@name` its children: an element holds them, and a
1053+ // Parse [param value, ...] arguments
2093− /// function's body puts them where its `@children` is.
1054+ let rest = &content[1 + name.len()..];
2094− fn complete(
1055+ let rest = rest.trim_start();
2095− &mut self,
1056+
2096− resolved: Resolved,
1057+ let (args, trailing_text) = if rest.starts_with('[') {
2097− children: Vec<Node>,
1058+ parse_attr_brackets_no_validate(rest, line_num, ctx)?
2098− ctx: &mut ParseContext,
1059+ } else {
2099− ) -> Result<Vec<Node>, ParseError> {
1060+ (Vec::new(), rest.to_string())
2100− match resolved {
1061+ };
2101− Resolved::Element(mut element) => {
1062+
2102− element.children.extend(children);
1063+ // Clone function definition (releases borrow on ctx). If the function is
2103− let line = element.line_num;
1064+ // missing (shouldn't normally happen — callers check ctx.functions.contains_key
2104− Ok(vec![inline_svg(Node::Element(element), line, ctx)])
1065+ // first — but we avoid panicking on malformed state).
1066+ let fn_def = match ctx.functions.get(name) {
1067+ Some(def) => def.clone(),
1068+ None => {
1069+ ctx.fn_call_stack.pop();
1070+ return Err(ParseError {
1071+ line: line_num,
1072+ message: format!("undefined function @{}", name),
1073+ });
2105 }
1074 }
2106− Resolved::Call(call) => self.expand_fn_call(*call, children, ctx),
1075+ };
2107− }
2108− }
2109
1076
2110− fn expand_fn_call(
1077+ // Parse caller's children, separating named slots from default children
2111− &mut self,
1078+ let all_caller_children = self.eval_block(children, ctx);
2112− call: Call,
2113− all_caller_children: Vec<Node>,
2114− ctx: &mut ParseContext,
2115− ) -> Result<Vec<Node>, ParseError> {
2116− let Call {
2117− name,
2118− function: fn_def,
2119− args,
2120− values,
2121− text: trailing_text,
2122− written_form,
2123− written_keys,
2124− line: line_num,
2125− name_at,
2126− source: content,
2127− } = call;
2128− let (name, content) = (name.as_str(), content.as_str());
2129−
2130− // Separate the caller's `@slot NAME` blocks from its other
2131− // children. A block for a slot the function doesn't declare was
2132− // reported where it is written (see `check_fillers`), like one
2133− // without a name.
2134 let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
1079 let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
2135 let mut caller_children = Vec::new();
1080 let mut caller_children = Vec::new();
2136 for child in all_caller_children {
1081 for child in all_caller_children {
2137− if let Node::Element(elem) = child {
1082+ if let Node::Element(ref elem) = child
2138− match elem.kind {
1083+ && let ElementKind::Slot(ref slot_name) = elem.kind
2139− ElementKind::Slot(slot_name) => {
1084+ && !slot_name.is_empty()
2140− slot_contents
1085+ {
2141− .entry(slot_name)
1086+ slot_contents
2142− .or_default()
1087+ .entry(slot_name.clone())
2143− .extend(elem.children);
1088+ .or_default()
2144− }
1089+ .extend(elem.children.clone());
2145− kind => caller_children.push(Node::Element(Element { kind, ..elem })),
2146− }
2147 continue;
1090 continue;
2148 }
1091 }
2149 caller_children.push(child);
1092 caller_children.push(child);
2150 }
1093 }
2151− // Text after the call, as in `@card [color red] New`, is content:
1094+ // 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
1095+ // it goes first among the caller's children.
2153− // evaluated where the call is written.
1096+ let trailing_text = trailing_text.trim();
2154− if let Some(text) = &trailing_text {
1097+ if !trailing_text.is_empty() {
2155− caller_children.insert(0, Node::Text(text_segments(text, ctx)));
1098+ 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 }
1099 }
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
1100
2187− // The body runs where the function is defined: it sees what was
1101+ // Save variable state, inject function parameters
2188− // visible there, the function itself (so it can call itself), and
1102+ 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()];
1103 let mut consumed = vec![false; args.len()];
2196− for param in &fn_def.params {
1104+ for (i, param) in fn_def.params.iter().enumerate() {
2197− let mut passed = None;
1105+ let named = args.iter().position(|a| a.key == *param);
2198− for (j, arg) in args.iter().enumerate() {
1106+ // Positional fallback, unless that argument is named for a
2199− if arg.key != param.name {
1107+ // different parameter.
2200− continue;
1108+ let positional = args
2201− }
1109+ .get(i)
1110+ .filter(|a| !fn_def.params.contains(&a.key) && a.value.is_some())
1111+ .map(|_| i);
1112+ let value = named
1113+ .filter(|&j| args[j].value.is_some())
1114+ .or(positional)
1115+ .and_then(|j| {
1116+ consumed[j] = true;
1117+ args[j].value.clone()
1118+ })
1119+ .or_else(|| fn_def.defaults.get(param).cloned())
1120+ .unwrap_or_default();
1121+ if let Some(j) = named {
2202 consumed[j] = true;
1122 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 }
1123 }
2229− let value = match passed {
1124+ 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 }
1125 }
2262 // Arguments that aren't parameters are attributes for the
1126 // Arguments that aren't parameters are attributes for the
2263 // function's root element, so a function can be styled like an
1127 // function's root element, so a function can be styled like an
2264− // element: `@card [background red] New`.
1128+ // element: `@badge [background red] New`.
2265 let forwarded: Vec<Attribute> = args
1129 let forwarded: Vec<Attribute> = args
2266 .iter()
1130 .iter()
2267 .zip(&consumed)
1131 .zip(&consumed)
@@ -2269,1435 +1133,221 @@ impl Evaluator {
2269 .map(|(a, _)| a.clone())
1133 .map(|(a, _)| a.clone())
2270 .collect();
1134 .collect();
2271
1135
2272− // Evaluate the body with the parameters in scope, as lines of the
1136+ // Evaluate the body with the parameters in scope
2273− // file that defines the function
1137+ let body_nodes = self.eval_block(&fn_def.body, ctx);
2274− let caller_file = std::mem::replace(&mut ctx.current_file, fn_def.file.clone());
2275− let before = ctx.diagnostics.len();
2276− let mut body_nodes = self.eval_block(&fn_def.body, ctx);
2277− ctx.current_file = caller_file;
2278− point_at_call(ctx, before, &fn_def, name, line_num, content);
2279
1138
2280− // Back to the caller's names and call stack
1139+ // Restore variables and call stack
2281− ctx.env = caller_env;
1140+ ctx.variables = saved_vars;
2282 ctx.fn_call_stack.pop();
1141 ctx.fn_call_stack.pop();
2283− if ctx.fn_call_stack.is_empty() {
2284− ctx.too_deep = false;
2285− }
2286− (ctx.current_line, ctx.current_source, ctx.current_node) = caller;
2287−
2288− // What the body wrote is the call's
2289− belongs_to_call(&mut body_nodes, name, line_num);
2290
1142
2291 // Replace @children with caller's children and @slot with slot content
1143 // Replace @children with caller's children and @slot with slot content
2292 let mut result_nodes =
1144 let mut result_nodes =
2293 replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
1145 replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
2294
1146
2295− // Attributes that aren't parameters style the function's root
1147+ 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 {
1148 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
2300 Node::Element(e) => Some(e),
1149 Node::Element(e) => Some(e),
2301 _ => None,
1150 _ => None,
2302 });
1151 });
2303− let root = match (roots.next(), roots.next()) {
1152+ match (roots.next(), roots.next()) {
2304− (Some(root), None) => Some(root),
1153+ (Some(root), None) => root.attrs.extend(forwarded),
2305− _ => None,
1154+ _ => ctx.diagnostics.push(Diagnostic {
2306− };
1155+ line: line_num,
2307− let mut problems = Vec::new();
1156+ column: None,
2308− match root {
1157+ message: format!(
2309− Some(root) => {
1158+ "attributes {} on @{} are not parameters, and its body has no single \
2310− // A call's `class=` adds to the root's own classes
1159+ root element to receive them",
2311− for attr in forwarded {
1160+ forwarded
2312− let existing = (attr.html && attr.key == "class")
1161+ .iter()
2313− .then(|| root.attrs.iter_mut().find(|a| a.html && a.key == "class"))
1162+ .map(|a| format!("'{}'", a.key))
2314− .flatten();
1163+ .collect::<Vec<_>>()
2315− match existing {
1164+ .join(", "),
2316− Some(class) => {
1165+ name
2317− let mut value = class.value.take().unwrap_or_default();
1166+ ),
2318− for name in attr.value.as_deref().unwrap_or("").split_whitespace() {
1167+ severity: Severity::Warning,
2319− if !value.split_whitespace().any(|c| c == name) {
1168+ source_line: Some(content.to_string()),
2320− value.push(' ');
1169+ }),
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 }
1170 }
2382 }
1171 }
2383
1172
1173+ // A function with a scoped @style wraps its output in the scope class
1174+ if ctx.scoped_functions.contains(name) {
1175+ let wrapper = Element {
1176+ kind: ElementKind::El,
1177+ attrs: vec![Attribute {
1178+ key: "class".to_string(),
1179+ value: Some(format!("hl-{}", name)),
1180+ html: true,
1181+ }],
1182+ argument: None,
1183+ children: result_nodes,
1184+ line_num,
1185+ };
1186+ result_nodes = vec![Node::Element(wrapper)];
1187+ }
1188+
2384 Ok(result_nodes)
1189 Ok(result_nodes)
2385 }
1190 }
2386−}
2387
1191
2388−/// A `@name` read where it is written: a built-in element, or a call.
1192+ fn parse_element_line(
2389−enum Resolved {
1193+ &mut self,
2390− Element(Element),
1194+ content: &str,
2391− Call(Box<Call>),
1195+ children: &[Syntax],
2392−}
1196+ line_num: usize,
1197+ ctx: &mut ParseContext,
1198+ ) -> Result<Node, ParseError> {
1199+ let segments = split_chain(content);
2393
1200
2394−/// A call of a function, with its arguments evaluated where it is written.
1201+ // Parse each segment into an Element
2395−struct Call {
1202+ let mut elements: Vec<Element> = Vec::new();
2396− name: String,
1203+ for seg in &segments {
2397− function: Rc<FnDef>,
1204+ let elem = parse_single_element(seg.trim(), line_num, ctx)?;
2398− args: Vec<Attribute>,
1205+ elements.push(elem);
2399− /// The value of each argument that passes a parameter, with its type
1206+ }
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
1207
2416−/// What `@name` is where it is written: a call of the function `name`
1208+ // Parse indented children (belong to the innermost element)
2417−/// when one is defined, else the built-in element. A whole line, each link
1209+ let children = self.eval_block(children, 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
1210
2457−/// A line of another file (the standard library, an included file) means
1211+ // Build chain right-to-left: rightmost gets children, each wraps the next
2458−/// nothing in this one: the problems reported since `before` while
1212+ let mut current_children = children;
2459−/// evaluating the function (its body, a default) are moved to the call.
1213+ for mut elem in elements.into_iter().rev() {
2460−fn point_at_call(
1214+ elem.children.extend(current_children);
2461− ctx: &mut ParseContext,
1215+ current_children = vec![Node::Element(elem)];
2462− before: usize,
1216+ }
2463− function: &FnDef,
1217+
2464− name: &str,
1218+ current_children.into_iter().next().ok_or(ParseError {
2465− line: usize,
1219+ line: line_num,
2466− source: &str,
1220+ message: "element chain produced no nodes (this is a parser bug)".to_string(),
2467−) {
1221+ })
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 }
1222 }
2486}
1223}
2487
1224
2488−/// A call nested deeper than [`MAX_CALL_DEPTH`].
1225+// ---------------------------------------------------------------------------
2489−fn too_deep(name: &str, stack: &[String], line: usize) -> Diagnostic {
1226+// @children replacement
2490− let message = if stack.iter().any(|n| n == name) {
1227+// ---------------------------------------------------------------------------
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
1228
2512−/// Mark what a function's body wrote as the call's: it gets the call's
1229+fn replace_children_and_slots(
2513−/// line, so what is reported about it points at the call, and the name of
1230+ nodes: Vec<Node>,
2514−/// the function, unless a call inside the body already claimed it.
1231+ caller_children: &[Node],
2515−fn belongs_to_call(nodes: &mut [Node], function: &str, line: usize) {
1232+ slot_contents: &HashMap<String, Vec<Node>>,
2516− fn claim(element: &mut Element, function: &str, line: usize) {
1233+) -> Vec<Node> {
2517− element.line_num = line;
1234+ 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 {
1235 for node in nodes {
2522 match node {
1236 match node {
2523− Node::Element(element) => claim(element, function, line),
1237+ Node::Element(elem) if elem.kind == ElementKind::Children => {
2524− Node::Text(segments) => {
1238+ if caller_children.is_empty() && !elem.children.is_empty() {
2525− for segment in segments {
1239+ // Use @children's own children as default/fallback content
2526− if let TextSegment::Inline(element) = segment {
1240+ result.extend(elem.children);
2527− claim(element, function, line);
1241+ } else {
2528− }
1242+ result.extend(caller_children.iter().cloned());
2529 }
1243 }
2530 }
1244 }
2531− Node::Raw(_) => {}
1245+ Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) =>
2532− }
1246+ {
2533− }
1247+ if let ElementKind::Slot(ref name) = elem.kind {
2534−}
1248+ if let Some(content) = slot_contents.get(name) {
2535−
1249+ result.extend(content.iter().cloned());
2536−/// What an inline `{@name ...}` stands for in its line of text: an
1250+ }
2537−/// element; the text of a function whose body is text; or, for a body with
1251+ // If no content provided for this slot, use the slot's own children as default
2538−/// several roots, its nodes as a fragment.
1252+ else if !elem.children.is_empty() {
2539−fn inline_segments(mut nodes: Vec<Node>, line: usize) -> Vec<TextSegment> {
1253+ result.extend(elem.children);
2540− if let [Node::Element(_)] = nodes.as_slice()
1254+ }
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 }
1255 }
2552− segments.extend(run);
2553 }
1256 }
1257+ Node::Element(mut elem) => {
1258+ elem.children =
1259+ replace_children_and_slots(elem.children, caller_children, slot_contents);
1260+ result.push(Node::Element(elem));
1261+ }
1262+ other => result.push(other),
2554 }
1263 }
2555− return segments;
2556 }
1264 }
2557− vec![TextSegment::Inline(Element {
1265+ result
2558− kind: ElementKind::Fragment,
2559− attrs: Vec::new(),
2560− argument: None,
2561− children: nodes,
2562− line_num: line,
2563− function: None,
2564− })]
2565}
1266}
2566
1267
2567−/// Fill in the default of a parameter that a call leaves out, like the
1268+// ---------------------------------------------------------------------------
2568−/// value of an attribute passed for it: variables are filled in, one
1269+// Element parsing
2569−/// `$name` keeps its type and one `"..."` is quoted text. It sees what the
1270+// ---------------------------------------------------------------------------
2570−/// body sees: the names visible where the function is defined, and the
1271+
2571−/// parameters before it. Its problems are reported at the definition, once.
1272+fn extract_element_name(content: &str) -> &str {
2572−fn fill_default(
1273+ let without_at = &content[1..];
2573− fn_def: &FnDef,
1274+ match without_at.find([' ', '[']) {
2574− param: &syntax::Param,
1275+ Some(i) => &without_at[..i],
2575− default: &str,
1276+ 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 }
1277 }
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}
1278}
2604
1279
2605−/// Read `@page`'s attribute list and title. The page is the root element,
1280+fn parse_single_element(
2606−/// and its attributes are checked like any element's: `key=value` (and a
1281+ content: &str,
2607−/// bare boolean HTML attribute) go on `<html>`, styles on `<body>`, and
1282+ 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,
1283 ctx: &mut ParseContext,
2614−) -> Page {
1284+) -> Result<Element, ParseError> {
2615− let mut page = Page::default();
1285+ let (kind, rest) = if let Some(without_at) = content.strip_prefix('@') {
2616− if let Some(list) = attrs {
1286+ match without_at.find([' ', '[']) {
2617− let words: Vec<String> = crate::vocab::PAGE_WORDS
1287+ Some(i) => {
2618− .iter()
1288+ let kind_str = &without_at[..i];
2619− .map(|w| w.to_string())
1289+ let rest = if without_at.as_bytes()[i] == b'[' {
2620− .collect();
1290+ without_at[i..].to_string()
2621− ctx.in_page = true;
1291+ } else {
2622− let read = parse_attr_list(&list.attrs, line, ctx, true, &words);
1292+ without_at[i + 1..].to_string()
2623− ctx.in_page = false;
1293+ };
2624− // How many of each name have been read, to find where the next
1294+ match parse_element_kind(kind_str, line_num) {
2625− // one is written
1295+ Ok(kind) => (kind, rest),
2626− let mut seen: HashMap<String, usize> = HashMap::new();
1296+ Err(mut e) => {
2627− for attr in read {
1297+ // Also suggest user-defined functions
2628− let boolean = attr.value.is_none() && crate::vocab::is_html_flag(&attr.key);
1298+ if let Some(fn_suggestion) = suggest_fn_name(kind_str, ctx) {
2629− // Where it is written, when it is written in the list itself
1299+ e.message =
2630− let nth = seen.entry(attr.key.clone()).or_default();
1300+ format!("{}, or did you mean @{}?", e.message, fn_suggestion);
2631− let column = list
1301+ }
2632− .attrs
1302+ return Err(e);
2633− .iter()
1303+ }
2634− .filter(|token| token.key == attr.key)
2635− .nth(*nth)
2636− .map(|token| token.span.column);
2637− *nth += 1;
2638− let value = attr
2639− .quoted
2640− .as_ref()
2641− .map(|q| q.text.clone())
2642− .or(attr.value.clone());
2643− if !check_page_attr(&attr.key, attr.html, value.as_deref(), line, column, ctx) {
2644− continue;
2645− }
2646− if words.contains(&attr.key) {
2647− if page.favicon.is_some() {
2648− let diagnostic = Diagnostic::warning(
2649− code::DUPLICATE_ATTRIBUTE,
2650− line,
2651− "duplicate attribute 'favicon': the later one wins".to_string(),
2652− )
2653− .subject("favicon");
2654− ctx.diagnostics.push(at_attribute(diagnostic, column, ctx));
2655 }
1304 }
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 }
1305 }
2667− }
1306+ None => match parse_element_kind(without_at, line_num) {
2668− }
1307+ Ok(kind) => (kind, String::new()),
2669− page.title = match title {
1308+ Err(mut e) => {
2670− Some(title) => ctx.interpolate_text(&title.raw, title.span.line, Some(title.span.column)),
1309+ if let Some(fn_suggestion) = suggest_fn_name(without_at, ctx) {
2671− None => String::new(),
1310+ e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion);
2672− };
2673− page
2674−}
2675−
2676−/// `@page`'s own rules for one of its attributes, checked where the
2677−/// `@page` runs and where it doesn't (a layout that isn't called): its
2678−/// word is written `favicon FILE`, and `inline` belongs to `@image`.
2679−/// Returns whether the attribute can be used (a `favicon` with its file,
2680−/// or any other attribute but `inline`).
2681−fn check_page_attr(
2682− key: &str,
2683− html: bool,
2684− value: Option<&str>,
2685− line: usize,
2686− column: Option<usize>,
2687− ctx: &mut ParseContext,
2688−) -> bool {
2689− let report = |ctx: &mut ParseContext, diagnostic: Diagnostic| {
2690− let diagnostic = at_attribute(diagnostic, column, ctx);
2691− ctx.diagnostics.push(diagnostic);
2692− };
2693− if crate::vocab::PAGE_WORDS.contains(&key) {
2694− if html {
2695− report(
2696− ctx,
2697− Diagnostic::error(
2698− code::PARAMETER_FORM,
2699− line,
2700− format!(
2701− "'{0}' is @page's own word, not an HTML attribute: write `{0} {1}`",
2702− key,
2703− value.filter(|v| !v.is_empty()).unwrap_or("FILE")
2704− ),
2705− )
2706− .subject(format!("{}=", key))
2707− .suggest(Some(format!("{} ", key))),
2708− );
2709− }
2710− if value.is_none_or(|v| v.trim().is_empty()) {
2711− report(
2712− ctx,
2713− Diagnostic::error(
2714− code::MISSING_VALUE,
2715− line,
2716− format!("'{0}' needs its file: `{0} favicon.png`", key),
2717− )
2718− .subject(key),
2719− );
2720− return false;
2721− }
2722− return true;
2723− }
2724− if !html && crate::vocab::base_attribute(key) == "inline" {
2725− report(
2726− ctx,
2727− Diagnostic::error(
2728− code::UNEXPECTED_ARGUMENT,
2729− line,
2730− "`inline` puts an image's file into the page, and @page has none: it only \
2731− goes on @image"
2732− .to_string(),
2733− )
2734− .subject("inline"),
2735− );
2736− return false;
2737− }
2738− true
2739−}
2740−
2741−/// A parameter passed `name=value`, the form of an HTML attribute.
2742−fn parameter_form(function: &str, key: &str, value: &str, line: usize) -> Diagnostic {
2743− Diagnostic::error(
2744− code::PARAMETER_FORM,
2745− line,
2746− format!(
2747− "'{}' is a parameter of @{}: parameters are written `name value`, so write `{} {}`",
2748− key, function, key, value
2749− ),
2750− )
2751− .subject(format!("{}=", key))
2752− .suggest(Some(format!("{} ", key)))
2753−}
2754−
2755−/// A call that leaves out a parameter without a default.
2756−fn missing_parameter(
2757− function: &str,
2758− param: &str,
2759− line: usize,
2760− column: Option<usize>,
2761−) -> Diagnostic {
2762− let diagnostic = Diagnostic::error(
2763− code::MISSING_PARAMETER,
2764− line,
2765− format!(
2766− "@{} needs '{}': the parameter has no default, so pass it as `{} VALUE`",
2767− function, param, param
2768− ),
2769− )
2770− .subject(param);
2771− match column {
2772− Some(column) => diagnostic.column(column),
2773− None => diagnostic,
2774− }
2775−}
2776−
2777−/// The column of a call's `@name`, when it is on the call's line.
2778−fn name_column(head: &syntax::Head, line: usize) -> Option<usize> {
2779− (head.name_span.line == line).then_some(head.name_span.column)
2780−}
2781−
2782−/// Define `$name`, or, for `--name`, the CSS custom property on `:root`
2783−/// (which gets quoted text with its quotes, and is read with
2784−/// `var(--name)`, never as a variable). `@let name.field value` gives the
2785−/// record `$name` that field (or the list `$name` a new item at that
2786−/// index), as a new value in this block.
2787−fn set_variable(name: &str, value: Value, line_num: usize, ctx: &mut ParseContext) {
2788− if name.starts_with("--") {
2789− let css = value
2790− .quoted_css()
2791− .map_or_else(|| value.to_string(), str::to_string);
2792− let at_line = |diagnostic: Diagnostic, ctx: &ParseContext| match &ctx.current_source {
2793− (current, Some(text)) if *current == line_num => diagnostic.source(text.clone()),
2794− _ => diagnostic,
2795− };
2796− // `:root` is one place for the whole page: a declaration inside a
2797− // block (an element, a loop, a function's body) would not be
2798− // kept to it, and each run would replace the last
2799− if ctx.env.frames.len() > FILE_FRAMES {
2800− let diagnostic = Diagnostic::error(
2801− code::INVALID_DEFINITION,
2802− line_num,
2803− format!(
2804− "`@let {name}` declares {name} on :root, for the whole page, so it goes at \
2805− the top level of the file: to set it on an element and what is inside \
2806− it, write `[{name} value]` on that element"
2807− ),
2808− )
2809− .subject(name);
2810− ctx.push_once(at_line(diagnostic, ctx));
2811− return;
2812− }
2813− match css_breakout(&css) {
2814− Some(reason) => {
2815− let diagnostic = Diagnostic::error(
2816− code::INVALID_VALUE,
2817− line_num,
2818− format!(
2819− "the value of '{}' has {}: `{}` can't be written into the page's CSS",
2820− name, reason, css
2821− ),
2822− )
2823− .subject(css.as_str());
2824− ctx.push_once(at_line(diagnostic, ctx));
2825− }
2826− None => match ctx.css_vars.iter_mut().find(|(n, _)| n == name) {
2827− Some(declared) => {
2828− let diagnostic = Diagnostic::warning(
2829− code::DUPLICATE_ATTRIBUTE,
2830− line_num,
2831− format!(
2832− "{name} is declared again: :root has one value for the whole page, \
2833− so every element gets this one, also above this line (a theme \
2834− redefines a token on an element: `[dark:{name} {css}]`)"
2835− ),
2836− )
2837− .subject(name);
2838− declared.1 = css;
2839− ctx.push_once(at_line(diagnostic, ctx));
2840− }
2841− None => ctx.css_vars.push((name.to_string(), css)),
2842− },
2843− }
2844− return;
2845− }
2846− let mut path = name.split('.');
2847− let root = path.next().unwrap_or(name);
2848− let fields: Vec<&str> = path.collect();
2849− let value = match fields.is_empty() {
2850− true => value,
2851− false => match with_field(ctx.env.value(root).cloned(), root, &fields, value) {
2852− Ok(value) => value,
2853− Err(message) => {
2854− let diagnostic =
2855− Diagnostic::error(code::INVALID_DEFINITION, line_num, message).subject(name);
2856− let diagnostic = match &ctx.current_source {
2857− (current, Some(text)) if *current == line_num => {
2858− diagnostic.source(text.clone())
2859− }
2860− _ => diagnostic,
2861− };
2862− ctx.push_once(diagnostic);
2863− // Reported: the name keeps what it held
2864− ctx.let_lines.entry(root.to_string()).or_insert(line_num);
2865− return;
2866− }
2867− },
2868− };
2869− ctx.bind(root, value);
2870− ctx.let_lines.entry(root.to_string()).or_insert(line_num);
2871−}
2872−
2873−/// `value` (the value of `$name`) with the field at `path` set to `new`:
2874−/// a record's field, or the item of a list at an index it has. A name
2875−/// without a value, or one whose value is empty (a field a record doesn't
2876−/// have), becomes a record; any other value has no fields to set.
2877−fn with_field(
2878− value: Option<Value>,
2879− name: &str,
2880− path: &[&str],
2881− new: Value,
2882−) -> Result<Value, String> {
2883− let Some((field, rest)) = path.split_first() else {
2884− return Ok(new);
2885− };
2886− let inner = format!("{}.{}", name, field);
2887− match value {
2888− Some(Value::Record(fields)) => {
2889− let mut fields = fields.as_ref().clone();
2890− match fields.iter().position(|(key, _)| key == field) {
2891− Some(i) => {
2892− let old = std::mem::replace(&mut fields[i].1, Value::empty());
2893− fields[i].1 = with_field(Some(old), &inner, rest, new)?;
2894− }
2895− None => fields.push((field.to_string(), with_field(None, &inner, rest, new)?)),
2896− }
2897− Ok(Value::Record(Rc::new(fields)))
2898− }
2899− Some(Value::List(list)) => {
2900− let count = list.items.len();
2901− let Some(i) = field.parse::<usize>().ok().filter(|&i| i < count) else {
2902− return Err(format!(
2903− "`${}` is {}, so `.{}` isn't one of its items (they are `.0` to `.{}`)",
2904− name,
2905− Value::List(list).describe(),
2906− field,
2907− count.saturating_sub(1)
2908− ));
2909− };
2910− let mut items = list.items.as_ref().clone();
2911− let old = std::mem::replace(&mut items[i], Value::empty());
2912− items[i] = with_field(Some(old), &inner, rest, new)?;
2913− // Its items changed, so it no longer prints as written
2914− Ok(Value::list(items))
2915− }
2916− None => Ok(Value::Record(Rc::new(vec![(
2917− field.to_string(),
2918− with_field(None, &inner, rest, new)?,
2919− )]))),
2920− Some(value) if value.to_string().is_empty() => with_field(None, name, path, new),
2921− Some(value) => Err(format!(
2922− "`@let {}.{}` sets a field of a record, but `${}` is {} (`{}`), which has no fields",
2923− name,
2924− path.join("."),
2925− name,
2926− value.describe(),
2927− value
2928− )),
2929− }
2930−}
2931−
2932−/// What a verbatim directive holds (`@head`, `@style`, `@raw`): the rest
2933−/// of its line (`@style .a { color: red }`), or else its indented block.
2934−/// The two together are a syntax error, reported by the tree; the line
2935−/// wins.
2936−fn verbatim_content(node: &syntax::Node) -> String {
2937− match node.directive().map(|d| &d.args) {
2938− Some(DirectiveArgs::Text(Some(line))) => format!("{}\n", line.raw),
2939− _ => verbatim_text(&node.children),
2940− }
2941−}
2942−
2943−/// The text of a verbatim body (`@head`, `@style`, `@markdown`, `@raw`).
2944−fn verbatim_text(children: &[syntax::Node]) -> String {
2945− let mut text = String::new();
2946− for child in children {
2947− if let NodeKind::Verbatim(body) = &child.kind {
2948− text.push_str(&body.text);
2949− text.push('\n');
2950− }
2951− }
2952− text
2953−}
2954−
2955−// ---------------------------------------------------------------------------
2956−// @children replacement
2957−// ---------------------------------------------------------------------------
2958−
2959−fn replace_children_and_slots(
2960− nodes: Vec<Node>,
2961− caller_children: &[Node],
2962− slot_contents: &HashMap<String, Vec<Node>>,
2963−) -> Vec<Node> {
2964− let mut result = Vec::new();
2965− for node in nodes {
2966− match node {
2967− Node::Element(elem) if elem.kind == ElementKind::Children => {
2968− if caller_children.is_empty() && !elem.children.is_empty() {
2969− // Use @children's own children as default/fallback content
2970− result.extend(elem.children);
2971− } else {
2972− result.extend(caller_children.iter().cloned());
2973− }
2974− }
2975− Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) =>
2976− {
2977− if let ElementKind::Slot(ref name) = elem.kind {
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− }
2987− Node::Element(mut elem) => {
2988− elem.children =
2989− replace_children_and_slots(elem.children, caller_children, slot_contents);
2990− result.push(Node::Element(elem));
2991− }
2992− other => result.push(other),
2993− }
2994− }
2995− result
2996−}
2997−
2998−// ---------------------------------------------------------------------------
2999−// Where @slot and @children are written
3000−// ---------------------------------------------------------------------------
3001−
3002−/// The files each `@include` line brought in, by the line's node id, with
3003−/// the include chain that leads to each.
3004−type IncludedBy = HashMap<usize, Vec<(Rc<Tree>, String)>>;
3005−
3006−/// The lines of the files an `@include` line brings in, and the include
3007−/// chain of each (for diagnostics).
3008−type Included<'a, 'f> = &'f dyn Fn(&syntax::Node) -> Vec<(&'a [syntax::Node], Option<&'a str>)>;
3009−
3010−/// Whether `name` is a function here: defined so far, or anywhere in the
3011−/// file and the files it includes.
3012−fn is_function(name: &str, ctx: &ParseContext) -> bool {
3013− ctx.env.function(name).is_some() || ctx.namespace.contains(name)
3014−}
3015−
3016−/// Whether `name` is one slot name: letters, digits, `-` and `_`,
3017−/// starting with a letter (like a function's name).
3018−fn is_slot_name(name: &str) -> bool {
3019− name.chars().next().is_some_and(char::is_alphabetic)
3020− && name
3021− .chars()
3022− .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
3023−}
3024−
3025−/// What a `@slot` or `@children` line is written in.
3026−#[derive(Clone, Copy)]
3027−enum Place<'a> {
3028− /// The top of a file or of a function's body, or a built-in element
3029− /// (by its name).
3030− Element(Option<&'a str>),
3031− /// A call: its content.
3032− Call,
3033−}
3034−
3035−/// Where a line is, for [`slot_uses`].
3036−#[derive(Clone, Copy)]
3037−struct At<'a> {
3038− /// Inside a function's body.
3039− body: bool,
3040− place: Place<'a>,
3041− /// Outside a body: the call (and its line) whose content it is in.
3042− call: Option<(&'a str, usize)>,
3043− /// The include chain of the file it is in, when that isn't the file
3044− /// being compiled.
3045− file: Option<&'a str>,
3046−}
3047−
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 }
1311 }
3156− slot_uses(&node.children, at, scan, out);
1312+ return Err(e);
3157 }
1313 }
3158 },
1314 },
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 }
1315 }
3193− }
1316+ } else {
3194−}
1317+ return Err(ParseError {
3195−
1318+ line: line_num,
3196−/// The slots a function's body declares, and whether it has a
1319+ message: format!("expected @element or [attrs], got: {}", content),
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 });
1320 });
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 };
1321 };
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
1322
3356−/// What is wrong with a `@slot` or `@children` where it is written.
1323+ // Parse optional [attrs]
3357−fn slot_problem(slot: &SlotUse) -> Option<Diagnostic> {
1324+ let (attrs, rest) = if rest.starts_with('[') {
3358− let line = slot.node.span.line;
1325+ parse_attr_brackets(&rest, line_num, ctx)?
3359− let what = if slot.is_slot() {
3360− match slot.name() {
3361− "" => "@slot".to_string(),
3362− name => format!("@slot {}", name),
3363− }
3364 } else {
1326 } else {
3365− "@children".to_string()
1327+ (Vec::new(), rest)
3366 };
1328 };
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
1329
3465−/// Whether `@slot` is the built-in element here, and not a function
1330+ let rest = rest.trim().to_string();
3466−/// defined so far that took its name.
1331+ track_var_refs(&rest, &mut ctx.used_variables);
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
1332
3632− // The leading argument: for an element whose row names a leading
1333+ // For @link, first token of rest is URL, remainder is inline text
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();
1334 let mut children = Vec::new();
3641− let mut argument = None;
1335+ let argument = if rest.is_empty() {
3642− let leading = kind.arg().attributes();
1336+ None
3643− match text {
1337+ } else if kind == ElementKind::Link {
3644− Some(text) if !leading.is_empty() => {
1338+ let rest_sub = substitute_vars(&rest, &ctx.variables);
3645− let (token, rest) = text.split_leading();
1339+ if let Some((url, text)) = rest_sub.split_once(' ') {
3646− let given = attrs
1340+ let text = text.trim();
3647− .iter()
1341+ if !text.is_empty() {
3648− .find(|a| a.html && leading.contains(&a.key.as_str()))
1342+ children.push(Node::Text(parse_text_segments(text, ctx)));
3649− .map(|a| (a.key.clone(), a.value.clone().unwrap_or_default()));
1343+ }
3650− if let Some((key, value)) = given {
1344+ Some(url.to_string())
3651− // The attribute wins; the text is content, as if the
1345+ } else {
3652− // element had no leading argument
1346+ Some(rest_sub)
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 }
1347 }
3699− None => {}
1348+ } else {
3700− }
1349+ Some(substitute_vars(&rest, &ctx.variables))
1350+ };
3701
1351
3702 // For @slot, the argument is the slot name
1352 // For @slot, the argument is the slot name
3703 let kind = if let ElementKind::Slot(_) = kind {
1353 let kind = if let ElementKind::Slot(_) = kind {
@@ -3706,352 +1356,209 @@ fn parse_single_element(
3706 kind
1356 kind
3707 };
1357 };
3708
1358
1359+ // Every other element treats its argument as leading text content, as
1360+ // in `@el [padding 8] Hello` or `@paragraph Read {@link /more more}`.
1361+ let argument = match argument {
1362+ Some(_) if !argument_is_special(&kind) && !renders_argument_as_text(&kind) => {
1363+ children.insert(0, Node::Text(parse_text_segments(&rest, ctx)));
1364+ None
1365+ }
1366+ other => other,
1367+ };
1368+
3709 Ok(Element {
1369 Ok(Element {
3710 kind,
1370 kind,
3711 attrs,
1371 attrs,
3712 argument,
1372 argument,
3713 children,
1373 children,
3714 line_num,
1374 line_num,
3715− function: None,
3716 })
1375 })
3717}
1376}
3718
1377
3719−/// Whether `node` or a line inside it calls `@name` outside any `@if`
1378+/// Elements whose argument is not text content: a URL, a source, an action,
3720−/// (in a chain, or inline in text).
1379+/// or a slot name.
3721−fn calls_unguarded(node: &syntax::Node, name: &str) -> bool {
1380+fn argument_is_special(kind: &ElementKind) -> bool {
3722− if node.is_directive("if") || node.is_directive("else") {
1381+ matches!(
3723− return false;
1382+ kind,
3724− }
1383+ ElementKind::Link | ElementKind::Image | ElementKind::Script | ElementKind::Slot(_)
3725− let text_calls = |text: &syntax::Text| {
1384+ ) || kind
3726− text.segments
1385+ .spec()
3727− .iter()
1386+ .is_some_and(|spec| matches!(spec.arg, TagArg::Attr(_)))
3728− .any(|s| matches!(s, Segment::Inline(inline) if inline.head.name == name))
1387+}
3729− };
1388+
3730− let here = match &node.kind {
1389+/// The directive names (without `@`) the parser recognizes, for tools such
3731− NodeKind::Element(element) => {
1390+/// as the language server.
3732− element.chain.iter().any(|head| head.name == name)
1391+pub fn known_directives() -> &'static [&'static str] {
3733− || element.text.as_ref().is_some_and(text_calls)
1392+ KNOWN_DIRECTIVES
3734− }
1393+}
3735− NodeKind::Text(text) => text_calls(text),
1394+
3736− _ => false,
1395+const KNOWN_DIRECTIVES: &[&str] = &[
3737− };
1396+ "page",
3738− here || node
1397+ "let",
3739− .children
1398+ "include",
3740− .iter()
1399+ "raw",
3741− .any(|child| calls_unguarded(child, name))
1400+ "if",
3742−}
1401+ "else",
3743−
1402+ "each",
3744−/// `@td [padding 8] @children`: `@children` and `@slot NAME` mark where a
1403+ "meta",
3745−/// call's content goes only on a line of their own; after an element's
1404+ "head",
3746−/// attributes, or in a verbatim body, they are text.
1405+ "style",
3747−fn marker_in_text(node: &syntax::Node) -> Option<Diagnostic> {
1406+ "markdown",
3748− let is_marker = |text: &str| {
1407+ "data",
3749− let text = text.trim();
1408+];
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
1409
3975−/// The element named `name`, or an "unknown element" error that suggests
1410+fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
3976−/// the closest element, directive or function.
1411+ if let Some(kind) = ElementKind::from_name(s) {
3977−fn parse_element_kind(
3978− name: &str,
3979− line_num: usize,
3980− ctx: &mut ParseContext,
3981−) -> Result<ElementKind, ParseError> {
3982− if let Some(kind) = ElementKind::from_name(name) {
3983 return Ok(kind);
1412 return Ok(kind);
3984 }
1413 }
3985− // A known name in a place it can't go: say why instead of suggesting
1414+ if let Some(hint) = removed_syntax_hint(&format!("@{}", s)) {
3986− // the name itself.
1415+ return Err(ParseError {
3987− let misplaced = if crate::ast::directive(name).is_some() {
1416+ line: line_num,
3988− Some(format!(
1417+ message: hint,
3989− "unknown element @{}: @{} is a directive, which goes at the start of its own line",
1418+ });
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 }
1419 }
4031 let all_known: Vec<&str> = ElementKind::all_names()
1420 let all_known: Vec<&str> = ElementKind::all_names()
4032− .chain(DIRECTIVES.iter().map(|d| d.name))
1421+ .chain(KNOWN_DIRECTIVES.iter().copied())
4033 .collect();
1422 .collect();
4034− // An HTML element htmlang writes under its own name (`@a` is `@link`).
1423+ let message = match suggest_closest(s, &all_known) {
4035− let written_otherwise = crate::ast::HTML_NAMES_WRITTEN_OTHERWISE
1424+ Some(closest) => format!("unknown element @{}, did you mean @{}?", s, closest),
4036− .iter()
1425+ None => format!("unknown element @{}", s),
4037− .find(|(html, _)| *html == name)
4038− .map(|(_, htmlang)| *htmlang);
4039− let closest = written_otherwise.or_else(|| suggest_closest(name, &all_known));
4040− let mut message = match (closest, written_otherwise) {
4041− (Some(closest), Some(_)) => format!(
4042− "unknown element @{}, did you mean @{}? (@{} writes <{}>)",
4043− name, closest, closest, name
4044− ),
4045− (Some(closest), None) => format!("unknown element @{}, did you mean @{}?", name, closest),
4046− (None, _) => format!("unknown element @{}", name),
4047 };
1426 };
4048− let function = suggest_fn_name(name, ctx);
1427+ Err(ParseError {
4049− if let Some(function) = &function {
1428+ line: line_num,
4050− message = format!("{}, or did you mean @{}?", message, function);
1429+ message,
1430+ })
1431+}
1432+
1433+/// Directives and element names removed when the language was simplified,
1434+/// with what replaces each. `htmlang upgrade` rewrites all of them.
1435+const REMOVED_SYNTAX: &[(&str, &str)] = &[
1436+ ("@fn", "use `@let name $param` with an indented body"),
1437+ ("@define", "use `@let name [attributes]`"),
1438+ ("@mixin", "use `@let name [attributes]`"),
1439+ ("@unless", "use `@if not <condition>`"),
1440+ ("@for", "use `@each $i in 1..10`"),
1441+ ("@repeat", "use `@each $_ in 1..N`"),
1442+ (
1443+ "@switch",
1444+ "use `@match`, with `@let __switch [...]` inside a case for its attributes",
1445+ ),
1446+ ("@use", "use `@include`"),
1447+ (
1448+ "@import",
1449+ "use `@include` (a file with only definitions emits no content)",
1450+ ),
1451+ ("@with", "use `@let alias $source`"),
1452+ (
1453+ "@layout",
1454+ "make the layout a function (`@let layout` with `@children`) and call it",
1455+ ),
1456+ (
1457+ "@extends",
1458+ "make the layout a function (`@let layout` with `@children` and `@slot`), \
1459+ `@include` its file and call it",
1460+ ),
1461+ ("@scope", "write the `@scope` rule in an `@style` block"),
1462+ (
1463+ "@starting-style",
1464+ "write the `@starting-style` rule in an `@style` block",
1465+ ),
1466+ ("@css-property", "write an `@property` rule in an `@style` block"),
1467+ ("@lang", "use `@page [lang ...] Title`"),
1468+ ("@favicon", "use `@page [favicon ...] Title`"),
1469+ ("@canonical", "use `@page [canonical ...] Title`"),
1470+ ("@base", "use `@page [base ...] Title`"),
1471+ ("@og", "use `@meta og:NAME VALUE`"),
1472+ ("@debug", "removed: a layout needs no compile-time messages"),
1473+ ("@svg", "use `@image [inline] file.svg`"),
1474+ ("@match", "use `@if $x == a` / `@else if $x == b` / `@else`"),
1475+ ("@case", "use `@if $x == a` / `@else if $x == b` / `@else`"),
1476+ ("@default", "use `@else` in an `@if` chain"),
1477+ ("@theme", "use a `@let --name value` line per token"),
1478+ (
1479+ "@json-ld",
1480+ "put a `<script type=\"application/ld+json\">` in `@head`",
1481+ ),
1482+ ("@font-face", "write the `@font-face` rule in an `@style` block"),
1483+ (
1484+ "@manifest",
1485+ "write a manifest.json file and link it from `@head`",
1486+ ),
1487+ ("@breakpoint", "write the media query in an `@style` block"),
1488+ ("@deprecated", "remove it"),
1489+ (
1490+ "@collection",
1491+ "use `@data $name dir/*.json` (each file becomes `$name.STEM.key`)",
1492+ ),
1493+ ("@env", "use `@data $name env:NAME [default]`"),
1494+ (
1495+ "@fetch",
1496+ "download the data before building and use `@data $name file.json`",
1497+ ),
1498+ (
1499+ "@translations",
1500+ "put each locale's strings in a JSON file and use `@data $t locales/$lang.json`",
1501+ ),
1502+ ("@defer", "remove it: the content is already in the page"),
1503+ ("@keyframes", "write the CSS `@keyframes` rule in `@style`"),
1504+ ("@log", "removed: a layout needs no compile-time messages"),
1505+ ("@warn", "removed: a layout needs no compile-time messages"),
1506+ ("@assert", "removed: a layout needs no compile-time checks"),
1507+ (
1508+ "@component",
1509+ "use `@let`: an `@style` block in a function body is scoped to it",
1510+ ),
1511+ ("@col", "use `@el`"),
1512+ ("@p", "use `@paragraph`"),
1513+ ("@img", "use `@image`"),
1514+ ("@btn", "use `@button`"),
1515+ ("@divider", "use `@hr`"),
1516+ ("@opt", "use `@option`"),
1517+];
1518+
1519+/// Attributes that became standard-library bundles or plain CSS.
1520+fn removed_attribute_hint(name: &str) -> Option<&'static str> {
1521+ match name {
1522+ "skeleton" => Some("use the `$skeleton` bundle"),
1523+ "no-scrollbar" => Some("use the `$no-scrollbar` bundle"),
1524+ "gradient" => Some("use `background linear-gradient(...)`"),
1525+ "animate" => Some("use `animation`"),
1526+ "inset-area" => Some("use `position-area`"),
1527+ "gap-x" => Some("use `column-gap`"),
1528+ "gap-y" => Some("use `row-gap`"),
1529+ "shadow" => Some("use `box-shadow`"),
1530+ "blur" => Some("use `filter blur(...)`"),
1531+ "backdrop-blur" => Some("use `backdrop-filter blur(...)`"),
1532+ "truncate" => Some("use the `$truncate` bundle"),
1533+ "critical" => Some("remove it"),
1534+ "grid" => Some("use `@grid`, or `display grid`"),
1535+ "bold" => Some("use `font-weight bold`"),
1536+ "italic" => Some("use `font-style italic`"),
1537+ "underline" => Some("use `text-decoration underline`"),
1538+ "size" => Some("use `font-size`"),
1539+ "rounded" => Some("use `border-radius`"),
1540+ "hidden" => Some("use `display none`"),
1541+ "padding-x" => Some("use `padding-inline`"),
1542+ "padding-y" => Some("use `padding-block`"),
1543+ "margin-x" => Some("use `margin-inline`"),
1544+ "margin-y" => Some("use `margin-block`"),
1545+ _ => None,
4051 }
1546 }
4052− Err(Diagnostic::error(code::UNKNOWN_ELEMENT, line_num, message)
1547+}
4053− .subject(name)
1548+
4054− .suggest(closest.map(str::to_string).or(function)))
1549+/// If `content` starts with removed syntax, the error message for it.
1550+fn removed_syntax_hint(content: &str) -> Option<String> {
1551+ let trimmed = content.trim_start();
1552+ REMOVED_SYNTAX.iter().find_map(|(name, replacement)| {
1553+ let rest = trimmed.strip_prefix(name)?;
1554+ if !(rest.is_empty() || rest.starts_with([' ', '['])) {
1555+ return None;
1556+ }
1557+ Some(format!(
1558+ "`{}` was removed: {} (run `htmlang upgrade` to rewrite it automatically)",
1559+ name, replacement
1560+ ))
1561+ })
4055}
1562}
4056
1563
4057/// Format include/import stack as a readable chain for error messages.
1564/// Format include/import stack as a readable chain for error messages.
@@ -4122,37 +1629,34 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
4122 best
1629 best
4123}
1630}
4124
1631
4125−/// Suggest the closest user-defined function name for typos, from the
1632+/// Suggest the closest user-defined function name for typos.
4126−/// functions defined so far and those defined anywhere in the file.
4127fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
1633fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
4128 let input_chars: Vec<char> = input.chars().collect();
1634 let input_chars: Vec<char> = input.chars().collect();
4129 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
1635 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
4130− let mut best: Option<(usize, &String)> = None;
1636+ let mut best: Option<String> = None;
4131− let visible: Vec<&String> = ctx.defined_functions.keys().collect();
1637+ let mut best_dist = usize::MAX;
4132− for name in visible.into_iter().chain(ctx.namespace.iter()) {
1638+ for name in ctx.functions.keys() {
4133 let nlen = name.chars().count();
1639 let nlen = name.chars().count();
4134 if nlen.abs_diff(input_chars.len()) > max_allowed {
1640 if nlen.abs_diff(input_chars.len()) > max_allowed {
4135 continue;
1641 continue;
4136 }
1642 }
4137 let name_chars: Vec<char> = name.chars().collect();
1643 let name_chars: Vec<char> = name.chars().collect();
4138 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
1644 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
4139− if dist <= max_allowed && best.is_none_or(|b| (dist, name) < b) {
1645+ if dist < best_dist && dist <= max_allowed {
4140− best = Some((dist, name));
1646+ best_dist = dist;
1647+ best = Some(name.clone());
4141 }
1648 }
4142 }
1649 }
4143− best.map(|(_, name)| name.clone())
1650+ best
4144}
1651}
4145
1652
4146/// Suggest the closest variable name for undefined `$var` references.
1653/// Suggest the closest variable name for undefined `$var` references.
4147−fn suggest_var_name(input: &str, vars: &[&str]) -> Option<String> {
1654+fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<String> {
4148 let input_chars: Vec<char> = input.chars().collect();
1655 let input_chars: Vec<char> = input.chars().collect();
4149 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
1656 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
4150 let mut best: Option<String> = None;
1657 let mut best: Option<String> = None;
4151 let mut best_dist = usize::MAX;
1658 let mut best_dist = usize::MAX;
4152− let mut vars = vars.to_vec();
1659+ for name in vars.keys().filter(|k| !k.ends_with('#')) {
4153− // The same answer every time, whatever the order of the names
4154− vars.sort_unstable();
4155− for name in vars {
4156 let nlen = name.chars().count();
1660 let nlen = name.chars().count();
4157 if nlen.abs_diff(input_chars.len()) > max_allowed {
1661 if nlen.abs_diff(input_chars.len()) > max_allowed {
4158 continue;
1662 continue;
@@ -4161,1988 +1665,1019 @@ fn suggest_var_name(input: &str, vars: &[&str]) -> Option<String> {
4161 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
1665 let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
4162 if dist < best_dist && dist <= max_allowed {
1666 if dist < best_dist && dist <= max_allowed {
4163 best_dist = dist;
1667 best_dist = dist;
4164− best = Some(name.to_string());
1668+ best = Some(name.clone());
4165 }
1669 }
4166 }
1670 }
4167 best
1671 best
4168}
1672}
4169
1673
4170−// ---------------------------------------------------------------------------
1674+/// Check for undefined `$var` references and return "did you mean?" diagnostics.
4171−// Attribute parsing
1675+/// `indent` is the line's leading whitespace, which `input` has had trimmed;
4172−// ---------------------------------------------------------------------------
1676+/// reported columns and source lines include it.
4173−
1677+fn check_undefined_vars(
4174−/// The quoted strings of a CSS value, without their quotes.
1678+ input: &str,
4175−fn css_strings(value: &str) -> Vec<&str> {
1679+ vars: &HashMap<String, String>,
4176− let mut strings = Vec::new();
1680+ line_num: usize,
4177− let mut start = None;
1681+ indent: usize,
4178− let mut escaped = false;
1682+) -> Vec<Diagnostic> {
4179− for (i, c) in value.char_indices() {
1683+ let mut warnings = Vec::new();
4180− match (c, start) {
1684+ if !input.contains('$') {
4181− _ if escaped => escaped = false,
1685+ return warnings;
4182− ('\\', Some(_)) => escaped = true,
1686+ }
4183− ('"', None) => start = Some(i + 1),
1687+ let chars: Vec<char> = input.chars().collect();
4184− ('"', Some(from)) => {
1688+ let mut i = 0;
4185− strings.push(&value[from..i]);
1689+ while i < chars.len() {
4186− start = None;
1690+ if chars[i] == '$'
1691+ && i + 1 < chars.len()
1692+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
1693+ {
1694+ let col = i;
1695+ let start = i + 1;
1696+ let mut end = start;
1697+ while end < chars.len()
1698+ && (chars[end].is_alphanumeric()
1699+ || chars[end] == '-'
1700+ || chars[end] == '_'
1701+ || chars[end] == '.')
1702+ {
1703+ end += 1;
4187 }
1704 }
4188− _ => {}
1705+ while end > start && chars[end - 1] == '.' {
1706+ end -= 1;
1707+ }
1708+ let name: String = chars[start..end].iter().collect();
1709+ if !name.is_empty()
1710+ && !vars.contains_key(&name)
1711+ && let Some(closest) = suggest_var_name(&name, vars)
1712+ {
1713+ warnings.push(Diagnostic {
1714+ line: line_num,
1715+ column: Some(indent + col),
1716+ message: format!(
1717+ "undefined variable '${}', did you mean '${}'?",
1718+ name, closest
1719+ ),
1720+ severity: Severity::Warning,
1721+ source_line: Some(format!("{}{}", " ".repeat(indent), input)),
1722+ });
1723+ }
1724+ i = end;
1725+ } else {
1726+ i += 1;
4189 }
1727 }
4190 }
1728 }
4191− strings
1729+ warnings
4192}
1730}
4193
1731
4194−/// An `if(...)` in a CSS value that isn't CSS's if(): CSS writes each
1732+// ---------------------------------------------------------------------------
4195−/// branch `CONDITION: VALUE`, so an if() without a `:` is something else.
1733+// Attribute parsing
4196−/// Quoted CSS strings are text, not calls.
1734+// ---------------------------------------------------------------------------
4197−fn not_css_if(value: &str) -> Option<&str> {
1735+
4198− let mut quote = None;
1736+/// Attributes that expect purely numeric values (px-based) or values with CSS units.
4199− let mut prev = None;
1737+const NUMERIC_ATTRS: &[&str] = &[
4200− let mut chars = value.char_indices();
1738+ "spacing",
4201− while let Some((at, c)) = chars.next() {
1739+ "gap",
4202− match quote {
1740+ "padding",
4203− Some(_) if c == '\\' => {
1741+ "padding-top",
4204− chars.next();
1742+ "padding-bottom",
1743+ "padding-left",
1744+ "padding-right",
1745+ "min-width",
1746+ "max-width",
1747+ "min-height",
1748+ "max-height",
1749+ "border-radius",
1750+ "font-size",
1751+ "column-gap",
1752+ "row-gap",
1753+ "top",
1754+ "right",
1755+ "bottom",
1756+ "left",
1757+ "letter-spacing",
1758+];
1759+
1760+const CSS_UNIT_SUFFIXES: &[&str] = &[
1761+ "px", "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm",
1762+ "mm", "in", "pt", "pc", "fr",
1763+];
1764+
1765+fn has_css_unit(value: &str) -> bool {
1766+ CSS_UNIT_SUFFIXES.iter().any(|u| value.ends_with(u))
1767+ || value.starts_with("var(")
1768+ || value.starts_with("calc(")
1769+ || value.starts_with("clamp(")
1770+ || value.starts_with("min(")
1771+ || value.starts_with("max(")
1772+}
1773+
1774+/// Attributes that accept numeric OR keyword values.
1775+const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
1776+const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
1777+
1778+fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext) {
1779+ let base_key = crate::vocab::base_attribute(attr.key.as_str());
1780+
1781+ if let Some(val) = &attr.value {
1782+ if NUMERIC_ATTRS.contains(&base_key) {
1783+ // All space-separated parts must be numeric or have a CSS unit
1784+ for part in val.split_whitespace() {
1785+ if part.parse::<f64>().is_err() && !has_css_unit(part) {
1786+ ctx.diagnostics.push(Diagnostic {
1787+ line: line_num,
1788+ column: None,
1789+ message: format!(
1790+ "'{}' expects a numeric value (with optional unit), got '{}'",
1791+ attr.key, val
1792+ ),
1793+ severity: Severity::Warning,
1794+ source_line: None,
1795+ });
1796+ return;
1797+ }
4205 }
1798 }
4206− Some(q) if c == q => quote = None,
1799+ } else if NUMERIC_OR_KEYWORD_ATTRS.contains(&base_key) {
4207− Some(_) => {}
1800+ let is_keyword = SIZE_KEYWORDS.contains(&val.as_str());
4208− None if matches!(c, '"' | '\'') => quote = Some(c),
1801+ let is_numeric = val.parse::<f64>().is_ok();
4209− None => {
1802+ let has_unit = has_css_unit(val);
4210− let word_before =
1803+ if !is_keyword && !is_numeric && !has_unit {
4211− prev.is_some_and(|p: char| p.is_alphanumeric() || matches!(p, '-' | '_'));
1804+ ctx.diagnostics.push(Diagnostic {
4212− if !word_before && value[at..].starts_with("if(") {
1805+ line: line_num,
4213− let mut depth = 0;
1806+ column: None,
4214− let close = value[at + 2..].char_indices().find_map(|(i, c)| {
1807+ message: format!(
4215− match c {
1808+ "'{}' expects a number or one of [{}], got '{}'",
4216− '(' => depth += 1,
1809+ attr.key,
4217− ')' => depth -= 1,
1810+ SIZE_KEYWORDS.join(", "),
4218− _ => {}
1811+ val
4219− }
1812+ ),
4220− (depth == 0).then_some(at + 2 + i)
1813+ severity: Severity::Warning,
1814+ source_line: None,
1815+ });
1816+ }
1817+ } else if base_key == "opacity" {
1818+ if let Ok(v) = val.parse::<f64>() {
1819+ if !(0.0..=1.0).contains(&v) {
1820+ ctx.diagnostics.push(Diagnostic {
1821+ line: line_num,
1822+ column: None,
1823+ message: format!("'opacity' should be between 0 and 1, got '{}'", val),
1824+ severity: Severity::Warning,
1825+ source_line: None,
4221 });
1826 });
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 }
1827 }
1828+ } else {
1829+ ctx.diagnostics.push(Diagnostic {
1830+ line: line_num,
1831+ column: None,
1832+ message: format!("'opacity' expects a numeric value, got '{}'", val),
1833+ severity: Severity::Warning,
1834+ source_line: None,
1835+ });
4227 }
1836 }
4228− }
1837+ } else if base_key == "z-index" {
4229− prev = Some(c);
1838+ if val.parse::<i32>().is_err() {
4230− }
1839+ ctx.diagnostics.push(Diagnostic {
4231− None
1840+ line: line_num,
4232−}
1841+ column: None,
4233−
1842+ message: format!("'z-index' expects an integer, got '{}'", val),
4234−/// Why a CSS value can't be written into a rule as it is, whatever it
1843+ severity: Severity::Warning,
4235−/// means: a `;` (outside parentheses, where CSS's own `if()` uses it), a
1844+ source_line: None,
4236−/// `{` or a `}` would end the declaration or the rule, and an unclosed
1845+ });
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 }
1846 }
4253− (Some(q), c) if c == q => quote = None,
1847+ } else if base_key == "display" {
4254− (Some(_), _) => {}
1848+ const DISPLAY_VALUES: &[&str] = &[
4255− (None, '"' | '\'') => quote = Some(c),
1849+ "none",
4256− (None, '(') => depth += 1,
1850+ "block",
4257− (None, ')') if depth == 0 => return Some("a `)` that closes nothing"),
1851+ "inline",
4258− (None, ')') => depth -= 1,
1852+ "inline-block",
4259− (None, ';') if depth == 0 => return Some("a `;`, which would end the declaration"),
1853+ "flex",
4260− (None, '{' | '}') => return Some("a `{` or `}`, which would end the CSS rule"),
1854+ "inline-flex",
4261− _ => {}
1855+ "grid",
4262− }
1856+ "inline-grid",
4263− }
1857+ "table",
4264− match (quote, depth) {
1858+ "table-row",
4265− (Some('"'), _) => Some("a `\"` that isn't closed"),
1859+ "table-cell",
4266− (Some(_), _) => Some("a `'` that isn't closed"),
1860+ "contents",
4267− (None, 0) => None,
1861+ "flow-root",
4268− (None, _) => Some("a `(` that isn't closed"),
1862+ "list-item",
4269− }
1863+ ];
4270−}
1864+ if !DISPLAY_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4271−
1865+ let suggestion = suggest_closest(val, DISPLAY_VALUES);
4272−/// The hex colors of a CSS value that don't have 3, 4, 6 or 8 hex
1866+ let msg = match suggestion {
4273−/// digits. A `#` word in a CSS value is a color, except inside `url(...)`
1867+ Some(s) => format!("unknown display value '{}', did you mean '{}'?", val, s),
4274−/// and quoted strings.
1868+ None => format!("unknown display value '{}'", val),
4275−fn bad_hex_colors(value: &str) -> Vec<&str> {
1869+ };
4276− let mut bad = Vec::new();
1870+ ctx.diagnostics.push(Diagnostic {
4277− let mut quote = None;
1871+ line: line_num,
4278− let mut in_url = 0usize;
1872+ column: None,
4279− let mut depth = 0usize;
1873+ message: msg,
4280− let mut chars = value.char_indices().peekable();
1874+ severity: Severity::Warning,
4281− while let Some((at, c)) = chars.next() {
1875+ source_line: None,
4282− match (quote, c) {
1876+ });
4283− (_, '\\') => {
4284− chars.next();
4285 }
1877 }
4286− (Some(q), c) if c == q => quote = None,
1878+ } else if base_key == "position" {
4287− (Some(_), _) => {}
1879+ const POSITION_VALUES: &[&str] = &["static", "relative", "absolute", "fixed", "sticky"];
4288− (None, '"' | '\'') => quote = Some(c),
1880+ if !POSITION_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4289− (None, '(') => {
1881+ let suggestion = suggest_closest(val, POSITION_VALUES);
4290− depth += 1;
1882+ let msg = match suggestion {
4291− if value[..at].ends_with("url") && in_url == 0 {
1883+ Some(s) => format!("unknown position value '{}', did you mean '{}'?", val, s),
4292− in_url = depth;
1884+ None => format!("unknown position value '{}'", val),
4293− }
1885+ };
1886+ ctx.diagnostics.push(Diagnostic {
1887+ line: line_num,
1888+ column: None,
1889+ message: msg,
1890+ severity: Severity::Warning,
1891+ source_line: None,
1892+ });
4294 }
1893 }
4295− (None, ')') => {
1894+ } else if base_key == "overflow" || base_key == "overflow-x" || base_key == "overflow-y" {
4296− if in_url == depth {
1895+ const OVERFLOW_VALUES: &[&str] = &["visible", "hidden", "scroll", "auto", "clip"];
4297− in_url = 0;
1896+ if !OVERFLOW_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4298− }
1897+ let suggestion = suggest_closest(val, OVERFLOW_VALUES);
4299− depth = depth.saturating_sub(1);
1898+ let msg = match suggestion {
1899+ Some(s) => format!("unknown overflow value '{}', did you mean '{}'?", val, s),
1900+ None => format!("unknown overflow value '{}'", val),
1901+ };
1902+ ctx.diagnostics.push(Diagnostic {
1903+ line: line_num,
1904+ column: None,
1905+ message: msg,
1906+ severity: Severity::Warning,
1907+ source_line: None,
1908+ });
4300 }
1909 }
4301− (None, '#') if in_url == 0 => {
1910+ } else if base_key == "text-align" {
4302− let end = value[at + 1..]
1911+ const TEXT_ALIGN_VALUES: &[&str] =
4303− .find(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_'))
1912+ &["left", "right", "center", "justify", "start", "end"];
4304− .map_or(value.len(), |n| at + 1 + n);
1913+ if !TEXT_ALIGN_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4305− let hex = &value[at + 1..end];
1914+ let suggestion = suggest_closest(val, TEXT_ALIGN_VALUES);
4306− if !(matches!(hex.len(), 3 | 4 | 6 | 8)
1915+ let msg = match suggestion {
4307− && hex.chars().all(|c| c.is_ascii_hexdigit()))
1916+ Some(s) => format!("unknown text-align value '{}', did you mean '{}'?", val, s),
4308− {
1917+ None => format!("unknown text-align value '{}'", val),
4309− bad.push(&value[at..end]);
1918+ };
4310− }
1919+ ctx.diagnostics.push(Diagnostic {
1920+ line: line_num,
1921+ column: None,
1922+ message: msg,
1923+ severity: Severity::Warning,
1924+ source_line: None,
1925+ });
4311 }
1926 }
4312− _ => {}
1927+ } else if base_key == "cursor" {
4313− }
1928+ const CURSOR_VALUES: &[&str] = &[
4314− }
1929+ "auto",
4315− bad
1930+ "default",
4316−}
1931+ "none",
4317−
1932+ "pointer",
4318−/// A diagnostic about an attribute, with its column and source line when
1933+ "wait",
4319−/// it is on the line being evaluated.
1934+ "text",
4320−fn at_attribute(diagnostic: Diagnostic, column: Option<usize>, ctx: &ParseContext) -> Diagnostic {
1935+ "move",
4321− match (&ctx.current_source, column) {
1936+ "not-allowed",
4322− ((current, Some(text)), Some(column)) if *current == diagnostic.line => {
1937+ "crosshair",
4323− diagnostic.column(column).source(text.clone())
1938+ "grab",
4324− }
1939+ "grabbing",
4325− _ => diagnostic,
1940+ "help",
4326− }
1941+ "progress",
4327−}
1942+ "col-resize",
4328−
1943+ "row-resize",
4329−/// Check a style's value. Values go to the CSS as written, so only what
1944+ "n-resize",
4330−/// can't be wrong in any CSS is checked: a value that would break out of
1945+ "s-resize",
4331−/// its rule is an error (and it is left out); a hex color without 3, 4, 6
1946+ "e-resize",
4332−/// or 8 digits, an `if()` that isn't CSS's and a quoted font stack are
1947+ "w-resize",
4333−/// warnings. Returns whether the value can be written.
1948+ "zoom-in",
4334−fn check_css_value(
1949+ "zoom-out",
4335− attr: &Attribute,
1950+ "context-menu",
4336− base: &str,
1951+ "cell",
4337− line: usize,
1952+ "copy",
4338− column: Option<usize>,
1953+ "alias",
4339− ctx: &mut ParseContext,
1954+ "no-drop",
4340−) -> bool {
1955+ ];
4341− let Some(value) = &attr.value else {
1956+ if !CURSOR_VALUES.contains(&val.as_str())
4342− return true;
1957+ && !val.starts_with("url(")
4343− };
1958+ && !val.starts_with("var(")
4344− if let Some(reason) = css_breakout(value) {
4345− let diagnostic = Diagnostic::error(
4346− code::INVALID_VALUE,
4347− line,
4348− format!(
4349− "the value of '{}' has {}: `{}` can't be written into the page's CSS",
4350− attr.key, reason, value
4351− ),
4352− )
4353− .subject(value.as_str());
4354− ctx.diagnostics.push(at_attribute(diagnostic, column, ctx));
4355− return false;
4356− }
4357− if crate::vocab::is_custom_property(base) {
4358− return true;
4359− }
4360−
4361− let warn = |ctx: &mut ParseContext, diagnostic: Diagnostic| {
4362− let diagnostic = at_attribute(diagnostic, column, ctx);
4363− ctx.diagnostics.push(diagnostic);
4364− };
4365− // In CSS, a quoted font-family is one family, commas and all
4366− if base == "font-family"
4367− && let Some(family) = css_strings(value).into_iter().find(|s| s.contains(','))
4368− {
4369− let stack = family
4370− .split(',')
4371− .map(str::trim)
4372− .collect::<Vec<_>>()
4373− .join("\\, ");
4374− warn(
4375− ctx,
4376− Diagnostic::warning(
4377− code::INVALID_VALUE,
4378− line,
4379− format!(
4380− "font-family \"{}\" is one family, whose name has a comma in it: \
4381− for a font stack, write `font-family {}`",
4382− family, stack
4383− ),
4384− )
4385− .subject(format!("\"{}\"", family))
4386− .suggest(Some(stack)),
4387− );
4388− }
4389− if let Some(call) = not_css_if(value) {
4390− warn(
4391− ctx,
4392− Diagnostic::warning(
4393− code::INVALID_VALUE,
4394− line,
4395− format!(
4396− "'{}' is not CSS's if(), whose branches are written \
4397− `if(CONDITION: VALUE; else: VALUE)`",
4398− call
4399− ),
4400− )
4401− .subject(call),
4402− );
4403− }
4404− for color in bad_hex_colors(value) {
4405− warn(
4406− ctx,
4407− Diagnostic::warning(
4408− code::INVALID_COLOR,
4409− line,
4410− format!(
4411− "invalid hex color '{}': a hex color has 3, 4, 6 or 8 hex digits",
4412− color
4413− ),
4414− )
4415− .subject(color),
4416− );
4417− }
4418− true
4419−}
4420−
4421−/// An unknown prefix: the message, the text it is about, and what to write
4422−/// instead, when something is close enough.
4423−struct PrefixProblem {
4424− message: String,
4425− subject: String,
4426− suggestion: Option<String>,
4427−}
4428−
4429−/// What is wrong with the prefix `rest` starts with, when the known
4430−/// prefixes are already split off it and what is left still starts with
4431−/// one: a word and a `:` (`hovr:`), or a word and a parenthesised argument
4432−/// and a `:` (`nth-chld(2):`). Selector prefixes are CSS's pseudo-classes
4433−/// and pseudo-elements under their CSS names, so an unknown one gets the
4434−/// closest name as a suggestion. Only names with the same first letter are
4435−/// suggested: `odd:` is two edits from `md:`, which it doesn't mean.
4436−fn unknown_prefix(rest: &str) -> Option<PrefixProblem> {
4437− use crate::vocab::{self, Pseudo};
4438− let name_len = rest.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))?;
4439− let name = &rest[..name_len];
4440− if name.is_empty() {
4441− return None;
4442− }
4443− if rest[name_len..].starts_with('(') {
4444− // `name(...):`: a pseudo-class with an argument
4445− let close = vocab::closing_paren(rest, name_len)?;
4446− if !rest[close + 1..].starts_with(':') {
4447− return None;
4448− }
4449− let written = &rest[..close + 2];
4450− if matches!(vocab::pseudo(name), Some(Pseudo::Class | Pseudo::Element)) {
4451− let bare = format!("{}:", name);
4452− return Some(PrefixProblem {
4453− message: format!(
4454− "'{}': `{}` takes no argument: write `{}`",
4455− written,
4456− vocab::pseudo_selector(&bare).unwrap_or_default(),
4457− bare
4458− ),
4459− subject: written.to_string(),
4460− suggestion: Some(bare),
4461− });
4462− }
4463− let functional: Vec<&str> = vocab::functional_pseudos()
4464− .into_iter()
4465− .filter(|p| p.chars().next() == name.chars().next())
4466− .collect();
4467− let suggestion = suggest_closest(name, &functional)
4468− .filter(|&closest| closest != name)
4469− .map(|closest| format!("{}(", closest));
4470− let written = format!("{}(", name);
4471− let message = match &suggestion {
4472− Some(closest) => format!("unknown prefix '{}', did you mean '{}'?", written, closest),
4473− None => format!("unknown prefix '{}'", written),
4474− };
4475− return Some(PrefixProblem {
4476− message,
4477− subject: written,
4478− suggestion,
4479− });
4480− }
4481− if !rest[name_len..].starts_with(':') {
4482− return None;
4483− }
4484− let written = &rest[..name_len + 1];
4485− // `has:`: its argument is missing
4486− if vocab::pseudo(name) == Some(Pseudo::Function) {
4487− return Some(PrefixProblem {
4488− message: format!(
4489− "'{}': `:{}()` takes an argument, in parentheses, as in CSS: `{}(...):`",
4490− written, name, name
4491− ),
4492− subject: written.to_string(),
4493− suggestion: None,
4494− });
4495− }
4496− let prefixes = vocab::all_prefixes();
4497− let candidates: Vec<&str> = prefixes
4498− .iter()
4499− .map(String::as_str)
4500− .filter(|p| p.chars().next() == written.chars().next())
4501− .collect();
4502− // The closest name that starts with the same letter (`hvoer:` →
4503− // `hover:`), else the first that starts with the word written and a `-`
4504− // (`first:` → `first-child:`)
4505− let suggestion = suggest_closest(written, &candidates)
4506− .or_else(|| {
4507− let longer = format!("{}-", name);
4508− candidates.iter().copied().find(|p| p.starts_with(&longer))
4509− })
4510− .filter(|&closest| closest != written)
4511− .map(str::to_string);
4512− let message = match &suggestion {
4513− Some(closest) => format!("unknown prefix '{}', did you mean '{}'?", written, closest),
4514− None => format!("unknown prefix '{}'", written),
4515− };
4516− Some(PrefixProblem {
4517− message,
4518− subject: written.to_string(),
4519− suggestion,
4520− })
4521−}
4522−
4523−/// Check an attribute's element prefix (`@td:padding 8`), when it has one:
4524−/// it names a built-in element that has an HTML tag of its own (not a
4525−/// function, and not `@el`, whose `<div>` other elements write too), there
4526−/// is one, not with `children:`, and what it styles is a CSS property, not
4527−/// one of htmlang's words, which compile against each element's layout and
4528−/// its parent's. Returns false, after reporting, when the attribute can't
4529−/// be written.
4530−fn check_element_prefixes(
4531− attr: &Attribute,
4532− prefixes: &[&str],
4533− base: &str,
4534− line: usize,
4535− column: Option<usize>,
4536− ctx: &mut ParseContext,
4537−) -> bool {
4538− use crate::ast::ElementKind;
4539− use crate::vocab;
4540− let elements: Vec<&str> = prefixes
4541− .iter()
4542− .copied()
4543− .filter(|p| vocab::element_prefix(p).is_some())
4544− .collect();
4545− let Some(&prefix) = elements.first() else {
4546− return true;
4547− };
4548− let fail = |ctx: &mut ParseContext,
4549− code: &'static str,
4550− message: String,
4551− subject: &str,
4552− suggestion: Option<String>| {
4553− let diagnostic = Diagnostic::error(code, line, message)
4554− .subject(subject)
4555− .suggest(suggestion);
4556− let diagnostic = at_attribute(diagnostic, column, ctx);
4557− ctx.diagnostics.push(diagnostic);
4558− false
4559− };
4560− let name = vocab::element_prefix(prefix).unwrap_or_default();
4561− if is_function(name, ctx) {
4562− return fail(
4563− ctx,
4564− code::INVALID_PREFIX,
4565− format!(
4566− "'{}': @{} is a function, and `{}` would pick elements out by their HTML tag, \
4567− which a function doesn't have: style what @{} writes in its body, or pass it \
4568− attributes",
4569− attr.key, name, prefix, name
4570− ),
4571− prefix,
4572− None,
4573− );
4574− }
4575− match ElementKind::from_name(name) {
4576− None => {
4577− let own: Vec<&str> = ElementKind::all_names()
4578− .filter(|n| ElementKind::from_name(n).is_some_and(|k| k.own_tag().is_some()))
4579− .collect();
4580− // An HTML name htmlang writes under another (`@a:` is `@link:`)
4581− let (written_otherwise, shared) = match crate::ast::HTML_NAMES_WRITTEN_OTHERWISE
4582− .iter()
4583− .find(|(html, _)| *html == name)
4584 {
1959 {
4585− Some((_, htmlang)) if own.contains(htmlang) => (Some(*htmlang), false),
1960+ let suggestion = suggest_closest(val, CURSOR_VALUES);
4586− Some(_) => (None, true),
1961+ let msg = match suggestion {
4587− None => (None, false),
1962+ Some(s) => format!("unknown cursor value '{}', did you mean '{}'?", val, s),
4588− };
1963+ None => format!("unknown cursor value '{}'", val),
4589− // `@each:`, `@markdown:`: a directive, which is no element
1964+ };
4590− if crate::ast::directive(name).is_some() {
1965+ ctx.diagnostics.push(Diagnostic {
4591− return fail(
1966+ line: line_num,
4592− ctx,
1967+ column: None,
4593− code::UNKNOWN_PREFIX,
1968+ message: msg,
4594− format!(
1969+ severity: Severity::Warning,
4595− "unknown element @{} in '{}': @{} is a directive, and an element \
1970+ source_line: None,
4596− prefix names an element with an HTML tag of its own, such as `@td:`",
1971+ });
4597− name, prefix, name
4598− ),
4599− prefix,
4600− None,
4601− );
4602 }
1972 }
4603− // `@div:`, `@span:`: several elements write it
1973+ } else if base_key == "font-weight" {
4604− if shared {
1974+ const WEIGHT_VALUES: &[&str] = &[
4605− return fail(
1975+ "normal", "bold", "bolder", "lighter", "100", "200", "300", "400", "500", "600",
4606− ctx,
1976+ "700", "800", "900",
4607− code::UNKNOWN_PREFIX,
1977+ ];
4608− format!(
1978+ if !WEIGHT_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
4609− "unknown element @{} in '{}': htmlang writes <{}> for several \
1979+ ctx.diagnostics.push(Diagnostic {
4610− elements, so no element prefix picks it out",
1980+ line: line_num,
4611− name, prefix, name
1981+ column: None,
1982+ message: format!(
1983+ "'font-weight' expects a weight keyword or number 100-900, got '{}'",
1984+ val
4612 ),
1985 ),
4613− prefix,
1986+ severity: Severity::Warning,
4614− None,
1987+ source_line: None,
4615− );
1988+ });
4616 }
1989 }
4617− // A name with the same first letter first (`@tdd:` is `@td:`)
1990+ } else if base_key == "color" || base_key == "background" {
4618− let same_letter: Vec<&str> = own
1991+ // Validate named CSS colors (only if not hex, rgb, hsl, var, etc.)
4619− .iter()
1992+ if !val.starts_with('#')
4620− .copied()
1993+ && !val.starts_with("rgb")
4621− .filter(|n| n.chars().next() == name.chars().next())
1994+ && !val.starts_with("hsl")
4622− .collect();
1995+ && !val.starts_with("var(")
4623− let closest = written_otherwise
1996+ && !val.starts_with("linear-gradient")
4624− .or_else(|| suggest_closest(name, &same_letter))
1997+ && !val.starts_with("radial-gradient")
4625− .or_else(|| suggest_closest(name, &own));
1998+ && !val.starts_with("conic-gradient")
4626− let message = match (closest, written_otherwise) {
1999+ && !val.starts_with("oklch")
4627− (Some(closest), Some(_)) => format!(
2000+ && !val.starts_with("oklab")
4628− "unknown element @{} in '{}', did you mean '@{}:'? (@{} writes <{}>)",
2001+ && !val.starts_with("color(")
4629− name, prefix, closest, closest, name
2002+ && !val.starts_with("light-dark(")
4630− ),
2003+ && !val.contains("url(")
4631− (Some(closest), None) => format!(
2004+ && !val.contains(' ')
4632− "unknown element @{} in '{}', did you mean '@{}:'?",
2005+ // skip shorthand multi-value
4633− name, prefix, closest
2006+ {
4634− ),
2007+ const NAMED_COLORS: &[&str] = &[
4635− (None, _) => format!("unknown element @{} in '{}'", name, prefix),
2008+ "transparent",
4636− };
2009+ "currentcolor",
4637− let suggestion = closest.map(|c| format!("@{}:", c));
2010+ "inherit",
4638− fail(ctx, code::UNKNOWN_PREFIX, message, prefix, suggestion)
2011+ "initial",
4639− }
2012+ "unset",
4640− Some(kind) if kind.own_tag().is_none() => {
2013+ "black",
4641− let reason = match kind.tag() {
2014+ "white",
4642− None => format!(
2015+ "red",
4643− "@{} writes no element of its own, so there is nothing for `{}` to style",
2016+ "green",
4644− name, prefix
2017+ "blue",
4645− ),
2018+ "yellow",
4646− Some(_) if kind.is_verbatim() => {
2019+ "orange",
4647− format!("@{} isn't shown, so there is nothing to style", name)
2020+ "purple",
4648− }
2021+ "pink",
4649− Some("span") => format!(
2022+ "brown",
4650− "@{} writes a <span>, as every line of text in a row or column does, so \
2023+ "gray",
4651− `{}` can't pick it out",
2024+ "grey",
4652− name, prefix
2025+ "cyan",
4653− ),
2026+ "magenta",
4654− Some(tag) => {
2027+ "lime",
4655− let others: Vec<String> = ElementKind::all_names()
2028+ "olive",
4656− .filter(|n| {
2029+ "navy",
4657− *n != name
2030+ "teal",
4658− && ElementKind::from_name(n).and_then(|k| k.tag()) == Some(tag)
2031+ "aqua",
4659− })
2032+ "fuchsia",
4660− .map(|n| format!("@{}", n))
2033+ "maroon",
4661− .collect();
2034+ "silver",
4662− let others = match others.split_last() {
2035+ "coral",
4663− Some((last, [])) => last.clone(),
2036+ "salmon",
4664− Some((last, rest)) => format!("{} and {}", rest.join(", "), last),
2037+ "tomato",
4665− None => String::new(),
2038+ "crimson",
2039+ "firebrick",
2040+ "darkred",
2041+ "indigo",
2042+ "violet",
2043+ "plum",
2044+ "orchid",
2045+ "thistle",
2046+ "lavender",
2047+ "gold",
2048+ "khaki",
2049+ "wheat",
2050+ "tan",
2051+ "sienna",
2052+ "chocolate",
2053+ "peru",
2054+ "beige",
2055+ "ivory",
2056+ "linen",
2057+ "snow",
2058+ "seashell",
2059+ "mintcream",
2060+ "skyblue",
2061+ "steelblue",
2062+ "royalblue",
2063+ "dodgerblue",
2064+ "cornflowerblue",
2065+ "slategray",
2066+ "slategrey",
2067+ "dimgray",
2068+ "dimgrey",
2069+ "lightgray",
2070+ "lightgrey",
2071+ "darkgray",
2072+ "darkgrey",
2073+ "gainsboro",
2074+ "whitesmoke",
2075+ ];
2076+ if !NAMED_COLORS.contains(&val.to_lowercase().as_str()) {
2077+ let lower = val.to_lowercase();
2078+ let suggestion = suggest_closest(&lower, NAMED_COLORS);
2079+ let msg = match suggestion {
2080+ Some(s) => format!("unknown color '{}', did you mean '{}'?", val, s),
2081+ None => format!("unknown color '{}'", val),
4666 };
2082 };
4667− format!(
2083+ ctx.diagnostics.push(Diagnostic {
4668− "@{} writes a <{}>, as {} do, so `{}` can't pick it out",
2084+ line: line_num,
4669− name, tag, others, prefix
2085+ column: None,
4670− )
2086+ message: msg,
2087+ severity: Severity::Warning,
2088+ source_line: None,
2089+ });
4671 }
2090 }
4672− };
2091+ }
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 }
2092 }
4742− Some(_) => true,
4743 }
2093 }
4744}
2094}
4745
2095
4746−/// Check an attribute's prefixes: each is known, their selector prefixes
2096+fn parse_attr_brackets(
4747−/// read left to right with a pseudo-element (`before:`) last, the argument
2097+ input: &str,
4748−/// of `has(...)` or `nth-child(...)` can be written into the CSS, and a word that
2098+ line_num: usize,
4749−/// places an element in its parent (`width fill`, `center-x`, `align-*`)
4750−/// isn't under `children:`, whose styles go on the children. Media,
4751−/// container and width prefixes go anywhere. Returns false, after
4752−/// reporting, when the attribute can't be written.
4753−fn check_prefixes(
4754− attr: &Attribute,
4755− line: usize,
4756− column: Option<usize>,
4757 ctx: &mut ParseContext,
2099 ctx: &mut ParseContext,
4758−) -> bool {
2100+) -> Result<(Vec<Attribute>, String), ParseError> {
4759− use crate::vocab;
2101+ parse_attr_brackets_inner(input, line_num, ctx, true)
4760− let (prefixes, base) = vocab::split_prefixes(&attr.key);
2102+}
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
2103
4771− if let Some(problem) = unknown_prefix(base) {
2104+fn parse_attr_brackets_no_validate(
4772− let diagnostic = Diagnostic::error(code::UNKNOWN_PREFIX, line, problem.message)
2105+ input: &str,
4773− .subject(problem.subject)
2106+ line_num: usize,
4774− .suggest(problem.suggestion);
2107+ ctx: &mut ParseContext,
4775− let diagnostic = at_attribute(diagnostic, column, ctx);
2108+) -> Result<(Vec<Attribute>, String), ParseError> {
4776− ctx.diagnostics.push(diagnostic);
2109+ parse_attr_brackets_inner(input, line_num, ctx, false)
4777− return false;
4778− }
4779− if !check_element_prefixes(attr, &prefixes, base, line, column, ctx) {
4780− return false;
4781− }
4782− // The argument of `has(...)` or `nth-child(...)` goes into the CSS as
4783− // written
4784− for prefix in &prefixes {
4785− if let Some(reason) = css_breakout(prefix.trim_end_matches(':')) {
4786− return fail(
4787− ctx,
4788− format!(
4789− "the prefix '{}' has {}: it can't be written into the page's CSS",
4790− prefix, reason
4791− ),
4792− prefix,
4793− None,
4794− );
4795− }
4796− }
4797− // `nth-child():` or `has( ):`: CSS has no pseudo-class with an empty
4798− // argument
4799− let empty_argument = |prefix: &str| {
4800− prefix
4801− .strip_suffix("):")
4802− .and_then(|p| p.split_once('('))
4803− .is_some_and(|(_, argument)| argument.trim().is_empty())
4804− };
4805− if let Some(empty) = prefixes.iter().find(|p| empty_argument(p)) {
4806− return fail(
4807− ctx,
4808− format!(
4809− "'{}': `:{}()` takes an argument between its parentheses, as in CSS",
4810− attr.key,
4811− vocab::pseudo_name(empty)
4812− ),
4813− empty,
4814− None,
4815− );
4816− }
4817− // A pseudo-element is the last selector prefix: CSS has no
4818− // `::before:hover`
4819− let selectors: Vec<&str> = prefixes
4820− .iter()
4821− .copied()
4822− .filter(|p| vocab::at_rule_rank(p).is_none())
4823− .collect();
4824− // Only one: an element has one `::before`, which has no `::after`
4825− let elements: Vec<&str> = selectors
4826− .iter()
4827− .copied()
4828− .filter(|p| vocab::is_pseudo_element(p))
4829− .collect();
4830− if elements.len() > 1 {
4831− return fail(
4832− ctx,
4833− format!(
4834− "'{}': `{}` and `{}` each select a pseudo-element, and a style applies to one \
4835− of them",
4836− attr.key, elements[0], elements[1]
4837− ),
4838− &attr.key,
4839− None,
4840− );
4841− }
4842− if let Some(at) = selectors.iter().position(|p| vocab::is_pseudo_element(p))
4843− && at + 1 < selectors.len()
4844− {
4845− let element = selectors[at];
4846− // The same prefixes, with the pseudo-element moved after the other
4847− // selector prefixes
4848− let mut order: Vec<&str> = prefixes.iter().copied().filter(|p| *p != element).collect();
4849− let last_selector = order
4850− .iter()
4851− .rposition(|p| vocab::at_rule_rank(p).is_none())
4852− .map_or(order.len(), |i| i + 1);
4853− order.insert(last_selector, element);
4854− let fixed = format!("{}{}", order.concat(), base);
4855− return fail(
4856− ctx,
4857− format!(
4858− "'{}': `{}` selects a pseudo-element, which comes after the other selector \
4859− prefixes, as in CSS: write `{}`",
4860− attr.key, element, fixed
4861− ),
4862− &attr.key,
4863− Some(fixed),
4864− );
4865− }
4866− // Under `children:` the styles go on the children, whose parent is this
4867− // element: a word that places an element in its parent can't say where
4868− if prefixes.contains(&"children:") && vocab::places_in_parent(base, attr.value.as_deref()) {
4869− // The CSS of `fill` and `shrink` depends on the direction: along
4870− // it, they are flex properties
4871− let (word, css) = match (base, attr.value.as_deref().map(str::trim)) {
4872− ("width", Some("fill")) => (
4873− "width fill",
4874− " (`children:flex 1` in a row, `children:width 100%` otherwise)",
4875− ),
4876− ("height", Some("fill")) => (
4877− "height fill",
4878− " (`children:flex 1` in a column, `children:height 100%` otherwise)",
4879− ),
4880− ("width", Some(_)) => (
4881− "width shrink",
4882− " (`children:flex-shrink 0` in a row, `children:width fit-content` otherwise)",
4883− ),
4884− ("height", Some(_)) => (
4885− "height shrink",
4886− " (`children:flex-shrink 0` in a column, `children:height fit-content` \
4887− otherwise)",
4888− ),
4889− _ => (base, ""),
4890− };
4891− return fail(
4892− ctx,
4893− format!(
4894− "'{}{}': `{}` places an element in its parent, and under `children:` that \
4895− parent is this element: write `{}` on the children themselves, or the CSS \
4896− you mean{}",
4897− attr.key,
4898− attr.value
4899− .as_deref()
4900− .map_or(String::new(), |v| format!(" {}", v.trim())),
4901− word,
4902− word,
4903− css
4904− ),
4905− &attr.key,
4906− None,
4907− );
4908− }
4909− true
4910}
2110}
4911
2111
4912−/// Check an attribute written as a style or a flag (not `key=value`): its
2112+fn parse_attr_brackets_inner(
4913−/// prefix, its name, and, for a style, its value. A name htmlang doesn't
2113+ input: &str,
4914−/// know but CSS could have (`corner-shape`) is passed to the CSS as written,
2114+ line_num: usize,
4915−/// with a warning; custom properties (`--brand`) and vendor-prefixed ones
4916−/// (`-webkit-...`) without one. Anything that can't be written into the
4917−/// page is an error; returns false then, and the attribute is left out.
4918−fn check_attribute(
4919− attr: &Attribute,
4920− line: usize,
4921− column: Option<usize>,
4922− before: Option<&Attribute>,
4923 ctx: &mut ParseContext,
2115 ctx: &mut ParseContext,
4924−) -> bool {
2116+ validate: bool,
4925− use crate::vocab;
2117+) -> Result<(Vec<Attribute>, String), ParseError> {
4926− let (prefixes, base) = vocab::split_prefixes(&attr.key);
2118+ let mut depth = 0;
4927− let fail = |ctx: &mut ParseContext, code: &'static str, message: String, subject: &str| {
2119+ let mut end_pos = 0;
4928− let diagnostic = Diagnostic::error(code, line, message).subject(subject);
4929− let diagnostic = at_attribute(diagnostic, column, ctx);
4930− ctx.diagnostics.push(diagnostic);
4931− false
4932− };
4933
2120
4934− if !check_prefixes(attr, line, column, ctx) {
2121+ for (i, c) in input.char_indices() {
4935− return false;
2122+ match c {
4936− }
2123+ '[' => depth += 1,
4937− let prefixed = !prefixes.is_empty();
2124+ ']' => {
4938− // `md:id=x`: `=` makes an HTML attribute, which has no states
2125+ depth -= 1;
4939− if prefixed && let Some((name, _)) = base.split_once('=') {
2126+ if depth == 0 {
4940− return fail(
2127+ end_pos = i;
4941− ctx,
2128+ break;
4942− code::INVALID_PREFIX,
2129+ }
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 }
2130 }
4972− return fail(
2131+ _ => {}
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 }
2132 }
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 }
2133 }
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
2134
5098−/// An attribute name htmlang doesn't know. A style whose name CSS could
2135+ if depth != 0 {
5099−/// have goes to the CSS as written, with a warning; anything else (a flag,
2136+ return Err(ParseError {
5100−/// a name that isn't a word) is an error. Returns whether it is kept.
2137+ line: line_num,
5101−fn unknown_attribute(
2138+ message: "unclosed '[' in attribute list".to_string(),
5102− attr: &Attribute,
2139+ });
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 }
2140 }
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
2141
5230−/// Evaluate the attributes of a list: bundles are spliced in, `if()`
2142+ let attrs_inner = &input[1..end_pos];
5231−/// chooses, and variables fill values. A variable fills only the value it
2143+ let remaining = input[end_pos + 1..].trim().to_string();
5232−/// is written in: attributes come from bundles, never from text. With
2144+ let attrs = parse_attr_list(attrs_inner, line_num, ctx, validate);
5233−/// `validate`, unknown names and invalid values are reported. `text_keys`
5234−/// are a called function's parameters: their values are text rather than
5235−/// CSS, and they aren't checked as attributes.
5236−fn parse_attr_list(
5237− tokens: &[syntax::Attr],
5238− line_num: usize,
5239− ctx: &mut ParseContext,
5240− validate: bool,
5241− text_keys: &[String],
5242−) -> Vec<Attribute> {
5243− parse_attrs(tokens, line_num, ctx, validate, text_keys)
5244− .into_iter()
5245− .map(|(attr, _)| attr)
5246− .collect()
5247−}
5248
2145
5249−/// [`parse_attr_list`], with the value of each attribute whose key is one
2146+ Ok((attrs, remaining))
5250−/// of `text_keys` (a call's parameters) as a value with its type (see
5251−/// [`ParseContext::slot_value`]), so a parameter can get a list or a
5252−/// record.
5253−fn parse_attrs(
5254− tokens: &[syntax::Attr],
5255− line_num: usize,
5256− ctx: &mut ParseContext,
5257− validate: bool,
5258− text_keys: &[String],
5259−) -> Vec<(Attribute, Option<Value>)> {
5260− let mut read = ReadAttrs::default();
5261− read_attrs(tokens, "", line_num, ctx, validate, text_keys, &mut read);
5262− read.attrs
5263}
2147}
5264
2148
5265−/// The attributes of a list read so far, and the keys written (for the
2149+fn is_valid_hex_color(s: &str) -> bool {
5266−/// duplicate check).
2150+ if !s.starts_with('#') {
5267−#[derive(Default)]
2151+ return true; // Not a hex color, skip
5268−struct ReadAttrs {
2152+ }
5269− attrs: Vec<(Attribute, Option<Value>)>,
2153+ let hex = &s[1..];
5270− seen_keys: Vec<(String, bool)>,
2154+ matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
5271}
2155}
5272
2156
5273−/// [`parse_attrs`] for the attributes of `tokens`, each under the prefixes
2157+fn parse_attr_list(
5274−/// `prefix` (`md:` for those of `md:[...]`), which distribute over a
2158+ input: &str,
5275−/// group, a bundle and the branch an `if()` takes. Only styles take a
5276−/// prefix, so an HTML attribute there is an error.
5277−fn read_attrs(
5278− tokens: &[syntax::Attr],
5279− prefix: &str,
5280 line_num: usize,
2159 line_num: usize,
5281 ctx: &mut ParseContext,
2160 ctx: &mut ParseContext,
5282 validate: bool,
2161 validate: bool,
5283− text_keys: &[String],
2162+) -> Vec<Attribute> {
5284− out: &mut ReadAttrs,
2163+ let mut attrs = Vec::new();
5285−) {
2164+ let mut seen_keys: Vec<String> = Vec::new();
5286− let mut chosen = Vec::new();
2165+
5287− choose_attrs(
2166+ for part in split_commas(input) {
5288− tokens,
2167+ let part = part.trim();
5289− prefix,
2168+ // A whole attribute `if(cond, a, b)` is the chosen branch's text.
5290− line_num,
2169+ let chosen;
5291− ctx,
2170+ let part = match choose_if(part, ctx, line_num) {
5292− validate,
2171+ Some(branch) => {
5293− text_keys,
2172+ chosen = branch;
5294− &mut chosen,
2173+ chosen.as_str()
5295− );
2174+ }
5296−
2175+ None => part,
5297− for token in &chosen {
5298− let line = if token.span.line == 0 {
5299− line_num
5300− } else {
5301− token.span.line
5302 };
2176 };
5303− let (key, value, html) = (token.key.clone(), token.value.clone(), token.html);
2177+ if part.is_empty() {
5304− let column = |at: usize| Some(token.span.column + at);
5305−
5306− // `md:[...]`, `md:$card`, `md:if(...)`: the prefixes apply to each
5307− // attribute there, and a prefix a parameter can't have
5308− if let Some(prefixed) = &token.prefixed {
5309− if let Some(inner) = group_prefix(token, prefixed, prefix, line, ctx) {
5310− read_attrs(
5311− prefixed.target.attrs(),
5312− &inner,
5313− line_num,
5314− ctx,
5315− validate,
5316− text_keys,
5317− out,
5318− );
5319− }
5320 continue;
2178 continue;
5321 }
2179 }
5322
2180
5323− // `$name` (or `${name}`) alone: an attribute bundle, spliced in
2181+ // $define reference — expand attribute bundle
5324− let bundle = key.strip_prefix('$').filter(|_| value.is_none() && !html);
2182+ if let Some(name) = part.strip_prefix('$')
5325− let bundle = bundle.and_then(|after| match interp::reference(after, &ctx.env)? {
2183+ && let Some(define_attrs) = ctx.defines.get(name)
5326− (interp::Reference::Var(name), len) if len == after.len() => Some(name),
2184+ {
5327− _ => None,
2185+ ctx.used_defines.insert(name.to_string());
5328− });
2186+ attrs.extend(define_attrs.clone());
5329− if let Some(name) = bundle {
5330− if let Some(define_attrs) = ctx.env.bundle(&name).cloned() {
5331− ctx.used_defines.insert(name.clone());
5332− for attr in define_attrs.iter() {
5333− if !prefix.is_empty() {
5334− let prefixed = Attribute {
5335− key: format!("{}{}", prefix, attr.key),
5336− ..attr.clone()
5337− };
5338− if prefixed_parameter(&prefixed, text_keys, line, column(0), validate, ctx)
5339− {
5340− continue;
5341− }
5342− if let Some(attr) =
5343− prefixed_member(attr, prefix, &name, line, column(0), validate, ctx)
5344− {
5345− out.attrs.push((attr, None));
5346− }
5347− continue;
5348− }
5349− let attrs = &out.attrs;
5350− // Checked here, where it is either a parameter or not
5351− let checked_here = validate
5352− && !ctx.in_bundle
5353− && could_be_parameter(attr)
5354− && !text_keys.contains(&attr.key);
5355− if checked_here
5356− && (misspelled_parameter(attr, text_keys, line, column(0), ctx)
5357− || !check_attribute(
5358− attr,
5359− line,
5360− column(0),
5361− attrs.last().map(|(a, _)| a),
5362− ctx,
5363− ))
5364− {
5365− continue;
5366− }
5367− out.attrs.push((attr.clone(), None));
5368− }
5369− } else {
5370− not_a_bundle(&name, line, column(0), ctx);
5371− }
5372− continue;
5373− }
5374− // A name never comes from a variable
5375− if key.contains('$') {
5376− track_var_refs(&key, &mut ctx.used_variables);
5377− ctx.diagnostics.push(
5378− Diagnostic::error(
5379− code::ATTRIBUTE_FROM_VARIABLE,
5380− line,
5381− format!(
5382− "'{}': an attribute's name can't come from a variable. A variable \
5383− fills a value (`padding $size`), and whole attributes come from a \
5384− bundle (`@let name [padding 8]`, used as `[$name]`)",
5385− key
5386− ),
5387− )
5388− .subject(key.as_str()),
5389− );
5390 continue;
2187 continue;
5391 }
2188 }
5392
2189
5393− // Only a style has states: `md:[id=x]`, `md:id=x`
2190+ track_var_refs(part, &mut ctx.used_variables);
5394− if html && (!prefix.is_empty() || !crate::vocab::split_prefixes(&key).0.is_empty()) {
2191+
5395− let written = format!("{}{}", prefix, key);
2192+ if let Some((attr, condition)) = split_trailing_if(part) {
5396− let (name, _) = crate::vocab::split_prefixes(&written);
2193+ ctx.diagnostics.push(Diagnostic {
5397− let diagnostic = Diagnostic::error(
2194+ line: line_num,
5398− code::INVALID_PREFIX,
2195+ column: None,
5399− line,
2196+ message: format!(
5400− format!(
2197+ "`KEY if CONDITION` was removed: write `if({}, {})`",
5401− "'{}=': a prefix applies to a style, and `{}=` is an HTML attribute, \
2198+ condition, attr
5402− which has no states",
5403− written,
5404− &written[name.concat().len()..]
5405 ),
2199 ),
5406− )
2200+ severity: Severity::Error,
5407− .subject(token.key.as_str());
2201+ source_line: None,
5408− let diagnostic = at_attribute(diagnostic, column(0), ctx);
2202+ });
5409− ctx.diagnostics.push(diagnostic);
5410 continue;
2203 continue;
5411 }
2204 }
5412− let key = if prefix.is_empty() {
2205+ // A value `if(cond, a, b)` picks `a` or `b` (free text) by `cond`;
5413− key
2206+ // an empty choice leaves the attribute out.
5414− } else {
2207+ let Some(part) = choose_if_value(part, ctx, line_num) else {
5415− format!("{}{}", prefix, key)
2208+ continue;
5416 };
2209 };
2210+ let part = substitute_vars(&part, &ctx.variables);
5417
2211
5418− // A value's variables are filled in. A style's value is CSS, where
2212+ let attr = if let Some((key, value)) = split_html_attribute(&part) {
5419− // quoted text keeps its quotes; an HTML attribute's (and a
2213+ Attribute {
5420− // parameter's) is text, where it loses them.
2214+ key: key.to_string(),
5421− let sink = if html || text_keys.contains(&key) {
2215+ value: Some(value.to_string()),
5422− Sink::Text
2216+ html: true,
5423− } else {
5424− Sink::Css
5425− };
5426− let mut filled = true;
5427− let mut quoted = None;
5428− let mut typed = None;
5429− let value = match value {
5430− None => None,
5431− // A parameter's value keeps its type
5432− Some(value) if !html && text_keys.contains(&key) => {
5433− let at = column(token.raw.len() - value.len());
5434− let (value, ok) = ctx.slot_value(&value, line, at);
5435− filled = ok;
5436− if let Value::Quoted(q) = &value {
5437− quoted = Some(q.clone());
5438− }
5439− let text = value.to_string();
5440− typed = Some(value);
5441− Some(text)
5442 }
2217 }
5443− Some(value) => {
2218+ } else if let Some((key, value)) = part.split_once(' ') {
5444− let at = column(token.raw.len() - value.len());
2219+ let value = value.trim().to_string();
5445− let (text, is_quoted, ok) = ctx.fill_value(&value, line, at, sink);
2220+ Attribute {
5446− filled = ok;
2221+ key: key.trim().to_string(),
5447− quoted = is_quoted;
2222+ value: Some(value),
5448− Some(text)
2223+ html: false,
2224+ }
2225+ } else {
2226+ Attribute {
2227+ key: part.to_string(),
2228+ value: None,
2229+ html: false,
5449 }
2230 }
5450 };
2231 };
5451− let attr = Attribute {
5452− key,
5453− value,
5454− html,
5455− quoted,
5456− };
5457− // A value that couldn't be filled in is already reported, and a
5458− // parameter's value is text for the function
5459− let validate = validate && filled && !text_keys.contains(&attr.key);
5460
2232
5461− // A duplicate is reported (compare the full key, so `border` and
2233+ // Warn on duplicate attributes (compare full key so pseudo-class
5462− // `hover:border` differ, and the form, so `width=800` and
2234+ // variants like `border` and `hover:border` are not conflated)
5463− // `width 200` do too), and the later one wins
5464 if validate {
2235 if validate {
5465− let seen = (normal_key(&attr.key), attr.html);
2236+ if seen_keys.contains(&attr.key) {
5466− if out.seen_keys.contains(&seen) {
2237+ ctx.diagnostics.push(Diagnostic {
5467− let written = if attr.html { "=" } else { "" };
2238+ line: line_num,
5468− let message = format!(
2239+ column: None,
5469− "duplicate attribute '{}{}': the later one wins",
2240+ message: format!("duplicate attribute '{}'", attr.key),
5470− attr.key, written
2241+ severity: Severity::Warning,
5471− );
2242+ source_line: None,
5472− let diagnostic = Diagnostic::warning(code::DUPLICATE_ATTRIBUTE, line, message)
2243+ });
5473− .subject(attr.key.as_str());
5474− let diagnostic = at_attribute(diagnostic, column(0), ctx);
5475− ctx.diagnostics.push(diagnostic);
5476 } else {
2244 } else {
5477− out.seen_keys.push(seen);
2245+ seen_keys.push(attr.key.clone());
5478 }
2246 }
5479− }
5480
2247
5481− if validate && misspelled_parameter(&attr, text_keys, line, column(0), ctx) {
2248+ // Color validation for hex colors
5482− continue;
2249+ if !attr.html
5483− }
2250+ && matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
5484− if prefixed_parameter(&attr, text_keys, line, column(0), validate, ctx) {
2251+ && let Some(ref val) = attr.value
5485− continue;
2252+ && val.starts_with('#')
5486− }
2253+ && !is_valid_hex_color(val)
5487− if validate && attr.html && attr.key == "class" {
2254+ {
5488− reserved_class(&attr, line, column(0), ctx);
2255+ ctx.diagnostics.push(Diagnostic {
5489− }
2256+ line: line_num,
5490− // `padding=4`: a style written as an HTML attribute, which the
2257+ column: None,
5491− // browser ignores (`width=` and `height=` are real on some
2258+ message: format!("invalid hex color '{}'", val),
5492− // elements, see `validate_tree`)
2259+ severity: Severity::Warning,
5493− if validate
2260+ source_line: None,
5494− && attr.html
2261+ });
5495− && crate::vocab::is_style_attribute(&attr.key)
2262+ }
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 }
2263 }
5512
2264
5513− // A style or a flag: its name is checked, and what can't be
2265+ // Warn on unknown attributes. `key=value` HTML attributes may use any
5514− // written into the page is an error and left out
2266+ // name; everything else must be a known style.
5515− let checked = validate && !attr.html && !(ctx.in_bundle && could_be_parameter(&attr));
2267+ if validate && !attr.html {
5516− let before = out.attrs.last().map(|(a, _)| a);
2268+ let base_key = crate::vocab::base_attribute(attr.key.as_str());
5517− if checked && !check_attribute(&attr, line, column(0), before, ctx) {
2269+ let is_boolean_html =
5518− continue;
2270+ attr.value.is_none() && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&base_key);
2271+ // A removed attribute that shares its name with a CSS property
2272+ // (bare `grid`) still gets its hint.
2273+ let removed = removed_attribute_hint(base_key)
2274+ .filter(|_| attr.value.is_none() || !crate::vocab::is_css_property(base_key));
2275+ if let Some(hint) = removed {
2276+ ctx.diagnostics.push(Diagnostic {
2277+ line: line_num,
2278+ column: None,
2279+ message: format!(
2280+ "attribute '{}' was removed: {} (run `htmlang upgrade`)",
2281+ base_key, hint
2282+ ),
2283+ severity: Severity::Warning,
2284+ source_line: None,
2285+ });
2286+ } else if is_boolean_html || crate::vocab::is_style_attribute(base_key) {
2287+ validate_attr_value(&attr, line_num, ctx);
2288+ } else if crate::vocab::HTML_ATTRIBUTES.contains(&base_key)
2289+ || base_key.starts_with("aria-")
2290+ || base_key.starts_with("data-")
2291+ {
2292+ ctx.diagnostics.push(Diagnostic {
2293+ line: line_num,
2294+ column: None,
2295+ message: format!(
2296+ "'{}' is an HTML attribute: write `{}={}` (run `htmlang upgrade`)",
2297+ attr.key,
2298+ attr.key,
2299+ attr.value.as_deref().unwrap_or("")
2300+ ),
2301+ severity: Severity::Warning,
2302+ source_line: None,
2303+ });
2304+ } else {
2305+ let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
2306+ let msg = match suggestion {
2307+ Some(closest) => {
2308+ format!(
2309+ "unknown attribute '{}', did you mean '{}'?",
2310+ attr.key, closest
2311+ )
2312+ }
2313+ None => format!("unknown attribute '{}'", attr.key),
2314+ };
2315+ ctx.diagnostics.push(Diagnostic {
2316+ line: line_num,
2317+ column: None,
2318+ message: msg,
2319+ severity: Severity::Warning,
2320+ source_line: None,
2321+ });
2322+ }
5519 }
2323 }
5520
2324
5521− out.attrs.push((attr, typed));
2325+ attrs.push(attr);
5522 }
2326 }
2327+
2328+ attrs
5523}
2329}
5524
2330
5525−/// At a call, a parameter under prefixes (`md:title x`, `md:[title x]`):
2331+/// Split an HTML attribute written `key=value` (`alt=`, `type=email`,
5526−/// a parameter has one value, with no states, so it is reported (with
2332+/// `aria-label=Close menu`). Returns `None` for style attributes.
5527−/// `validate`) and left out; returns true then. A parameter named like a
2333+fn split_html_attribute(part: &str) -> Option<(&str, &str)> {
5528−/// style (`color`) is a style under a prefix, for the root element.
2334+ let first_token = part.split(char::is_whitespace).next()?;
5529−fn prefixed_parameter(
2335+ let eq = first_token.find('=')?;
5530− attr: &Attribute,
2336+ let key = &part[..eq];
5531− text_keys: &[String],
2337+ let valid_key = key.starts_with(|c: char| c.is_ascii_alphabetic())
5532− line: usize,
2338+ && key
5533− column: Option<usize>,
2339+ .chars()
5534− validate: bool,
2340+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
5535− ctx: &mut ParseContext,
2341+ 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}
2342}
5566
2343
5567−/// The prefixes of a prefixed group, bundle or `if()` inside `outer` (the
2344+fn split_commas(input: &str) -> Vec<&str> {
5568−/// prefixes of the groups it is in), when they are all known; else the
2345+ let mut parts = Vec::new();
5569−/// unknown one is reported. Text after a group's `]` is an error too.
2346+ let mut start = 0;
5570−fn group_prefix(
2347+ let mut depth = 0;
5571− token: &syntax::Attr,
2348+ let mut in_quotes = false;
5572− prefixed: &syntax::Prefixed,
2349+
5573− outer: &str,
2350+ for (i, c) in input.char_indices() {
5574− line: usize,
2351+ match c {
5575− ctx: &mut ParseContext,
2352+ '"' => in_quotes = !in_quotes,
5576−) -> Option<String> {
2353+ _ if in_quotes => {}
5577− let at = |ctx: &ParseContext, diagnostic: Diagnostic, span: syntax::Span| match &ctx
2354+ '[' | '(' => depth += 1,
5578− .current_source
2355+ ']' | ')' => depth -= 1,
5579− {
2356+ ',' if depth == 0 => {
5580− (current, Some(text)) if span.line != 0 && *current == span.line => {
2357+ parts.push(&input[start..i]);
5581− diagnostic.column(span.column).source(text.clone())
2358+ 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 }
2359 }
5612− });
2360+ _ => {}
5613− let diagnostic = Diagnostic::error(code::UNKNOWN_PREFIX, line, problem.message)
2361+ }
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 }
2362 }
5620− Some(format!("{}{}", outer, known.concat()))
2363+ parts.push(&input[start..]);
2364+ parts
5621}
2365}
5622
2366
5623−/// An attribute of the bundle `bundle`, spliced in under `prefix`
2367+// ---------------------------------------------------------------------------
5624−/// (`md:$card`): the same attribute with the prefixes in front, when it can
2368+// Chain splitting (the > operator)
5625−/// take them. An HTML attribute can't; a style's prefixes are checked, as
2369+// ---------------------------------------------------------------------------
5626−/// is a flag or a name that isn't a style (a bundle's parameter), which
2370+
5627−/// under a prefix are attributes like any other.
2371+fn split_chain(content: &str) -> Vec<String> {
5628−fn prefixed_member(
2372+ let mut segments = Vec::new();
5629− attr: &Attribute,
2373+ let mut current = String::new();
5630− prefix: &str,
2374+ let chars: Vec<char> = content.chars().collect();
5631− bundle: &str,
2375+ let mut i = 0;
5632− line: usize,
2376+ let mut bracket_depth = 0;
5633− column: Option<usize>,
2377+
5634− validate: bool,
2378+ while i < chars.len() {
5635− ctx: &mut ParseContext,
2379+ if chars[i] == '[' {
5636−) -> Option<Attribute> {
2380+ 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 }
2381 }
5653− return None;
2382+ if chars[i] == ']' {
5654− }
2383+ 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 }
2384 }
5668− }
5669− Some(prefixed)
5670−}
5671
2385
5672−/// `class=hl-x`: the `hl-` prefix is htmlang's, for its generated classes,
2386+ // Match " > @" outside brackets
5673−/// whose rules would then apply to the element too. It goes into the page
2387+ if bracket_depth == 0
5674−/// as written, so this is a warning.
2388+ && i + 3 < chars.len()
5675−fn reserved_class(attr: &Attribute, line: usize, column: Option<usize>, ctx: &mut ParseContext) {
2389+ && chars[i] == ' '
5676− let classes = attr.value.as_deref().unwrap_or_default();
2390+ && chars[i + 1] == '>'
5677− for class in classes.split_whitespace() {
2391+ && chars[i + 2] == ' '
5678− if class.starts_with(crate::codegen::CLASS_PREFIX) {
2392+ && chars[i + 3] == '@'
5679− let diagnostic = Diagnostic::warning(
2393+ {
5680− code::INVALID_VALUE,
2394+ segments.push(current.trim().to_string());
5681− line,
2395+ current = String::new();
5682− format!(
2396+ i += 3; // skip " > ", keep the "@"
5683− "class '{}' starts with `{}`, which htmlang keeps for the classes it \
2397+ 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 }
2398 }
2399+
2400+ current.push(chars[i]);
2401+ i += 1;
5694 }
2402 }
5695−}
5696
2403
5697−/// A style's key as the CSS reads it, for finding duplicates: its at-rule
2404+ if !current.trim().is_empty() {
5698−/// prefixes in one order (`dark:md:` is `md:dark:`), then its selector
2405+ 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 }
2406 }
5715− normal.push_str(name);
5716− normal
5717−}
5718
2407
5719−/// `[$name]` where `$name` isn't an attribute bundle.
2408+ 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}
2409}
5770
2410
5771// ---------------------------------------------------------------------------
2411// ---------------------------------------------------------------------------
5772// Text segment parsing (inline {...} elements)
2412// Text segment parsing (inline {...} elements)
5773// ---------------------------------------------------------------------------
2413// ---------------------------------------------------------------------------
5774
2414
5775−/// While a slot is filled in, each escape (see [`syntax::ESCAPES`]) is a
2415+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
2416+ let mut segments = Vec::new();
5777−/// can't start a variable, a quote or an inline element. Afterwards it
2417+ let mut current_text = String::new();
5778−/// becomes what it stands for.
2418+ 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;
2419 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
2420
5818−/// Inside the quoted strings of a CSS value, `\"` and `\\` mean in CSS what
2421+ while i < chars.len() {
5819−/// they mean in htmlang, so they stay as written there.
2422+ if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '@' {
5820−fn keep_css_string_escapes(protected: &str) -> String {
2423+ // Flush accumulated plain text
5821− let mut out = String::with_capacity(protected.len());
2424+ if !current_text.is_empty() {
5822− let mut inside = false;
2425+ segments.push(TextSegment::Plain(substitute_vars(
5823− for c in protected.chars() {
2426+ &current_text,
5824− match escape_of(c) {
2427+ &ctx.variables,
5825− Some(escape @ ("\\\"" | "\\\\")) if inside => out.push_str(escape),
2428+ )));
5826− _ => {
2429+ current_text.clear();
5827− if c == '"' {
2430+ }
5828− inside = !inside;
2431+
2432+ // Find matching }
2433+ let mut depth = 0;
2434+ let start = i + 1; // after {
2435+ i += 1;
2436+ loop {
2437+ if i >= chars.len() {
2438+ break;
5829 }
2439 }
5830− out.push(c);
2440+ if chars[i] == '{' {
2441+ depth += 1;
2442+ } else if chars[i] == '}' {
2443+ if depth == 0 {
2444+ break;
2445+ }
2446+ depth -= 1;
2447+ }
2448+ i += 1;
5831 }
2449 }
5832− }
5833− }
5834− out
5835−}
5836
2450
5837−/// Evaluate text: fill in the variables of its plain runs and build its
2451+ let inner: String = chars[start..i].iter().collect();
5838−/// inline elements. Each run is one slot, so a value can't make markup.
2452+ i += 1; // skip }
5839−fn text_segments(text: &syntax::Text, ctx: &mut ParseContext) -> Vec<TextSegment> {
2453+
5840− let mut segments = Vec::new();
2454+ match parse_single_element(&inner, ctx.current_line, ctx) {
5841− // The plain text so far, and where it starts
2455+ Ok(elem) => segments.push(TextSegment::Inline(elem)),
5842− let mut plain = String::new();
2456+ Err(e) => {
5843− let mut start: Option<syntax::Span> = None;
2457+ // Keep the braces as literal text, but flag what looks
5844− let flush = |plain: &mut String,
2458+ // like a mistyped inline element.
5845− start: &mut Option<syntax::Span>,
2459+ if inner.trim_start().starts_with('@') {
5846− segments: &mut Vec<TextSegment>,
2460+ ctx.diagnostics.push(Diagnostic {
5847− ctx: &mut ParseContext| {
2461+ line: ctx.current_line,
5848− if !plain.is_empty() {
2462+ column: None,
5849− let at = start.unwrap_or(text.span);
2463+ message: e.message,
5850− let filled = ctx.interpolate_text(plain, at.line, Some(at.column));
2464+ severity: Severity::Warning,
5851− segments.push(TextSegment::Plain(filled));
2465+ source_line: Some(input.to_string()),
5852− plain.clear();
2466+ });
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 }
2467 }
2468+ current_text.push('{');
2469+ current_text.push_str(&inner);
2470+ current_text.push('}');
5905 }
2471 }
5906 }
2472 }
2473+ } else {
2474+ current_text.push(chars[i]);
2475+ i += 1;
5907 }
2476 }
5908 }
2477 }
5909− flush(&mut plain, &mut start, &mut segments, ctx);
5910− segments
5911−}
5912
2478
5913−/// The variable `raw` is, when it is exactly one `$name` or `${name}`.
2479+ if !current_text.is_empty() {
5914−fn whole_reference(raw: &str, env: &Env) -> Option<String> {
2480+ segments.push(TextSegment::Plain(substitute_vars(
5915− let after = raw.trim().strip_prefix('$')?;
2481+ &current_text,
5916− match interp::reference(after, env)? {
2482+ &ctx.variables,
5917− (interp::Reference::Var(path), len) if len == after.len() => Some(path),
2483+ )));
5918− _ => None,
5919 }
2484 }
2485+
2486+ segments
5920}
2487}
5921
2488
5922−/// The attributes of `tokens` after each whole-attribute `if()` has
2489+// ---------------------------------------------------------------------------
5923−/// chosen (see [`syntax::Choice`]), in order, into `out`. The branch not
2490+// Variable substitution
5924−/// taken isn't evaluated; with `validate`, its attribute names are checked
2491+// ---------------------------------------------------------------------------
5925−/// like code that doesn't run, under the prefixes `prefix` the `if()` is
2492+
5926−/// under (`hover:if(...)`).
2493+// ---------------------------------------------------------------------------
5927−fn choose_attrs(
2494+// Condition evaluation for @if
5928− tokens: &[syntax::Attr],
2495+// ---------------------------------------------------------------------------
5929− prefix: &str,
2496+
5930− line_num: usize,
2497+/// Inclusive integer range from `start` to `end` (counting down when
5931− ctx: &mut ParseContext,
2498+/// `start > end`), stepping by `step`. Stops instead of overflowing.
5932− validate: bool,
2499+/// The items of a text list: a range `A..B [step N]`, or comma-separated.
5933− text_keys: &[String],
2500+fn text_list_items(list: &str) -> Vec<String> {
5934− out: &mut Vec<syntax::Attr>,
2501+ if let Some((start, rest)) = list.split_once("..") {
5935−) {
2502+ let (end, step) = match rest.split_once(" step ") {
5936− for token in tokens {
2503+ Some((end, step)) => (end.trim(), step.trim().parse::<i64>().unwrap_or(1).max(1)),
5937− let Some(choice) = &token.choice else {
2504+ None => (rest.trim(), 1),
5938− out.push(token.clone());
5939− continue;
5940− };
5941− let Some(condition) = choice_shape(token, choice, line_num, ctx) else {
5942− continue;
5943− };
5944− let taken = match condition.span.line {
5945− 0 => ctx.condition(&condition.raw, line_num, None),
5946− line => ctx.condition(&condition.raw, line, Some(condition.span.column)),
5947− };
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 };
2505 };
5952− if let Some(other) = other {
2506+ if let (Ok(start), Ok(end)) = (start.trim().parse::<i64>(), end.parse::<i64>()) {
5953− track_var_refs(&branch_text(other), &mut ctx.used_variables);
2507+ return numeric_range(start, end, step);
5954− if validate {
5955− check_branch(token, other, prefix, line_num, ctx, text_keys);
5956− }
5957− }
5958− if let Some(branch) = chosen {
5959− choose_attrs(
5960− branch.attrs(),
5961− prefix,
5962− line_num,
5963− ctx,
5964− validate,
5965− text_keys,
5966− out,
5967− );
5968 }
2508 }
5969 }
2509 }
2510+ list.split(',')
2511+ .map(|s| s.trim().to_string())
2512+ .filter(|s| !s.is_empty())
2513+ .collect()
5970}
2514}
5971
2515
5972−/// Report what is wrong with the shape of `if()`: an empty condition, a
2516+/// Make `$target` (and `$target.key`, and nested lists) a copy of the list
5973−/// number of branches other than one or two, text after a group's `]`.
2517+/// item stored under `source`.
5974−/// Returns the condition when the `if()` can be evaluated.
2518+fn bind_item(vars: &mut HashMap<String, String>, source: &str, target: &str) {
5975−fn choice_shape<'a>(
2519+ let under = |key: &str, name: &str| {
5976− token: &syntax::Attr,
2520+ key.strip_prefix(name)
5977− choice: &'a syntax::Choice,
2521+ .is_some_and(|rest| rest.is_empty() || rest.starts_with(['.', '#']))
5978− line_num: usize,
5979− ctx: &mut ParseContext,
5980−) -> Option<&'a syntax::Arg> {
5981− let line = if token.span.line == 0 {
5982− line_num
5983− } else {
5984− token.span.line
5985 };
2522 };
5986− // The line the column points into, when it is the one being evaluated
2523+ vars.retain(|key, _| !under(key, target));
5987− let source = |ctx: &ParseContext, diagnostic: Diagnostic, at: usize| match &ctx.current_source {
2524+ let copies: Vec<(String, String)> = vars
5988− (current, Some(text)) if *current == at => diagnostic.source(text.clone()),
2525+ .iter()
5989− _ => diagnostic,
2526+ .filter(|(key, _)| under(key, source))
5990− };
2527+ .map(|(key, value)| (format!("{}{}", target, &key[source.len()..]), value.clone()))
5991− for branch in &choice.branches {
2528+ .collect();
5992− if let syntax::Branch::Group {
2529+ vars.extend(copies);
5993− trailing: Some(trailing),
2530+ // A record has no text of its own
5994− ..
2531+ vars.entry(target.to_string()).or_default();
5995− } = branch
2532+}
5996− {
2533+
5997− let diagnostic = source(
2534+fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
5998− ctx,
2535+ let mut items = Vec::new();
5999− Diagnostic::error(
2536+ let mut n = start;
6000− code::UNEXPECTED_ARGUMENT,
2537+ if start <= end {
6001− trailing.span.line,
2538+ while n <= end {
6002− format!(
2539+ items.push(n.to_string());
6003− "'{}' after the `]` of a group in if(): a branch is one attribute, \
2540+ match n.checked_add(step) {
6004− one `[group]` of attributes or one `$bundle`",
2541+ Some(next) => n = next,
6005− trailing.raw
2542+ None => break,
6006− ),
2543+ }
6007− )
6008− .column(trailing.span.column)
6009− .subject(trailing.raw.as_str()),
6010− trailing.span.line,
6011− );
6012− ctx.push_once(diagnostic);
6013 }
2544 }
6014− }
2545+ } else {
6015− match &choice.condition {
2546+ while n >= end {
6016− Some(condition) if (1..=2).contains(&choice.branches.len()) => Some(condition),
2547+ items.push(n.to_string());
6017− _ => {
2548+ match n.checked_sub(step) {
6018− let diagnostic = source(
2549+ Some(next) => n = next,
6019− ctx,
2550+ None => break,
6020− Diagnostic::error(
2551+ }
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 }
2552 }
6035 }
2553 }
2554+ items
6036}
2555}
6037
2556
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
2557
6044−/// Check attributes that are never evaluated (in code that doesn't run,
2558+
6045−/// or the branch an `if()` didn't take), as far as they can be checked:
2559+/// Find a leftover `key if condition` (ignoring ` if ` inside parentheses
6046−/// names are, and literal values; a value with a variable depends on what
2560+/// or quotes), to point at `if()`.
6047−/// the variable holds. Each branch of an `if()` is checked on its own.
2561+fn split_trailing_if(part: &str) -> Option<(&str, &str)> {
6048−fn check_attrs(tokens: &[syntax::Attr], line: usize, ctx: &mut ParseContext, text_keys: &[String]) {
2562+ let mut depth = 0;
6049− parse_attr_list(&literal_attrs(tokens), line, ctx, true, text_keys);
2563+ let mut quote = None;
6050− check_choices(tokens, "", line, ctx, text_keys);
2564+ for (i, c) in part.char_indices() {
2565+ match c {
2566+ '"' | '\'' if quote == Some(c) => quote = None,
2567+ '"' | '\'' if quote.is_none() => quote = Some(c),
2568+ _ if quote.is_some() => {}
2569+ '(' => depth += 1,
2570+ ')' => depth -= 1,
2571+ _ if depth == 0 && part[i..].starts_with(" if ") => {
2572+ return Some((&part[..i], part[i + 4..].trim()));
2573+ }
2574+ _ => {}
2575+ }
2576+ }
2577+ None
6051}
2578}
6052
2579
6053−/// [`check_attrs`] for a branch of the `if()` `token`, under the prefixes
2580+/// Evaluate `if(cond, a)` or `if(cond, a, b)` when it is all of `text`,
6054−/// `prefix` it is under: `hover:if($on, color red, [id=x])` checks
2581+/// returning the chosen branch (empty when `cond` fails and there is no `b`).
6055−/// `hover:[id=x]`, an error whichever branch is taken.
2582+fn choose_if(text: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
6056−fn check_branch(
2583+ let inner = text.strip_prefix("if(")?.strip_suffix(')')?;
6057− token: &syntax::Attr,
2584+ let args = split_if_args(inner);
6058− branch: &syntax::Branch,
2585+ // `if(a) (b)` isn't one call
6059− prefix: &str,
2586+ if split_trailing_paren(inner) || !(2..=3).contains(&args.len()) {
6060− line: usize,
2587+ return None;
6061− ctx: &mut ParseContext,
6062− text_keys: &[String],
6063−) {
6064− if prefix.is_empty() {
6065− check_attrs(branch.attrs(), line, ctx, text_keys);
6066− return;
6067 }
2588 }
6068− let list = syntax::AttrList {
2589+ let branch = if ctx.condition(args[0].trim(), line) {
6069− attrs: branch.attrs().to_vec(),
2590+ args[1]
6070− span: token.span,
2591+ } else {
6071− closed: true,
2592+ args.get(2).copied().unwrap_or("")
6072− quoted_close: None,
6073− };
6074− let grouped = syntax::Attr {
6075− key: prefix.to_string(),
6076− value: None,
6077− html: false,
6078− raw: token.raw.clone(),
6079− span: token.span,
6080− choice: None,
6081− prefixed: Some(Box::new(syntax::Prefixed {
6082− prefix: prefix.to_string(),
6083− prefix_span: token.span,
6084− target: syntax::Branch::Group {
6085− list,
6086− trailing: None,
6087− },
6088− })),
6089 };
2593 };
6090− check_attrs(std::slice::from_ref(&grouped), line, ctx, text_keys);
2594+ Some(branch.trim().to_string())
6091}
2595}
6092
2596
6093−/// [`check_attrs`] for the branches of each `if()` of `tokens`, which are
2597+/// Whether the parentheses in `inner` close before its end, as in the
6094−/// under the prefixes `prefix`, also inside a prefixed group or as a
2598+/// inside of `if(a)(b)`.
6095−/// prefixed `if()`.
2599+fn split_trailing_paren(inner: &str) -> bool {
6096−fn check_choices(
2600+ let mut depth = 0i32;
6097− tokens: &[syntax::Attr],
2601+ let mut quote = None;
6098− prefix: &str,
2602+ for c in inner.chars() {
6099− line: usize,
2603+ match c {
6100− ctx: &mut ParseContext,
2604+ '"' | '\'' if quote == Some(c) => quote = None,
6101− text_keys: &[String],
2605+ '"' | '\'' if quote.is_none() => quote = Some(c),
6102−) {
2606+ _ if quote.is_some() => {}
6103− for token in tokens {
2607+ '(' => depth += 1,
6104− if let Some(choice) = &token.choice
2608+ ')' => {
6105− && choice_shape(token, choice, line, ctx).is_some()
2609+ depth -= 1;
6106− {
2610+ if depth < 0 {
6107− for branch in &choice.branches {
2611+ return true;
6108− check_branch(token, branch, prefix, line, ctx, text_keys);
2612+ }
6109 }
2613 }
2614+ _ => {}
6110 }
2615 }
6111− if let Some(prefixed) = &token.prefixed {
2616+ }
6112− let inner = format!("{}{}", prefix, prefixed.prefix);
2617+ false
6113− check_choices(prefixed.target.attrs(), &inner, line, ctx, text_keys);
2618+}
2619+
2620+/// Resolve a value written `if(cond, a, b)`: evaluate `cond` and keep the
2621+/// chosen branch's text, or `None` when the choice is empty. Other
2622+/// attributes are returned unchanged.
2623+fn choose_if_value(part: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
2624+ let Some(pos) = part.find(['=', ' ']) else {
2625+ return Some(part.to_string());
2626+ };
2627+ let (head, value) = part.split_at(pos + 1);
2628+ match choose_if(value.trim(), ctx, line) {
2629+ Some(branch) if branch.is_empty() => None,
2630+ Some(branch) => Some(format!("{}{}", head, branch)),
2631+ None => Some(part.to_string()),
2632+ }
2633+}
2634+
2635+fn split_if_args(input: &str) -> Vec<&str> {
2636+ let mut parts = Vec::new();
2637+ let mut start = 0;
2638+ let mut depth = 0;
2639+ let mut quote = None;
2640+ for (i, c) in input.char_indices() {
2641+ match c {
2642+ '"' | '\'' if quote == Some(c) => quote = None,
2643+ '"' | '\'' if quote.is_none() => quote = Some(c),
2644+ _ if quote.is_some() => {}
2645+ '(' => depth += 1,
2646+ ')' => depth -= 1,
2647+ ',' if depth == 0 => {
2648+ parts.push(&input[start..i]);
2649+ start = i + 1;
2650+ }
2651+ _ => {}
6114 }
2652 }
6115 }
2653 }
2654+ parts.push(&input[start..]);
2655+ parts
6116}
2656}
6117
2657
6118// ---------------------------------------------------------------------------
2658// ---------------------------------------------------------------------------
6119// Post-parse validation (context-dependent warnings)
2659// Post-parse validation (context-dependent warnings)
6120// ---------------------------------------------------------------------------
2660// ---------------------------------------------------------------------------
6121
2661
2662+/// Attributes that only make sense on container elements (@row, @el, @el).
2663+const CONTAINER_ONLY_ATTRS: &[&str] = &[
2664+ "spacing",
2665+ "gap",
2666+ "wrap",
2667+ "grid-cols",
2668+ "grid-rows",
2669+ "container",
2670+ "container-name",
2671+ "container-type",
2672+];
2673+
6122fn element_kind_name(kind: &ElementKind) -> String {
2674fn element_kind_name(kind: &ElementKind) -> String {
6123 format!("@{}", kind.name())
2675 format!("@{}", kind.name())
6124−}
2676+}
6125−
6126−/// HTML elements whose start tag ends an open `<p>`: written inside a
6127−/// `@paragraph`, the browser moves them out of it. (`div` isn't here:
6128−/// inside text, htmlang writes its `<div>`s as `<span>`s.)
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
2677
6137−/// The HTML element `kind` is written as, if the browser would take it out
2678+fn is_container(kind: &ElementKind) -> bool {
6138−/// of a `<p>`.
2679+ matches!(kind, ElementKind::Row | ElementKind::El)
6139−fn ends_a_paragraph(kind: &ElementKind) -> Option<&'static str> {
2680+ || kind.spec().is_some_and(|spec| spec.container)
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}
2681}
6147
2682
6148/// Stricter checks for `htmlang lint`, on top of the diagnostics every
2683/// Stricter checks for `htmlang lint`, on top of the diagnostics every
@@ -6152,171 +2687,133 @@ pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
6152 fn walk(nodes: &[Node], depth: usize, out: &mut Vec<Diagnostic>) {
2687 fn walk(nodes: &[Node], depth: usize, out: &mut Vec<Diagnostic>) {
6153 for node in nodes {
2688 for node in nodes {
6154 let Node::Element(elem) = node else { continue };
2689 let Node::Element(elem) = node else { continue };
6155− let start = out.len();
2690+ let mut warn = |message: String| {
6156− let mut warn = |code: &'static str, message: String| {
2691+ out.push(Diagnostic {
6157− out.push(Diagnostic::warning(code, elem.line_num, message))
2692+ line: elem.line_num,
2693+ column: None,
2694+ message,
2695+ severity: Severity::Warning,
2696+ source_line: None,
2697+ })
6158 };
2698 };
6159 if depth > 10 {
2699 if depth > 10 {
6160− warn(
2700+ warn(format!(
6161− code::DEEP_NESTING,
2701+ "deeply nested element ({} levels): consider simplifying",
6162− format!(
2702+ depth
6163− "deeply nested element ({} levels): consider simplifying",
2703+ ));
6164− depth
6165− ),
6166− );
6167 }
2704 }
6168− // A function's body may draw with an empty element (a spacer,
2705+ if matches!(elem.kind, ElementKind::Row | ElementKind::El)
6169− // a dot): only one written in the page is flagged
6170− if elem.kind.layout().is_container()
6171 && elem.children.is_empty()
2706 && elem.children.is_empty()
6172− && elem.function.is_none()
6173 {
2707 {
6174− warn(
2708+ 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 }
2709 }
6179 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
2710 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
6180− warn(
2711+ 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 }
2712 }
6185− in_function_body(elem, &mut out[start..]);
6186 walk(&elem.children, depth + 1, out);
2713 walk(&elem.children, depth + 1, out);
6187 }
2714 }
6188 }
2715 }
6189 let mut out = Vec::new();
2716 let mut out = Vec::new();
6190 walk(nodes, 0, &mut out);
2717 walk(nodes, 0, &mut out);
6191− dedupe(&mut out);
6192 out
2718 out
6193}
2719}
6194
2720
6195−/// `in_paragraph`: the nodes are inside a `@paragraph` (and not inside a
2721+
6196−/// `@button` in it, which the browser keeps whole).
6197fn validate_tree(
2722fn validate_tree(
6198 nodes: &[Node],
2723 nodes: &[Node],
6199 parent_kind: Option<&ElementKind>,
2724 parent_kind: Option<&ElementKind>,
6200− in_paragraph: bool,
6201 diagnostics: &mut Vec<Diagnostic>,
2725 diagnostics: &mut Vec<Diagnostic>,
6202) {
2726) {
6203 for node in nodes {
2727 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 {
2728 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 {
2729 for attr in &elem.attrs {
6237 let base = crate::vocab::base_attribute(&attr.key);
2730 let base = crate::vocab::base_attribute(&attr.key);
6238−
2731+ if base == "width"
6239− // htmlang's words for laying out children, on an element
2732+ && attr.value.as_deref() == Some("fill")
6240− // that doesn't lay out its children: left out (codegen)
2733+ && !matches!(parent_kind, Some(ElementKind::Row))
6241− if !attr.html
2734+ {
6242− && crate::vocab::CONTAINER_ATTRIBUTES.contains(&base)
2735+ diagnostics.push(Diagnostic {
6243− && !elem.kind.layout().is_container()
2736+ line: elem.line_num,
6244− && !has_no_element(&elem.kind)
2737+ column: None,
6245− && !crate::vocab::split_prefixes(&attr.key)
2738+ message: "'width fill' works best inside @row; using 100% as fallback"
6246− .0
2739+ .to_string(),
6247− .contains(&"children:")
2740+ severity: Severity::Warning,
2741+ source_line: None,
2742+ });
2743+ }
2744+ if base == "height"
2745+ && attr.value.as_deref() == Some("fill")
2746+ && !parent_kind.is_some_and(ElementKind::is_column)
6248 {
2747 {
6249− diagnostics.push(not_a_container(&elem.kind, elem.line_num, base));
2748+ diagnostics.push(Diagnostic {
2749+ line: elem.line_num,
2750+ column: None,
2751+ message: "'height fill' works best inside @el; using 100% as fallback"
2752+ .to_string(),
2753+ severity: Severity::Warning,
2754+ source_line: None,
2755+ });
2756+ }
2757+
2758+ // Container-only attributes on non-container elements
2759+ if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
2760+ diagnostics.push(Diagnostic {
2761+ line: elem.line_num,
2762+ column: None,
2763+ message: format!(
2764+ "'{}' has no effect on {} (only works on @row, @el, @el)",
2765+ base,
2766+ element_kind_name(&elem.kind)
2767+ ),
2768+ severity: Severity::Warning,
2769+ source_line: None,
2770+ });
6250 }
2771 }
6251
2772
6252 // Form-specific: placeholder only on @input/@textarea
2773 // Form-specific: placeholder only on @input/@textarea
6253 if base == "placeholder"
2774 if base == "placeholder"
6254 && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
2775 && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
6255 {
2776 {
6256− diagnostics.push(Diagnostic::new(
2777+ diagnostics.push(Diagnostic {
6257− code::NO_EFFECT,
2778+ line: elem.line_num,
6258− Severity::Warning,
2779+ column: None,
6259− elem.line_num,
2780+ message: format!(
6260− format!(
6261 "'placeholder' has no effect on {} (only works on @input, @textarea)",
2781 "'placeholder' has no effect on {} (only works on @input, @textarea)",
6262 element_kind_name(&elem.kind)
2782 element_kind_name(&elem.kind)
6263 ),
2783 ),
6264− ));
2784+ severity: Severity::Warning,
2785+ source_line: None,
2786+ });
6265 }
2787 }
6266
2788
6267− // 'for' only on @label and @output
2789+ // 'for' only on @label
6268− if base == "for" && !(elem.kind.is_tag("label") || elem.kind.is_tag("output")) {
2790+ if base == "for" && !elem.kind.is_tag("label") {
6269− diagnostics.push(Diagnostic::new(
2791+ diagnostics.push(Diagnostic {
6270− code::NO_EFFECT,
2792+ line: elem.line_num,
6271− Severity::Warning,
2793+ column: None,
6272− elem.line_num,
2794+ message: format!(
6273− format!(
2795+ "'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)
2796 element_kind_name(&elem.kind)
6276 ),
2797 ),
6277− ));
2798+ severity: Severity::Warning,
6278− }
2799+ source_line: None,
6279−
2800+ });
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 }
2801 }
6307
2802
6308 // 'rows'/'cols' only on @textarea
2803 // 'rows'/'cols' only on @textarea
6309− if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea") {
2804+ if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea")
6310− diagnostics.push(Diagnostic::new(
2805+ {
6311− code::NO_EFFECT,
2806+ diagnostics.push(Diagnostic {
6312− Severity::Warning,
2807+ line: elem.line_num,
6313− elem.line_num,
2808+ column: None,
6314− format!(
2809+ message: format!(
6315 "'{}' has no effect on {} (only works on @textarea)",
2810 "'{}' has no effect on {} (only works on @textarea)",
6316 base,
2811 base,
6317 element_kind_name(&elem.kind)
2812 element_kind_name(&elem.kind)
6318 ),
2813 ),
6319− ));
2814+ severity: Severity::Warning,
2815+ source_line: None,
2816+ });
6320 }
2817 }
6321
2818
6322 // Media-specific attributes only on @video/@audio
2819 // Media-specific attributes only on @video/@audio
@@ -6331,35 +2828,40 @@ fn validate_tree(
6331 | "preload"
2828 | "preload"
6332 ) && !matches!(elem.kind.name(), "video" | "audio")
2829 ) && !matches!(elem.kind.name(), "video" | "audio")
6333 {
2830 {
6334− diagnostics.push(Diagnostic::new(
2831+ diagnostics.push(Diagnostic {
6335− code::NO_EFFECT,
2832+ line: elem.line_num,
6336− Severity::Warning,
2833+ column: None,
6337− elem.line_num,
2834+ message: format!(
6338− format!(
6339 "'{}' has no effect on {} (only works on @video, @audio)",
2835 "'{}' has no effect on {} (only works on @video, @audio)",
6340 base,
2836 base,
6341 element_kind_name(&elem.kind)
2837 element_kind_name(&elem.kind)
6342 ),
2838 ),
6343− ));
2839+ severity: Severity::Warning,
2840+ source_line: None,
2841+ });
6344 }
2842 }
6345 }
2843 }
6346 // Missing alt text on @image
2844 // Missing alt text on @image
6347 if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt")
2845 if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt")
6348 {
2846 {
6349− diagnostics.push(Diagnostic::new(
2847+ diagnostics.push(Diagnostic {
6350− code::MISSING_ALT,
2848+ line: elem.line_num,
6351− Severity::Warning,
2849+ column: None,
6352− elem.line_num,
2850+ message: "@image missing 'alt' attribute (accessibility)".to_string(),
6353− "@image missing 'alt' attribute (accessibility)".to_string(),
2851+ severity: Severity::Warning,
6354− ));
2852+ source_line: None,
2853+ });
6355 }
2854 }
6356− if elem.kind.is_tag("input") && !elem.attrs.iter().any(|a| a.key == "type") {
2855+ if elem.kind.is_tag("input")
6357− diagnostics.push(Diagnostic::new(
2856+ && !elem.attrs.iter().any(|a| a.key == "type")
6358− code::MISSING_INPUT_TYPE,
2857+ {
6359− Severity::Warning,
2858+ diagnostics.push(Diagnostic {
6360− elem.line_num,
2859+ line: elem.line_num,
6361− "@input missing 'type' attribute (defaults to 'text')".to_string(),
2860+ column: None,
6362− ));
2861+ message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
2862+ severity: Severity::Warning,
2863+ source_line: None,
2864+ });
6363 }
2865 }
6364 if matches!(elem.kind, ElementKind::Link) {
2866 if matches!(elem.kind, ElementKind::Link) {
6365 // For @link, argument is the URL, not text content
2867 // For @link, argument is the URL, not text content
@@ -6370,50 +2872,44 @@ fn validate_tree(
6370 .iter()
2872 .iter()
6371 .any(|a| a.key == "aria-label" || a.key == "title")
2873 .any(|a| a.key == "aria-label" || a.key == "title")
6372 {
2874 {
6373− diagnostics.push(Diagnostic::new(
2875+ diagnostics.push(Diagnostic {
6374− code::MISSING_LINK_TEXT,
2876+ line: elem.line_num,
6375− Severity::Warning,
2877+ column: None,
6376− elem.line_num,
2878+ message: "@link has no visible text or aria-label (accessibility)"
6377− "@link has no visible text or aria-label (accessibility)".to_string(),
2879+ .to_string(),
6378− ));
2880+ severity: Severity::Warning,
2881+ source_line: None,
2882+ });
6379 }
2883 }
6380 }
2884 }
6381
2885
6382− // Contrast ratio check for hex color pairs, each pair on the
2886+ // Contrast ratio check for hex color pairs
6383− // same elements: the element's own, or those an element prefix
2887+ {
6384− // (`@td:`) picks out inside it
2888+ let bg_color = elem
6385− let inside = |key: &str| {
2889+ .attrs
6386− crate::vocab::split_prefixes(key)
2890+ .iter()
6387− .0
2891+ .find(|a| crate::vocab::base_attribute(&a.key) == "background")
6388− .into_iter()
2892+ .and_then(|a| a.value.as_deref());
6389− .find_map(crate::vocab::element_prefix)
2893+ let fg_color = elem
6390− .map(str::to_string)
2894+ .attrs
6391− };
2895+ .iter()
6392− let mut targets: Vec<Option<String>> = Vec::new();
2896+ .find(|a| crate::vocab::base_attribute(&a.key) == "color")
6393− for attr in elem.attrs.iter().filter(|a| !a.html) {
2897+ .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)
2898 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))
2899+ && let (Some(bg_rgb), Some(fg_rgb)) = (crate::expr::parse_hex_rgb(bg), crate::expr::parse_hex_rgb(fg))
6410 {
2900 {
6411 let ratio = contrast_ratio(bg_rgb, fg_rgb);
2901 let ratio = contrast_ratio(bg_rgb, fg_rgb);
6412 if ratio < 4.5 {
2902 if ratio < 4.5 {
6413− diagnostics.push(Diagnostic::new(code::LOW_CONTRAST, Severity::Warning, elem.line_num, format!(
2903+ diagnostics.push(Diagnostic {
2904+ line: elem.line_num,
2905+ column: None,
2906+ message: format!(
6414 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
2907 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
6415 ratio, fg, bg
2908 ratio, fg, bg
6416− )));
2909+ ),
2910+ severity: Severity::Warning,
2911+ source_line: None,
2912+ });
6417 }
2913 }
6418 }
2914 }
6419 }
2915 }
@@ -6428,34 +2924,45 @@ fn validate_tree(
6428 let has_title = elem.attrs.iter().any(|a| a.key == "title");
2924 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"));
2925 let in_label = parent_kind.is_some_and(|k| k.is_tag("label"));
6430 if !has_id && !has_aria_label && !has_title && !in_label {
2926 if !has_id && !has_aria_label && !has_title && !in_label {
6431− diagnostics.push(Diagnostic::new(code::MISSING_LABEL, Severity::Warning, elem.line_num, format!(
2927+ diagnostics.push(Diagnostic {
2928+ line: elem.line_num,
2929+ column: None,
2930+ message: format!(
6432 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
2931 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
6433 element_kind_name(&elem.kind)
2932 element_kind_name(&elem.kind)
6434− )));
2933+ ),
2934+ severity: Severity::Warning,
2935+ source_line: None,
2936+ });
6435 }
2937 }
6436 }
2938 }
6437
2939
6438 // @iframe should have title attribute
2940 // @iframe should have title attribute
6439− if elem.kind.is_tag("iframe") && !elem.attrs.iter().any(|a| a.key == "title") {
2941+ if elem.kind.is_tag("iframe")
6440− diagnostics.push(Diagnostic::new(
2942+ && !elem.attrs.iter().any(|a| a.key == "title")
6441− code::MISSING_TITLE,
2943+ {
6442− Severity::Warning,
2944+ diagnostics.push(Diagnostic {
6443− elem.line_num,
2945+ line: elem.line_num,
6444− "@iframe missing 'title' attribute (accessibility)".to_string(),
2946+ column: None,
6445− ));
2947+ message: "@iframe missing 'title' attribute (accessibility)".to_string(),
2948+ severity: Severity::Warning,
2949+ source_line: None,
2950+ });
6446 }
2951 }
6447
2952
6448 // @button should have accessible text
2953 // @button should have accessible text
6449 if elem.kind.is_tag("button") {
2954 if elem.kind.is_tag("button") {
6450− let has_text = !elem.children.is_empty();
2955+ let has_text = elem.argument.is_some() || !elem.children.is_empty();
6451 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
2956 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
6452 if !has_text && !has_aria {
2957 if !has_text && !has_aria {
6453− diagnostics.push(Diagnostic::new(
2958+ diagnostics.push(Diagnostic {
6454− code::MISSING_BUTTON_TEXT,
2959+ line: elem.line_num,
6455− Severity::Warning,
2960+ column: None,
6456− elem.line_num,
2961+ message: "@button has no text content or aria-label (accessibility)"
6457− "@button has no text content or aria-label (accessibility)".to_string(),
2962+ .to_string(),
6458− ));
2963+ severity: Severity::Warning,
2964+ source_line: None,
2965+ });
6459 }
2966 }
6460 }
2967 }
6461
2968
@@ -6468,15 +2975,16 @@ fn validate_tree(
6468 let has_track = elem
2975 let has_track = elem
6469 .children
2976 .children
6470 .iter()
2977 .iter()
6471− .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("track")));
2978+ .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("source")));
6472 if !has_aria && !has_track {
2979 if !has_aria && !has_track {
6473− diagnostics.push(Diagnostic::new(
2980+ diagnostics.push(Diagnostic {
6474− code::MISSING_CAPTIONS,
2981+ line: elem.line_num,
6475− Severity::Warning,
2982+ column: None,
6476− elem.line_num,
2983+ 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(),
2984 .to_string(),
6479− ));
2985+ severity: Severity::Warning,
2986+ source_line: None,
2987+ });
6480 }
2988 }
6481 }
2989 }
6482
2990
@@ -6486,204 +2994,20 @@ fn validate_tree(
6486 && let Ok(n) = val.parse::<i32>()
2994 && let Ok(n) = val.parse::<i32>()
6487 && n > 0
2995 && n > 0
6488 {
2996 {
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)));
2997+ diagnostics.push(Diagnostic {
2998+ line: elem.line_num,
2999+ column: None,
3000+ message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n),
3001+ severity: Severity::Warning,
3002+ source_line: None,
3003+ });
6490 }
3004 }
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
3005
6662−/// Diagnostics about an element a function's body wrote are reported at
3006+ 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 }
3007 }
6669 }
3008 }
6670}
3009}
6671
3010
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 {
3011fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
6688 fn linearize(c: u8) -> f64 {
3012 fn linearize(c: u8) -> f64 {
6689 let s = c as f64 / 255.0;
3013 let s = c as f64 / 255.0;
@@ -6696,24 +3020,6 @@ fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
6696 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
3020 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
6697}
3021}
6698
3022
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 {
3023fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
6718 let l1 = relative_luminance(c1.0, c1.1, c1.2);
3024 let l1 = relative_luminance(c1.0, c1.1, c1.2);
6719 let l2 = relative_luminance(c2.0, c2.1, c2.2);
3025 let l2 = relative_luminance(c2.0, c2.1, c2.2);
@@ -6721,48 +3027,65 @@ fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
6721 (lighter + 0.05) / (darker + 0.05)
3027 (lighter + 0.05) / (darker + 0.05)
6722}
3028}
6723
3029
3030+// ---------------------------------------------------------------------------
3031+// Variable substitution
3032+// ---------------------------------------------------------------------------
3033+
3034+/// Interpolate `$name` variables and `${expr}` expressions into text.
3035+/// Undefined variables, and expressions that don't evaluate, are left as
3036+/// written so they show up in the output.
3037+fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
3038+ if !input.contains('$') {
3039+ return input.to_string();
3040+ }
3041+ let is_name_char = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
3042+ let mut result = String::with_capacity(input.len());
3043+ let mut rest = input;
3044+ while let Some(pos) = rest.find('$') {
3045+ result.push_str(&rest[..pos]);
3046+ let after = &rest[pos + 1..];
3047+ if after.starts_with('{')
3048+ && let Some(close) = matching_brace(after)
3049+ {
3050+ let source = &after[1..close];
3051+ match crate::expr::eval(source, &|name: &str| lookup(vars, name)) {
3052+ Ok(value) => result.push_str(&value.to_string()),
3053+ Err(_) => result.push_str(&rest[pos..pos + 1 + close + 1]),
3054+ }
3055+ rest = &after[close + 1..];
3056+ continue;
3057+ }
3058+ let mut end = after.find(|c: char| !is_name_char(c)).unwrap_or(after.len());
3059+ while end > 0 && after[..end].ends_with('.') {
3060+ end -= 1;
3061+ }
3062+ let name = &after[..end];
3063+ match vars.get(name) {
3064+ Some(value) if !name.is_empty() => result.push_str(value),
3065+ _ => {
3066+ result.push('$');
3067+ result.push_str(name);
3068+ }
3069+ }
3070+ rest = &after[end..];
3071+ }
3072+ result.push_str(rest);
3073+ result
3074+}
3075+
6724/// `@image [inline] icon.svg` becomes the SVG's markup, with `width`,
3076/// `@image [inline] icon.svg` becomes the SVG's markup, with `width`,
6725−/// `height`, `color` / `fill` and the HTML attributes (`class=`, `id=`,
3077+/// `height`, `color` / `fill`, `class=` and `id=` applied to the `<svg>`
6726−/// `aria-label=`, ...) applied to the `<svg>` tag; any other attribute is
3078+/// 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 {
3079fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
6729 let Node::Element(elem) = &node else {
3080 let Node::Element(elem) = &node else {
6730 return node;
3081 return node;
6731 };
3082 };
6732− let inline = elem.kind == ElementKind::Image
3083+ let is_inline_svg = elem.kind == ElementKind::Image
6733 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
3084 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
6734− && elem.argument.as_deref().is_some_and(|src| !src.is_empty());
3085+ && elem.argument.as_deref().is_some_and(|src| src.ends_with(".svg"));
6735− if !inline {
3086+ if !is_inline_svg {
6736 return node;
3087 return node;
6737 }
3088 }
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();
3089 let filename = elem.argument.clone().unwrap_or_default();
6767 let resolved = match &ctx.base_path {
3090 let resolved = match &ctx.base_path {
6768 Some(base) => base.join(&filename),
3091 Some(base) => base.join(&filename),
@@ -6771,104 +3094,104 @@ fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
6771 let mut svg = match std::fs::read_to_string(&resolved) {
3094 let mut svg = match std::fs::read_to_string(&resolved) {
6772 Ok(text) => text.trim().to_string(),
3095 Ok(text) => text.trim().to_string(),
6773 Err(e) => {
3096 Err(e) => {
6774− ctx.diagnostics.push(Diagnostic::new(
3097+ ctx.diagnostics.push(Diagnostic {
6775− code::UNREADABLE_FILE,
3098+ line: line_num,
6776− Severity::Error,
3099+ column: None,
6777− line_num,
3100+ message: format!("cannot load SVG '{}': {}", filename, e),
6778− format!("cannot load SVG '{}': {}", filename, e),
3101+ severity: Severity::Error,
6779− ));
3102+ source_line: None,
3103+ });
6780 return node;
3104 return node;
6781 }
3105 }
6782 };
3106 };
6783 ctx.included_files.push(resolved);
3107 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 {
3108 for attr in &elem.attrs {
6792− let target = match (attr.key.as_str(), attr.html, &attr.value) {
3109+ let Some(value) = &attr.value else { continue };
6793− ("inline", false, None) | ("src", true, _) => continue,
3110+ let target = match (attr.key.as_str(), attr.html) {
6794− // `fill` and `shrink` are layout words, which need a class
3111+ ("width", false) | ("height", false) => attr.key.as_str(),
6795− ("width" | "height", false, Some(value))
3112+ ("color", false) | ("fill", false) => "fill",
6796− if matches!(value.as_str(), "fill" | "shrink") =>
3113+ ("class", true) | ("id", true) => attr.key.as_str(),
6797− {
3114+ _ => 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 };
3115 };
6825− let value = attr.value.as_deref().unwrap_or("");
3116+ svg = set_svg_attr(&svg, target, value);
6826− svg = set_svg_attr(&svg, target, &html_escape_md(value));
6827 }
3117 }
6828− if !style.is_empty() {
3118+ Node::Raw(svg)
6829− // After the file's own `style=`, which stays: these come later, so
3119+}
6830− // they win where both set a property
3120+
6831− let mut value = html_escape_md(&style.join(";"));
3121+/// The first `$name|filter` (old filter syntax) in `text`, as (name, filter).
6832− if let Some(own) = svg_attr_value(&svg, "style")
3122+fn old_filter_syntax(text: &str) -> Option<(String, String)> {
6833− .map(|range| svg[range].trim().trim_end_matches(';').to_string())
3123+ const FILTERS: &[&str] = &[
6834− .filter(|own| !own.is_empty())
3124+ "uppercase", "lowercase", "capitalize", "trim", "length", "reverse", "truncate",
6835− {
3125+ "replace", "default", "lighten", "darken", "alpha", "mix",
6836− value = format!("{};{}", own, value);
3126+ ];
3127+ let mut rest = text;
3128+ while let Some(pos) = rest.find('$') {
3129+ let after = &rest[pos + 1..];
3130+ let end = after
3131+ .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.')))
3132+ .unwrap_or(after.len());
3133+ if let Some(filter) = after[end..].strip_prefix('|') {
3134+ let name_end = filter.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(filter.len());
3135+ if end > 0 && FILTERS.contains(&&filter[..name_end]) {
3136+ return Some((after[..end].to_string(), filter[..name_end].to_string()));
3137+ }
6837 }
3138 }
6838− svg = set_svg_attr(&svg, "style", &value);
3139+ rest = &after[end..];
6839 }
3140 }
6840− if !left_out.is_empty() {
3141+ None
6841− let alt = left_out.iter().any(|name| name == "alt");
3142+}
6842− let names = left_out
3143+
6843− .iter()
3144+/// Index of the `}` matching the `{` that `s` starts with (skipping
6844− .map(|name| format!("'{}'", name))
3145+/// braces inside string literals).
6845− .collect::<Vec<_>>()
3146+fn matching_brace(s: &str) -> Option<usize> {
6846− .join(", ");
3147+ let mut depth = 0;
6847− let mut message = format!(
3148+ 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=`, ...)",
3149+ for (i, c) in s.char_indices() {
6849− names
3150+ match c {
6850− );
3151+ '"' => in_string = !in_string,
6851− if alt {
3152+ _ if in_string => {}
6852− message.push_str("; an SVG has no alt text: name it with `aria-label=` and `role=img`");
3153+ '{' => depth += 1,
3154+ '}' => {
3155+ depth -= 1;
3156+ if depth == 0 {
3157+ return Some(i);
3158+ }
3159+ }
3160+ _ => {}
6853 }
3161 }
6854− ctx.diagnostics.push(Diagnostic::new(
6855− code::UNEXPECTED_ARGUMENT,
6856− Severity::Error,
6857− line_num,
6858− message,
6859− ));
6860 }
3162 }
6861− Node::Raw(svg)
3163+ None
6862}
3164}
6863
3165
6864// ---------------------------------------------------------------------------
3166// ---------------------------------------------------------------------------
6865// Variable usage tracking
3167// Variable usage tracking
6866// ---------------------------------------------------------------------------
3168// ---------------------------------------------------------------------------
6867
3169
6868−/// Record the names a string refers to (by syntax) as used.
3170+/// Scan a string for $name references and record them in the used set.
6869fn track_var_refs(input: &str, used: &mut HashSet<String>) {
3171fn track_var_refs(input: &str, used: &mut HashSet<String>) {
6870− if input.contains('$') {
3172+ if !input.contains('$') {
6871− used.extend(interp::names(input).into_iter().map(String::from));
3173+ return;
3174+ }
3175+ let chars: Vec<char> = input.chars().collect();
3176+ let mut i = 0;
3177+ while i < chars.len() {
3178+ if chars[i] == '$'
3179+ && i + 1 < chars.len()
3180+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
3181+ {
3182+ let start = i + 1;
3183+ let mut end = start;
3184+ while end < chars.len()
3185+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
3186+ {
3187+ end += 1;
3188+ }
3189+ let name: String = chars[start..end].iter().collect();
3190+ used.insert(name);
3191+ i = end;
3192+ } else {
3193+ i += 1;
3194+ }
6872 }
3195 }
6873}
3196}
6874
3197
@@ -6876,63 +3199,49 @@ fn track_var_refs(input: &str, used: &mut HashSet<String>) {
6876// Unused definition warnings
3199// Unused definition warnings
6877// ---------------------------------------------------------------------------
3200// ---------------------------------------------------------------------------
6878
3201
6879−/// Warn about definitions nothing uses, except `exported` ones (the
3202+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
3203 // Check unused @let variables
6883 for (name, &line) in &ctx.let_lines {
3204 for (name, &line) in &ctx.let_lines {
6884− if exported.contains(name) {
3205+ if name.starts_with("--") {
6885− // A library's names are for other files (a custom property
3206+ continue; // CSS vars are always used
6886− // isn't a variable, so it is never listed here)
6887− continue;
6888 }
3207 }
6889 if !ctx.used_variables.contains(name) {
3208 if !ctx.used_variables.contains(name) {
6890− ctx.diagnostics.push(
3209+ ctx.diagnostics.push(Diagnostic {
6891− Diagnostic::new(
3210+ line,
6892− code::UNUSED_VARIABLE,
3211+ column: None,
6893− Severity::Warning,
3212+ message: format!("unused variable '${}' (defined but never referenced)", name),
6894− line,
3213+ severity: Severity::Warning,
6895− format!("unused variable '${}' (defined but never referenced)", name),
3214+ source_line: None,
6896− )
3215+ });
6897− .subject(name.as_str()),
6898− );
6899 }
3216 }
6900 }
3217 }
6901
3218
6902− // Check unused attribute bundles (@let name [...]). A `$name` in code
3219+ // 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 {
3220 for (name, &line) in &ctx.define_lines {
6905− if !ctx.used_defines.contains(name)
3221+ if !ctx.used_defines.contains(name) {
6906− && !ctx.used_variables.contains(name)
3222+ ctx.diagnostics.push(Diagnostic {
6907− && !exported.contains(name)
3223+ line,
6908− {
3224+ column: None,
6909− ctx.diagnostics.push(
3225+ message: format!(
6910− Diagnostic::new(
3226+ "unused attribute bundle '${}' (defined but never referenced)",
6911− code::UNUSED_BUNDLE,
3227+ name
6912− Severity::Warning,
3228+ ),
6913− line,
3229+ severity: Severity::Warning,
6914− format!(
3230+ source_line: None,
6915− "unused attribute bundle '${}' (defined but never referenced)",
3231+ });
6916− name
6917− ),
6918− )
6919− .subject(name.as_str()),
6920− );
6921 }
3232 }
6922 }
3233 }
6923
3234
6924 // Check unused functions (@let name ... with body)
3235 // Check unused functions (@let name ... with body)
6925 for (name, &line) in &ctx.fn_lines {
3236 for (name, &line) in &ctx.fn_lines {
6926− if !ctx.used_functions.contains(name) && !exported.contains(name) {
3237+ if !ctx.used_functions.contains(name) {
6927− ctx.diagnostics.push(
3238+ ctx.diagnostics.push(Diagnostic {
6928− Diagnostic::new(
3239+ line,
6929− code::UNUSED_FUNCTION,
3240+ column: None,
6930− Severity::Warning,
3241+ message: format!("unused function '@{}' (defined but never called)", name),
6931− line,
3242+ severity: Severity::Warning,
6932− format!("unused function '@{}' (defined but never called)", name),
3243+ source_line: None,
6933− )
3244+ });
6934− .subject(name.as_str()),
6935− );
6936 }
3245 }
6937 }
3246 }
6938}
3247}
@@ -7018,6 +3327,34 @@ fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
7018 }
3327 }
7019}
3328}
7020
3329
3330+/// Visit the lines of a block in source order, children after their line.
3331+fn for_each_line<'a>(block: &'a [Syntax], f: &mut impl FnMut(&'a Syntax)) {
3332+ for node in block {
3333+ f(node);
3334+ if let Syntax::Line { children, .. } = node {
3335+ for_each_line(children, f);
3336+ }
3337+ }
3338+}
3339+
3340+/// The text of a block whose lines are content, not htmlang (`@head`,
3341+/// `@style`, `@markdown`): each line trimmed, verbatim text as is.
3342+fn block_text(block: &[Syntax]) -> String {
3343+ let mut text = String::new();
3344+ for_each_line(block, &mut |node| match node {
3345+ Syntax::Line { text: line, .. } => {
3346+ text.push_str(line.trim());
3347+ text.push('\n');
3348+ }
3349+ Syntax::Raw { text: raw, .. } => {
3350+ text.push_str(raw);
3351+ text.push('\n');
3352+ }
3353+ _ => {}
3354+ });
3355+ text
3356+}
3357+
7021fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
3358fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
7022 pos = skip_ws(chars, pos);
3359 pos = skip_ws(chars, pos);
7023 if pos >= chars.len() {
3360 if pos >= chars.len() {
@@ -7198,21 +3535,68 @@ fn skip_ws(chars: &[char], mut pos: usize) -> usize {
7198 pos
3535 pos
7199}
3536}
7200
3537
7201−/// A JSON value as an htmlang value: an object is a record, an array a
3538+/// Flatten a JSON value into variable assignments.
7202−/// list, a string text (never quoted text), `null` empty. A number keeps
3539+/// - Top-level object: each key becomes `prefix.key`
7203−/// the text it is written with (`1.50`), and reads as a number.
3540+/// - Top-level array: `prefix` becomes comma-separated, `prefix._count` set
7204−fn json_value(json: JsonValue) -> Value {
3541+/// - Array of objects: each item becomes space-separated values for @each destructuring
7205− match json {
3542+fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, String>) {
7206− JsonValue::Null => Value::empty(),
3543+ match value {
7207− JsonValue::Bool(b) => Value::Bool(b),
3544+ JsonValue::Str(s) => {
7208− JsonValue::Number(n) | JsonValue::Str(n) => Value::Str(n),
3545+ vars.insert(prefix.to_string(), s.clone());
7209− JsonValue::Array(items) => Value::list(items.into_iter().map(json_value).collect()),
3546+ }
7210− JsonValue::Object(pairs) => Value::Record(Rc::new(
3547+ JsonValue::Number(n) => {
7211− pairs
3548+ vars.insert(prefix.to_string(), n.clone());
7212− .into_iter()
3549+ }
7213− .map(|(key, value)| (key, json_value(value)))
3550+ JsonValue::Bool(b) => {
3551+ vars.insert(prefix.to_string(), b.to_string());
3552+ }
3553+ JsonValue::Null => {
3554+ vars.insert(prefix.to_string(), String::new());
3555+ }
3556+ JsonValue::Object(pairs) => {
3557+ for (key, val) in pairs {
3558+ flatten_json(&format!("{}.{}", prefix, key), val, vars);
3559+ }
3560+ }
3561+ JsonValue::Array(items) => {
3562+ // A list: its length under `NAME#` (see `lookup`), its text
3563+ // items joined as its value, and each item as `NAME.INDEX`
3564+ vars.insert(format!("{}#", prefix), items.len().to_string());
3565+ let text: Vec<String> = items
3566+ .iter()
3567+ .map(json_value_to_string)
3568+ .filter(|s| !s.is_empty())
3569+ .collect();
3570+ vars.insert(prefix.to_string(), text.join(", "));
3571+ // Also set indexed access: prefix.0, prefix.1, etc.
3572+ for (i, item) in items.iter().enumerate() {
3573+ flatten_json(&format!("{}.{}", prefix, i), item, vars);
3574+ }
3575+ }
3576+ }
3577+}
3578+
3579+/// Resolve `$name` for an expression: a list if `name` was loaded from a
3580+/// JSON array, else its text.
3581+fn lookup(vars: &HashMap<String, String>, name: &str) -> Option<crate::expr::Value> {
3582+ use crate::expr::Value;
3583+ match vars.get(&format!("{}#", name)).and_then(|n| n.parse::<usize>().ok()) {
3584+ Some(len) => Some(Value::List(
3585+ (0..len)
3586+ .map(|i| vars.get(&format!("{}.{}", name, i)).cloned().unwrap_or_default())
7214 .collect(),
3587 .collect(),
7215 )),
3588 )),
3589+ None => vars.get(name).cloned().map(Value::Str),
3590+ }
3591+}
3592+
3593+fn json_value_to_string(v: &JsonValue) -> String {
3594+ match v {
3595+ JsonValue::Str(s) => s.clone(),
3596+ JsonValue::Number(n) => n.clone(),
3597+ JsonValue::Bool(b) => b.to_string(),
3598+ JsonValue::Null => String::new(),
3599+ JsonValue::Array(_) | JsonValue::Object(_) => String::new(),
7216 }
3600 }
7217}
3601}
7218
3602
@@ -7220,29 +3604,6 @@ fn json_value(json: JsonValue) -> Value {
7220// SVG attribute injection helper
3604// SVG attribute injection helper
7221// ---------------------------------------------------------------------------
3605// ---------------------------------------------------------------------------
7222
3606
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 {
3607fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
7247 // Only the opening <svg ...> tag is touched, so child attributes such as
3608 // Only the opening <svg ...> tag is touched, so child attributes such as
7248 // `stroke-width` are left alone.
3609 // `stroke-width` are left alone.
@@ -7253,14 +3614,26 @@ fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
7253 .find('>')
3614 .find('>')
7254 .map(|p| tag_start + p)
3615 .map(|p| tag_start + p)
7255 .unwrap_or(svg.len());
3616 .unwrap_or(svg.len());
3617+ let tag = &svg[tag_start..tag_end];
7256
3618
7257− // Replace an existing attribute
3619+ // Replace an existing attribute (must be preceded by whitespace so that
7258− if let Some(existing) = svg_attr_value(svg, attr_name) {
3620+ // `width` doesn't match `stroke-width`).
7259− let mut result = String::with_capacity(svg.len());
3621+ let pattern = format!("{}=\"", attr_name);
7260− result.push_str(&svg[..existing.start]);
3622+ let existing = tag.match_indices(&pattern).find(|(pos, _)| {
7261− result.push_str(value);
3623+ tag[..*pos]
7262− result.push_str(&svg[existing.end..]);
3624+ .chars()
7263− return result;
3625+ .next_back()
3626+ .is_some_and(char::is_whitespace)
3627+ });
3628+ if let Some((pos, _)) = existing {
3629+ let value_start = tag_start + pos + pattern.len();
3630+ if let Some(end) = svg[value_start..tag_end].find('"') {
3631+ let mut result = String::with_capacity(svg.len());
3632+ result.push_str(&svg[..value_start]);
3633+ result.push_str(value);
3634+ result.push_str(&svg[value_start + end..]);
3635+ return result;
3636+ }
7264 }
3637 }
7265
3638
7266 // Otherwise inject it into the opening tag (before a self-closing `/`).
3639 // Otherwise inject it into the opening tag (before a self-closing `/`).
@@ -7583,10 +3956,7 @@ mod tests {
7583 #[test]
3956 #[test]
7584 fn parses_page_directive() {
3957 fn parses_page_directive() {
7585 let r = parse_ok("@page My Title\n");
3958 let r = parse_ok("@page My Title\n");
7586− assert_eq!(
3959+ 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 }
3960 }
7591
3961
7592 #[test]
3962 #[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 solid 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 @li.
41+@let breadcrumb
42+ @nav [aria-label=breadcrumb]
43+ @ol [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.rs+371−2826

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−776

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+175−248

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+303−271

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+888−1294

This diff is too big to show here.

src/bin/htmlang_lsp/completion.rs+158−1359

This diff is too big to show here.

src/bin/htmlang_lsp/docs.rs+99−356

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

src/bin/htmlang_lsp/tree.rsdeleted−222

This diff is too big to show here.

src/cli.rs+6−4

This diff is too big to show here.

src/fmt.rs+401−394

This diff is too big to show here.

src/lib.rs+1−3

This diff is too big to show here.

src/main.rs+159−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+2012

This diff is too big to show here.

tests/common/mod.rsdeleted−140

This diff is too big to show here.

tests/docs.rs+19−115

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+46−2075

This diff is too big to show here.

tests/snapshots.rs+667−4341

This diff is too big to show here.

tests/snapshots/accessibility.html+1−1

This diff is too big to show here.

tests/snapshots/alignment.html+1−1

This diff is too big to show here.

tests/snapshots/anchor_positioning.html+1−1

This diff is too big to show here.

tests/snapshots/animate_shorthand.html+1−1

This diff is too big to show here.

tests/snapshots/animations.html+1−1

This diff is too big to show here.

tests/snapshots/arithmetic.html+1−1

This diff is too big to show here.

tests/snapshots/attributes.html+1−1

This diff is too big to show here.

tests/snapshots/basic_elements.html+1−1

This diff is too big to show here.

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

This diff is too big to show here.

tests/snapshots/clamp_css.html+1−1

This diff is too big to show here.

tests/snapshots/code_samples.hldeleted−28

This diff is too big to show here.

tests/snapshots/code_samples.htmldeleted−5

This diff is too big to show here.

tests/snapshots/color_scheme.html+1−1

This diff is too big to show here.

tests/snapshots/colour_variants.hldeleted−12

This diff is too big to show here.

tests/snapshots/colour_variants.htmldeleted−1

This diff is too big to show here.

tests/snapshots/computed_let.html+1−1

This diff is too big to show here.

tests/snapshots/conditionals.html+1−1

This diff is too big to show here.

tests/snapshots/container_queries.hl+2−2

This diff is too big to show here.

tests/snapshots/container_queries.html+1−1

This diff is too big to show here.

tests/snapshots/css_property.html+1−1

This diff is too big to show here.

tests/snapshots/css_shorthands.hl+4−1

This diff is too big to show here.

tests/snapshots/css_shorthands.html+1−1

This diff is too big to show here.

tests/snapshots/css_units.html+1−1

This diff is too big to show here.

tests/snapshots/css_vars.hl+5−5

This diff is too big to show here.

tests/snapshots/css_vars.html+1−1

This diff is too big to show here.

tests/snapshots/custom_properties.hldeleted−47

This diff is too big to show here.

tests/snapshots/custom_properties.htmldeleted−1

This diff is too big to show here.

tests/snapshots/dark_mode.html+1−1

This diff is too big to show here.

tests/snapshots/data_directive.html+1−1

This diff is too big to show here.

tests/snapshots/direction_attr.html+1−1

This diff is too big to show here.

tests/snapshots/each_destructuring.html+1−1

This diff is too big to show here.

tests/snapshots/each_else.html+1−1

This diff is too big to show here.

tests/snapshots/each_index.html+1−1

This diff is too big to show here.

tests/snapshots/each_range.html+1−1

This diff is too big to show here.

tests/snapshots/each_step.html+1−1

This diff is too big to show here.

tests/snapshots/element_prefixes.hldeleted−42

This diff is too big to show here.

tests/snapshots/element_prefixes.htmldeleted−3

This diff is too big to show here.

tests/snapshots/else_if.html+1−1

This diff is too big to show here.

tests/snapshots/env_directive.html+1−1

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.hldeleted−19

This diff is too big to show here.

tests/snapshots/escapes_and_quotes.htmldeleted−1

This diff is too big to show here.

tests/snapshots/extra_css.html+1−1

This diff is too big to show here.

tests/snapshots/fn_defaults.hl+1−1

This diff is too big to show here.

tests/snapshots/fn_defaults.html+1−1

This diff is too big to show here.

tests/snapshots/form_elements.html+1−1

This diff is too big to show here.

tests/snapshots/fragment.html+1−1

This diff is too big to show here.

tests/snapshots/function_calls.hldeleted−66

This diff is too big to show here.

tests/snapshots/function_calls.htmldeleted−1

This diff is too big to show here.

tests/snapshots/function_definitions.hldeleted−35

This diff is too big to show here.

tests/snapshots/function_definitions.htmldeleted−1

This diff is too big to show here.

tests/snapshots/functions.hl+2−2

This diff is too big to show here.

tests/snapshots/functions.html+1−1

This diff is too big to show here.

tests/snapshots/grid_areas.html+1−1

This diff is too big to show here.

tests/snapshots/has_pseudo.html+1−1

This diff is too big to show here.

tests/snapshots/hidden_attr.html+1−1

This diff is too big to show here.

tests/snapshots/html_elements.hldeleted−42

This diff is too big to show here.

tests/snapshots/html_elements.htmldeleted−1

This diff is too big to show here.

tests/snapshots/if_expr.hl+3−21

This diff is too big to show here.

tests/snapshots/if_expr.html+1−1

This diff is too big to show here.

tests/snapshots/initial_letter.html+1−1

This diff is too big to show here.

tests/snapshots/inline_text.html+1−1

This diff is too big to show here.

tests/snapshots/lang_directive.hl+1−1

This diff is too big to show here.

tests/snapshots/lang_directive.html+1−1

This diff is too big to show here.

tests/snapshots/layer_wrapping.html+1−1

This diff is too big to show here.

tests/snapshots/layout_direction.hldeleted−33

This diff is too big to show here.

tests/snapshots/layout_direction.htmldeleted−1

This diff is too big to show here.

tests/snapshots/layout_modes.hldeleted−44

This diff is too big to show here.

tests/snapshots/layout_modes.htmldeleted−2

This diff is too big to show here.

tests/snapshots/leading_argument.hldeleted−50

This diff is too big to show here.

tests/snapshots/leading_argument.htmldeleted−1

This diff is too big to show here.

tests/snapshots/list_elements.html+1−1

This diff is too big to show here.

tests/snapshots/loops.html+1−1

This diff is too big to show here.

tests/snapshots/manifest_directive.html+1−1

This diff is too big to show here.

tests/snapshots/markdown_block.html+1−1

This diff is too big to show here.

tests/snapshots/match_directive.html+1−1

This diff is too big to show here.

tests/snapshots/media_elements.html+1−1

This diff is too big to show here.

tests/snapshots/media_extended.html+1−1

This diff is too big to show here.

tests/snapshots/meta_head.html+1−1

This diff is too big to show here.

tests/snapshots/mixin_spread.hl+1−1

This diff is too big to show here.

tests/snapshots/mixin_spread.html+1−1

This diff is too big to show here.

tests/snapshots/named_slots.hl+1−1

This diff is too big to show here.

tests/snapshots/named_slots.html+1−1

This diff is too big to show here.

tests/snapshots/names_and_values.hldeleted−18

This diff is too big to show here.

tests/snapshots/names_and_values.htmldeleted−1

This diff is too big to show here.

tests/snapshots/new_css.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_attrs.hl+1−1

This diff is too big to show here.

tests/snapshots/new_css_attrs.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_2.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_3.html+1−1

This diff is too big to show here.

tests/snapshots/new_css_properties_4.html+1−1

This diff is too big to show here.

tests/snapshots/new_directives.hl+1−4

This diff is too big to show here.

tests/snapshots/new_directives.html+2−3

This diff is too big to show here.

tests/snapshots/new_elements.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_2.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_3.html+1−1

This diff is too big to show here.

tests/snapshots/new_elements_4.hl+11−2

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

This diff is too big to show here.

tests/snapshots/new_html_attrs.html+1−1

This diff is too big to show here.

tests/snapshots/new_pseudos.html+1−1

This diff is too big to show here.

tests/snapshots/no_page.html+1−1

This diff is too big to show here.

tests/snapshots/nth_pseudo.hl+2−2

This diff is too big to show here.

tests/snapshots/nth_pseudo.html+1−1

This diff is too big to show here.

tests/snapshots/og_directive.html+1−1

This diff is too big to show here.

tests/snapshots/one_css_path.hldeleted−14

This diff is too big to show here.

tests/snapshots/one_css_path.htmldeleted−4

This diff is too big to show here.

tests/snapshots/page_root.hldeleted−15

This diff is too big to show here.

tests/snapshots/page_root.htmldeleted−1

This diff is too big to show here.

tests/snapshots/parameters.hldeleted−31

This diff is too big to show here.

tests/snapshots/parameters.htmldeleted−1

This diff is too big to show here.

tests/snapshots/popover_api.html+1−1

This diff is too big to show here.

tests/snapshots/positional.html+1−1

This diff is too big to show here.

tests/snapshots/prefixes_compose.hldeleted−17

This diff is too big to show here.

tests/snapshots/prefixes_compose.htmldeleted−1

This diff is too big to show here.

tests/snapshots/pseudo_elements.html+1−1

This diff is too big to show here.

tests/snapshots/pseudo_states.html+1−1

This diff is too big to show here.

tests/snapshots/pseudo_states_extended.hl+2−2

This diff is too big to show here.

tests/snapshots/pseudo_states_extended.html+1−1

This diff is too big to show here.

tests/snapshots/px_rule.hldeleted−20

This diff is too big to show here.

tests/snapshots/px_rule.htmldeleted−1

This diff is too big to show here.

tests/snapshots/raw_html.html+1−1

This diff is too big to show here.

tests/snapshots/repeat_directive.html+1−1

This diff is too big to show here.

tests/snapshots/responsive.html+1−1

This diff is too big to show here.

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

This diff is too big to show here.

tests/snapshots/script_element.html+1−1

This diff is too big to show here.

tests/snapshots/scroll_driven_animations.html+1−1

This diff is too big to show here.

tests/snapshots/selection_pseudo.html+1−1

This diff is too big to show here.

tests/snapshots/semantic_elements.html+1−1

This diff is too big to show here.

tests/snapshots/sizing.html+1−1

This diff is too big to show here.

tests/snapshots/slots_and_children.hldeleted−59

This diff is too big to show here.

tests/snapshots/slots_and_children.htmldeleted−1

This diff is too big to show here.

tests/snapshots/starting_style.html+1−1

This diff is too big to show here.

tests/snapshots/stress_deeply_nested.hl+20−20

This diff is too big to show here.

tests/snapshots/stress_deeply_nested.html+1−1

This diff is too big to show here.

tests/snapshots/stress_large.hl+139−139

This diff is too big to show here.

tests/snapshots/stress_large.html+1−1

This diff is too big to show here.

tests/snapshots/string_interpolation.html+1−1

This diff is too big to show here.

tests/snapshots/style_block.html+1−1

This diff is too big to show here.

tests/snapshots/subgrid_css.html+1−1

This diff is too big to show here.

tests/snapshots/table_elements.html+1−1

This diff is too big to show here.

tests/snapshots/text_everywhere.hldeleted−41

This diff is too big to show here.

tests/snapshots/text_everywhere.htmldeleted−3

This diff is too big to show here.

tests/snapshots/translations_i18n.html+1−1

This diff is too big to show here.

tests/snapshots/unless_directive.html+1−1

This diff is too big to show here.

tests/snapshots/values_and_scope.hldeleted−62

This diff is too big to show here.

tests/snapshots/values_and_scope.htmldeleted−1

This diff is too big to show here.

tests/snapshots/variable_filters.html+1−1

This diff is too big to show here.

tests/snapshots/variable_slots.hldeleted−18

This diff is too big to show here.

tests/snapshots/variable_slots.htmldeleted−1

This diff is too big to show here.

tests/snapshots/variables_defines.html+1−1

This diff is too big to show here.

tests/snapshots/view_transitions.html+1−1

This diff is too big to show here.

tests/snapshots/with_directive.html+1−1

This diff is too big to show here.