Repository navigation
Make htmlang more elegant: the 24 review proposals - #1
Merged
Merged
Conversation
The compiler, the formatter and the language server now read htmlang through one syntax tree, and the compiler checks the whole file rather than only the code that runs. For htmlang writers: - An attribute list belongs to the element name right before it; any other `[` is text, so `@text Use [ to open` is one line of text. A `>` chains only between element heads. - An indented line under a directive that takes no body (@page, @include, @DaTa, @meta) is an error instead of a silent sibling. - Untaken @if/@else branches, uncalled function bodies and loops over empty lists are checked for unknown elements, functions and attributes. Functions may be used before their definition and from included files. - Every diagnostic has a stable code: `error[unknown-element]: line 3:` on the command line, a `code` (and `column`) field in --format json. - `htmlang fmt` prints the tree back: comments and blank lines are kept, unclosed lists and multi-line JSON are left as written. - The language server resolves relative @include and @DaTa from the file's folder, keys its quick fixes on codes, and takes definitions, folding, semantic tokens, links, lenses, rename and hover from the tree (so CSS inside @Style is no longer read as htmlang). How: htmlang-core gains syntax.rs (a lossless tree with spans for heads, chains, attributes, arguments, text segments and trivia), diagnostic.rs (Diagnostic with code, subject and suggestion) and a DIRECTIVES table in ast.rs with each directive's argument grammar and body kind. The evaluator walks the tree; the formatter, the LSP and tests/editor.rs (the VS Code grammar's word lists) all read the same table. Compiled output of every snapshot and example is unchanged. Left out: attribute values are still substituted from the raw token (the tree already exposes key and value), text children of a line are still flattened as siblings, and mixed tab/space indentation is not reported. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- A known name in the wrong place no longer suggests itself ("unknown
element @b, or did you mean @b?"). A function called before its @let
has run, a function used inside text and a directive in a chain each
say what is wrong, and the function counts as used.
- @include and @meta without their argument, and @meta without a value,
are missing-argument errors instead of being dropped silently.
- Syntax errors are reported in line order (a stray @else came after the
errors found while reading the lines).
- An escaped quote inside a quoted attribute value no longer splits the
attribute at a following comma: comma splitting now matches quotes the
way the attribute list's bracket matching does.
- DESIGN.md no longer says a function may be called before the line that
defines it; that holds for code that doesn't run (and so for function
bodies), not for calls that run.
- Tests: the cases above, and the language server resolving a relative
@include and @DaTa from the document's folder.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A `$name` or `${...}` now fills exactly the slot it is written in: a run
of text, an attribute's value, an element's argument, a @let value, a
@data/@include/@markdown path, the @page title or a @meta value. What it
inserts is never read again, so a variable can no longer become an
attribute, an attribute's name, a comma between attributes, markup or
another `$name`. `@let attr href=/x` + `@el [$attr]` is now an error
(attribute-from-variable) that says attributes come from a bundle, and so
is `[$key 8]`.
Where a name ends is one rule: `$` then a letter or `_`, then letters,
digits, `_` and `-` (a `-` only before another name character);
`$--name` stays the custom-property value. `.field` continues a name only
when the value is a record or list, so after `@let lang fr`,
`$lang.json` is `fr.json` and DESIGN's `@data $t locales/$lang.json`
works. `${name}` is the delimited form, `${EXPR}` an expression; `$5`,
`$$` and a trailing `$` are text. An undefined name is an error in text,
attributes, @let, paths, @if, @each and expressions (it used to print
itself, or warn only when a close name existed); a field a record lacks
stays empty.
How: a new interp.rs holds the one routine (name scanning, reference
resolution, interpolation, syntactic name listing). expr.rs lexes names
with it and interpolates string literals with it; parser.rs uses it for
every slot, reading attribute keys and values from the syntax tree
instead of re-splitting substituted text. Diagnostics carry a column and
the source line where the line is a single physical line, and a problem
in a loop is reported once. Attribute names are now checked in code that
doesn't run even when the value holds a variable. The language server's
semantic tokens and inlay hints end names with the compiler's rule, and
the TextMate grammar highlights `$--name` and `${...}`.
Tests: regressions for each rule above, an LSP test, and a new
variable_slots snapshot. Two tests changed deliberately: one used an
undefined variable as false in @if and one relied on a leaked @let
reading as text; both are errors now. The mixin_spread snapshot wrote a
literal `$define`, now `\$define` (same output).
Left out: escapes in attribute values (P17), typed values and scoping
(P18), and the rest of the never-drop policy (P5).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- The language server reads variables as the compiler does. `${name}` is
the variable `$name`, so hover, go to definition, find references,
rename and linked editing now find it, and it is highlighted. A name
ends where the compiler ends it: `$a- b` is `$a`. The shared helper is
a new `interp::name_spans`, which `interp::names` is now built on.
- `[${card}]` splices the bundle, like `[$card]`. It used to say
"undefined attribute bundle '${card}'".
- A column after a text escape (`\@ \$ $nobody`) now points at the
variable. It used to be one byte too far for each escape before it.
- An invalid `${...}` points at its `$`. An invalid @if condition or
`@let x = ...` inside a loop is now reported once, with a column, like
the other variable problems.
- The same problem in the main file and in an included file, on the same
line and column, is reported for both. It used to be reported once.
- `padding $card` (a bundle used as a value) no longer also warns that
`$card` is unused.
- Removed a stale "Variable substitution" section header in parser.rs.
Tests: 4 regressions and 1 language-server test.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: - One closed escape table in every htmlang string (text, arguments, attribute values, @let, @page, @meta, paths, inline @each lists): \\ \$ \@ \{ \} \[ \] \, \" \--. A backslash before any other character is kept as written, so CSS's `content "\201C"` and a pattern's `pattern=\d{3}` pass through. Before, escapes worked only in text and arguments (`title=a\$b` kept the backslash) and there was no \, \] \}. - `\,` keeps a comma in an attribute's value: `transition opacity 0.3s\, transform 0.3s`. A value split at a comma (`box-shadow 0 1px red, 0 2px blue`) now says to write `\,` in its unknown-attribute warning. - Quoted text remembers that it was quoted, as in Sass: `"..."` keeps its quotes in a CSS value and loses them in text, HTML attribute values and parameters, including after passing through @let or a parameter. `@let arrow "→ "` then `before:content $arrow` gives `content:"→ "`; `@card [lede "Fast, simple"]` shows `Fast, simple` (it used to show `"Fast, simple"`). Inside a quoted CSS string, quoted text inserts what it says. A quoted `@let --name` custom property keeps its quotes. `@meta name "..."` loses them like any HTML attribute value. - `@let areas "head head" "side main"` is no longer cut to `head head" "side main`: only one whole `"..."` is quoted text. - The font-family special case is gone. This reverses 81f89d2's "font-family keeps unquoting a font stack": CSS means what CSS says, so `font-family "Inter, sans-serif"` is one family. It now warns (invalid-value, with a language-server quick fix) and suggests `font-family Inter\, sans-serif`. How: - syntax.rs holds `ESCAPES`, `escape_len`, `unescape` and `quoted_string`; attribute lists, comma splitting, text and inline elements skip escapes, so the formatter (which prints the tree) keeps `\,` inside its attribute. - interp.rs has a `Sink` (Text or Css) and `Scope::quoted`: a quoted variable inserts its CSS form in CSS and its text elsewhere. - parser.rs fills every slot through one escape-aware routine (`fill`/`fill_value`); escapes are private-use placeholders while a slot is filled. Quoted variables keep their CSS form under `name"` next to `name`, as lists keep their length under `name#`, so scoping, loops and parameters handle them for free. `Attribute` carries `quoted` (both forms) so a quoted argument binds a quoted parameter; a function call's parameter values are filled as text. - codegen no longer strips quotes from HTML attribute values (the parser already did) and no longer unquotes font-family. - The TextMate grammar highlights escapes and quoted strings in attribute lists (so `\]` and `"]"` don't close the list); a test checks its escape rule against `ESCAPES`. Language-server variable highlighting and rename skip `\$name`. - DESIGN.md: escapes table, `\,`, quoted text, font stacks. README, CONTRIBUTING, VS Code README, @let hover docs, demo.hl (a two-layer box-shadow with `\,`). Tests: new snapshot escapes_and_quotes, regressions for each rule, unit tests in syntax.rs, interp.rs and fmt.rs, an LSP quick-fix test and a grammar drift test. Changed on purpose: quoted_font_stack_is_one_attribute (now font_stack_keeps_its_commas_escaped plus quoted_font_family_is_one_family_and_warns) and quoted_let_values_are_not_evaluated_as_arithmetic (now uses the bare `@let span 1 / -1`, since quoted text keeps its quotes in CSS). Left out: single-quoted HTML attribute values ('...') are no longer unquoted; `"` is the one quote. Expression string literals (`${...}`, @if) keep their own syntax and have no escapes. A computed `@let x = "a"` is not quoted text. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- `if(cond, a, b)` split its branches at an escaped comma, so `transition if($on, opacity 1s\, color 1s, none)` was left unevaluated as `if(true, opacity 1s, color 1s, none)`. Its arguments now skip escapes. - An unquoted value inserted into a CSS string could break the string: `@let dir C:\\` in `content "$dir"` gave `"C:\"`, and a value holding `\"` got a second backslash. A backslash and the character after it stay a CSS escape (`\201C`), `"` is escaped, and a backslash at the end of the value is doubled. - The unknown-attribute hint about `\,` now also covers the most common case, an unquoted font stack: `font-family Inter, sans-serif` warns about `sans-serif` and says how to keep the comma. It is given for a bare unknown word after another attribute when there is no "did you mean". - The language server read attribute lists with plain `[`, `]` and `,` scans: signature help counted `\,` as a new argument, and value completions and the owning element were thrown off by `\,`, `\]` and quoted text. One `attr_context` helper now reads the list the way the compiler does. Tests: regressions for the if() branches, the CSS string and the font stack hint; interp unit cases; LSP tests for escaped commas in completion and signature help. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A function is now defined as `@let @name [params]` with an indented body, or `@let @name` when it takes no parameters. The parameter list is read like a call's attributes: a bare word is a parameter (`title`) and `name value` is one with a default (`tone #f9fafb`, `words Hello there`), so a default may hold spaces and commas (`\,`). The body uses them as `$title`. Values (`@let x v`, `@let x = expr`, `@let x "s"`), bundles (`@let x [..]`) and custom properties (`@let --x v`) keep their bare names. Values, bundles and functions share one namespace: a @let replaces whatever the name meant before, and calling a value or a bundle says so. New errors: an indented body under a value or a bundle (what the old `@let card $title` + body gets now; the body is not rendered), `@let $x` (suggests `@let x`), `@let x` with no value, `@let @x` with no body, a parameter written `$p` or `p=v` or declared twice, a name no `$name` reaches, and text after a bundle's or parameter list's `]`. `@each` writes its variables with `$`, like `@data $name`. How: syntax.rs decides the kind once from the line (LetForm::Function holds the list and its Params, with name spans), check_body only checks bodies, and the evaluator forgets a name's old meaning before a @let. The language server already read definitions from the tree; its parameter labels, signature help, symbols, semantic tokens, extract-to-function and snippets now use the new form, and a quick fix moves the `$` for the new invalid-definition / invalid-loop diagnostics. The formatter prints function heads canonically. std.hl, the examples, DESIGN.md, README, the VS Code grammar and snippets, and every test source were rewritten; the examples compile byte-identical. Tests whose positions moved (LSP rename, definition ranges, signature label) were updated for the new spelling. Left for P21: required parameters, defaults evaluated at call time, and the call-site rules (bare word is true, `name=value` for a parameter). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A @let inside a function call, an @if branch or an @each body no longer deletes an outer definition of another kind for good. The scope used to restore only values, so `@let gap 9` inside a function body removed the file's `gap` bundle for the rest of the file. A scope now saves the whole namespace and, when it ends, restores its values and brings back a bundle or function that a value inside it replaced, while bundles and functions defined inside it still stay (as before) and remove the value they replaced. Functions are held behind Rc, so saving them is cheap. The formatter no longer drops text from a malformed function head: `@let @c $t` lost its `$t`, and `@let $pad [..]` / `@let @$c [..]` lost their `$`. Such lines are kept as written, like text after a `]`. Smaller: `@each $ in ...` said "`@each $$ in LIST`" and suggested `$$`; the message now shows the whole corrected header (`@each $x, $i in LIST`) and offers no fix for a missing name. Parameter diagnostics carry a column on the head's line. The function-name message mentions `_`. The language server offers no CSS attributes inside a definition's parameter list, and escapes `\`, `$` and `}` in a call snippet's default. Tests: scope regressions, the @each messages, the parameter column, the formatter's kept heads, and two completion tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A function's parameter list now means at a call what it says at the definition. `@let @card [title, tone #f9fafb]` declares a required `title` and a `tone` with a default, and: - A call that leaves out a required parameter is an error naming the function and the parameter (missing-parameter), instead of binding an empty value silently. Code that doesn't run is checked too, unless a bundle or if() in the call could pass it. - A default is filled in at each call that leaves its parameter out, like a value passed for it: spaces, quotes, escapes, if() and $variables, including the parameters declared before it (`heading "About $title"`). It used to be raw text, so `tone $brand` gave the text `$brand`. A default that uses a later parameter is an invalid-definition error, and a problem in a default is reported once, at the definition. - At a call, `name value` binds a parameter and the name alone binds true (`@post-card [post $p, featured]`); it used to be ignored. - `title=Hi` for a parameter is an error (parameter-form): `=` writes an HTML attribute, parameters are written `name value`. The value is still bound, so nothing cascades. - Unnamed attributes are no longer bound to parameters by position; they style the root element like any other attribute that isn't a parameter. How: FnDef keeps the syntax Params (order, defaults, spans) and the definition's line, text and node; expand_fn_call binds by name and fills defaults with the definition as the current source, so columns point into the head and loops report once (ParseContext::push_once). New codes missing-parameter and parameter-form. The language server offers the parameters a call hasn't passed yet, highlights the parameter being written by name in signature help, marks required parameters in hover and signature help, and rewrites `name=value` as `name value` as a quick fix. DESIGN.md, README, the @let hover docs, the VS Code README and snippet description, and demo.hl (a `dot` flag on @badge; demo.html regenerated with -d) follow. Tests: snapshot `parameters`, focused tests for each rule in snapshots.rs, two regressions, LSP tests for the quick fix, completion and signature help. Changed on purpose: the regression named_argument_is_not_used_positionally_for_another_param left out a required parameter, which is now an error; its `title` has a default now. Left for P2: validating the attributes forwarded to the root, inline calls, and the warning for a @let that shadows a built-in element. P18 owns scoping: a default sees the same scope as the body (the call-time variables plus the earlier parameters) until functions see the scope of their definition. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- `title="a, b"` for a parameter said "write `title a, b`", which splits the value at the comma: the evaluated value was shown. The error is now reported from the call's list as written, with the value as written and its column (also on a later line of a multi-line list, where the quick fix now finds it). A `name=value` that comes from a bundle is still reported at the call, without a column. - missing-parameter points at the call's `@name` (line and column). - Code that doesn't run showed its line without its indentation, so a column (now given by missing-parameter and parameter-form) put the caret in the wrong place; the line is shown as written. - A default that uses a later parameter also gave an undefined-variable error at every call (or silently read an outer variable of that name); the definition's error is now the only report. - The language server's call snippet passed every default as a value. Since defaults are filled in at the call and may use earlier parameters (`heading "About $title"`), passing one as text reads the caller's `$title` instead; the snippet now passes only the required parameters, and completion in the list offers the others. Its test is replaced. Tests for each fix in snapshots.rs and the LSP quick-fix test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A function is now called wherever an element can be: on its own line, as a link of a chain (`@el > @card [title T]`, `@card [title T] > @link /x More`) and inline in text (`Press {@key Ctrl+K}`). An inline or chained call whose body has several roots, or is text, stands in its place like @Fragment. Every attribute of a call that isn't a declared parameter, key=value HTML attributes and flags included, goes to the root element and is checked like an attribute written there, so `@box [paddin 20]` and `@box [20]` get the ordinary unknown-attribute warning instead of vanishing, and `@label [spacing 4]` says spacing has no effect on @text. A function may appear in the content its caller passes it, and it may call itself under a condition that stops it: the outright recursion ban is replaced by a depth limit of 64, whose error (`recursive-call`) says the function needs a condition. A parameter passed one `$name` holding a record or a list gets the whole value, so a guarded function renders nested menus and trees. A function named like a built-in element or a directive (`@let @button`, `@let @if`) is a new warning, `shadows-built-in`; the README's example function is renamed @CTA. Elements a function's body writes carry the call's line (also in --dev source maps) and the function's name, so warnings about them (low contrast, no-effect, lint) point at the call and end "in the body of @card". A problem in the text of a body stays on its own line when the function is in the file being compiled; for std.hl or an included file it is reported at the call, "in @box (line 3 of lib.hl)", without a subject so no quick fix edits the wrong line. Identical diagnostics are reported once, so a call in a loop warns once, and lint no longer flags the empty @el of @spacer. How: one resolver (`resolve` + `Evaluator::complete`) turns a head into an element or a prepared `Call` for whole lines, chain links and inline segments; `expand_fn_call` evaluates the caller's text before entering the call and restores the caller's current line afterwards; calls are parsed with validation on, skipping parameter keys; `check_call` checks the other attributes of calls that never run, inline ones included; `Element.function` and `belongs_to_call` attribute expansions; FnDef records its file. The LSP completes elements and functions after `{@`, and its @let doc says where calls go. DESIGN.md (functions, chains), README, the demo (an inline @key) and the VS Code README follow; the docs test now rejects shadowing names. Changed test: a regression that expected `{@f}` to be an unknown element now checks a call before its @let has run. Left out: `title=x` for a parameter stays the parameter-form error settled by P21 rather than the spec's forwarded HTML title; problems in a default of another file's function still point into that file; @text/@Li arguments still print `{@...}` literally (P3). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
An inline call's attributes are now checked against its root element, like
those of a call on its own line: `{@Label [spacing 4]}` warns that spacing
has no effect on @text. The check covers every element inside a line of
text, so `{@text [spacing 2] x}` warns too, as it would on its own line.
A problem in a default of a function from std.hl or an included file is
reported at the call, "in @box (line 1 of lib.hl)", like a problem in its
body, instead of at a line of the other file. The doc comment of
is_definition_name, which the shadowing helper had split from its
function, is back on it.
Tests: an inline call checked against its root, and an included
function's default reported at the call.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
@children and @slot NAME keep their names and meaning; what changes is that content which would go nowhere is now an error instead of vanishing: - a `@slot NAME` block at a call whose function declares no such slot (unknown-slot): "@card has no slot 'foter', did you mean 'footer'? (its slots: footer)"; - text after a call, or lines under it, for a function whose body has no @children (unexpected-content), also for chained and inline calls, so `@spacer Hi` or `{@box text}` no longer drop their text; - a `@slot` block nested inside an element at the call instead of directly under it, and @slot or @children outside a function's body or inline in text (misplaced-slot). Blocks under @if, @else and @each directly under the call still fill slots, and a body can still pass on its content (`@children` under a call) and its slots (`@slot footer` holding `@slot footer`); - a slot name that is not one word (invalid-slot-name, with `my-footer` suggested for `my footer`), a bare `@slot` (missing-argument), and attributes on @slot/@children (unexpected-argument). A function's slots and whether it has @children are read from its body when it is defined (a `@slot` directly under a call in the body fills that call; anywhere else it declares one), so a slot declared under an @if counts. Unknown slots are checked where the block is written, content at the call as evaluated, and code that doesn't run gets the same checks. DESIGN.md documents @children's fallback content next to @slot's and lists the errors; README and the demo prose mention them. Language server: hover on a function lists its slots and whether it takes content, `@slot ` under a call completes that function's slot names, and the unknown-slot / invalid-slot-name diagnostics have quick fixes. The @slot and @children hover docs are rewritten. Formatter, CLI, grammar, snippets and std.hl needed no change. demo.html is regenerated with -d. New snapshot slots_and_children; focused tests in snapshots.rs, a regression for the silent drops, LSP tests. No existing expectation had to change. Left out: an @include inside a function body is checked as the top level of its file, so a @slot there is reported as outside a body. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A file included in a function's body, or under a call, is checked where its @include is written. Before, its top level was checked as the top of a file, so code that worked before this change failed: a partial included in a layout's body that holds @children or @slot NAME was reported as misplaced-slot, and the function was said to take no content and to have none of those slots (unexpected-content and unknown-slot at every call); a file of @slot blocks included under a call was reported as misplaced too. How: each @include line records the files it brought in (by its node id, with the include chain); the placement walk follows them from the main file instead of walking every included tree as a top level, and the filler check follows them too, so a misspelled slot in an included file of blocks is still an unknown-slot error that names the file (the check now runs after the lines under the call). A function's declared slots and @children read the files its body includes when it is defined, since the @include runs only at a call. The walker's position is one struct (At) and its lookups another (Scan), instead of seven arguments. New test: an included file's slots are where its include is. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a condition in an attribute list is written
one way. `if(CONDITION, A, B)` as a whole attribute chooses attributes,
and each branch is an attribute, a `[group]` of attributes or a `$bundle`;
an empty branch (or a missing B) leaves the attribute out. One condition
that decides several attributes is one if() with a group:
`@link [if($id == $current, [background #eee, font-weight 600, aria-current=page])]`.
A value that depends on a condition is an expression, `${if(c, a, b)}`,
whose branches are expressions (a number or one word as written, other
CSS text quoted). The old value form (`padding if($on, 8, 16)`) is gone:
an if() written in a value is CSS's own if(), which passes through like
var(). An if() in a value without CSS's `:` gets an ordinary invalid-value
warning describing CSS's syntax. In expressions, if() evaluates only the
branch it takes (and takes 2 or 3 arguments), and `and`/`or` stop at the
side that decides, so `$n != 0 and 10 / $n > 1` works for 0.
How: syntax.rs reads a whole-attribute if() into `Attr.choice`
(`Choice { condition, branches }`, `Branch::{Empty, Attr, Group}`) with
real spans, so a group spanning lines keeps each attribute's line and
column. parser.rs `choose_attrs` picks branches before the attribute loop;
the branch not taken is checked like code that doesn't run (names and
literal values; its variables are not read, but count as used), and a
misshapen if() is an error (invalid-expression for the wrong number of
parts, unexpected-argument for text after a group's `]`). choose_if and
its splitters are removed, and so is the value form in parameter
defaults. expr.rs parses skipped parts without evaluating them (syntax,
function names and argument counts still checked, via one FUNCTIONS
table). The language server reads if() branches as the attribute being
written (completion inside a branch or group), offers an `if()` snippet
completion and hovers it; the VS Code grammar highlights `if` at an
attribute's start and nested groups, and has an `if(` snippet. Examples,
DESIGN.md, README, CONTRIBUTING and the VS Code README use the new forms;
docs.hl and blog.hl use one grouped if() instead of three.
Tests changed on purpose: the value-form tests in snapshots.rs and the
escaped-comma regression now use the whole-attribute or `${if()}` form;
if_expr.hl and parameters.hl are rewritten (parameters.html unchanged).
New: 8 snapshots.rs tests, an expr unit test, a syntax unit test and two
language-server tests.
Left out: no migration hint for the old value form (ordinary diagnostics
only); an attribute whose value fills to empty still emits `key:;`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
…ixes
- A `$bundle` named only in an if() branch that isn't taken (or in an
`@if` that isn't taken) counts as used, like a variable there, instead
of being reported as an unused bundle.
- The warning for an if() in a value that isn't CSS's if() no longer fires
on `if(` inside quoted CSS text (`content "see if(this)"`).
- An expression if() whose condition is followed by something other than
a comma reports that token ("expected `,` but found `.active`") rather
than a wrong argument count.
- The formatter prints a whole-attribute if() canonically: its groups like
lists, on one line (`if($on, [padding 8, color red], [])`), so a group
written over several lines no longer comes out as `[ padding 8, ... ]`.
A misshapen if() is kept as written.
- Language server: hovering `if` works on a later line of a list that
spans lines and inside groups (read from the syntax tree), and
completion inside a group opened on a later line knows its element.
The hover function's doc comment is back on it.
Tests: a formatter test; new assertions in the untaken-branch, CSS if(),
expression, hover and completion tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a name now holds a whole value, one of text, number, true/false, list or record, and @let, @DaTa, @each and parameters bind it whole. `@let fruits apple, banana, cherry` is a list of three: `${length($fruits)}` is 3, `@each` walks its items, and `$fruits` still prints as written. A quoted item or `\,` keeps one text with a comma in it (`"Fast, simple"` has length 12). A whole `$name` or `${expr}` keeps its type, so a record passed as `[post $p]` arrives as a record and `$post.title` works under any parameter name. Text functions (upper, slice, ...) given a list or record are an error that says what to write instead; length counts list items. @each takes a value: a written list, a whole `$name`, a `${...}`, or a range `a..b` with an optional `step n`; ranges also work inside `${...}` (`reverse(1..5)`), and a variable's name ends at `..`. Iterating a record, or writing the source in brackets, is an error instead of silently doing one thing. `@let t.greeting hi` sets one field of a record. Truthiness and comparison are defined once: false, 0 (any spelling, such as 0.0), empty text and empty lists/records are false; numbers compare as numbers, everything else by its text, lists and records structurally. Numbers print without float noise (`${0.1 + 0.2}` is 0.3, `${100 / 3}` is 33.3333). Scope is lexical: a definition is visible from its line to the end of its block. A @let inside an element or loop no longer changes the name outside it, each @each iteration starts fresh, bundles and functions defined in a block stop leaking out of it, and a parameter hides a function or bundle of the same name in the body. A function's body and its parameter defaults see its parameters and the names visible where the function is defined, not the caller's. Deliberate output changes: in tests/snapshots/parameters.hl, a default (`tone $brand`) now uses the `$brand` visible where @card is defined, not the one defined later before the call, so the third card keeps #eff6ff. Two tests that relied on a bundle or function leaking out of a block now see it end with the block. The error for a function used outside its block explains where definitions are visible. The four examples compile byte-identical, with the same diagnostics. How: a new value.rs holds Value (text, quoted text, number, bool, list with its written form, record) with truthiness, comparison, number printing and ranges in one place. parser.rs replaces the flat variable, bundle and function maps with one Env of copy-on-write frames; blocks, iterations and calls push a frame, and a function captures the Env at its definition. slot_value decides once what a written value is (whole reference, quoted text, comma list, or filled text). expr.rs parses `a..b step n` and works on typed values. The language server offers and goes to the names visible at the cursor (syntax::Tree::visible_at); the VS Code grammar highlights @each sources and ranges, with a range snippet. DESIGN.md gains a Values section; README, CONTRIBUTING and the hover docs follow. Left out: list and record literals in expressions, filter and sort. Unused-name warnings and LSP references/rename still go by name, not by scope, and a whole record written in text prints nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
`@let name.N value` on a list turned the list into a record with a field named N, so `$fruits` printed nothing and its length became 1. It now replaces that item and the list stays a list. A field set on text, a number or true/false, or past a list's end, is an invalid-definition error instead of silently replacing the value with a record. A `$name` that a `@let` defines, but that the line can't see (it was defined in a block that has ended, or below a function whose body uses it), now says so and gives the scoping rule, like the error for a function used outside its block. Before, it was a plain "undefined variable". The language server's hover on a `$name` shows the definition visible at that line (an inner `@let`, a parameter, an `@each` variable, `@data`), as completion and go-to-definition already did, instead of the first `@let` of that name in the file. DESIGN.md says what `@let t.field` and `@let items.0` do. Tests cover all three. The examples compile byte-identical. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: values go to the CSS as written, so
`max-width none`, `z-index auto`, `opacity 50%`, `color rebeccapurple`
and `width fit-content` no longer warn. A CSS property htmlang doesn't
know (`corner-shape squircle`) is written to the CSS with a "did you
mean" warning instead of being dropped; `--custom` and `-vendor-`
properties pass without one. Names are checked: a misspelled prefix
(`hovr:`) or parameter (`tnoe` for `tone`), a second prefix
(`md:hover:`, which codegen dropped), a flag htmlang doesn't know, an
HTML attribute written like a style, a style without a value and an
unknown element inside `{@...}` are errors. A value with `;` (outside
parentheses), `{`, `}` or an unclosed quote or parenthesis is an error,
also when it comes from `@data`, so data can't escape a CSS rule. What
codegen would drop is an error: attributes on `@fragment`, styles and a
text argument on `@script`, content in a void element, an unknown
`@page` attribute, attributes for a function body without one root.
A record written where text goes is an error; a field of a missing
field is empty. `width fill` in a column and `width=800, width 200` no
longer warn. `hidden` is a boolean attribute. The playground shows
warnings as well as errors, and the docs test fails on any warning.
How: vocab.rs gets prefix_len/split_prefixes (balanced has()),
is_property_name / is_custom_property / is_vendor_property and
HTMLANG_FLAGS. parser.rs replaces validate_attr_value's value lists
with check_attribute (names and prefixes) and check_css_value
(css_breakout, hex digit count, the font-family and CSS if() warnings);
an attribute reported as an error is left out. A bundle's names that
could be parameters are checked where it is spliced. codegen passes
unknown well-formed names through, leaves out a style whose value came
out empty, and gives @script every HTML attribute. interp.rs walks
missing fields and reports records in text. New codes unknown-prefix,
invalid-prefix, missing-value; unknown-color and fill-fallback are gone.
Tests changed on purpose: the value-checker and fill-warning tests now
assert the opposite; `[container]`/`[contain]` defaults (invented
output) became missing-value errors, so container tests write
`container-type inline-size`; tests that used a bare `bold` flag use
`font-weight bold`; stress_large.hl drops the `dark:hover:` attributes
that were silently ignored and escapes `$48\,295` (its output now shows
the whole number); inline unknown elements are errors, not warnings.
Left out: stacked prefixes compose in P7; `padding=4` (a CSS name as an
HTML attribute) still passes silently as an HTML attribute; a parameter
passed twice still keeps the first with a warning (P21).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a quoted `;` in a value now survives a later duplicate of the same property (`content "a;b", ..., content "c;d"` used to leave `b"` in the CSS). A value with `</style`, even quoted and even from `@data`, is an error, since it would end the page's style element and start HTML. `inline` on anything but `@image`, or with a prefix, was ignored without a word and is now an error. A misspelled parameter that comes from a bundle spliced into a call (`@let b [tnoe red]`, `@card [$b]`) is the same error as when written directly, instead of a CSS property warning. `htmlang fmt` no longer wraps a list that holds a custom property, which would have put `--gap 12,` at the start of a line where it is a comment, and DESIGN says so. How: dedupe_declarations tracks quotes and backslashes; css_breakout rejects `</style`; check_attribute rejects a prefixed `inline` and dropped_by_the_element an `inline` on another element; the parameter check is one function (misspelled_parameter) used for written and spliced attributes. Two messages read better: the comma hint no longer follows a `?` with a `.`, and the no-effect message names containers instead of "@Row, @el, @el". Tests: four regressions and one formatter test. Left out: a line of a multi-line list starting with `--gap` is still a comment (P8 changes the comment rule). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
Every element now has one layout, and the layout decides what the lines written inside it are. In a row, column or grid each line of text is a child of its own and `spacing` is the gap between the children; in a text element (@paragraph, @h1-@h6, @button, @Label, @td, @link, ...) the argument, the lines and the children flow as one run joined with spaces. So `@ol [spacing 4]` really spaces its items (@ul, @ol, @dl, @search, @address and @noscript are flex columns now, with lists keeping their marker reset), and a @button with the lines "Save" and "changes" says "Save changes". A row, column or grid inside text is laid out inline (inline-flex, inline-grid), and inside text @el, @Row and @grid are written as <span>, so no <div> lands inside a <p>. Tables, @select, @pre, media and form controls keep HTML's own layout; their text lines are separated by a line break. `spacing`, `wrap`, `grid-cols` and `grid-rows` on an element that isn't a row, column or grid are now an error and are left out (CSS's own `gap` still passes through). A section, list or heading inside @paragraph gets the new `block-in-paragraph` warning, since the browser would move it out of the <p>. `width fill`, `center-x` and `align-*` compile against the parent's layout, and `children:` styles against the element itself. How: ast.rs gains `Layout`; every TAGS row has one `layout` value, which replaces the wraps_text, container, inline and void flags, the "flex-direction:column" search and the display in each row's CSS. Codegen passes the parent's layout down (a `Site` for CSS), writes the display from the layout, and joins children by layout. The parser's container checks and the empty-container lint key off the layout. The language server offers `spacing`, `wrap` and `grid-cols` only on containers and shows each element's layout on hover. DESIGN.md has the full table (a test checks it against the compiler), with README, CONTRIBUTING and the VS Code README updated and demo.html regenerated. Tests changed on purpose: spacing on @text is an error, not a no-effect warning; @dd's and @Li's text is a span; @noscript, @address and @search have a flex-column class; an inline call with two roots joins them with a space in a paragraph; a function called inline in text is a span, not a div; the completion test checks `spacing` on @ul, not @input. Snapshots that changed show the same. Left out: `{@...}` in @h2's argument stays literal until text arguments are parsed (next), `width shrink` still compiles to flex-shrink:0 and `flex-direction row` doesn't yet switch a column's children to row semantics (both belong with direction-aware layout words). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
…ew fixes - An empty @Fragment between two lines of a text element left two spaces ("a b"), and in a native element an extra line break. A child that writes nothing now takes no separator. - block-in-paragraph warned again for every block inside the first one (`@paragraph > @section > @ul` gave two warnings, the second saying the browser moves the <ul> out of the <p>, which it had already left). Only the element that ends the <p> is reported now; the test that expected two warnings for `{@ul {@Li x}}` expects one, for the <ul>. - The language server still offered `spacing`, `wrap` and `grid-cols` after a prefix (`@h2 [hover:`) on an element that isn't a row, column or grid. It now filters them there too, except after `children:`. - Hover for @ul and @ol described bulleted and numbered lists, and @ol's example `[list-style decimal]` shows no numbers on a column of flex items. Both now say the markers are hidden and how to show them. - DESIGN: @In-Front and @Behind are spans inside text too, and a block in a paragraph takes what follows it out of the <p>. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
The text after an element's attributes is now read like any other line
of text for every element: inline `{@...}` elements, escapes and `$names`
all work in it. `@h1 Hello {@text [color red] world}`, `@li Read {@link /x
more}` and `@td {@kbd Ctrl}` make elements where they used to print the
braces, and a misspelled `{@lnk ...}` in a @text or @h2 argument is now an
"unknown element" error instead of text on the page. How an element's
first line combines with the lines under it is its layout's business, as
P1 set it: in a row or column each line is a child (the argument is the
first one), in a text element the lines flow, joined with a space. Text
that @each, @if or a function writes counts as lines written in its place,
so in a text element it joins the run with a space too.
The text of @code and @textarea is shown as written: a `{@...}` in it is
text, on its line, in the lines under it and inline, so `{@code {@link /x
y}}` still prints `{@link /x y}` and pages that document htmlang keep
working. Escapes and `$names` still work there, and a multi-line @textarea
keeps its line breaks. Two lines of text at the top of the page are now
separated by a line break (a space on the page) instead of being glued
together ("Readmore").
How:
- ast.rs: TagArg::Text and renders_argument_as_text are gone; those rows
take the default TagArg::Child. TagSpec gets `literal` (true for code and
textarea) and ast::has_literal_text(name) reads it.
- syntax.rs: after the tree is built, the text of a literal element (any
head of a chain, its line, every line under it, inline `{@code ...}`) is
one plain segment, so the evaluator, the formatter and the language
server all see the same thing: no inline heads inside literal text.
- parser.rs: only @link, @image, @script, @slot and TagArg::Attr rows keep
a one-slot argument; every other argument becomes the element's first
text child through text_segments, like any line. The empty-container
lint and the button-text lint no longer look at the argument.
- codegen.rs: the argument-as-first-line path is gone; generate_children
takes Option<Layout>, and the top of the page separates adjacent text
lines like a native element.
- Language server: completion after `{@` offers nothing inside literal
text (it asks the syntax tree), hover for @code/@textarea says the text
is shown as written, @paragraph's summary no longer implies it is the
only element with inline elements.
- VS Code grammar: `literal-line` and `literal-inline` rules keep
`{@...}` in @code/@textarea text unhighlighted as elements, and inline
elements nest; tests/editor.rs checks the names against the table.
- DESIGN.md (Text section, native-element paragraph, @paragraph row),
README, CONTRIBUTING and the VS Code README say it.
Tests: snapshot text_everywhere; snapshots.rs tests for arguments of every
text element, the unknown-inline error, literal @code/@textarea, the
argument as the first line of a row and of a text element (with @each, @if
and a function), top-level lines; two regressions; a syntax unit test; an
LSP completion test; the grammar drift test. The one snapshot that
changed is new_elements_2: `@text This is {@mark highlighted} text` and
`@text Press {@kbd Ctrl+C} to copy` now produce <mark> and <kbd> instead
of the literal braces, which is the fix. Every other snapshot and the four
examples compile byte-identical.
Left out: @code/@textarea content is literal only for inline elements;
element lines under @code are still elements and escapes/$names still
apply (P13 decides verbatim bodies). Content a call passes into a
function whose body wraps it in @code is read at the call, so it isn't
literal. @option's text is parsed like any text, so an inline element in
it is written (the browser shows only its text); a content-model check
belongs with P15.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A @Fragment's lines were glued to the lines around it in a native element and at the top of the page: `@pre` with `s`, a @Fragment of `m` and `n`, then `t` gave "sm\nnt", and a function whose body is a @Fragment did the same. The text rule says text a @Fragment writes counts as lines written in its place, so generate_children now writes a fragment's children in the same run as its siblings, and they get the same line breaks. Text elements and rows and columns are unchanged, and so are every snapshot and the four examples. DESIGN's Text section names @Fragment with @if, @each and functions, and says the lines are on lines of their own in a native element. Tests: a regression for the fragment separators; an LSP hover test that @code and @textarea say their text is shown as written. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
htmlang now accepts the rest of HTML's body elements under their HTML names, from a fixed list, each with its layout: - text, with the browser's own style and no CSS of htmlang's: @b, @i, @Strong, @em, @Small, @s, @U, @sub, @sup, @q, @var, @samp, @dfn, @bdi, @bdo, @ins, @del (so `{@Strong important}` and `H{@sub 2}O` work inside a line); - column: @hgroup, and @menu (a list like @ul); - native: @caption, @COLGroup, @Tfoot, @optgroup (its argument is its label), @ruby, @rt, @rp, @object (argument: data), @Map; - void: @br, @wbr, @col, @track and @embed (argument: src), @area (argument: href). @link, @image, @text, @paragraph and @el keep their names. @A, @img, @span, @p and @div are unknown elements whose error suggests them ("unknown element @A, did you mean @link? (@link writes <a>)"). <data>, <slot> and <template> share names with htmlang's own or hold content only a script uses, so they, and <svg> and <math>, stay in @raw. A name that isn't on the list is still an unknown element, never a new tag. How: new rows in TAGS; ast::HTML_NAMES_WRITTEN_OTHERWISE is read only by the unknown-element suggestion (edit distance can't find @link from @A). hgroup and menu join ENDS_A_PARAGRAPH. The @video captions lint looked for a @source child as a stand-in for the missing @track; it now looks for @track. DESIGN.md's principle reads "an element that isn't about layout or text has its HTML name", and its element section, layout table and examples list the new elements; README, CONTRIBUTING, the LSP hover docs and per-element attribute completion, the TextMate element list, a @table snippet and the examples follow. Inline `{@text [font-weight 700]}` and `{@text [font-style italic]}` runs that only set weight or style became @b, @Strong, @em or @code (demo.html regenerated). Tests: snapshot html_elements, 6 snapshot tests, 1 regression, ast unit tests. Test functions named @b and @q were renamed (@later, @box, @quote), since those names are elements now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- `srclang`, `shape` and `coords` (of the new @track and @area) are in the HTML attribute list, so `@track [srclang en]` gets "write `srclang=en`" instead of an unknown-CSS-property warning (and `shape` no longer suggests `scale`). - An LSP test that the `@a` error's suggestion becomes a "Replace with '@link'" quick fix, on its own line and inside `{...}`. - CONTRIBUTING says a new element also needs its name in the grammar's element list (a test checks it); the VS Code README lists the `@a` to `@link` fix. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a child's `width fill`, `height fill`, `width shrink` and `height shrink` now compile against the direction the parent's CSS really has: `@row`, a column, or the parent's own `flex-direction` / `flex-flow`. So `@nav [flex-direction row]` is a row for its children too, and a direction set under a media or container prefix (`md:flex-direction row`, `print:`, `landscape:`, `cq-md:`) moves the children's words with it at that condition. No new words. Along the direction `fill` takes the remaining space and `shrink` keeps the content's size (`flex-shrink: 0`); across it `fill` is the full size and `shrink` is now `fit-content` (it used to be `flex-shrink: 0`, which still stretched in a column). `center-x`, `center-y` and `align-*` are auto margins in every parent, so the centred column is simply `width fill, max-width 800, center-x`. A layout word never overrides a property the element writes itself (`min-width 200, width fill` keeps the 200px, whatever the order). How: codegen gives every element a `Flow` (base direction plus the changes under block prefixes, and its class). Children compile their words against `flow.at(prefix)`, where a width prefix holds from its width up. For each change, the child gets a rule keyed on the parent's class in the parent's own @media/@container block, `:where(.a)>.b{...}` (one class of specificity, written before the class rules of the block so the child's own prefixed styles still win), with every property a layout word could have set put back to its initial value. `children:` words get `.a>*` rules the same way. Elements with the same CSS but different keyed rules get different classes. Examples: landing's nav is `@nav [flex-direction row, ...]` without the inner @Row, and its header/footer rows use `width fill` instead of `width 100%` (the rows stay: they carry the max-width inside full-bleed bands); demo's footer is `@footer [flex-direction row, ...]` and the tour shows a column that is a row from md up (demo.html regenerated with -d). DESIGN.md (layout words, the rule, keyed rules, the responsive header and centred column examples), README, CONTRIBUTING, LSP docs (width, height, center/align, new flex-direction entry, @el, column summary), LSP and VS Code snippets (navbar, page column) moved with it. Tests: snapshot layout_direction; 11 snapshots.rs tests; 3 regressions. Changed on purpose: fill_and_center_compile_against_the_parent_s_layout (center-x is auto margins in a column now); snapshots alignment and layout_modes (align-self → auto margins) and stress_large (its `sm:flex-direction column` row's `width fill` children are now full width at sm and fill again at lg). Left out: a direction under a state prefix (`hover:`, `nth:`, `has()`, `children:`) doesn't move the children's words; a child's own words under a non-width media prefix (`dark:width fill`) compile against the parent's direction under that prefix only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: `center-x` (and `center-y`, `align-*`) keeps centring when the same element writes a `margin`, whatever the order: `[center-x, margin 20]` is centred with 20px above and below. With auto margins in every parent, a later `margin` shorthand wrote over them, so an element in a column that used to be centred (align-self) no longer was. And `width fill` next to a `flex-grow`, `flex-shrink` or `flex-basis` the element writes itself still fills: `[flex-shrink 0, width fill]` grows and never shrinks, `[flex-basis 200, width fill]` grows from 200px. Before, any flex longhand left out the whole `flex:1`, so the element no longer filled the row, and `width shrink` dropped its `flex-shrink:0` beside a `flex-grow`. How: the alignment margins of a pass are written after its other declarations. `Sizes` records each flex longhand; a layout word's `flex` (the fill's `1` or the reset's `0 1 auto`) is written as the longhands the element doesn't write when it writes some of them, in the class and in the rules keyed on the parent. Tests: 2 regressions. The layout_direction snapshot and demo.html (-d) change only by the order of declarations (the margins come last); landing and blog likewise. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: an element that points at a URL or a file takes it as the first word after its attributes, and every such element reads it the same way. Its row names the one attribute it fills: href for @link and @area, src for @image, @script, @iFrame, @video, @Audio, @track and @embed, action for @Form, data for @object, label for @optgroup, and for @source srcset inside @picture and src elsewhere. The word ends at a space outside "..." and ${...}, and it is taken before any $name is filled in, so `@link $url More`, `@link /page/${$n + 1} Next` and `@link "/my page" Open` work. The rest of the text is content, read like any text (`@form /subscribe Sign up` no longer puts "Sign up" in the action); on an element without content (@image, @source, @track, @embed, @area) a word after it is an error. `@link [href=/a]` means the same as `@link /a`, and both at once (`@link [href=/a] About`) is an error that says which word was taken. `@script app.js` is src (it used to be dropped with an error), every HTML attribute passes through, and a src with a body is an error. How: TagArg gains Source, ElementKind::arg() gives @link's and @image's too, and TagSpec gains `verbatim`: @script is now a TAGS row (the Script kind is gone) and body_kind reads the flag. syntax::Text::split_leading and leading_token_len replace split_first_word. parse_single_element reads the token, reports the duplicate at its column, and moves a lone `href=` / `src=` / `action=` attribute into the argument, so both forms compile the same (the attribute is written first, and `@image [src=a.svg, inline]` inlines). Codegen writes the argument through one emit_argument_attr, with srcset for @source in @picture; @image without a src no longer writes src="". An attribute passed to a function's root that repeats the body's leading argument is reported too. LSP: element docs, and completion no longer offers the attribute an argument written after the list already gives. VS Code: a leading-argument grammar rule (drift-tested against the compiler), the external-script snippet. DESIGN (new "The leading argument" section), README, CONTRIBUTING. Changed tests: snapshot new_elements (`@form [method=post, action=/submit]` now writes action first, as the argument form does) and fragment_script_and_void_elements_drop_nothing_silently (`@script b.js` is its src, so the "write [src=...]" error is gone; src comes first). Left out: @Form, @video and @Audio keep their leading argument (their attribute is often left out, and DESIGN says text then goes on the lines under them). The duplicate check doesn't run on code that never runs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a word after an @optgroup's label is an error, as after the source of an element without content. The one-token rule made `@optgroup Citrus fruits` the label "Citrus" with "fruits" as text the browser doesn't show; the error says to quote it, `@optgroup "Citrus fruits"`. An element under a @script (`@script [defer] > @b x`) is an error instead of being left out of the page. The one-way form in the duplicate-attribute message quotes a value that is empty or has a space in it (`@link "/my page" About`), and the quoting hint for a word after a quoted source doesn't quote it twice. How: parser.rs gains takes_no_text (void layout, or @optgroup), used for the trailing-text error, the duplicate case and its suggestion; dropped_by_the_element reports an element child of a verbatim element without a src. DESIGN and the LSP's @optgroup doc say so. Tests: three new snapshots.rs tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: the lines indented under @code and @textarea are a verbatim block. Nothing in them is htmlang (`@`, `$`, `{`, `\` and `--` are text), and they are HTML-escaped with their line breaks and indentation kept. So `@pre > @code` over an indented block shows a code sample, htmlang included, where docs.hl used to paste it with @raw as live HTML. @pre stays an ordinary element, so `<pre><code>` and a function that wraps @pre around @children keep working. The text on @code's own line and inline `{@code ...}` keep the P3 rule (a `{@...}` is text, escapes and $names work), so a one-line sample can still use a variable. Text on the line and a block together is an error. The verbatim directives share one header rule. For @raw, @Style and @Head the rest of the line is a one-line body: `@style .a { color: red }` used to fail with "unknown element @Style, did you mean @Style?", and the text after @Head was dropped. For @markdown the line is the file, and for @script the src (P10). A line and a body together are an error for all of them. Only @script takes attributes: `@raw [id=x]` used to write "[id=x]", `@markdown [padding 8]` looked for a file of that name, and `@head [id=x]` dropped them. Each is now an error with the list's column, so content that starts with `[`, like a CSS attribute selector, goes in the block. Which lines are verbatim comes from the tables, also at the end of a chain (`@pre > @code`, `@el > @script`). Readable output (-d, and so `htmlang serve`) no longer indents or breaks lines inside <pre> and <textarea>, where every space shows. How: body_kind makes a `literal` TAGS row's body Verbatim, and the syntax tree's Verbatim node records `escaped` (the last head of the chain is literal). The evaluator turns an escaped body into a text node and every other body into raw output as before. @Style and @Head take ArgGrammar::Text like @raw and @markdown, so ArgGrammar::None, DirectiveArgs::None and DirectiveSpec::one_line are gone. A `[` right after a verbatim directive's name is read as an attribute list (P19's `[` rule) and reported. check_body reports @code and @textarea that have both. LSP: directive and element docs, and no completions in a verbatim body. VS Code: a verbatim-line rule for the one-line bodies of @raw, @Style and @Head (drift-tested against DIRECTIVES), a code-sample snippet, and the @Head and @font-face snippets written on one line. DESIGN (Text, Verbatim bodies, the Directives table, CSS, Page and head), README, CONTRIBUTING, and docs.hl, whose samples are now @code blocks. Changed tests: code_and_textarea_show_their_text_as_written (a @code block keeps its line break instead of joining with a space; `@textarea [..] text` plus a block is now an error), verbatim_directives_take_a_line_or_a_body and the syntax unit test body_kinds_are_checked (`@style .a {...}` is a one-line body, so `@style x` plus a block is unexpected-body instead of unexpected-argument), and the_text_of_code_and_textarea_has_no_inline_elements (the lines under @textarea are a verbatim node now). Left out: a block has no $names, so a version number in a sample can't come from a variable (written on @code's line it can). The TextMate grammar still can't mark the indented lines of a verbatim block. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
The language server now treats the one-line body of @raw, @Style and @Head like an indented body: the compiler reads that text as written, so a `$x` in `@raw <b>$x</b>` or `@style .a { content: "$x" }` is no longer a variable token, a reference, a rename target or a code-lens hit. Find-references (local and across the workspace) now leaves out verbatim bodies as rename already did, so a code sample under @code that mentions `$x` or `@card` is not listed as a use of them. @Keyframes document symbols come only from @Style's CSS (its block or its line, `@style @Keyframes spin {...}`), not from a @code sample that shows some CSS, which P13 made a verbatim body. @markdown with both a file on its line and an indented block now says it reads either the file or the Markdown in the block, instead of calling the file "its content". How: tree::verbatim_lines includes the header line of a verbatim directive other than @markdown whose line holds its body, and a new tree::style_lines gives @Style's lines. Tests: an LSP analysis test (no `$` tokens on one-line bodies or code samples; keyframes symbols), a navigation test (references skip verbatim bodies), and the @markdown message in the syntax unit test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
`@page` now styles the page itself. Its styles (prefixes included) go on <body>, its key=value attributes on <html> (`@page [lang=en, dir=rtl, class=x, background #f8fafc, dark:background #0b1220] Home`), and `favicon FILE` stays its one word of htmlang's own. The reset makes <body> a flex column with min-height 100dvh, so a page's background covers the window, top-level `height fill` takes the rest of it and `center-y` works. The top of a page is laid out like any column: a line of text or an inline element there now stacks and takes the full width, and a centred column at the top writes `width fill` as it does anywhere else. A fragment's top level is unchanged. @page's attributes are checked like any element's, also in code that doesn't run: the old `lang en` form is the ordinary html-attribute-form error, a misspelled CSS property the usual warning, `favicn` an "unknown @page word" error, `favicon=x` a parameter-form error. The unknown-page-attribute code is gone. A second @page is an error (duplicate-page): the page's own next to a layout's, a layout called twice, or an @page in a loop; the first one stays. The same @meta tag twice is written once, and a `@meta viewport` replaces the usual one. A file that holds only @let definitions is a library: its definitions aren't reported unused, and `htmlang build`, `serve`, `watch` and the directory mode don't write a page for it (quietly; `check` still checks it, and a single file named on the command line is still compiled). How: ast::Page on the Document (title, html_attrs, styles, favicon); parser::read_page; codegen writes <body> through element_class with a `root` Site whose layout display the reset already gives, and the top level with the column layout and the body's Flow, so `flex-direction` on @page moves its children's fill. syntax::Tree::is_library, vocab::PAGE_WORDS. Examples: landing's wrapper @el became @page attributes; demo and docs moved their page colours to @page (demo's dark background now covers the window), docs' `min-height 100vh` row is `height fill`, blog's layout column gains `width fill`. DESIGN, README, CONTRIBUTING, LSP (completion offers favicon and <html>'s attributes first on @page, hover), VS Code grammar (@page's list is an attribute list), snippets and README, CLI help. Tests: snapshot page_root and 11 tests; all snapshot HTML changes only by the reset's body rule. Changed on purpose: lang_sets_html_attr, the page-attribute test in snapshots.rs and the regression page_takes_lang_and_favicon_only (renamed), which asserted the old narrow @page; tests/docs.rs no longer needs its library exception. Left out: `dark:[...]` groups (P7); custom properties on @page land on <body>, not :root (P8/P22 decide). --partial output still drops @page. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A layout's @page that doesn't run (a library checked on its own) now gets @page's own checks too: `favicon=x` is a parameter-form error, a favicon without its file is missing-value and `inline` is unexpected-argument, as where the @page runs. A value made from a parameter (`favicon $icon`) counts as given. Both paths share one helper, check_page_attr. @page's own diagnostics now point at their attribute (column and source line) when the list is written on the @page's line, and a repeated favicon points at the one that repeats. A page that includes a library and uses only some of its definitions no longer gets unused warnings for the rest (they were reported with the library's line numbers as if they were the page's). DESIGN says so. Tests: three snapshots.rs tests (unevaluated page words, columns, an included library's unused definitions). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: - Every CSS property is written under its own name with its value as written. `outline 2 solid red` is `outline:2px solid red` (it was `2px solid solid red`), `outline 2 red` no longer gets an invented `solid`, and `container card / inline-size` is CSS's own shorthand. A bare `contain` or `content-visibility` stays a missing-value error. Only the layout words (spacing, width/height fill and shrink, center-*, align-*, wrap, grid-cols/rows, col/row-span) and line-clamp's -webkit-box fallback write anything of their own. - Generated classes are `hl-a`, `hl-b`, ..., so `class=a` no longer picks up a generated `.a` rule. A class of your own starting with `hl-` gets an invalid-value warning. A function's scoped @Style class is now `hl-fn-NAME`, which can't meet a generated class. - Element defaults stay on htmlang's own elements, in their class: the page reset's global `a{...}` and `img{display:block}` moved into @link's and @image's class, so Markdown and @raw links keep their underline. The focus outline is kept to htmlang's elements. - A closed @dialog, an element with `hidden` and a popover that isn't showing stay hidden: htmlang's display:flex used to beat the browser's display:none. The rule is in @layer hl-reset, scoped to hl- classes, with !important. - A fragment (no @page, or --partial) gets a reset only for htmlang's own elements (box-sizing and the hidden rule), never a global one. No output has an empty `@layer htmlang{}` any more. How: attrs_to_css lost its ~30 per-property arms; the one generic arm writes any property name with `with_px`, which follows `vocab::px_rule`. px_rule states today's pixel rule exactly (whole-value px for the properties whose arm used css_px, none for border-inline and border-block, per-word px for other length properties), so no px behaviour changed except outline's: P12 replaces px_rule with its one rule. reset_css takes page/fragment; OWN_ELEMENTS scopes its element rules. demo.hl's focus outline is now valid CSS (demo.html regenerated). Tests: snapshot one_css_path; 6 snapshots.rs tests, 5 regressions, a px_rule unit test and an LSP hover test. Changed on purpose: every snapshot and assertion that names a generated class (hl- prefix, and the extra class on links and images), css_outline/css_outline_no_color (no invented solid), tests that asserted no `:where(` or a trailing `</style>` on a fragment without styles, and the scoped-style test (hl-fn-card). Left out: `inset 0 10` still gives `inset:0 10` and `gap 10 20` `gap:10 20` (P12 owns px); inline SVG's own attribute mapping (color to fill) is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
…eview fixes - tests/snapshots/new_css_attrs.hl wrote `outline 2 red`, which with CSS's own shorthand now draws no outline; it writes `outline 2 solid red` so the snapshot still shows an outline (one_css_path keeps the style-less case). - DESIGN.md's reset paragraph said a fragment's own elements get box-sizing "and nothing else", right before naming the hidden rule they also get; it now says no other element on the page is touched. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
In a length property, every bare number outside parentheses and quotes is pixels; every other property, custom properties included, takes its value as written. `box-shadow 0 2 4 rgba(0,0,0,.1)` is now `0 2px 4px rgba(0,0,0,.1)`, and so are `translate 10 20`, `gap 10 20`, `inset 0 10`, `grid-template-columns 200 1fr`, `border-inline 1 solid red`, `background-size 20 20` and `text-shadow 0 1 2 black`, which used to produce unitless, invalid CSS. `outline 2 dashed red` and `border 1 solid rgb(255 128 0)` keep their numbers where they belong, and `border-image-width 2` stays a number instead of becoming 2px. `flex`, grid lines, `initial-letter`, `columns`, `opacity`, `z-index`, `line-height` and the other number-valued properties are untouched. A zero stays `0`. `spacing` and a `grid-cols` track list follow the same rule. How: vocab.rs has one sorted table, LENGTH_PROPERTIES, and one function, with_px, that applies the rule (words are split at spaces, commas and slashes outside parentheses and quotes). It replaces is_length_property's prefix/suffix guesses, px_rule with its Whole/EachWord/AS_WRITTEN lists, and codegen's css_px/css_px_multi. DESIGN.md states the rule in one sentence with the table; README, CONTRIBUTING, the LSP hover (which says whether a property's numbers are px), a grid snippet and two example box-shadows follow it. stress_large.html's frozen `box-shadow:0 4 12` is now valid; no other snapshot changed. New snapshot px_rule, vocab unit tests, snapshot and regression tests, and an LSP hover test. Left out: the `background`, `mask`, `grid`, `grid-template` and `font` shorthands are not length properties (they mix numbers of other kinds), so lengths in them need their unit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
`10!important` in a length is now `10px!important`: a `!` starts a word of its own, as a space does. The last length-valued properties CSS lists join the table, so a bare number in `vertical-align`, `shape-margin`, `offset-distance`, `offset-position`, `offset-anchor`, `overflow-clip-margin` and `view-timeline-inset` is px too instead of invalid CSS. DESIGN's table lists them and says that shorthands mixing lengths with other numbers (`font`, `background`, `grid`, `flex`, ...) need their unit; DESIGN and the language server say that `grid-cols` and `grid-rows` also take a track list, and the hover says quotes are skipped too. Two regression tests and two vocab assertions cover the fixes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: a custom property (`--name`) is a style like any other. It goes on any element, under any prefix (`dark:--surface #0b1220`, `md:--gap 24px`, `hover:--x 4px`), in bundles and on calls, and is written into the element's class, so it holds for the element and everything inside it. Its value is written exactly as it is, with no px, because a custom property has no type: `--hue 200` feeds hsl() and `--cols 3` feeds repeat(); a length takes its unit (`--gap 16px`, `@let --radius 8px`). `@let --x` declares a token for the whole page on :root; `[--x v]` sets one on an element and its subtree, so dark mode redefines a token once instead of repeating every colour. A comment is now `--` followed by a space or the end of the line. A `--gap 16px` line inside a multi-line attribute list used to be read as a comment and dropped without a word; it is now a declaration, and the formatter wraps lists that hold custom properties like any other. How: - syntax.rs: `is_comment` (public) decides comments at the start of a line and between the lines of a list; fmt, the LSP (hover docs above a function, slot completion) and the fmt corpus test use it. - vocab.rs: CSS_PROPERTIES gains the SVG presentation properties (fill, fill-opacity, fill-rule, stroke, stroke-width, stroke-dasharray, stroke-dashoffset, stroke-linecap, stroke-linejoin, stroke-miterlimit, stroke-opacity, marker*, stop-*, flood-*, lighting-color, clip-rule, color-interpolation*, dominant-baseline, alignment-baseline, baseline-shift, shape-rendering, vector-effect) and other common gaps (corner-shape, anchor-scope, overlay, reading-flow/-order, text-autospace, text-spacing-trim, caret*, margin-trim, mask-border*, font-synthesis-*, font-variant-emoji, ...). baseline-shift is a length. - An inline SVG (`@image [inline] x.svg`) has no generated class, so a style it can't take (a custom property, stroke, a prefixed style) or `alt=` is now an unexpected-argument error instead of being left out silently; its HTML attributes (aria-label=, role=, ...) now reach the svg root, escaped. - LSP: completion offers the custom properties the file names, also after a prefix; hover explains a custom property (element and subtree, no px, `@let` for :root); the @let doc says the same. - VS Code: comment rule `-- ` only, also inside attribute lists; custom and vendor property names highlighted; prefixes before `--`; a "Dark mode tokens" snippet. - Docs: DESIGN (Comments, a Custom properties subsection with the @let/[--x] difference and a dark-mode token example, CSS custom properties with units, inline SVG row), README bullet, VS Code README. - Examples: landing and demo redefine --ink/--muted/--line/--surface/ --subtle once under dark: on @page instead of repeating dark colours per element (which also fixes text that used var(--ink) staying dark in dark mode); demo shows a custom property on an element; demo.html regenerated with -d. blog and docs output unchanged. Tests changed on purpose: the formatter test that kept custom-property lists on one line now checks they wrap; css_vars, stress_large and stress_deeply_nested give their numeric tokens units (only their :root lines change); tests that used corner-shape as the example of an unknown property now use text-grow, since corner-shape is known; an inline SVG regression passes aria-label= instead of alt=. New: snapshot custom_properties, 5 snapshots.rs tests, a regression, a syntax unit test and an LSP completion test. Left out: `$--name` stays until P24 removes it. SVG geometry properties (cx, cy, r, rx, ry, x, y, d) are not listed, since single letters would collide with parameter names; they pass through with a warning. Inline SVG still maps only width/height/color to the root rather than going through the class path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- Inline SVG: `width fill` / `height shrink` (layout words, which need a class) were written onto the svg root as `width="fill"`; they are now part of the unexpected-argument error like any other style the markup can't take. The error names `fill` as accepted too, and inline_svg's doc comment and DESIGN's @image [inline] row say what the root takes. - The custom-property hover no longer reads as if the word under the cursor sets the property (it is also shown inside `var(--x)`). - DESIGN's comment rule says that `--name` or `---` starting a line outside a list is text. - tests/docs.rs detects comment lines with syntax::is_comment, the one definition. - vocab.rs: a LENGTH_PROPERTIES line over the width limit rewrapped. - Tests: vendor-prefixed properties under hover:/dark:, -moz- and -ms- names; `width fill` on an inline SVG is an error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A style's key is now a chain of prefixes and a property, and the style applies where all of them hold. `md:hover:color`, `dark:md:padding` and `hover:children:opacity` work instead of being errors. Selector prefixes read left to right, and a pseudo-element comes last: `hover:before:color` works, and `before:hover:color` is an error that suggests it. Width, media and container prefixes may appear anywhere. They nest as at-rules, so `md:dark:` and `dark:md:` are the same style. A prefix before a `[group]`, a `$bundle` or an `if()` applies to each style in it (`md:[padding 32, font-size 20]`, `md:$card`, `hover:if($on, [...])`). Such a group or bundle holds styles only: an HTML attribute or flag in it is an error, as `md:id=x` is. `children:` styles now compile through `:where()`, so a child's own styles win over its parent's. The layout words that place an element in its parent (`width fill`/`shrink`, `center-*`, `align-*`) are errors under `children:`. The fix-it hint names the CSS to use instead. A `key=value` whose name has a colon (`xml:lang=`, `x-on:click=`) is an HTML attribute. Rules are written in one fixed order, whatever the source order: plain rules first, then selector-only rules, then each at-rule block in table order, with a stack after its innermost prefix alone. How: the syntax tree gets a `Prefixed` attribute (prefix + a P4 `Branch` target). The parser distributes the prefix over the group, bundle or chosen branch, and checks each member. Codegen replaces the six hard-coded media fields with one list of (Condition, declarations) per class. A Condition is a sorted at-rule chain plus a selector chain. Parent direction changes are keyed by the at-rule chain, so a `md:dark:flex-direction` moves the children too. The formatter, the LSP, the VS Code grammar and snippets, DESIGN.md, README.md, CONTRIBUTING.md and the demo/landing examples follow: - the formatter prints `prefix[a, b]`; - the LSP completes, hovers and fixes stacked prefixes and groups; - the examples use `dark:[...]` groups. Left out: keyed `children:` sizing rules are removed rather than composed, since the words they served are now errors. Duplicate-key checks compare the written key, so `md:hover:color` next to `hover:md:color` is not flagged; the later one wins. Tests changed on purpose: - the stacked-prefix regression and `prefixes_are_checked_by_name_and_number` now expect stacks to compose; - `children:` expectations now use the `:where()` form, and `children:width fill` is now an error; - the layout_modes and layout_direction snapshots no longer use `children:width fill`, and exercise a stacked direction instead; - stress_large gets back the `dark:hover:` styles P5 had to remove; - has_pseudo, nth_pseudo and custom_properties only reorder rules. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
`children:` compiled through `:where()` lost to every default of the
child's kind, not only to what the child writes itself, because those
defaults were part of the child's class. `children:display list-item`
(DESIGN's recipe for list markers, used in blog.hl) left the items flex
columns, and `children:margin-bottom 8` left headings at `margin:0`. An
element kind's defaults (its layout, a heading's margin, a list's
markers, a link's colour) are now written first, as
`:where(.hl-a,.hl-b){...}`, less the properties the element sets itself.
A parent's `children:` styles, which come later with the same zero
specificity, win over them, and the element's own styles still win over
both. The snapshots change only in that split: a script that merges each
class's defaults with its own rule found every class's declarations and
every other rule unchanged. DESIGN and CONTRIBUTING say where the
defaults go.
The branch a prefixed `if()` doesn't take was checked without its
prefixes, so `hover:if($on, color red, id=x)`, `children:if(..., width
fill)` and `before:if(..., hover:color x)` passed whenever the other
branch was taken. It is now checked under them, like the taken one.
At a call, a parameter under a prefix (`md:pad 40`, `md:[pad 40]`,
`md:$b` with `pad` in it, `md:featured`) was forwarded to the root as an
unknown CSS property `pad` with a warning, or reported as an unknown
attribute. It is now an invalid-prefix error that says it is a
parameter. A parameter named like a style (`color`) is still a style for
the root under a prefix.
Two pseudo-elements (`before:after:`) suggested `after:before:`, which is
invalid too. It is now its own error, with no suggestion. The hint for
`children:width fill` and the other sizing words names the CSS for a row
and for a column, instead of `flex 1` alone. An unknown CSS property
that happens to be an HTML attribute's name (`hover:title x`) no longer
says "did you mean 'title'?".
Tests: a_parent_s_children_style_wins_over_a_child_s_defaults,
the_branch_a_prefixed_if_doesn_t_take_is_checked_under_its_prefixes,
a_parameter_takes_no_prefix, a_style_takes_one_pseudo_element. Tests
that matched a whole class rule now match the defaults rule and the own
rule separately, and "no keyed rule" checks look for `)>.hl-` rather
than any `:where(.hl-`, which the defaults now contain.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A selector prefix is now a CSS pseudo-class or pseudo-element under its own name, checked against a list in vocab.rs that also says which ones CSS writes with `::`. The new ones include marker:, backdrop:, only-child:, user-invalid:, placeholder-shown:, open:, popover-open:, first-letter: and file-selector-button:. Pseudo-classes that take an argument take it in balanced parentheses: nth-child(), nth-last-child(), nth-of-type(), nth-last-of-type(), not(), is(), where() and has(). A key ends at the first space outside parentheses, so `has(> img):padding 0` and `is(:hover, .x):color red` are one key each. The five renamed prefixes are gone: first: is first-child:, last: is last-child:, odd: and even: are nth-child(odd): and nth-child(even):, and nth:EXPR: is nth-child(EXPR):. An old name gets the ordinary unknown-prefix error. children: stays as htmlang's one combinator word. The list-marker recipe can now colour the markers with `children:marker:color`. How: - vocab.rs: PSEUDOS (name, Class | Function | Element) replaces PSEUDO_PREFIXES. prefix_len reads a word and a `:`, or a word, a balanced (quote-aware) argument and a `:`. pseudo_selector writes a prefix as CSS (`::marker`, `:nth-child(odd)`), and CHILDREN is the combinator. - syntax.rs: split_attribute ends the key at the first space outside parentheses. When a `(` is never closed, it uses the first space. - parser.rs: unknown_prefix covers the three failure shapes. `hvoer:` gets the ordinary did-you-mean, restricted to names with the same first letter (`odd:` would otherwise suggest `md:`), or else a name that starts with the word and a `-` (`first:` suggests `first-child:`). `nth-chld(2):` suggests `nth-child(`. `first-child(2):` and `has:` get an error that says what the pseudo takes. An empty argument (`nth-child():`) is invalid-prefix. There is no migration table. - codegen.rs: selectors come from pseudo_selector. Selector chains follow the PSEUDOS order, which is CSS's usual order: link and visited, then position and form states, then focus-within, hover, focus, focus-visible and active, with enabled and disabled last. Before, visited: and nth-child(odd): came after hover:, and focus: came after active:. Four snapshots changed only in that rule order, which is intended, and the four examples compile clean. - LSP: completion offers every pseudo, and one with an argument is a snippet with the cursor inside its parentheses (`nth-child($1):`). Styles are completed after `has(> img):`, and hover reads a prefix with its argument as one word. - Grammar: the state-prefix pattern takes a word with an argument (one level of nested parentheses). A @ul-markers snippet is added. - Docs: DESIGN (the prefix table, the full list of names, the argument rule, the selector order, the marker recipe), README, CONTRIBUTING, the VS Code README, and demo.hl (with demo.html regenerated). A docs test checks that DESIGN lists exactly the compiler's pseudos. - Tests: the old-prefix tests are rewritten to the CSS names, and expectations changed only for rule order. New tests: CSS names and their errors (snapshots.rs), key splitting (syntax.rs), the pseudo table (vocab.rs), and LSP completion and hover. Left out: a pseudo's argument can't come from a variable (`nth-child($n):` is attribute-from-variable, as any name is). Pseudo-element legacy single-colon forms are not accepted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- A key now ends at the first space outside parentheses with quotes inside the argument taken into account, as closing_paren already did: `not([title=") x"]):color blue` was split at the quoted `)` and reported as an unknown attribute. - A whitespace-only argument (`has( ):`) is an invalid-prefix error like an empty one; it was written into the CSS as `:has( )`. - Language server: value completions (enum values and colours) work after a prefix whose argument holds spaces (`has(> img):cursor `, `is(:hover, .x):cursor `), because the key is split with vocab::split_prefixes. The colour list after a plain `color ` works again: the segment was trimmed before checking for the space after the name. The @ol hover mentions `children:marker:color`. - Tests: the quoted-parenthesis split (syntax.rs and snapshots.rs), the whitespace-only argument, value completions after a spaced prefix, and the quick fixes for `nth-chld(`, `first:` and `first-child(2):`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
Authors can now style every element of one kind inside an element with an element prefix: `@table [@td:padding 8]` pads every cell of the table, at any depth, including the cells a loop writes and the headings, code and links Markdown writes. The prefix composes with the others: the ones before it apply to the element itself (`hover:@link:color red`), the ones after it to the elements inside (`@link:hover:color blue`), and at-rules go anywhere (`md:@h2:font-size 24`). The nearest element's rule wins, and an element's own attributes win over it. demo.hl, docs.hl and landing.hl use it for table cells, headings, Markdown and repeated links. The prefix names an element that writes an HTML tag of its own: @td, @link, @paragraph, @h2, @code and so on. @el, @Row, @grid, @text and the other elements that write a <div> or <span>, @script, @Fragment, @slot, functions and unknown names are errors, with a suggestion where one fits (`@tdd:` gives `@td:`, `@a:` gives `@link:`). Only CSS properties go under it: htmlang's layout words (`spacing`, `width fill`, `center-x`) and HTML attributes are errors, as are two element prefixes on one key and one combined with `children:`. How: vocab::prefix_len recognises `@name:`, and the parser checks it against ElementKind::own_tag. Codegen writes such rules as `@scope (.hl-x) { :scope td {...} }`. The CSS now has four layers, `hl-reset, hl-kind, hl-inside, htmlang`: the element defaults moved from htmlang to hl-kind, the scoped rules sit in hl-inside, and the elements' own styles, `children:` included, stay in htmlang. DESIGN.md gives the browser baseline for @scope (Baseline 2025: Chrome and Edge 118, Safari 17.4, Firefox 146); older browsers ignore the rules. The language server completes, hovers and quick-fixes element prefixes, and the VS Code grammar, snippets (Table, Styled Markdown) and README follow. A function's scoped @Style, added in 74bc0c3 and documented in 74fc727, stays, with two fixes: it is written only when the function is called, once, and in hl-inside, so a caller's `[padding 20]` now wins over the function's `& { padding: 12px; }`. Before, it was written for every defined function, called or not, outside any layer, so it beat every attribute of every element it reached. Left out: identical scoped rules under different roots are not merged, `@image:` does not reach an inline SVG, and a key reported as an error can still be reported again as a duplicate. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
The language server's hover for an element prefix had runs of spaces in the middle of its sentence, and said "has no HTML tag of its own" for a name that isn't an element at all (`@zzz:`); it now says so, and a test hovers an element prefix inside an attribute list. The @Style hover says what a function's scoped @Style does now: it is written once the function is called, and an element's own attributes win over it. The contrast lint paired a colour and a background whatever element prefix they had, so `[background #fff, @td:color #fefefe]` warned about the cells' colour against the table's background, and `[@th:background #000, @td:color #111]` about two different kinds of cell. It now pairs them on the same elements: the element's own, or those one element prefix picks out. `[@td padding 8]`, an element prefix without its colon, said "did you mean 'id'?"; it now says an element prefix ends in a colon and shows the key written with it. A directive named as an element prefix (`@each:`, `@markdown:`, `@page:`) got a suggestion from the element names (`@search:`, `@pre:`); it now says it is a directive, with no suggestion. README said the generated classes are in `@layer htmlang` and the defaults part of each class; it now names the four layers. DESIGN's `@ul [@ul:...]` example said it leaves "its own items" alone, where it means the list itself. StyleEntry's comment names the hl-kind layer. Tests: a regression test that a caller's attributes win over a function's scoped @Style and that a function called only in a branch the page doesn't take writes nothing, the contrast pairs, the missing colon, the directive, and the hover. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
A design token is read one way, `var(--name)`, and a colour variant of it is CSS: `color-mix(in srgb, var(--brand), black 8%)` for darker, `white` for lighter, `rgb(from var(--brand) r g b / 0.5)` for transparency and `color-mix(in srgb, a, b 50%)` for a mix. Because the browser computes them, variants follow a token that dark: or a stylesheet redefines, and they take any CSS colour, not only hex. `in srgb` reproduces what the old compile-time functions gave (darken(#3b82f6, 8) was #3678e2, the srgb 8% mix); DESIGN suggests oklab for new tokens. This reverses 4361500 ("Custom properties without magic"), which made `$--name` the compile-time value of a token and documented `${darken($--brand, 10)}`, and a67f200, which kept lighten/darken/alpha/mix when the filters went. `$--name` also inlined token values and let numeric tokens borrow px from the property; tokens now carry their units (`@let --radius 8px`), as P8 already required. How: - expr.rs: lighten, darken, alpha and mix are gone from FUNCTIONS and call(), with their colour helpers; they are now unknown functions. The hex parser the contrast lint uses moved next to it in parser.rs. - interp.rs: name_len no longer takes `--name`. Under P6's lexing `$--x` would silently be text, so `$--` + a name is a new Problem:: CustomProperty, reported as an invalid-value error "`$--brand` is not a variable: a custom property is read with `var(--brand)`", with the subject and suggestion the LSP turns into a quick fix. In an expression (`${$--x}`, `@if $--x`, `@let y = $--x`) it is the same message as an invalid expression. `\$--x` and a `$--` before no name are text. - parser.rs: `@let --name` declares only the :root property; it no longer binds a variable. syntax.rs: `@let --name` is not a visible `$name` (completion, hover, navigation), and its name follows the custom property rule rather than the variable rule. - Examples (landing, demo; demo.html regenerated with -d), DESIGN (CSS custom properties: one way to read a token, a table of colour variants, a dark-mode example; the Color row of the expression table removed; the compile-time principle says CSS's own functions stay CSS), README, LSP (@let doc, color-mix()/rgb(from) completions after colour properties, no `$--` in variable completion), VS Code grammar, snippet and README. - Snapshots: css_vars, stress_large and stress_deeply_nested rewrite `$--x` as `var(--x)`; their HTML now reads the tokens with var() instead of inlined hex (classes renumber where values stopped deduplicating). New snapshot colour_variants (the spec's After snippet with dark:). - Tests: the four test_color_filter_* tests are replaced by colour_variants_are_css_and_follow_their_token, the_colour_functions_are_gone and a_custom_property_is_read_with_var_not_as_a_variable; expr and interp unit tests, an LSP quick-fix test and a completion test. Left out: CSS colour functions still can't be computed inside `=` expressions or `${...}` (they go in a value or a plain @let). The contrast lint still sees only hex pairs, so colours read through var() are no longer checked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
- Completion offers the file's custom properties inside `var(`, the one place a token is read now; before, `$--brand` was offered after `$`, and nothing replaced it. The name scan is shared with the attribute completion of custom properties. - `@let --name` takes the names `[--name value]` takes (vocab::is_custom_property), so `@let --a-` and `@let ---x` are no longer refused while `[--a- 1]` is accepted, and a bad token name says what a custom property's name is instead of "starting with a letter (`@let gap 8`)". - Tests for both; the VS Code README lists the new completions. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: - An @Style's lines are joined with a space in compact and minified output, so `margin: 0` over `auto;` is `margin: 0 auto`, not `0auto`. - A call's `class=` adds to the root element's own classes, and the function's scoped-style class stays on it. - A parameter passed again takes the later value, as any attribute does: after a bundle silently, twice in the call's own list with a warning. - `@let --name` inside an element, loop or function body is an error that points to `[--name value]`: :root is one place for the whole page. Declaring a token twice at the top level warns. - `&` at the top level of an @Style that isn't scoped (not at the top of a function body) warns: there it is the whole page. - `@link /a > @image x` warns that `>` after a leading argument is text, and shows `@link [href=/a] > @image x`. - `spacing`, `wrap` and the grid words on a text, native or void element are reported in code that doesn't run too. - `@children` or `@slot` after an element's attributes, or in a verbatim body, is an error instead of printing as text. - An unclosed inline `{@...` warns. - A fixed width or height under a prefix undoes an unprefixed `fill` along a flex parent's direction (`width fill, lg:width 200` in a row). - Name hints: `[card]` for a bundle suggests `$card`, `$fn` for a function says it is called `@fn`, `$i-label` with `$i` defined shows `${i}-label`, and `@svg`, `@math` and `@template` point to @raw. - Prefixes written in another order (`md:dark:` and `dark:md:`) are the same key for duplicate warnings. - `padding=4` (a style written as an HTML attribute) warns with the style's form, and so does `width=`/`height=` on an element that has no such attribute. `for=` on @output is no longer flagged. - `@let --x` without a value no longer suggests `@let @--x`. - A function named after a built-in element whose body calls that name outside an @if is one error, not a cascade of four. - An inline SVG's width, height and fill go in its `style=`, so `var(--c)` and units work. - `@image [inline]` reads a raster next to the file (not the working directory), and a file it can't read is an error; `favicon` reads its file the same way and falls back to a link. - `--partial` warns that @page, @meta and @Head have nowhere to go, and a file without @page warns about its @meta and @Head. - A list of records written into text or a value is an error, like a record; a list inside a list prints its items. - A library may include other libraries and read @DaTa; it isn't built into a page, and its names aren't reported unused. An included file's unused names are never reported under the including file. - Indentation that mixes tabs and spaces warns (mixed-indentation). - A leading argument that starts with `{@` or an unclosed quote is an error. - `htmlang fmt` formats every file it is given and prints its usage when given none. Tests: new regressions for each; the inline-SVG expectations move to `style=`, the scoped-style tests to the spaced compact form, and nine snapshots change only by those spaces and, in responsive, by the flex reset under sm:/md:/lg:/xl: (the sized widths now apply in the row). Left as it was: a function body's problems stay reported at each call (P2's settled "at the call, once"); a missing comma between styles stays part of the value (values are CSS's). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
DESIGN.md: - Diagnostics says what lexical scope means for code that doesn't run: calls are checked against every function, but a body sees only what is defined above its function, so two functions can't call each other, and a `$name` is checked only where the code runs. - The principles name every layout (row, column, grid, text, HTML's own) and state the one thing left out without an error: a style whose value comes out empty. - `@page` is called out as the directive that is also the root element. - The HTML "Elements" heading is "HTML elements" and "Layouts" is "Page layouts", so the links to the layout table and to libraries land. - Quoted text loses its quotes in expressions, not in parameters. - Naming a function after a built-in is a warning (it replaces it), and one that wraps the element it is named after is an error. - Forwarded attributes need a single root, a call's `class=` adds to the root's, and a parameter passed twice takes the later value. - `@let --name` goes at the top level, once; only a top-of-body @Style is scoped; a clickable card is a link laid out as a column; a `$list` in a comma list is a nested list; a missing comma between styles makes one value; `padding=8` warns; `>` after a leading argument is text; `@children` after attributes is an error; mixed tabs and spaces warn. - `@data $name env:NAME DEFAULT`, inline images read next to the page, an inline SVG's styles in its `style=`, libraries that include libraries, and fragments' @meta/@Head. README.md: the prefixes are CSS's common pseudo-classes and elements (the list is in DESIGN.md), and undefined names are errors where code runs. The @let hover in the language server says the same as DESIGN. Examples: - demo.hl: chapter 5 is about `${...}` again (tags with `${if()}` in a value); the colour-mix swatches move to chapter 3 with CSS labels. The card bundle sets `color var(--ink)`, so the button and the popover follow dark mode; the @Behind gradient has a dark variant; the dashed rule has a width. demo.html is regenerated with -d. - landing.hl: the nav is hidden on phones (the page no longer scrolls sideways at 390px), and each perk's check mark sits beside it. - blog.hl passes `featured $post.featured`, and docs.hl writes its samples `@code-block > @code`; both compile to the same bytes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone using the language server or the VS Code extension: - The missing-alt and missing-type quick fixes insert `alt=` and `type=text`, which compile; before, they inserted `alt` and `type text`, both errors now. A style written as an HTML attribute (`padding=4`) gets a fix to `padding 4` from the compiler's suggestion. - Extracting lines into a function or a bundle puts the definition above the top-level line the selection is in, not at the top of the file, so it sees the definitions the lines saw (a body sees only what is above it). A loop variable or parameter the lines read becomes a parameter of the function; a bundle that would need one isn't offered. - Renaming a parameter renames its name at every call of the function, including inline calls and chain links, so the calls keep compiling. - Custom properties have navigation again: go to definition, references and rename from `var(--brand)`, `[--brand ...]` or `@let --brand`, a code lens that counts them, and `--brand` (not `$--brand`) in the outline. - Completion offers `inline` only on @image, nothing on @Fragment and HTML attributes only on @script; bundles after prefixes (`md:$card`); and nothing for the new name after `@let `/`@let @`. - Hover on a parameter's name, where it is declared or passed, describes the parameter instead of an HTML attribute of the same name. - Format Selection keeps a nested selection at its indentation. - The TextMate grammar reads the leading argument after a list with a nested group (`@link [md:[color red]] /about`) and highlights `if` after a prefix (`hover:if(...)`). - The VS Code README describes all of this. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
For someone writing htmlang: - Mixed tabs and spaces are checked only in the indentation that places a line: a tab inside a verbatim body after the body's own indentation (a Go sample in @code, a script) and a continuation line of an open list or inline JSON are content, and no longer warn. A body indented with the other one than the lines around it is still reported. - An inline SVG keeps the file's own `style=`: the element's width, height and fill are added after it, where before they replaced it. For someone using the language server: - Format Selection keeps each line at its level when the selection starts deeper than a later line, and keeps the blank lines between such runs; before, the shallower lines were moved to the first line's indentation. Tests: the mixed-indentation, inline-SVG and range-formatting tests cover each case. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Before: the compiler, the formatter and the language server each re-read raw lines their own way. A
$variablewas pasted into a line before it was split, so it could turn into an attribute or a comma. Each element decided text joining, spacing and centring through its own flags. Functions were written@let card $title $tone=#f9fafband bound unnamed attributes by position. The same idea often had two spellings (first:andfirst-child:,$--xandvar(--x),lighten()and CSS colour functions), and several mistakes were dropped without a word.After: there is one syntax tree, shared by the compiler,
htmlang fmt, the language server and a test that checks the VS Code grammar. A line is tokenized first, and then a$namefills exactly its own slot. Every element has one layout mode (column, row, grid, text, native or void), and that mode decides how text, spacing, fill and centring behave. Functions are declared as@let @card [title, tone #f9fafb]and called the same way,@card [title Hello]. Every remaining spelling is CSS's or HTML's own, and every mistake gets a diagnostic with a stable code.This implements all 24 proposals from the elegance review. Each proposal is one commit plus a review-fixes commit, and the final four commits come from a whole-branch review.
fmtand the LSP$xfills one slot only;${x}is the delimited form;$5is text\\ \$ \@ \{ \} \[ \] \, \" \--) everywhere@let @card [...]; values, bundles and functions share one namespacename valuebinds, a bare name is true, and a missing required parameter is an errorif(c, [group])for whole attributes and${if(...)}for values; CSS's ownif()passes through@each $xneeds its$$xis an error; unknown CSS properties warn; nothing is dropped silentlyspacingworks only on containersflex-direction, even undermd:; centring uses auto margins<body>and putskey=valueon<html>; a second @page is an errorhl-prefix; resets stay on htmlang's own elements--x valueworks on any element and under any prefix, used as writtenmd:hover:composes left to right;hover:[group]andhover:$bundledistribute; a fixed rule orderfirst-child:,nth-child(2n):and the rest are checked against CSS's list@table [@td:padding 8]compiles through@scopecolor-mix()and relative colours; a token is read asvar(--x)Intended breaking changes. Following 32f2947, this adds no migration layer, so old spellings get the ordinary diagnostics:
@let card $a $b=1becomes@let @card [a, b 1].$--xbecomesvar(--x).lighten(),darken(),alpha()andmix()become CSS colour functions.first:,last:,odd:,even:andnth:become their CSS names.@each xbecomes@each $x.hl-*.Left as they are, on purpose:
How: each proposal was implemented against a written spec, then a separate adversarial review probed it with the compiler and fixed what it found. A final pass ran five independent reviews over the whole branch: docs, tools, semantics, visual output and leftovers.
cargo fmt,clippy -D warningsand the full suite pass at every commit. The suite grew from 563 to 1003 tests, including newtests/editor.rsandtests/fmt.rs. The four examples compile with no warnings.🤖 Generated with Claude Code
https://claude.ai/code/session_01UPgNK4RWgo8kEjN9H1wZhc