diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77c89ce..48d60ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,14 +19,82 @@ Linux, macOS, and Windows. Your change should pass all three. ## Repository layout - `crates/htmlang-core/` — parser, AST, code generator. No I/O lives here. - - `ast.rs` — the element kinds; every plain HTML element is one row in `TAGS`. + - `syntax.rs` — the syntax tree: every line as written, with spans. The + compiler evaluates it, the formatter prints it back, and the language + server takes definitions, directives and verbatim bodies from it. It + also holds `ESCAPES`, the one escape table of every htmlang string, + and decides where `{@...}` is an inline element: in every line of text + except the text of a `literal` element. + - `parser.rs` — evaluates the tree: definitions, data, loops and + conditions, and the checks over code that doesn't run. Names live in + `Env`, one frame per block, so a definition is visible to the end of + its block and a function keeps the frames of its definition. + - `diagnostic.rs` — `Diagnostic` and the stable diagnostic codes. + - `codegen.rs` — writes the HTML and the CSS. A parent's `Flow` (the + direction its CSS sets, with the changes under media and container + prefixes) is what its children's `fill` and `shrink` compile against; + the rules for a change are keyed on the parent's class in that block. + A page's `` is written like an `@el` whose styles are `@page`'s + (the reset makes it the column), so the top level of a page is laid + out like any column; a fragment's top level has no layout of its own. + Every CSS property goes through one generic path (its name, its value + as written, with `vocab::with_px`'s pixels); only htmlang's layout + words and `line-clamp` have code of their own. Generated classes are + `hl-` + a short name; an element's defaults (its layout, + `ElementKind::css`) go with its class, as `:where(.hl-a)`, never a + rule on a global element selector, so `@markdown`/`@raw` HTML and a + page embedding a fragment are untouched. The rules are in cascade + layers (`LAYERS`): `hl-reset`, `hl-kind` (the defaults), `hl-inside` + (element prefixes, `@td:`, each an `@scope` on the class, and + functions' scoped `@style`, written when a function is called) and + `htmlang` (the elements' own styles and `children:`), so which one + wins is the layer's order, not specificity. + A style's prefixes are a `Condition`: its at-rule prefixes (sorted, so + `dark:md:` is `md:dark:`, and written as nested `@media`/`@container` + blocks) and its selector chain (read left to right; an element prefix + splits it into the scope's root and the elements it styles). Each + class keeps one list of (condition, declarations), and every block and + chain is written in one fixed order, never the order of the source. + - `ast.rs` — the element kinds; every plain HTML element is one row in + `TAGS` with its one `Layout` (column, row, grid, text, native or void, + which decides its text lines, `spacing` and its children's layout + words), whether its text is shown as written (`literal`: `@code`, + `@textarea`, whose indented body is verbatim text, HTML-escaped), the + one HTML attribute its first word fills (`arg`: `src`, `action`, ...; + `ElementKind::arg` gives `@link`'s and `@image`'s too) and whether its + body is foreign text written into the page as it is (`verbatim`: + `@script`), and every directive one row in `DIRECTIVES` (its argument + and its kind of body; a verbatim directive's line is its one-line body + or file, and it takes no attributes). The list of + elements is fixed; `HTML_NAMES_WRITTEN_OTHERWISE` maps HTML's `a`, `img`, + `span`, `p` and `div` to htmlang's own names for the unknown-element + suggestion only. - `vocab.rs` — the attribute vocabulary (htmlang attributes, CSS properties, - HTML attributes) and the state/media prefixes. - - `expr.rs` — the expression language for conditions and computed values. + HTML attributes, `@page`'s own `favicon`), the pixel rule + (`LENGTH_PROPERTIES`, the one table of properties whose bare numbers + are px, and `with_px`, the one function that applies it) and the + prefixes: `PSEUDOS`, CSS's pseudo-classes and pseudo-elements under + their CSS names, each marked `:`, `::` or taking an argument, in the + order their rules are written; `children:`; element prefixes (`@td:`, + any name; the parser checks it is an element with a tag of its own, + `ElementKind::own_tag`); and the width, media and container prefixes, + with the rank that orders the at-rule blocks. A new pseudo-class is one + row in `PSEUDOS` and one word in DESIGN.md's list. + - `expr.rs` — the expression language for conditions, computed values and + `${...}`. It evaluates only what decides the result (the branch `if()` + takes, the side of `and`/`or` that decides) and only reads the rest. + - `value.rs` — the value types (text, number, true/false, list, + record) and the rules defined once on them: truthiness, comparison, + how numbers and lists print, ranges. + - `interp.rs` — `$name` and `${...}`: where a name ends and how one slot + of text (a text run, an attribute's value, an argument, a path) is + filled in, including what quoted text inserts in CSS and in text. The + one place variables are interpolated. - `std.hl` — the standard library, written in htmlang and loaded before every file. - `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground. -- `src/` — CLI, dev server and formatter. +- `src/` — CLI, dev server and formatter (`fmt.rs`, which prints the syntax + tree back with its comments and blank lines). - `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`). Its completions come from the compiler's tables; hover and completion text live in `docs.rs`. @@ -34,7 +102,9 @@ Linux, macOS, and Windows. Your change should pass all three. - `tests/snapshots.rs` — integration / snapshot tests for the compiler. - `tests/regressions.rs` — one test per fixed bug. - `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`, - and every page in `examples/`. + and every page in `examples/`: any diagnostic fails it. +- `tests/fmt.rs` — formats every example and snapshot input: the result is + stable, compiles to the same HTML and keeps every comment. - `examples/` — complete pages: a landing page, a blog, a docs page, and a tour of the whole language (`demo.hl`). @@ -45,7 +115,9 @@ Linux, macOS, and Windows. Your change should pass all three. parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit tests alongside the code. 2. Prefer the smallest mechanism: a component belongs in `std.hl`, an HTML - element is a row in `TAGS`, and a CSS property needs no code at all. Only + element is a row in `TAGS` (and a line in DESIGN.md's table of layouts + and a name in the VS Code grammar's element list, which tests check), and + a CSS property needs no code at all. Only thread a feature through the parser and codegen when it needs to be, and describe it in the LSP's `docs.rs`. 3. Document it in `DESIGN.md` (the examples there are compiled by the tests). @@ -57,9 +129,20 @@ Linux, macOS, and Windows. Your change should pass all three. - Prefer enum-based ASTs and pattern matching over stringly-typed dispatch. - Keep `@` prefixes and bracket-attribute syntax consistent with existing - directives. Add `KNOWN_DIRECTIVES` entries when you add a new `@foo`. + directives. A new `@foo` is a row in `ast::DIRECTIVES`; the parser, the + formatter and the language server read that table, and `tests/editor.rs` + fails when the VS Code grammar's word lists drift from it. +- Every diagnostic has a code from `diagnostic::code`; add one there when no + existing code fits, and key tool behavior (quick fixes, tests) on the code + rather than on the message. Put the name the diagnostic is about in + `subject` and a replacement in `suggestion`. - Diagnostics should include `line` and, when practical, `column` and a `source_line` excerpt. Use `Severity::Help` for suggestions, not `Warning`. +- Check names, not values, and never drop input silently. When something + the author wrote can't go into the page, report an error; a warning means + it went into the page as written. Check a CSS value only for what is + wrong in any CSS (see `css_breakout` in `parser.rs`), not against a list + of values the browser may know better. - No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a diagnostic so the compiler keeps going. diff --git a/DESIGN.md b/DESIGN.md index 621e134..7f5e76c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -5,10 +5,10 @@ tree of elements written one per line, with indentation for nesting: ``` @page Hello -@el [max-width 640, center-x, padding 40, spacing 16] +@el [width fill, max-width 640, center-x, padding 40, spacing 16] @h1 Hello @paragraph - This page was written in {@text [font-weight bold] htmlang}. + This page was written in {@b htmlang}. ``` Every example block in this file is compiled by the test suite @@ -19,23 +19,28 @@ Every example block in this file is compiled by the test suite - **`@` starts structure, and any other line is content.** An element is `@name`, and a line without `@` is text. - **htmlang's own vocabulary is about layout.** It comes from elm-ui. An - element is a row or a column, `spacing` sets the gap between its children, + element is a row, a column, a grid or text (or keeps HTML's own layout), + `spacing` sets the gap between its children, and each child says how it sits in its parent (`width fill`, `center-x`, `align-right`). The layout attributes are the only styling words htmlang adds. - **Everything else is CSS or HTML, under its own name.** A style is any CSS - property with a CSS value. An element that isn't about layout has its HTML - name, and an HTML attribute is written `key=value`. Nothing is renamed or - abbreviated. + property with a CSS value. An element that isn't about layout or text has + its HTML name, and an HTML attribute is written `key=value`. Nothing is + renamed or abbreviated. - **Each thing is done one way.** `@let` defines every reusable piece, `@data` loads data, `@include` brings in a file, and `if()` makes an attribute conditional. A layout is an ordinary function. - **The output is what you wrote.** Each page compiles to one self-contained HTML file. Its CSS is inside, and it has no JavaScript unless you write some. The compiler doesn't add attributes, tags or rewritten values that - the source doesn't ask for. + the source doesn't ask for, and it doesn't leave out anything you wrote: + what can't go into the page is an error. The one exception is a style + whose value comes out empty, such as an optional field a record doesn't + have: it is simply not written. - **Everything runs at compile time.** Variables, expressions, loops and - conditions are resolved when the page is built. + conditions are resolved when the page is built. What CSS computes in the + browser (`var()`, `calc()`, `color-mix()`) stays CSS, written as it is. ## Syntax @@ -46,7 +51,9 @@ Every example block in this file is compiled by the test suite children ``` -Children are indented under their parent. Attributes go inside `[...]`, +Children are indented under their parent, with spaces or with tabs (a +tab counts as one space, so a file that mixes them is a warning). +Attributes go inside `[...]`, separated by commas, and the list may span several lines: ``` @@ -54,7 +61,7 @@ separated by commas, and the list may span several lines: padding 20, background white, border-radius 8, - box-shadow 0 2px 4px rgba(0,0,0,0.1) + box-shadow 0 2 4 rgba(0,0,0,0.1) ] Content ``` @@ -66,53 +73,137 @@ An attribute can take one of three forms: - `key=value` is an **HTML attribute** (`id=main`, `type=email`, `aria-label=Close`). - A bare word is a **flag**: a layout attribute such as `center-x`, or a - boolean HTML attribute such as `required`, `disabled` or `open`. + boolean HTML attribute such as `required`, `disabled`, `hidden` or `open`. -A comma inside `(...)` or `"..."` doesn't split attributes, so a font stack -is written `font-family "Inter, sans-serif"`. +A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else, +`\,` keeps a comma in the value: + +``` +@el [transition opacity 0.3s\, transform 0.3s, font-family "Open Sans"\, sans-serif] +``` + +A variable fills an attribute's value (`padding $gap`, `alt=$title`), never +its name or a whole attribute: attributes come from a +[bundle](#definitions), written `[$card]`, and a condition chooses whole +attributes with [`if()`](#conditional-attributes). + +The attribute list belongs to the element name right before it. Anywhere +else, `[` is an ordinary character, so a line of text can contain one: + +``` +@text Use [ to open a list +@paragraph Arrays look like [1, 2, 3]. +``` ### Text A line that doesn't start with `@` is text. Text after an element's -attributes is the element's content. Inside a line of text, `{...}` holds an -inline element: +attributes is the element's content: its first line of text, read like any +other. Inside a line of text, `{...}` holds an inline element, and +[escapes](#escapes) and [`$names`](#variables) work: ``` @paragraph - This is {@text [font-weight bold] important}, and this is {@link https://example.com a link}. + This is {@strong important}, and this is {@link https://example.com a link}. @text [font-weight bold, font-size 24, color #333] Hello world +@h2 Welcome to {@text [color #3b82f6] htmlang} +@ul + @li Read {@link /docs the docs} @section [padding 8] Text after the attributes is content too. ``` -A backslash makes the next character literal: +How an element's lines combine is its [layout](#rows-columns-and-text)'s +business: in a row or column each line is a child; in a text element lines +flow. Text that `@if`, `@each`, `@fragment` or a function writes counts as +lines written in its place, so in a text element it flows with the rest, +joined with a space, and in a native element it is on lines of its own. + +The text of `@code` and `@textarea` is shown as written. On its line and +inline, a `{@...}` in it is text, while escapes and `$names` still work. +The lines indented under it are a [verbatim](#verbatim-bodies) sample: +nothing in them is htmlang (`@`, `$`, `{`, `\` and `--` are text), and +they are HTML-escaped with their line breaks and indentation kept. This +is how a page shows code, htmlang included. `@pre` is an ordinary element, +so `@pre > @code` is HTML's `
`, and a function can wrap `@pre`
+around a sample. A sample is text on the line or lines in the block, not
+both.
+
+```
+@paragraph
+  Write {@code {@link /docs docs}} for a link.
+@pre [padding 16] > @code
+  @el [padding 40]
+    shown as text
+@textarea [aria-label=Notes]
+  First line
+    Second line, indented
+```
+
+A sample in the block has no `$names`, so a version number in it can't
+come from a variable; a one-line sample on `@code`'s own line can
+(`@code cargo install htmlang@$version`).
+
+### Escapes
 
-- `\@` and `\--` let a line of text start with `@` or `--`.
-- `\$` writes a `$` that isn't a variable.
-- `\{` writes a brace that doesn't start an inline element.
-- `\\` writes a backslash.
+A backslash before one of these characters stands for the character itself.
+The table is the same in every htmlang string: text, arguments, attribute
+values, `@let`, `@page` and `@meta`.
+
+| Escape | Writes | For |
+|---|---|---|
+| `\@`, `\--` | `@`, `--` | a line of text that starts with `@` or `--` |
+| `\$` | `$` | a `$` before a name, which would start a variable |
+| `\{`, `\}` | `{`, `}` | braces that don't start or end an inline element |
+| `\[`, `\]` | `[`, `]` | brackets that don't open or close an attribute list |
+| `\,` | `,` | a comma in an attribute's value |
+| `\"` | `"` | a quote that doesn't start or end quoted text |
+| `\\` | `\` | a backslash before one of these characters |
+
+A backslash before any other character is kept as written, so CSS's own
+escapes and patterns pass through: `before:content "\201C"`,
+`pattern=\d{3}`. A `$` that isn't followed by a letter or `_` is text
+anyway: `$5`, `$$`.
 
 ```
 \@htmlang on social media
 @let price 5
-@text \$price is $price dollars
+@paragraph \$price is $price dollars: {@mark \{braces\}}, \[brackets\] and a comma\, too
+@input [type=text, pattern=\d{3}-\d{4}, title=Costs \$5]
 ```
 
 ### Comments
 
-A line starting with `--` is a comment. A comment must be on its own line,
-because `--` later in a line is ordinary text.
+A line whose first word is `--` is a comment: `--` followed by a space, or
+alone on its line. A comment must be on its own line, because `--` later in
+a line is ordinary text. A comment may sit between the lines of an attribute
+list, and `--name` with no space is not a comment but a
+[custom property](#custom-properties), so it can start a line of a list
+(anywhere else, a line starting `--name` or `---` is text).
 
 ```
 -- this is a comment
 @row [spacing 10]
   -- todo: more nav items
   @link / Home
+@el [
+  -- the gap, wider on larger screens
+  --gap 12px,
+  md:--gap 24px,
+  spacing var(--gap)
+]
+  @text One
+  @text Two
 ```
 
 ### Chains
 
 `>` puts elements that have one child each on one line. The last element in
-the chain gets the indented children.
+the chain gets the indented children. A `>` is a chain only between two
+elements (`@name [attributes]`); in text it is just a character. A
+function call can be a link of a chain, like any element. After an
+element's leading argument (`@link /about`) the rest of the line is text,
+so a link around an image is `@link [href=/] > @image logo.png` (the
+compiler warns when `> @name` follows a leading argument).
 
 ```
 @el [padding 16, background blue, border-radius 8] > @link https://example.com
@@ -121,10 +212,20 @@ the chain gets the indented children.
 
 ### Verbatim bodies
 
-`@raw` writes HTML into the output exactly as given, either the rest of its
-line or its indented block. The bodies of `@style`, `@head`, `@script` and
-`@markdown` are also kept exactly as written: nothing in them is parsed as
-htmlang, and a `--` line in them is not a comment.
+The bodies of `@raw`, `@style`, `@head`, `@markdown`, `@script`, `@code`
+and `@textarea` are foreign text, kept exactly as written: nothing in them
+is parsed as htmlang, and a `--` line in them is not a comment. `@raw`
+writes its HTML into the page, `@style` its CSS and `@head` its HTML into
+the ``; `@code` and `@textarea` show theirs as text. Which lines are
+verbatim is decided by the element or directive on the line, also at the
+end of a chain (`@pre > @code`, `@el > @script`).
+
+The header's line follows one rule. For `@raw`, `@style` and `@head`, text
+on the line is a one-line body; for `@markdown` it is the file and for
+`@script` the `src`. Text on the line and an indented body together are an
+error. Only `@script` takes attributes: an attribute list on `@raw`,
+`@style`, `@head` or `@markdown` is an error, so content that starts with
+`[` (a CSS attribute selector) goes in the indented block.
 
 ```
 @raw 
@@ -132,16 +233,99 @@ htmlang, and a `--` line in them is not a comment.
Hand-written HTML
+@style .note { color: gray; } ``` +### Directives + +A directive is a built-in word that isn't an element. Each one takes a +fixed kind of argument and a fixed kind of body. `@page` is the one +directive that is also an element: it stands for the page's root, ``, +whose content is the rest of the file (see [Page and head](#page-and-head)). + +| Directive | Argument | Body | +|---|---|---| +| `@page` | attributes (styles for ``, `key=value` for ``) and a title | none | +| `@let` | a name and a value or bundle, or `@name` and parameters | a function's body | +| `@include` | a file | none | +| `@data` | a variable and a source | none | +| `@meta` | a name and a value | none | +| `@if`, `@else`, `@each` | a condition or a loop | htmlang | +| `@raw`, `@style`, `@head` | the rest of the line: a one-line body | verbatim, instead of the line | +| `@markdown` | a file | verbatim Markdown, instead of the file | + +An indented line under a directive that takes no body is an error, because +it would otherwise silently become a sibling. + +### Diagnostics + +The compiler checks the whole file, including code that doesn't run: the +branch of an `@if` or an `if()` that isn't taken, the body of a function that is never +called and the body of a loop over an empty list. Unknown elements, +functions, attributes and prefixes are reported there too. In code that +doesn't run, a call is checked against every function in the file and the +files it includes; where the code runs, a name is looked up where it is +written, so a function's body sees only what is defined above the function +(a call to a function defined further down is reported when the body +runs, and two functions can't call each other). A `$name` is checked only +where the code runs, since any of them may come from data: an undefined +variable in a branch that isn't taken, or in a function that is never +called, isn't reported. + +**Names are checked, and values are CSS's.** A misspelled element, prefix, +parameter or slot is an error, and so is an undefined variable. A CSS +property htmlang doesn't know, but whose name CSS could have, is written to +the CSS as it is, with a warning that suggests the closest known name +(`text-grow`, or `colr`, which asks "did you mean `color`?"). Values go to +the CSS as written, so the browser decides what `z-index auto` or `color +rebeccapurple` means; only what is wrong in any CSS is reported (see +[CSS properties](#css-properties)). + +**Nothing you write is left out silently.** An error means something +couldn't go into the page: a flag htmlang doesn't know, an attribute +`@fragment` has no element for, content for a function without +`@children`. A warning means it went into the page as written, but may not +be what you meant. A warning is reported once, even from a line that runs +many times in a loop or a function. + +Every diagnostic has a stable code, such as `unknown-element` or +`unused-variable`. The command line prints it as `error[unknown-element]`, +`--format json` has it in a `code` field, and the editor's quick fixes are +keyed on it. + ## Layout -### Rows and columns +### Rows, columns and text + +Every element has one layout, which decides what the lines written inside +it are. **In a row or column each line is a child; in a text element lines +flow.** -`@el` is the container, and it lays out its children in a column. `@row` -lays them out side by side. Every other container (`@section`, `@nav`, -`@article`, `@form`, and so on) is a column too, so `@row` is the only -element that changes direction. `@grid` is a CSS grid. +| Layout | Elements | What is inside it | +|---|---|---| +| column | `@el`, and the containers: `@section`, `@nav`, `@form`, `@ul`, `@li`, ... | Each line of text is a child of its own, and the children are laid out top to bottom (side by side with `flex-direction row`) | +| row | `@row` | The same, side by side | +| grid | `@grid` | Each line of text is a cell | +| text | `@paragraph`, `@text`, `@link`, `@h1` … `@h6`, `@button`, `@label`, `@td`, `@strong`, `@em`, ... | The argument, the lines and the children flow as one run of text, joined with spaces | +| native | `@table`, `@caption`, `@select`, `@pre`, `@textarea`, `@video`, ... | HTML's own layout, which htmlang leaves alone | +| void | `@input`, `@hr`, `@br`, `@image`, `@source` | Nothing: it takes no content | + +The table under [HTML elements](#html-elements) gives every element's layout. The +page itself is a column too: `@page` is the [root element](#page-and-head), +``, so what is written at the top of a page stacks like the children +of an `@el`, `height fill` there takes the rest of the window and +`center-y` centres in it. + +``` +@el [spacing 4] + First line + Second line +@button [type=button] + Save + changes +``` + +The column shows two lines 4px apart; the button says "Save changes". These attributes are htmlang's own. They describe how an element lays out its children, and how it sits in its parent: @@ -149,14 +333,31 @@ its children, and how it sits in its parent: | Attribute | Effect | |---|---| | `spacing N` | Gap between children | -| `width fill` / `width shrink` / `width N` | Take the remaining space in a row (the full width in a column), fit the content, or an exact size | -| `height fill` / `height shrink` / `height N` | Take the remaining space in a column, fit the content, or an exact size | -| `center-x`, `center-y` | Center the element in its parent | -| `align-left`, `align-right`, `align-top`, `align-bottom` | Align the element in its parent | +| `width fill` / `width shrink` / `width N` | In a row, take the remaining width or keep the content's width; in a column or anywhere else, take the full width or fit the content; or an exact size | +| `height fill` / `height shrink` / `height N` | In a column, take the remaining height or keep the content's height; in a row or anywhere else, take the full height or fit the content; or an exact size | +| `center-x`, `center-y` | Center the element in its parent (auto margins) | +| `align-left`, `align-right`, `align-top`, `align-bottom` | Align the element in its parent (an auto margin on the other side) | | `wrap` | Let a row wrap onto more lines | -| `grid-cols N`, `grid-rows N` | Equal grid columns or rows (on `@grid`) | +| `grid-cols N`, `grid-rows N` | Equal grid columns or rows (on `@grid`); any other value is a track list, as in `grid-template-columns` (`grid-cols 200 1fr`) | | `col-span N`, `row-span N` | Cells a grid child spans | +`spacing`, `wrap`, `grid-cols` and `grid-rows` lay out an element's +children, so they go on a row, column or grid. On a text, native or void +element they are an error: text has no gap between its lines. For a flex +layout of your own on such an element, write the CSS (`@label [display +flex, gap 8]`). + +A child's `fill` and `shrink` follow the direction its parent's CSS +actually sets: a row for `@row`, a column for `@el` and the other columns, +or whatever the parent's own `flex-direction` (or `flex-flow`) says. Along +that direction, `fill` takes the remaining space and `shrink` keeps the +content's size; across it, `fill` is the full size and `shrink` fits the +content. `center-x`, `center-y` and `align-*` are auto margins, which work +in either direction; a `margin` on the same element keeps its other sides +(`[center-x, margin 20]`). A layout word never overrides a CSS property +the element writes itself: `[width fill, min-width 200]` keeps its 200px, +and `[flex-shrink 0, width fill]` still grows but never shrinks. + ``` @row [spacing 8] @el [width fill, padding 12, background #f3f4f6] Fills the row @@ -165,15 +366,77 @@ its children, and how it sits in its parent: @text [center-x] Centered @el [height fill, background #dbeafe] Fills the rest of the column @text [align-right] Right-aligned +@nav [flex-direction row, spacing 20] + @link /features Features + @el [width fill] + @link /signup Sign up @grid [grid-cols 3, spacing 8, md:grid-cols 4] @el [col-span 2, background #fef3c7] Two columns wide @el [background #fde68a] One @el [background #fcd34d] One ``` +A direction set under media or container prefixes (`md:flex-direction +row`, `print:`, `cq-md:`, or a stack of them such as `md:dark:`) switches +the children's `fill` and `shrink` in that same condition. As in elm-ui, whose child rules are keyed on the +parent's class (`.r > .wf`), each child gets a rule keyed on its parent's +class in the parent's `@media` or `@container` block. A width prefix holds +from its width up, so `sm:flex-direction column` still applies at `lg:` +unless `lg:` sets a direction of its own. A direction under a state prefix +(`hover:`, `first-child:`, `children:`, or a stack with one in it) leaves the +children's layout words as they are. + +``` +@header [spacing 16, md:flex-direction row, md:align-items center] + @text [font-weight 800] Launchpad + @el [width fill] + @nav [flex-direction row, spacing 20] + @link /features Features + @link /pricing Pricing +@section [padding 24] + @el [width fill, max-width 800, center-x, spacing 16] + The full width up to 800px, in the middle +``` + +On a phone the header is a column and its `@el [width fill]` is the full +width; from md up it is a row and the `@el` takes the free space, pushing +the navigation to the right. The section's `@el` is the usual centred +column: the full width, at most 800px, with auto margins on both sides. + Everything else about layout is plain CSS: `justify-content`, `align-items`, `flex 1 1 240px`, `grid-template-areas`, `position`. +### Layout inside text + +A row, column or grid written inside text, inline in a line (`{@el ...}`) or +as a child of a text element, is laid out inline (`display: inline-flex`, or +`inline-grid`), and keeps its attributes. Anywhere inside text, `@el`, `@row` +and `@grid` (and `@in-front` and `@behind`) are written as a `` rather +than a `
`, since text can't hold a `
`. + +``` +@paragraph + Price: {@el [padding 2 6, background #fef3c7, border-radius 4] $9} a month. +@h2 + Plans + @row [spacing 4, font-size 14] > @text New +``` + +Any other element keeps its HTML element. One whose HTML ends a paragraph, +such as `@section`, `@ul` or `@h2`, is a warning inside `@paragraph` +(`block-in-paragraph`): the browser would move it, and what follows it, out +of the `

`. + +`@link` is text too, so a card written inside a link is an inline `` +that shrinks to its content. A clickable card is the link itself, laid out +as a column with CSS: + +``` +@link [href=/post, display flex, flex-direction column, gap 8, padding 16, border 1 solid #e5e7eb] + @strong A post + @text Its summary. +``` + ### Overlays: `@in-front` and `@behind` These layers work like elm-ui's `inFront` and `behind`. Their children fill @@ -214,15 +477,114 @@ same name and value as in CSS: `padding 20`, `font-weight bold`, `border 1 solid #e5e7eb`, `border-radius 8`, `background red`, `grid-template-areas "a b"`, `display none`. -Values mean what they mean in CSS, with one addition: **a bare number in a -length is pixels**. So `padding 20` is `20px`, `border 1 solid red` is -`1px solid red`, and `margin 0 auto` is `0 auto`. Values that have a unit, -keywords and CSS functions pass through as written (`width 50%`, -`max-width min(100%, 800px)`), and so do numbers that aren't lengths -(`opacity 0.5`, `z-index 2`, `line-height 1.5`, `flex 1`). +A quoted string in a value is CSS's own, so it keeps its quotes and its +commas: `font-family "Inter, sans-serif"` names one family called +"Inter, sans-serif", and the compiler warns about it. A font stack is +several values with commas between them, written `\,`: +`font-family Inter\, sans-serif`. + +Values mean what they mean in CSS, with one addition: **in a length +property, every bare number outside parentheses and quotes is pixels**. So +`padding 20` is `20px`, `border 1 solid red` is `1px solid red`, +`box-shadow 0 2 4 rgba(0,0,0,0.1)` is `0 2px 4px rgba(0,0,0,0.1)`, and +`margin 0 auto` is `0 auto` (a zero stays `0`). Numbers inside a function +belong to it (`rgb(255 128 0)`, `calc(100% - 20px)`), and values that have +a unit or are keywords pass through as written (`width 50%`, `max-width +min(100%, 800px)`). Every other property takes numbers as CSS does, so +`opacity 0.5`, `z-index 2`, `line-height 1.5`, `flex 1 1 240px`, +`grid-column 1 / 3`, `initial-letter 3` and `border-image-width 2` are +written as they are, and so are custom properties (`--cols 3`), which have +no type to go by. + +The length properties are these, and only these: + +| Group | Properties | +|---|---| +| Size | `width`, `height`, `min-*` and `max-*` of both, `block-size`, `inline-size` and their `min-`/`max-`, `flex-basis`, `column-width`, `contain-intrinsic-*` | +| Space | `margin`, `padding`, `scroll-margin`, `scroll-padding` and every side of them, `gap`, `row-gap`, `column-gap`, `border-spacing` | +| Position | `top`, `right`, `bottom`, `left`, `inset` and `inset-*`, `translate`, `transform-origin`, `perspective`, `perspective-origin`, `offset-position`, `offset-anchor`, `offset-distance` | +| Border | `border` and every `border-*` shorthand, width and radius (not `border-image-*`), `outline`, `outline-width`, `outline-offset`, `column-rule`, `column-rule-width` | +| Shadow | `box-shadow`, `text-shadow` | +| Grid | `grid-template-columns`, `grid-template-rows`, `grid-auto-columns`, `grid-auto-rows` | +| Background | `background-position` (and `-x`, `-y`), `background-size`, `object-position`, `mask-position`, `mask-size` | +| Text | `font-size`, `letter-spacing`, `word-spacing`, `text-indent`, `text-decoration-thickness`, `text-underline-offset`, `vertical-align`, `baseline-shift` | +| Other | `shape-margin`, `overflow-clip-margin`, `view-timeline-inset` | + +Shorthands that mix lengths with numbers of other kinds (`font`, +`background`, `mask`, `grid`, `grid-template`, `offset`, `flex`) are not in +the table, so the lengths in them are written with their unit: +`font 700 16px/1.5 Inter`. + +Nothing else is added to a value, and a shorthand means what it means in +CSS: `outline 2 solid red` is `2px solid red`, while `outline 2 red` names +no line style, so CSS draws no outline. `container card / inline-size` +and `contain content` are CSS's own shorthands too, and a bare `contain` is +an error, like any style without a value. `line-clamp N` alone also adds +the `-webkit-box` declarations that browsers still need to cut text off +after N lines. + +A property htmlang doesn't know is written to the CSS as it is, so new CSS +(`text-grow per-line`) works before htmlang lists it; the +compiler warns and suggests the closest known name, in case it is a typo. +Vendor-prefixed properties (`-webkit-tap-highlight-color`) pass without a +warning. The value of a property htmlang doesn't know is written exactly as +it is, without pixels added. A style needs a value: `[padding]` is an error. + +``` +@el [-webkit-tap-highlight-color transparent, -webkit-text-stroke 1px black] + Passed through as written +``` -`line-clamp N` also adds the `-webkit-box` declarations that browsers still -need to cut text off after N lines. +A value is checked only for what is wrong in any CSS. A `;`, `{` or `}` +(outside a quoted string, and for `;` outside parentheses, as in CSS's own +`if()`) or an unclosed quote or parenthesis would break out of the rule, and +a ` img):`, `is(...):`, ... | When that pseudo-class, with its argument, matches | +| `before:`, `after:`, `marker:`, `placeholder:`, `backdrop:`, ... | To that pseudo-element (`::before`, `::marker`, ...) | | `children:` | To each direct child | -| `before:`, `after:` | To the `::before` / `::after` pseudo-element (together with `content`) | -| `has(SELECTOR):` | When the element contains a match | +| `@td:`, `@h2:`, `@link:`, ... | To every such element inside it, at any depth (see [Styling the elements inside](#styling-the-elements-inside)) | | `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640, 768, 1024, 1280, 1536px) | | `cq-sm:` … `cq-2xl:` | From that container width up (the same widths, for an ancestor with `container-type inline-size`) | | `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Under that media condition | +The selector prefixes are CSS's own pseudo-classes and pseudo-elements, +under their CSS names, without the colons: `link`, `visited`, +`first-child`, `last-child`, `only-child`, `first-of-type`, +`last-of-type`, `only-of-type`, `empty`, `target`, `open`, +`popover-open`, `default`, `checked`, `indeterminate`, +`placeholder-shown`, `autofill`, `required`, `optional`, `valid`, +`invalid`, `user-valid`, `user-invalid`, `read-only`, `read-write`, +`focus-within`, `hover`, `focus`, `focus-visible`, `active`, `enabled` +and `disabled`; with an argument, `nth-child()`, `nth-last-child()`, +`nth-of-type()`, `nth-last-of-type()`, `not()`, `is()`, `where()` and +`has()`; and the pseudo-elements `before`, `after`, `marker`, +`placeholder`, `selection`, `backdrop`, `first-line`, `first-letter` and +`file-selector-button`, which htmlang writes with CSS's `::`. The argument +is CSS, written as it is between balanced parentheses, and it may hold +spaces and colons: the key ends at the first space outside parentheses, so +`has(> img):padding 0` and `is(:hover, :focus-visible):color red` are one +key each. `children:` is htmlang's one combinator word, and an element's +name with `@` (`@td:`) picks out the elements of that kind inside. A name +that isn't one of these (`hvoer:`, or `first:` for `first-child:`) is an +unknown prefix, an error, not a selector that silently never matches. + +A prefix goes on a style or a layout flag, and prefixes stack: the key +is a chain of prefixes, then the property, and the style applies where +all of them hold. The selector prefixes (every row above the widths) +read left to right: `hover:children:` styles the children of a hovered +element, `children:hover:` a hovered child. A pseudo-element is not an +element with states of its own, so it comes last among them: +`hover:before:color` is the `::before` of a hovered element, and +`before:hover:color` is an error that says so. The width, media and +container prefixes go anywhere in the chain, in any order, and each is +an `@media` or `@container` rule around the ones inside it: +`md:dark:padding 32` and `dark:md:padding 32` are the same style, from +768px up in dark mode. + +A prefix written before a `[group]` applies to each style in it, and one +written before a `$bundle` to each style the bundle holds, so styles that +share a condition are written under it once. A prefix before an `if()` +applies to the branch it picks. The group or bundle holds styles only: +an HTML attribute (`md:[id=x]`) or a flag that is not a style has no +states, and it is an error there, as it is with a prefix of its own +(`md:id=x`). + +`children:` styles each direct child, as the parent sees it. They take +effect only where the child sets nothing itself, since what an element +says about itself wins over what its parent says about it. They do win +over the defaults of the child's kind, so `children:display list-item` +brings back a list's markers and `children:margin-bottom 8` spaces +headings. The words +that place an element in its parent (`width fill`, `width shrink`, +`center-x`, `align-*`) are errors under `children:`, because there the +parent they measure against is this element: they go on the children +themselves, or the CSS they stand for does (`children:flex 1`). + +#### Styling the elements inside + +An element prefix, `@NAME:`, styles every element of that kind inside +this one, at any depth. `@table [@td:padding 8]` pads every cell of the +table, and `@article [@h2:font-size 20]` sizes every heading in the +article. It is how content that has no attributes of its own is styled: +the HTML `@markdown` writes, and the rows a loop writes. + +``` +@let team Ada, Grace, Linus +@table [border-collapse collapse, width 100%, @th:[padding 8, text-align left], @td:padding 8, @tr:border-bottom 1 solid #e5e7eb] + @tr + @th Name + @th Team + @each $name in $team + @tr + @td $name + @td [padding 2] Platform +@article [spacing 12, @h2:font-size 20, @code:[padding 1 4, background #f1f5f9], @link:hover:color #2563eb] + @markdown + ## Notes + + Run `htmlang check` before you [publish](/publish). ``` -@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a] + +- **The element has a tag of its own.** `@td:` is every ``, + `@paragraph:` every `

`, `@link:` every `` and `@image:` every + ``, the ones `@markdown` and `@raw` write included. An element that + shares its tag can't be picked out, so its prefix is an error: `@el`, + `@row`, `@grid`, `@in-front` and `@behind` all write `

`, and `@text` + writes the `` that every line of text in a row or column is too. + So is a function's name (`@card:`), since a function writes whatever its + body says and has no tag of its own, and a name that isn't an element + (`@a:` suggests `@link:`). +- **It takes CSS properties.** htmlang's layout words (`spacing`, `wrap`, + `grid-cols`, `col-span`, `width fill`, `center-x`, `align-*`) compile + against each element's own layout and its parent's, which the prefix + doesn't know, so they are errors under it: write them on the elements, + or the CSS they stand for (`@li:margin-inline auto`). HTML attributes + have no states: `@td:colspan=2` is an error, like `md:colspan=2`. +- **It composes with the other prefixes, read left to right.** Selector + prefixes after it choose among the elements: + `@tr:nth-child(even):background #f8fafc` shades every other row, and + `@link:hover:color red` colours a hovered link. Those before it are + about the element itself: `hover:@link:color red` recolours its links + while it is hovered. Width, media and container prefixes go anywhere + (`md:@td:padding 12`). A `[group]` or a `$bundle` takes it like any + prefix: `@th:[padding 8, text-align left]`. A style has one element + prefix, and not `children:` as well: each says which elements the style + is for. +- **What an element says about itself wins.** A cell's own `[padding 2]` + wins over the table's `@td:padding 8`, and a parent's `children:` wins + over it too. The prefix wins over the defaults of the element's kind, so + `@main [@h2:margin-block-start 16]` spaces headings, which start with + `margin: 0`. Where two elements around one both style it, the nearest + wins, whatever the order they are written in: in an `@article + [@td:padding 16]` around a `@table [@td:padding 8]`, the table's cells + get 8. +- **It styles what is inside, not the element itself.** `@ul + [@ul:padding-inline-start 16]` indents the lists inside the list, not + the list itself. + +`children:` and an element prefix answer different questions: +`children:` is for the direct children, whatever they are, as the parent +sees them; `@td:` is for every cell inside, however deep. An element's +own attributes are for it alone. + +Each style under an element prefix compiles to a rule in CSS's `@scope`, +on the element's class, in the layer `hl-inside` (see [CSS](#css)): +`@scope (.hl-a) { :scope td { padding: 8px } }`. `@scope`'s proximity +rule is what makes the nearest element win. `@scope` is Baseline 2025, +supported by Chrome and Edge 118, Safari 17.4 and Firefox 146; an older +browser ignores these rules, so the elements inside keep the styles they +have without them. + +``` +@let card [padding 16, border 1 solid #e5e7eb, border-radius 8] +@let active true +@el [padding 16, background #3b82f6, hover:background #2563eb, md:[padding 32, font-size 20], dark:[background #1e3a8a, hover:background #1e40af]] @text [color white] Click me -@row [spacing 4, children:flex 1] - @el [odd:background #f3f4f6] A - @el [odd:background #f3f4f6] B - @el [odd:background #f3f4f6] C -@el [before:content "→ ", before:color red] +@row [spacing 4, children:flex 1, hover:children:opacity 0.8] + @el [nth-child(odd):background #f3f4f6] A + @el [nth-child(odd):background #f3f4f6] B + @el [nth-child(odd):background #f3f4f6] C +@el [before:content "→ ", before:color red, hover:before:color blue] Item with an arrow +@section [spacing 16, md:$card, hover:if($active, [background #eef2ff, color #3730a3])] + A card from md up ``` +The styles of one element follow a fixed order, whatever the order they +are written in: those without a prefix, then those with selector +prefixes only, then each width, media and container condition in the +order of the table (a stack like `md:dark:` after `dark:` on its own), +with its selector chains inside it. So where two styles both hold, the +one later in the table wins (`dark:` over `md:`, `lg:` over `md:`), and a +stack wins over each of its prefixes on its own (`md:dark:` over `dark:` +and `md:`). Selector chains follow CSS's usual order: `link:` and +`visited:`, the position and form states, then `hover:`, `focus:`, +`active:`, and `enabled:` and `disabled:` last. So a hovered row wins over +`nth-child(odd):`, and a pressed button's `active:` over its `focus:`. + ### Conditional attributes -`if(CONDITION, A, B)` chooses `A` or `B` depending on the condition. It works -as a value or as a whole attribute. If the chosen side is empty, the attribute -is left out, and `B` itself can be omitted. +`if(CONDITION, A, B)`, written as a whole attribute, is `A` when the +condition holds and `B` otherwise. Each branch is an attribute, a `[group]` +of attributes or a `$bundle`. An empty branch leaves the attribute out, and +`B` itself can be left out. Several attributes that depend on one condition +are one `if()` with a group: ``` @let active true -@el [background if($active, blue, gray), if($active, font-weight bold), padding if($active, 12)] +@el [if($active, background blue, background gray), if($active, font-weight bold)] Conditionally styled +@link [if($active, [background #eef2ff, font-weight 600, aria-current=page])] /docs Docs ``` +A value that depends on a condition is an [expression](#expressions), +`${if(CONDITION, A, B)}`. Its branches are expressions, so a number or a +single word is written as it is, and other CSS text is quoted: +`padding ${if($active, 24, 0)}`, `color ${if($active, #10b981, "var(--muted)")}`. +An `if()` written in a value without `${...}` is CSS's own `if()`, which the +browser decides, and it is passed through like any CSS function: +`width if(media(width > 40em): 50%; else: 100%)`. + ## HTML -### Elements +### HTML elements The layout and text elements have elm-ui's names. Every other element has -its HTML name. +its HTML name: `@strong`, `@em`, `@small`, `@br`, `@sub`, `@caption`, +`@tfoot`, `@optgroup`, `@hgroup` and the others in the table of layouts +below. | Element | Output | Purpose | |---|---|---| @@ -276,38 +795,82 @@ its HTML name. | `@grid` | div, grid | Grid container (with `grid-cols` or `grid-template-*`) | | `@in-front` / `@behind` | div, absolute | Overlay layers that fill the parent | | `@text` | span | Styled inline text | -| `@paragraph` | p | Flowing text with inline elements | -| `@link URL` | a | Link, whose content is the text after the URL | -| `@image SRC` | img | Image | -| `@fragment` | (none) | Its children, without a wrapper element | - -The HTML elements, by kind: - -- **Semantic containers**, all laid out as columns: `@nav`, `@header`, - `@footer`, `@main`, `@section`, `@article`, `@aside`, `@address`, - `@search`, `@form`, `@details` / `@summary`, `@dialog`, `@figure` / - `@figcaption`, `@blockquote` / `@cite`, `@fieldset` / `@legend`, - `@noscript`. -- **Text**: `@h1` … `@h6`, `@code`, `@pre`, `@mark`, `@kbd`, `@abbr`, - `@time`. -- **Lists and tables**: `@ul` / `@ol` / `@li`, `@dl` / `@dt` / `@dd`, - `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`. -- **Forms**: `@input`, `@button`, `@select` / `@option`, `@textarea`, - `@label`, `@datalist`, `@progress`, `@meter`, `@output`. For `@form URL`, - the URL is the form's `action`. -- **Media and embeds**: `@picture` / `@source`, `@video SRC`, `@audio SRC`, - `@iframe SRC`, `@canvas`, `@script`, `@hr`. +| `@paragraph` | p | A paragraph of flowing text | +| `@link URL` | a | Link: the URL is its `href`, and the text after it is its content | +| `@image SRC` | img | Image: the one word after its attributes is its `src` | +| `@fragment` | (none) | Its children, without a wrapper element (so it takes no attributes) | + +Every element has one [layout](#rows-columns-and-text): + +| Layout | Elements | +|---|---| +| column | `@el`, `@in-front`, `@behind`, `@nav`, `@header`, `@footer`, `@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@noscript`, `@form`, `@details`, `@dialog`, `@figure`, `@blockquote`, `@fieldset`, `@hgroup`, `@ul`, `@ol`, `@menu`, `@li`, `@dl`, `@dd` | +| row | `@row` | +| grid | `@grid` | +| text | `@text`, `@paragraph`, `@link`, `@h1`, `@h2`, `@h3`, `@h4`, `@h5`, `@h6`, `@button`, `@label`, `@legend`, `@summary`, `@figcaption`, `@cite`, `@dt`, `@td`, `@th`, `@code`, `@kbd`, `@mark`, `@abbr`, `@time`, `@b`, `@i`, `@strong`, `@em`, `@small`, `@s`, `@u`, `@sub`, `@sup`, `@q`, `@var`, `@samp`, `@dfn`, `@bdi`, `@bdo`, `@ins`, `@del` | +| native | `@table`, `@caption`, `@colgroup`, `@thead`, `@tbody`, `@tfoot`, `@tr`, `@select`, `@optgroup`, `@option`, `@datalist`, `@textarea`, `@progress`, `@meter`, `@output`, `@pre`, `@ruby`, `@rt`, `@rp`, `@picture`, `@video`, `@audio`, `@iframe`, `@object`, `@map`, `@canvas`, `@script` | +| void | `@image`, `@input`, `@hr`, `@br`, `@wbr`, `@col`, `@source`, `@track`, `@embed`, `@area` | + +`@fragment`, `@children` and `@slot` have no element of their own: what +they hold takes the layout of the element they are in. The top of a page is +a column, `` (see [Page and head](#page-and-head)). In a native +element, and at the top of a fragment, lines of text are separated by a line break, +which HTML shows as a space except where whitespace is kept, as in `@pre` +and `@textarea`. + +The text-level elements (`@strong`, `@em`, `@b`, `@i`, `@small`, `@sub`, +`@q`, `@del`, ...) are text with the browser's own style and no CSS of +htmlang's, so they work inside a line like any inline element. Table parts, +`@optgroup` and `@ruby` keep HTML's own layout: + +``` +@paragraph + {@strong Note:} water is H{@sub 2}O,{@br} + and {@em this} is {@small fine print}. +@table + @caption Team + @thead + @tr + @th Name + @tbody + @tr + @td Ada + @tfoot + @tr + @td One person +@select [aria-label=Fruit] + @optgroup Citrus + @option Lemon +``` + +The list of elements is fixed: a name that isn't in it is an unknown +element, not a new HTML tag, since a function is called with the same `@` +and a misspelled one would otherwise become a tag. HTML's `
`, ``, +`

`, `` and `` are htmlang's own `@el`, `@text`, `@paragraph`, +`@link` and `@image`, so `@a` is an unknown element whose error suggests +`@link`. The head's elements (``, ` - - -{body}\ - - -", - title = html_escape(title), - lang_attr = lang_attr, - meta_html = meta_html, - favicon_html = favicon_html, - head_html = head_html, - og_html = og_html, - reset_css = reset_css, - element_css = element_css, - body = body, - ) - } else { - format!( - "{title}{meta_html}{og_html}{favicon_html}{head_html}{body}", - title = html_escape(title), - lang_attr = lang_attr, - meta_html = meta_html, - og_html = og_html, - favicon_html = favicon_html, - head_html = head_html, - reset_css = reset_css, - element_css = element_css, - body = body, - ) - } - } - None => { - if element_css.is_empty() { - body - } else if dev { - format!("\n{}", element_css, body) - } else { - format!("{}", element_css, body) - } - } - } + format!( + "{nl}{nl}{nl}{nl}\ + {meta_html}{nl}{nl}\ + {nl}{body}{nl}{nl}", + ) } /// Assemble every CSS block the document needs (font faces, custom @@ -784,9 +907,9 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri root_vars.push((name.clone(), value.clone())); } - // Generated rules always go in `@layer htmlang`, so unlayered user CSS + // Generated rules always go in htmlang's layers, so unlayered user CSS // (`@style`, `@raw`) overrides them regardless of specificity. - let styles_css = styles.to_css_formatted(dev); + let styles_css = styles.to_css_formatted(dev, &doc.scoped_css); // Emit the :root block first so the cascade picks up the custom // properties before the class rules consume them. @@ -817,31 +940,90 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri element_css.push_str(block); element_css.push('\n'); } else { - let minified: String = block.lines().map(|l| l.trim()).collect::>().join(""); - element_css.push_str(&minified); + element_css.push_str(&compact_css(block)); } } element_css } +/// An `@style` body on one line: each line trimmed, and the lines joined with +/// a space, so a value that goes on over two lines (`margin: 0` then `auto;`) +/// keeps its words apart. +fn compact_css(block: &str) -> String { + block + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect::>() + .join(" ") +} + +/// htmlang's own elements: those with a generated class, or the class of a +/// function's scoped `@style`. The reset's rules for elements are kept to +/// them, so `@raw` and `@markdown` content, and the page a fragment is put +/// into, keep their own. +const OWN_ELEMENTS: &str = ":where([class^=\"hl-\"],[class*=\" hl-\"])"; + +/// htmlang's cascade layers, from the one that loses to the one that wins: +/// the reset; the defaults of each element's kind (a heading's `margin:0`, +/// a column's `display:flex`); the styles an element gets from an +/// element prefix on an element around it (`@td:padding 8`) and from a +/// function's scoped `@style`; and the element's own styles. So a +/// scope's styles win over the defaults, and what an element says about +/// itself wins over both. CSS outside any layer (`@style`, `@raw`) wins +/// over all of them. +const LAYERS: [&str; 4] = ["hl-reset", "hl-kind", "hl-inside", "htmlang"]; + /// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats -/// every layer, so an unlayered `a{color:inherit}` would override -/// `@link [color red]`. -fn reset_css(dev: bool, focus_visible_css: &str) -> String { - let base = if dev { - "*, *::before, *::after { box-sizing: border-box; }\nbody { margin: 0; font-family: system-ui, -apple-system, sans-serif; }\nimg { display: block; }\na { text-decoration: none; color: inherit; }\n" +/// every layer, so an unlayered rule here would override the generated +/// ones. A page resets `box-sizing` everywhere and makes `` a column +/// that fills the window; a fragment, which goes into a page it doesn't +/// own, touches only htmlang's own elements. In both, an element htmlang +/// lays out stays hidden while it is `hidden`, a closed `

` or a +/// popover that isn't showing: its generated `display` would otherwise +/// beat the browser's `display: none`. +fn reset_css(dev: bool, page: bool, focus_visible_css: &str) -> String { + let own = OWN_ELEMENTS; + let mut rules = Vec::new(); + if page { + rules.push(if dev { + "*, *::before, *::after { box-sizing: border-box; }".to_string() + } else { + "*,*::before,*::after{box-sizing:border-box}".to_string() + }); + rules.push(if dev { + "body { margin: 0; font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; min-height: 100dvh; }".to_string() + } else { + "body{margin:0;font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;min-height:100dvh}".to_string() + }); } else { - "*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}" - }; - let rules = format!("{}{}", base, focus_visible_css); + rules.push(if dev { + format!("{own}, {own}::before, {own}::after {{ box-sizing: border-box; }}") + } else { + format!("{own},{own}::before,{own}::after{{box-sizing:border-box}}") + }); + } + let hidden = "[hidden]:not([hidden=\"until-found\"]),dialog:not([open]):not(:popover-open),\ + [popover]:not(:popover-open):not(dialog[open])"; + rules.push(if dev { + format!("{own}:where({hidden}) {{ display: none !important; }}") + } else { + format!("{own}:where({hidden}){{display:none!important}}") + }); + let mut rules = rules.join(if dev { "\n" } else { "" }); + if dev { + rules.push('\n'); + } + rules.push_str(focus_visible_css); if dev { format!( - "@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", + "@layer {};\n@layer hl-reset {{\n{}}}\n", + LAYERS.join(", "), rules ) } else { - format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules) + format!("@layer {};@layer hl-reset{{{}}}", LAYERS.join(","), rules) } } @@ -852,14 +1034,20 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String { dev, depth: 0, has_interactive: false, + in_text: false, + flow: Flow::default(), + in_picture: false, }; let mut body = String::new(); - for node in &doc.nodes { - generate_node(node, None, &mut body, &mut styles, &mut ctx); - } + generate_children(&doc.nodes, None, &mut body, &mut styles, &mut ctx); - let element_css = build_element_css(doc, &styles, dev); + let mut element_css = build_element_css(doc, &styles, dev); + // htmlang's own elements get the reset's rules for elements (which + // also puts htmlang's layers in order) + if !styles.entries.is_empty() || !doc.scoped_css.is_empty() { + element_css.insert_str(0, &reset_css(dev, false, "")); + } if element_css.is_empty() { body @@ -874,20 +1062,20 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String { // Node generation // --------------------------------------------------------------------------- +/// Write `node`, which is inside an element with the layout `parent` +/// (`None` at the top of the page). fn generate_node( node: &Node, - parent_kind: Option<&ElementKind>, + parent: Option, out: &mut String, styles: &mut StyleCollector, ctx: &mut GenContext, ) { match node { - Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx), + Node::Element(elem) => generate_element(elem, parent, out, styles, ctx), Node::Text(segments) => { - let needs_wrap = matches!(parent_kind, Some(ElementKind::Row | ElementKind::El)) - || parent_kind - .and_then(ElementKind::spec) - .is_some_and(|spec| spec.wraps_text); + // In a row, column or grid each line of text is a child + let needs_wrap = parent.is_some_and(Layout::is_container); if needs_wrap { out.push_str(&ctx.indent()); out.push_str(""); @@ -906,23 +1094,109 @@ fn generate_node( } } -/// Emit the argument of an element whose argument is an HTML attribute -/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`). -fn emit_argument_attr(out: &mut String, elem: &Element) { - if let Some(TagSpec { - arg: TagArg::Attr(attr), - .. - }) = elem.kind.spec() - && let Some(value) = &elem.argument - { - out.push(' '); - out.push_str(attr); - out.push_str("=\""); - out.push_str(&html_escape(value)); - out.push('"'); +/// What goes between two things written one after the other in an element +/// with this layout (`None`: the top of the page): text flows, so its lines +/// and children are joined with a space; in HTML's own layout, and at the +/// top of the page, two lines of text are separated by a line break (a +/// space, except where whitespace is kept, as in `@pre`). +fn separator(layout: Option, previous_is_text: bool, next: &Node) -> Option { + match layout { + Some(Layout::Text) => Some(' '), + Some(Layout::Native) | None if previous_is_text && matches!(next, Node::Text(_)) => { + Some('\n') + } + _ => None, + } +} + +/// Write the children of an element (or of a `@fragment`, which has no +/// element of its own) laid out as `layout` (`None`: the top of the page). +/// The first line of an element's text is its first child. +fn generate_children( + children: &[Node], + layout: Option, + out: &mut String, + styles: &mut StyleCollector, + ctx: &mut GenContext, +) { + generate_run(children, layout, &mut None, out, styles, ctx); +} + +/// [`generate_children`], with `previous` saying whether what was written +/// last was a line of text (`None`: nothing yet). A `@fragment`'s children +/// are written as if they stood in its place, so its lines are separated +/// from the lines around it like any other lines. +fn generate_run( + children: &[Node], + layout: Option, + previous: &mut Option, + out: &mut String, + styles: &mut StyleCollector, + ctx: &mut GenContext, +) { + for child in children { + if let Node::Element(elem) = child + && elem.kind == ElementKind::Fragment + { + generate_run(&elem.children, layout, previous, out, styles, ctx); + continue; + } + let start = out.len(); + if let Some(previous_is_text) = *previous + && let Some(sep) = separator(layout, previous_is_text, child) + { + out.push(sep); + } + let body = out.len(); + generate_node(child, layout, out, styles, ctx); + // A child that writes nothing (an empty `@fragment`) takes no + // separator either, so text never gets two spaces in a row + if out.len() == body { + out.truncate(start); + continue; + } + *previous = Some(matches!(child, Node::Text(_))); } } +/// Emit the element's leading argument as its attribute (`@iframe URL` → +/// `src="URL"`, `@form /submit` → `action="/submit"`, `@link /a` → +/// `href="/a"`), first, before the class. `@image [inline] photo.png` puts +/// the file itself into the page. +fn emit_argument_attr(out: &mut String, elem: &Element, in_picture: bool) { + let (Some(attr), Some(value)) = (elem.kind.leading_attribute(in_picture), &elem.argument) + else { + return; + }; + // `@image [inline]`'s file is already a `data:` URI (see the parser) + let value = html_escape(value); + out.push(' '); + out.push_str(attr); + out.push_str("=\""); + out.push_str(&value); + out.push('"'); +} + +/// A file's bytes as a `data:` URI, its type from its extension. +pub(crate) fn data_uri(path: &str, data: &[u8]) -> String { + let mime = match path + .rsplit('.') + .next() + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("avif") => "image/avif", + Some("svg") => "image/svg+xml", + Some("ico") => "image/x-icon", + _ => "application/octet-stream", + }; + format!("data:{};base64,{}", mime, base64_encode(data)) +} + /// Emit HTML attributes: `key=value` ones (except `id` / `class`, which /// are emitted with the generated class) and bare booleans like `required`. fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) { @@ -932,9 +1206,8 @@ fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) { out.push(' '); out.push_str(key); out.push_str("=\""); - out.push_str(&html_escape(strip_string_quotes( - attr.value.as_deref().unwrap_or(""), - ))); + // Quoted text has already lost its quotes (see parser.rs) + out.push_str(&html_escape(attr.value.as_deref().unwrap_or(""))); out.push('"'); } else if !attr.html && attr.value.is_none() @@ -946,32 +1219,15 @@ fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) { } } -/// If the value is wrapped in a single pair of matching quotes (e.g. -/// `"Avatar"`), return the inner content. Quotes act as source-level -/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute -/// values. Multi-quoted values like `"h h" "s m"` are left unchanged — -/// those are real string tokens (used e.g. by CSS `grid-template-areas`). -fn strip_string_quotes(val: &str) -> &str { - let bytes = val.as_bytes(); - if bytes.len() < 2 { - return val; - } - let first = bytes[0]; - let last = bytes[bytes.len() - 1]; - if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { - let inner = &val[1..val.len() - 1]; - if !inner.as_bytes().contains(&first) { - return inner; - } - } - val -} - // True if any direct child is `@in-front` or `@behind`. Such children render // as absolutely positioned overlays, so the parent automatically becomes a // positioning context (position:relative + isolation:isolate). fn has_overlay_children(elem: &Element) -> bool { - elem.children.iter().any(|child| { + holds_overlays(&elem.children) +} + +fn holds_overlays(children: &[Node]) -> bool { + children.iter().any(|child| { matches!( child, Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind") @@ -981,48 +1237,32 @@ fn has_overlay_children(elem: &Element) -> bool { fn generate_element( elem: &Element, - parent_kind: Option<&ElementKind>, + parent: Option, out: &mut String, styles: &mut StyleCollector, ctx: &mut GenContext, ) { // Self-closing elements - if elem.kind == ElementKind::Image || elem.kind.spec().is_some_and(|spec| spec.void) { - generate_self_closing(elem, parent_kind, out, styles, ctx); + if elem.kind.layout() == Layout::Void { + generate_self_closing(elem, parent, out, styles, ctx); return; } - // @script renders as "); + out.push_str("'); out.push_str(ctx.nl()); return; } @@ -1047,13 +1289,13 @@ fn generate_element( return; } if elem.kind == ElementKind::Fragment { - // Render children without a wrapper element - for child in &elem.children { - generate_node(child, parent_kind, out, styles, ctx); - } + // Render children without a wrapper element, as if they were + // written where the fragment is + generate_children(&elem.children, parent, out, styles, ctx); return; } + let mut own = Flow::of(elem); let tag = match &elem.kind { ElementKind::Row | ElementKind::El => "div", ElementKind::Text => "span", @@ -1062,6 +1304,11 @@ fn generate_element( ElementKind::Tag(spec) => spec.html, _ => "", }; + // Inside text, htmlang's own `
` is a ``, which text can + // hold (directly in text, a row, column or grid is also laid out + // inline: see `Site::inline`) + let in_text = ctx.in_text || parent == Some(Layout::Text); + let tag = if in_text && tag == "div" { "span" } else { tag }; let kind_label = elem.kind.name(); // Track interactive elements for focus-visible CSS @@ -1072,14 +1319,8 @@ fn generate_element( } // Compute CSS for each state and get a class name - let overlay_children = has_overlay_children(elem); - let gen_class = compute_class( - &elem.attrs, - &elem.kind, - parent_kind, - styles, - overlay_children, - ); + let gen_class = element_class(elem, &Site::new(elem, parent, &ctx.flow), styles); + own.class = gen_class.clone(); let (id, user_class) = extract_id_class(&elem.attrs); if ctx.dev && elem.line_num > 0 { @@ -1093,15 +1334,7 @@ fn generate_element( out.push('<'); out.push_str(tag); - if elem.kind == ElementKind::Link - && let Some(url) = &elem.argument - { - out.push_str(" href=\""); - out.push_str(&html_escape(url)); - out.push('"'); - } - - emit_argument_attr(out, elem); + emit_argument_attr(out, elem, ctx.in_picture); emit_class_attr(out, gen_class.as_deref(), user_class.as_deref()); @@ -1122,28 +1355,27 @@ fn generate_element( } out.push('>'); + // In `
` and `
 
+

 
@@ -61,7 +64,11 @@ var ms=(performance.now()-start).toFixed(1); lastOutput=html; document.getElementById('preview').srcdoc=html; - statusEl.textContent='Compiled in '+ms+'ms'; + // Errors and warnings, so a warning is seen even when the page compiles + var problems=wasm_bindgen.diagnostics(editor.value); + document.getElementById('problems').textContent=problems; + var count=problems.split('\n').filter(function(l){return /^(error|warning|info|help)\[/.test(l);}).length; + statusEl.textContent='Compiled in '+ms+'ms'+(count?', '+count+(count==1?' problem':' problems'):''); } function copyOutput(){ if(lastOutput)navigator.clipboard.writeText(lastOutput).then(function(){statusEl.textContent='Copied to clipboard!'}); diff --git a/crates/htmlang-wasm/src/lib.rs b/crates/htmlang-wasm/src/lib.rs index b8bc65a..d0ecd67 100644 --- a/crates/htmlang-wasm/src/lib.rs +++ b/crates/htmlang-wasm/src/lib.rs @@ -1,30 +1,91 @@ +use htmlang_core::parser::{Diagnostic, Severity}; use wasm_bindgen::prelude::*; +/// One diagnostic on one line: `warning[unknown-attribute] line 3:5: ...`. +fn format_diagnostic(d: &Diagnostic) -> String { + let severity = match d.severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + Severity::Help => "help", + }; + let at = match d.column { + Some(column) => format!("{}:{}", d.line, column + 1), + None => d.line.to_string(), + }; + format!("{}[{}] line {}: {}", severity, d.code, at, d.message) +} + +/// Every diagnostic, errors first, one per line. +fn report(diagnostics: &[Diagnostic]) -> Vec { + let errors = diagnostics.iter().filter(|d| d.severity == Severity::Error); + let others = diagnostics.iter().filter(|d| d.severity != Severity::Error); + errors.chain(others).map(format_diagnostic).collect() +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +/// The page for `source`, or, when it has errors, a page that lists them +/// (and the warnings after them). #[wasm_bindgen] pub fn compile(source: &str) -> String { let result = htmlang_core::parser::parse(source); - - let errors: Vec = result + if result .diagnostics .iter() - .filter(|d| d.severity == htmlang_core::parser::Severity::Error) - .map(|d| { - let loc = if let Some(col) = d.column { - format!("{}:{}", d.line, col + 1) - } else { - d.line.to_string() - }; - format!("Line {}: {}", loc, d.message) - }) - .collect(); - - if !errors.is_empty() { + .any(|d| d.severity == Severity::Error) + { return format!( "\

Compilation Errors

{}
", - errors.join("\n") + html_escape(&report(&result.diagnostics).join("\n")) ); } - htmlang_core::codegen::generate(&result.document) } + +/// Everything the compiler reports about `source`, errors first, one +/// diagnostic per line (empty when there is nothing to report). The +/// playground shows it next to the page, so a warning is seen even when the +/// page compiles. +#[wasm_bindgen] +pub fn diagnostics(source: &str) -> String { + report(&htmlang_core::parser::parse(source).diagnostics).join("\n") +} + +#[cfg(test)] +mod tests { + #[test] + fn warnings_are_reported_with_errors() { + let report = super::diagnostics("@el [text-grow per-line, paddin 4]\n $nope"); + let lines: Vec<&str> = report.lines().collect(); + assert!( + lines[0].starts_with("error[undefined-variable] line 2:3"), + "{}", + report + ); + assert!( + lines[1].starts_with("warning[unknown-attribute] line 1:6"), + "{}", + report + ); + let page = super::compile("@el [text-grow per-line, paddin 4]\n $nope"); + assert!( + page.contains("Compilation Errors") && page.contains("warning["), + "{}", + page + ); + } + + #[test] + fn a_page_with_warnings_still_compiles() { + let page = super::compile("@el [text-grow per-line] Hi"); + assert!(page.contains("text-grow:per-line"), "{}", page); + assert!(super::diagnostics("@el [text-grow per-line] Hi").starts_with("warning[")); + assert_eq!(super::diagnostics("@el Hi"), ""); + } +} diff --git a/editors/vscode/README.md b/editors/vscode/README.md index bd0a1fb..2f57ede 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -6,25 +6,74 @@ Syntax highlighting, snippets, and language server support for ## Features - **Syntax highlighting**: `@` elements and directives, `$variables`, - `[attribute]` lists with state and media prefixes, `key=value` HTML - attributes, `{@inline}` elements, colors, numbers and `-- comments`. -- **Diagnostics**: parse errors, unknown elements and attributes with "did - you mean" suggestions, undefined and unused definitions, and accessibility - warnings. -- **Completion** of elements, directives, layout attributes, CSS properties, - HTML attributes, prefixes, variables and functions. It triggers on `@`, - `$`, `[` and `,`. -- **Hover** documentation for elements, directives and attributes (CSS - properties link to MDN), function signatures, variable values, and color - swatches. -- **Navigation**: go to definition, find references and rename for - `$variables`, bundles and `@let` functions. `@include` paths are links. -- **Code actions**: fixes for common diagnostics, removing unused definitions, - and extracting a selection into a `@let` function or attribute bundle. + `[attribute]` lists (`@page`'s included) with state and media prefixes + (stacked, `md:hover:`, and on a group or bundle, `md:[...]`, `dark:$card`) + and element prefixes (`@td:`, `@link:hover:`), + `if(...)` and its `[groups]`, `key=value` HTML attributes (`xml:lang=` too), the leading argument of + `@link`, `@image`, `@script` and the other elements that take one, quoted text, escapes (`\,`, `\$`, `\]`), `{@inline}` elements + (shown as text in `@code` and `@textarea`), the one-line body of `@raw`, + `@style` and `@head` as raw text, + `@each $item in` headers and ranges (`1..5`), custom and vendor-prefixed + property names (`--gap`, `md:--gap`, `-webkit-text-stroke`), colors, + numbers and `-- comments` (`--` followed by a space, also between the lines + of an attribute list). +- **Diagnostics**: the compiler's own, each with its code: parse errors, + unknown elements, prefixes and CSS properties with "did you mean" + suggestions, anything that would be left out of the page, undefined and + unused definitions, and accessibility warnings. The whole file is + checked, including branches and functions that don't run, and relative + `@include` and `@data` paths resolve from the file's folder. +- **Completion** of elements, directives, layout attributes (`spacing`, + `wrap` and `grid-cols` only on a row, column or grid), CSS properties, + HTML attributes (not the one an element's leading argument already + gives, as `href=` in `@link [] /about About`; on `@page`, `lang=`, + `dir=`, `class=` and `favicon` first), the custom properties the file + names (`--surface`, and `dark:--surface` after a prefix, and inside + `var(`, where a token is read), colour variants after a colour property + (`color-mix()`, `rgb(from)`), prefixes (the CSS + pseudo-classes and pseudo-elements htmlang knows, one with an argument placing the + cursor between its parentheses, as `nth-child(|):`; and + after one, those that can follow it, as `md:hover:`; after a + pseudo-element such as `before:`, only media, width and container + prefixes; after `@`, the element prefixes, `@td:`, of the elements with + an HTML tag of their own, and after one, CSS properties only), bundles + after prefixes (`md:$card`), only styles inside a prefixed group + (`md:[...]`), `if()` (and attributes inside its branches), + the variables visible where you type (definitions above in the block and + the blocks around it, `@each` variables and a function's parameters), + functions and the parameters a function call hasn't passed yet, the slot names of a call's `@slot` + blocks, and elements and functions inside `{@...}` in text. Only what + the element takes is offered: `inline` on `@image`, nothing on + `@fragment`, HTML attributes on `@script`; and nothing for the new name + after `@let`. It triggers on `@`, `$`, `[` and `,`. +- **Hover** documentation for elements (with their layout: column, row, + grid, text, native or void), directives, attributes (CSS + properties link to MDN), prefixes and stacks of them (`md:hover:`, `@td:`) and `if()`, function signatures with their slots and + whether they take content, a parameter's name where it is declared or + passed (required, or its default), variable values, and color swatches. +- **Navigation**: go to definition (the definition a name means at that + line), find references and rename for + `$variables`, bundles, `@let @name` functions and custom properties + (`@let --brand`, `var(--brand)`). Renaming a parameter renames it at + every call too (`@card [title Hi]`). `@include`, `@markdown` and + `@data` file paths are links. +- **Code actions**: fixes keyed on diagnostic codes (replace a misspelled + name, prefix (`@tdd:` as `@td:`, `@a:` as `@link:`) or slot name, put a pseudo-element's prefix last + (`before:hover:` as `hover:before:`), replace `@a` with `@link`, write a slot name as one word, add a missing `alt=` or `type=text`, write a + style given as an HTML attribute (`padding=4`) as `padding 4`, use a + bundle named without its `$` as `$card`, include the file that defines an + unknown function, write a quoted font stack as `A\, B`, write `$--x` as + `var(--x)`, drop the `$` from + `@let $x` and add it to `@each x`, pass a parameter as `name value` + instead of `name=value`), removing unused + definitions, and extracting a selection + into a `@let` function or attribute bundle, placed above the top-level + line it comes from so it sees the same definitions (a loop variable or + parameter the lines read becomes a parameter of the function). - **Outline and symbols**: the document outline, and `Ctrl-T` workspace search across every `.hl` file. - **Formatting**: `Format Document` and `Format Selection` use the same - formatter as `htmlang fmt`. + formatter as `htmlang fmt`; a formatted selection keeps its nesting. - **Also**: reference counts as code lenses on each `@let`, variable values as inlay hints, a color picker, folding, semantic tokens, signature help for function calls, and linked editing of a variable's uses. @@ -44,8 +93,10 @@ to its absolute path. `htmlang.server.args` passes extra arguments to it. ## Snippets Snippets cover common patterns: `@page`, `@let-fn`, `@let-slots`, -`@let-tokens`, `@navbar`, `@hero`, `@form`, `@grid`, `@each`, `@if`, -`@layout` and more. Type a prefix and press `Tab` to expand it. +`@let-tokens`, `@navbar`, `@hero`, `@form`, `@grid`, `@table` (its cells +padded by `@td:`), `@markdown-styled`, `@each`, `@if`, +`if(` (attributes chosen by a condition), `@code-sample` (`@pre > @code` +over a block shown as written), `@layout` and more. Type a prefix and press `Tab` to expand it. ## Development diff --git a/editors/vscode/snippets/htmlang.json b/editors/vscode/snippets/htmlang.json index 0f84ddd..d115f5b 100644 --- a/editors/vscode/snippets/htmlang.json +++ b/editors/vscode/snippets/htmlang.json @@ -2,10 +2,10 @@ "Page": { "prefix": "@page", "body": [ - "@page [lang ${1:en}] ${2:My Page}", + "@page [lang=${1:en}] ${2:My Page}", "@let --${3:brand} ${4:#3b82f6}", "", - "@el [max-width ${5:800}, center-x, padding ${6:40}, spacing ${7:20}]", + "@el [width fill, max-width ${5:800}, center-x, padding ${6:40}, spacing ${7:20}]", " $0" ], "description": "A page with a title, tokens and a centered column" @@ -13,11 +13,10 @@ "Page metadata": { "prefix": "@page-meta", "body": [ - "@page [lang ${1:en}, favicon ${2:favicon.png}] ${3:My Page}", + "@page [lang=${1:en}, favicon ${2:favicon.png}] ${3:My Page}", "@meta description ${4:Page description}", "@meta og:title $3", - "@head", - " ", + "@head ", "$0" ], "description": "A page with a description, Open Graph tags and a canonical link" @@ -30,7 +29,17 @@ "@let --${5:muted} ${6:#64748b}", "$0" ], - "description": "CSS custom properties: var(--name) at run time, \\$--name at compile time" + "description": "CSS custom properties on :root, values as written (a length takes its unit): read as var(--name), and colour variants as color-mix(in srgb, var(--name), black 10%); [--name value] sets one on an element and everything inside it" + }, + "Dark mode tokens": { + "prefix": "@page-theme", + "body": [ + "@let --${1:ink} ${2:#0f172a}", + "@let --${3:surface} ${4:white}", + "@page [color var(--$1), background var(--$3), dark:[--$1 ${5:#e2e8f0}, --$3 ${6:#0b1220}]] ${7:My Page}", + "$0" + ], + "description": "Colour tokens on :root, redefined once for dark mode on the page: everything that uses them follows" }, "Attribute bundle": { "prefix": "@let-bundle", @@ -43,7 +52,7 @@ "Function": { "prefix": "@let-fn", "body": [ - "@let ${1:card} \\$${2:title}", + "@let @${1:card} [${2:title}]", " @el [${3:padding 20, spacing 8, border 1 solid #e5e7eb, border-radius 8}]", " @text [font-weight bold] \\$$2", " @children", @@ -51,12 +60,12 @@ "@$1 [$2 ${4:Hello}]", " $0" ], - "description": "A function with a parameter and @children" + "description": "A function with a required parameter and @children" }, "Function with slots": { "prefix": "@let-slots", "body": [ - "@let ${1:panel} \\$${2:title}", + "@let @${1:panel} [${2:title}]", " @article [${3:padding 20, spacing 12}]", " @row [align-items center]", " @h2 \\$$2", @@ -76,7 +85,7 @@ "Scoped style": { "prefix": "@let-style", "body": [ - "@let ${1:note}", + "@let @${1:note}", " @style", " & { ${2:border-left: 4px solid #3b82f6;} }", " @el [${3:padding 12 16}]", @@ -129,7 +138,7 @@ "Grid with named areas": { "prefix": "@grid-areas", "body": [ - "@grid [grid-template-columns ${1:240px 1fr}, grid-template-areas \"${2:header header}\" \"${3:sidebar main}\", spacing ${4:20}]", + "@grid [grid-template-columns ${1:240 1fr}, grid-template-areas \"${2:header header}\" \"${3:sidebar main}\", spacing ${4:20}]", " @header [grid-area header]", " ${5:Header}", " @aside [grid-area sidebar]", @@ -146,14 +155,12 @@ " {\"label\": \"${2:Home}\", \"url\": \"/\"},", " {\"label\": \"${3:About}\", \"url\": \"/about\"}", "]", - "@header [padding ${4:12 24}, border-bottom 1 solid #e5e7eb]", - " @row [spacing ${5:20}, align-items center]", - " @text [font-weight bold] ${6:Logo}", - " @spacer", - " @nav", - " @row [spacing $5]", - " @each \\$link in \\$$1", - " @link [hover:text-decoration underline] \\$link.url \\$link.label", + "@header [flex-direction row, spacing ${5:20}, align-items center, padding ${4:12 24}, border-bottom 1 solid #e5e7eb]", + " @text [font-weight bold] ${6:Logo}", + " @spacer", + " @nav [flex-direction row, spacing $5]", + " @each \\$link in \\$$1", + " @link [hover:text-decoration underline] \\$link.url \\$link.label", "$0" ], "description": "A header with a logo, a spacer and links from data" @@ -211,6 +218,14 @@ ], "description": "Loop over a list, with a fallback for an empty one" }, + "Each in a range": { + "prefix": "@each range", + "body": [ + "@each \\$${1:i} in ${2:1}..${3:5}", + " $0" + ], + "description": "Loop over the numbers from a to b (add `step n` to skip)" + }, "If": { "prefix": "@if", "body": [ @@ -221,13 +236,27 @@ ], "description": "A conditional with an else branch" }, + "Conditional attributes": { + "prefix": "if(", + "body": [ + "if(${1:\\$condition}, [${2:font-weight 600}]${3:, [${4:font-weight 400}]})" + ], + "description": "Attributes chosen by a condition: each branch is an attribute, a [group] or a \\$bundle" + }, "Dark mode": { "prefix": "@dark", "body": [ - "@el [background ${1:white}, color ${2:#111827}, dark:background ${3:#111827}, dark:color ${4:#f9fafb}]", + "@el [background ${1:white}, color ${2:#111827}, dark:[background ${3:#111827}, color ${4:#f9fafb}]]", " $0" ], - "description": "Light and dark colors on one element" + "description": "Light and dark colors on one element: a prefix applies to a [group] of styles" + }, + "Responsive group": { + "prefix": "md:[", + "body": [ + "${1:md}:[${2:padding 32}, ${3:font-size 20}]" + ], + "description": "Prefixes applied to a group of styles (or to a bundle: md:\\$card); prefixes stack, as in md:hover:color" }, "Keyframes": { "prefix": "@keyframes", @@ -246,8 +275,7 @@ "Font face": { "prefix": "@font-face", "body": [ - "@style", - " @font-face { font-family: '${1:Inter}'; src: url('${2:fonts/inter.woff2}') format('woff2'); font-display: swap; }", + "@style @font-face { font-family: '${1:Inter}'; src: url('${2:fonts/inter.woff2}') format('woff2'); font-display: swap; }", "$0" ], "description": "A custom font in @style" @@ -279,12 +307,53 @@ ], "description": "An inline script" }, + "Table": { + "prefix": "@table", + "body": [ + "@table [border-collapse collapse, @th:padding ${4:8}, @td:padding ${4:8}]", + " @caption ${1:Title}", + " @thead", + " @tr", + " @th ${2:Name}", + " @tbody", + " @tr", + " @td ${3:Value}", + "$0" + ], + "description": "A table with a caption, a header row and a body, its cells padded by the table" + }, + "Styled Markdown": { + "prefix": "@markdown-styled", + "body": [ + "@article [@h2:font-size ${1:20}, @code:background ${2:#f1f5f9}, @link:color ${3:#2563eb}]", + " @markdown", + " $0" + ], + "description": "Markdown whose headings, code and links are styled by the element around it" + }, + "List with markers": { + "prefix": "@ul-markers", + "body": [ + "@ul [list-style ${1:disc}, padding-inline-start 20, children:display list-item, children:marker:color ${2:currentColor}]", + " @li ${3:Item}", + "$0" + ], + "description": "A list that shows its markers, with ::marker styled" + }, "External script": { "prefix": "@script-src", "body": [ - "@script [src=${1:app.js}, ${2:defer}]", + "@script [${2:defer}] ${1:app.js}", "$0" ], "description": "A script loaded from a file" + }, + "Code sample": { + "prefix": "@code-sample", + "body": [ + "@pre [padding ${1:16}, background ${2:#f8fafc}, overflow-x auto] > @code", + " $0" + ], + "description": "A code sample: the lines under @code are shown as written, HTML-escaped" } } diff --git a/editors/vscode/syntaxes/htmlang.tmLanguage.json b/editors/vscode/syntaxes/htmlang.tmLanguage.json index d23bcf8..b4f3018 100644 --- a/editors/vscode/syntaxes/htmlang.tmLanguage.json +++ b/editors/vscode/syntaxes/htmlang.tmLanguage.json @@ -15,18 +15,42 @@ { "include": "#include-directive" }, + { + "include": "#each-directive" + }, + { + "include": "#verbatim-line" + }, + { + "include": "#directive" + }, + { + "include": "#literal-line" + }, + { + "include": "#leading-argument" + }, { "include": "#element" }, { "include": "#function-call" }, + { + "include": "#escape" + }, { "include": "#variable" }, + { + "include": "#range" + }, { "include": "#attributes" }, + { + "include": "#literal-inline" + }, { "include": "#inline-element" }, @@ -39,27 +63,42 @@ ], "repository": { "comment": { + "comment": "A line whose first word is --: -- followed by a space or the end of the line (--gap 12px is a custom property)", "name": "comment.line.double-dash.htmlang", - "match": "^\\s*--.*$" + "match": "^\\s*--(?:\\s.*)?$" }, "page-directive": { - "match": "(@page)\\s+(.*)", - "captures": { + "comment": "@page [attributes] Title: the page is the root element, so its attributes are an ordinary list", + "begin": "(@page)(?![a-zA-Z0-9_-])", + "end": "$", + "beginCaptures": { "1": { "name": "keyword.control.directive.htmlang" + } + }, + "contentName": "string.unquoted.page-title.htmlang", + "patterns": [ + { + "include": "#attributes" }, - "2": { - "name": "string.unquoted.page-title.htmlang" + { + "include": "#escape" + }, + { + "include": "#variable" } - } + ] }, "let-definition": { - "match": "(@let)\\s+([a-zA-Z_][a-zA-Z0-9_-]*)", + "match": "(@let)\\s+(?:(@[a-zA-Z][a-zA-Z0-9_-]*)|(--[a-zA-Z][a-zA-Z0-9_-]*|[a-zA-Z_][a-zA-Z0-9_.-]*))", "captures": { "1": { "name": "keyword.control.directive.htmlang" }, "2": { + "name": "entity.name.function.htmlang" + }, + "3": { "name": "variable.other.htmlang" } } @@ -75,17 +114,106 @@ } } }, + "each-directive": { + "match": "^\\s*(@each)\\s+(\\$[a-zA-Z_][a-zA-Z0-9_-]*)(?:\\s*,\\s*(\\$[a-zA-Z_][a-zA-Z0-9_-]*))?\\s+(in)(?![a-zA-Z0-9_-])", + "captures": { + "1": { + "name": "keyword.control.directive.htmlang" + }, + "2": { + "name": "variable.other.htmlang" + }, + "3": { + "name": "variable.other.htmlang" + }, + "4": { + "name": "keyword.control.in.htmlang" + } + } + }, + "verbatim-line": { + "comment": "@raw, @style and @head: the rest of the line is a one-line body, kept as written (no variables, escapes or inline elements)", + "begin": "^\\s*(@(?:raw|style|head))(?![a-zA-Z0-9_-])", + "end": "$", + "beginCaptures": { + "1": { + "name": "keyword.control.directive.htmlang" + } + }, + "contentName": "markup.raw.htmlang" + }, + "directive": { + "name": "keyword.control.directive.htmlang", + "match": "@(?:page|let|include|data|meta|if|else|each|style|head|raw|markdown)(?![a-zA-Z0-9_-])" + }, + "literal-line": { + "comment": "@code and @textarea show their text as written: a {@...} in it is text", + "begin": "(?:^\\s*|\\s>\\s*)(@(?:code|textarea))(?![a-zA-Z0-9_-])", + "end": "$", + "beginCaptures": { + "1": { + "name": "entity.name.tag.htmlang" + } + }, + "contentName": "markup.inline.raw.htmlang", + "patterns": [ + { + "include": "#attributes" + }, + { + "include": "#escape" + }, + { + "include": "#variable" + } + ] + }, + "leading-argument": { + "comment": "The first word after the attributes of an element with a leading argument (@link's href, @image's src, ...): one token, which \"...\" and ${...} keep whole", + "match": "(?:^\\s*|\\s>\\s*)(@(?:link|image|script|form|video|audio|iframe|source|track|embed|object|area|optgroup))(?![a-zA-Z0-9_-])(\\s*\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\])?\\s+(?![\\[>])((?:\"(?:[^\"\\\\]|\\\\.)*\"|\\$\\{[^}]*\\}|\\\\.|[^\\s\"$\\\\]|\\$(?!\\{))+)", + "captures": { + "1": { + "name": "entity.name.tag.htmlang" + }, + "2": { + "patterns": [ + { + "include": "#attributes" + } + ] + }, + "3": { + "name": "markup.underline.link.htmlang", + "patterns": [ + { + "include": "#escape" + }, + { + "include": "#variable" + } + ] + } + } + }, "element": { "name": "entity.name.tag.htmlang", - "match": "@(?:row|el|text|paragraph|image|link|raw|children|slot|fragment|script|nav|header|footer|main|section|article|aside|address|search|form|details|summary|dialog|figure|figcaption|blockquote|cite|fieldset|legend|noscript|datalist|ul|ol|li|dl|dt|dd|table|thead|tbody|tr|td|th|select|picture|progress|meter|output|canvas|button|label|option|textarea|time|mark|abbr|code|kbd|pre|h1|h2|h3|h4|h5|h6|input|hr|source|video|audio|iframe|grid|in-front|behind)(?![a-zA-Z0-9_-])" + "match": "@(?:row|el|text|paragraph|image|link|children|slot|fragment|script|nav|header|footer|main|section|article|aside|address|search|form|details|summary|dialog|figure|figcaption|blockquote|cite|fieldset|legend|noscript|datalist|ul|ol|li|dl|dt|dd|table|thead|tbody|tr|td|th|select|picture|progress|meter|output|canvas|button|label|option|textarea|time|mark|abbr|code|kbd|pre|h1|h2|h3|h4|h5|h6|input|hr|source|video|audio|iframe|hgroup|menu|b|i|strong|em|small|s|u|sub|sup|q|var|samp|dfn|bdi|bdo|ins|del|br|wbr|caption|colgroup|col|tfoot|optgroup|ruby|rt|rp|track|embed|object|map|area|grid|in-front|behind)(?![a-zA-Z0-9_-])" }, "function-call": { "name": "entity.name.function.htmlang", "match": "@[a-zA-Z_][a-zA-Z0-9_-]*" }, + "escape": { + "name": "constant.character.escape.htmlang", + "match": "\\\\(?:\\\\|\\$|@|\\{|\\}|\\[|\\]|,|\"|--)" + }, "variable": { "name": "variable.other.htmlang", - "match": "\\$[a-zA-Z_][a-zA-Z0-9_-]*" + "match": "\\$[a-zA-Z_][a-zA-Z0-9_]*(?:-+[a-zA-Z0-9_]+)*|\\$\\{[^{}]*\\}" + }, + "range": { + "name": "keyword.operator.range.htmlang", + "match": "(?<=[0-9a-zA-Z_})])\\.\\.(?=[-$0-9(])" }, "attributes": { "name": "meta.attribute-list.htmlang", @@ -102,6 +230,21 @@ } }, "patterns": [ + { + "include": "#comment" + }, + { + "include": "#conditional" + }, + { + "include": "#attributes" + }, + { + "include": "#escape" + }, + { + "include": "#string" + }, { "include": "#variable" }, @@ -111,9 +254,15 @@ { "include": "#number" }, + { + "include": "#element-prefix" + }, { "include": "#state-prefix" }, + { + "include": "#custom-property" + }, { "include": "#html-attribute" }, @@ -126,14 +275,92 @@ } ] }, + "string": { + "name": "string.quoted.double.htmlang", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "include": "#escape" + }, + { + "include": "#variable" + } + ] + }, + "element-prefix": { + "name": "keyword.other.state.htmlang", + "comment": "An element prefix: every such element inside (@td:, @link:). The names are the elements with an HTML tag of their own", + "match": "@(?:paragraph|image|link|nav|header|footer|main|section|article|aside|address|search|form|details|summary|dialog|figure|figcaption|blockquote|cite|fieldset|legend|noscript|datalist|ul|ol|li|dl|dt|dd|table|thead|tbody|tr|td|th|select|picture|progress|meter|output|canvas|button|label|option|textarea|time|mark|abbr|code|kbd|pre|h1|h2|h3|h4|h5|h6|input|hr|source|video|audio|iframe|hgroup|menu|b|i|strong|em|small|s|u|sub|sup|q|var|samp|dfn|bdi|bdo|ins|del|br|wbr|caption|colgroup|col|tfoot|optgroup|ruby|rt|rp|track|embed|object|map|area):(?=[a-z\\[$-])" + }, "state-prefix": { "name": "keyword.other.state.htmlang", - "match": "(?:[a-z0-9-]+|nth:[^:\\s,\\]]+|has\\([^)]*\\)):(?=[a-z])" + "comment": "A prefix: a word (hover:, md:, children:) or a CSS pseudo-class with its argument (nth-child(odd):, has(> img):, not(:nth-child(2)):)", + "match": "(?:[a-z0-9-]+(?:\\((?:[^()]|\\([^()]*\\))*\\))?):(?=[a-z\\[$@-])(?![a-zA-Z0-9_.:-]*=)" + }, + "custom-property": { + "comment": "A custom property's or a vendor-prefixed property's name where an attribute starts: --gap, md:--gap, -webkit-text-stroke", + "match": "(?:(?<=[\\[,:])|^)(\\s*)(--[A-Za-z0-9_][A-Za-z0-9_-]*|-[a-z]+-[a-z][a-z0-9-]*)(?=[\\s,\\]]|$)", + "captures": { + "2": { + "name": "support.type.property-name.htmlang" + } + } }, "attribute-name": { "name": "support.type.property-name.htmlang", "match": "\\b(?:spacing|center-x|center-y|align-left|align-right|align-top|align-bottom|wrap|grid-cols|grid-rows|col-span|row-span|inline|padding|margin|width|height|min-width|max-width|min-height|max-height|background|color|border|border-radius|font-size|font-weight|font-family|line-height|text-align|display|gap|flex|position|overflow|opacity|cursor|transition|transform|box-shadow|z-index)\\b" }, + "literal-inline": { + "comment": "{@code ...} and {@textarea ...}: their text is shown as written, so a {@...} in it is text", + "name": "meta.inline-element.htmlang", + "begin": "(\\{)(@(?:code|textarea))(?![a-zA-Z0-9_-])", + "end": "(\\})", + "beginCaptures": { + "1": { + "name": "punctuation.section.braces.begin.htmlang" + }, + "2": { + "name": "entity.name.tag.htmlang" + } + }, + "endCaptures": { + "1": { + "name": "punctuation.section.braces.end.htmlang" + } + }, + "contentName": "markup.inline.raw.htmlang", + "patterns": [ + { + "include": "#escape" + }, + { + "include": "#attributes" + }, + { + "include": "#variable" + }, + { + "include": "#literal-braces" + } + ] + }, + "literal-braces": { + "comment": "Braces inside literal text, kept as written and balanced", + "begin": "\\{", + "end": "\\}", + "patterns": [ + { + "include": "#escape" + }, + { + "include": "#variable" + }, + { + "include": "#literal-braces" + } + ] + }, "inline-element": { "name": "meta.inline-element.htmlang", "begin": "(\\{)(?=@)", @@ -149,6 +376,15 @@ } }, "patterns": [ + { + "include": "#escape" + }, + { + "include": "#literal-inline" + }, + { + "include": "#inline-element" + }, { "include": "#element" }, @@ -173,7 +409,16 @@ }, "html-attribute": { "name": "entity.other.attribute-name.htmlang", - "match": "\\b[a-zA-Z][a-zA-Z0-9_.-]*(?==)" + "match": "\\b[a-zA-Z][a-zA-Z0-9_.:-]*(?==)" + }, + "conditional": { + "comment": "A whole attribute if(CONDITION, A, B); an if( inside a value is CSS's own", + "match": "(?:(?<=\\[)|(?<=,)|(?<=:)|^)(\\s*)(if)(?=\\()", + "captures": { + "2": { + "name": "keyword.control.conditional.htmlang" + } + } } } } diff --git a/examples/blog.hl b/examples/blog.hl index 57a0099..573157f 100644 --- a/examples/blog.hl +++ b/examples/blog.hl @@ -1,6 +1,6 @@ -- A blog index: a layout function with named slots, posts loaded from JSON, -- and loops and conditions over records. -@page [lang en] Notes on the Web +@page [lang=en] Notes on the Web @meta description Short essays on static HTML, layout and tooling. @let --ink #1f2937 @@ -13,8 +13,8 @@ -- A layout is an ordinary function. @slot blocks are filled by name; the -- rest of the caller's content replaces @children. -@let site - @el [max-width 720, center-x, padding 48 24, spacing 40, color var(--ink), line-height 1.6] +@let @site + @el [width fill, max-width 720, center-x, padding 48 24, spacing 40, color var(--ink), line-height 1.6] @header [spacing 4] @link [font-weight 800, font-size 28, letter-spacing -0.5] / Notes on the Web @slot tagline @@ -26,7 +26,7 @@ Built with htmlang. -- A pill for each tag -@let tag $name +@let @tag [name] @link [ padding 2 10, border-radius 999, @@ -38,13 +38,11 @@ #$name -- A post summary. `featured` is optional: a parameter with a default -@let post-card $post $featured=false +@let @post-card [post, featured false] @article [ spacing 8, - padding if($featured, 24, 0), border-radius 12, - if($featured, background #f8fafc), - if($featured, border 1 solid var(--line)) + if($featured, [padding 24, background #f8fafc, border 1 solid var(--line)]) ] @if $featured @text [font-size 12, font-weight 700, text-transform uppercase, letter-spacing 1, color var(--accent)] Featured @@ -58,9 +56,9 @@ @paragraph [color var(--muted), line-clamp 2] $post.summary -- Multi-line content: a function whose body is text -@let about +@let @about I write about building for the web with as little machinery as possible: - plain HTML, a little CSS, and tools that {@text [font-style italic] stay out of the way}. + plain HTML, a little CSS, and tools that {@em stay out of the way}. @site @slot tagline @@ -69,7 +67,7 @@ @section [spacing 32] @each $post in $posts - @post-card [post $post, featured ${default($post.featured, false)}] + @post-card [post $post, featured $post.featured] @else @text [color var(--muted)] Nothing here yet. diff --git a/examples/demo.hl b/examples/demo.hl index 6d6e175..be1a1ee 100644 --- a/examples/demo.hl +++ b/examples/demo.hl @@ -1,6 +1,21 @@ -- A one-page tour of htmlang. Each section shows one part of the language: -- layout, text, CSS styles, functions, expressions, control flow and HTML. -@page [lang en] htmlang tour +@page [ + lang=en, + color var(--ink), + background var(--page), + line-height 1.5, + -- A custom property is a style like any other: dark mode redefines the + -- tokens here, once, and everything that uses them follows + dark:[ + --ink #e5e7eb, + --muted #94a3b8, + --line #1f2937, + --subtle #1e293b, + --page #030712, + --surface #111827 + ] +] htmlang tour @meta description A one-page tour of htmlang. @meta og:title htmlang tour @@ -10,6 +25,8 @@ @let --muted #64748b @let --line #e5e7eb @let --subtle #f8fafc +@let --page white +@let --surface white -- Computed values: `=` makes the rest an expression @let unit 8 @@ -19,16 +36,15 @@ -- Attribute bundles @let card [ padding $gap, - background white, + color var(--ink), + background var(--surface), border 1 solid var(--line), - border-radius $unit, - dark:background #111827, - dark:border-color #1f2937 + border-radius $unit ] @let label [font-size 12, font-weight 700, color var(--muted), text-transform uppercase, letter-spacing 1] -- A section of the tour: a function with a parameter, a default, and children -@let chapter $title $number= +@let @chapter [title, number ""] @section [spacing $gap, padding-block $gutter, border-top 1 solid var(--line)] @row [spacing $unit, align-items baseline] @if $number @@ -43,15 +59,7 @@ to { opacity: 1; transform: none; } } -@el [ - max-width 960, - center-x, - padding $gutter, - color var(--ink), - line-height 1.5, - dark:background #030712, - dark:color #e5e7eb -] +@el [width fill, max-width 960, center-x, padding $gutter] @header [spacing $unit, padding-block $gutter, animation rise 0.4s ease] @text [$label] A one-page tour @h1 [font-size 44, line-height 1.1, letter-spacing -1] Welcome to htmlang @@ -65,6 +73,9 @@ @paragraph {@code @el} stacks its children in a column and {@code @row} lays them out side by side. {@code spacing} is the gap between children. + {@code flex-direction row} makes any column a row, also from a width + up ({@code md:flex-direction row}), and the children's + {@code width fill} follows it. @row [spacing $unit, wrap] @el [$card, width fill] width fill @el [$card, width fill] shares the row @@ -73,6 +84,9 @@ @text [font-weight 700] Logo @spacer @text @spacer pushes this to the end + @el [spacing $unit, md:flex-direction row] + @el [$card, width fill] Stacked on a phone + @el [$card, width fill] side by side from md up @row [spacing $unit, height 120] @el [$card, align-top] align-top @el [$card, center-y] center-y @@ -82,7 +96,7 @@ @el [$card, text-align center, if($n == 1, col-span 2)] Cell $n @el [$card, height 120, overflow hidden] @behind - @el [background linear-gradient(135deg, #dbeafe, #fae8ff), height fill, justify-content flex-end, padding $unit] (behind) + @el [background linear-gradient(135deg, #dbeafe, #fae8ff), dark:background linear-gradient(135deg, #1e3a8a, #581c87), height fill, justify-content flex-end, padding $unit] (behind) @text [font-weight 700] @in-front and @behind layer content @in-front @text [align-right, align-bottom, padding $unit, font-size 12, color var(--muted)] painted on top @@ -91,7 +105,7 @@ @chapter [title Text, number 2] @paragraph [max-width 640] Lines without an {@code @} are text. Braces put elements inside a line: - {@text [font-weight 700] bold}, {@text [font-style italic] italic}, + {@b bold}, {@i italic}, {@strong important}, H{@sub 2}O, {@mark highlighted}, {@link [color var(--brand), text-decoration underline] https://example.com a link}, and {@kbd [padding 1 6, border 1 solid var(--line), border-radius 4] Ctrl+K}. @paragraph @@ -107,27 +121,40 @@ @chapter [title Style, number 3] @paragraph Besides the layout attributes, every attribute is a CSS property with its - CSS name and value. A bare number is pixels for lengths. + CSS name and value. A bare number in a length, shadows included, is pixels, and \\, keeps a comma in a value. + A custom property (--name) is one too: set on an element, it holds for everything inside it. + Prefixes stack, and one prefix before a [group] applies to each style in it. + A selector prefix is a CSS pseudo-class or pseudo-element under its own name: hover:, first-child:, nth-child(odd):, has(...):, marker:. + An element prefix styles every such element inside, at any depth: the table in chapter 6 pads all its cells with {@code @td:padding 8}. @row [spacing $unit, wrap] - @el [$card, box-shadow 0 1px 3px rgba(0,0,0,0.1), transform rotate(-2deg)] Any CSS property + @el [$card, box-shadow 0 1 3 rgba(0,0,0,0.1)\, 0 8 24 rgba(0,0,0,0.08), transform rotate(-2deg)] Any CSS property @el [$card, hover:background #eff6ff, hover:border-color var(--brand), transition all 0.15s ease, cursor pointer] hover: - @el [$card, md:background #ecfdf5, dark:background #064e3b] md: and dark: + @el [$card, md:[background #ecfdf5, border-color #10b981], dark:[background #064e3b, hover:background #065f46]] md:[...] and dark:hover: + @el [$card, --tone #a855f7, color var(--tone), border-color var(--tone)] custom properties @el [$card, before:content "→ ", before:color var(--brand)] before: - @ul [$card, spacing 4, children:padding 4 8] - @each $item in first: bolds this one, odd: shades every other row, children: pads them all - @li [first:font-weight 700, odd:background var(--subtle), border-radius 4] $item - @el [$card, has(input:checked):border-color var(--brand), has(input:checked):background #eff6ff] + @ul [$card, spacing 4, list-style disc, padding-inline-start 36, children:display list-item, children:marker:color var(--brand), children:padding 4 8] + @each $item in first-child: bolds this one, nth-child(odd): shades every other row, children: pads them all, children:marker: colours the bullets + @li [first-child:font-weight 700, nth-child(odd):background var(--subtle), border-radius 4] $item + @el [$card, has(input:checked):[border-color var(--brand), background #eff6ff]] @label [cursor pointer] @input [type=checkbox, margin-inline-end 8] Tick the box: has() styles the card around it + @paragraph + Colour maths is CSS too, so a variant follows its token wherever it is redefined: + @row [spacing $unit, wrap] + @each $shade in 0, 10, 20, 30 + @el [padding 16, border-radius $unit, color white, background color-mix(in srgb, var(--brand), black $shade%)] + color-mix … black $shade% + @el [padding 16, border-radius $unit, background rgb(from var(--brand) r g b / 0.2)] rgb(from …) / 0.2 + @el [padding 16, border-radius $unit, background color-mix(in srgb, var(--brand), #f43f5e 50%)] color-mix … 50% -- 4. Functions ----------------------------------------------------------- - @let stat $value $caption $tone=var(--brand) + @let @stat [value, caption, tone var(--brand)] @el [$card, spacing 4, flex 1 1 160px] @text [font-size 32, font-weight 800, color $tone] $value @text [color var(--muted)] $caption - @let panel $title + @let @panel [title] @article [$card, spacing $unit] @row [align-items center] @h3 [font-size 18] $title @@ -137,21 +164,30 @@ @slot footer @text [font-size 13, color var(--muted)] Default footer - @let badge $text + @let @badge [text, dot false] @style & { animation: rise 0.3s ease; } .dot { color: var(--brand); } @row [spacing 6, padding 2 10, border-radius 999, background var(--subtle), font-size 13] - @text [class=dot] • + @if $dot + @text [class=dot] • $text - @let pitch - Functions are {@text [font-weight 700] @let} with a body. Their - parameters are attributes, and a body can be text like this one. + @let @pitch + Functions are {@code @let @name} with a body. Their + parameters are listed and passed like attributes, a name alone passes + true, and a body can be text like this one. + + @let @key + @kbd [padding 1 6, border 1 solid var(--line), border-radius 4, font-size 13] + @children @chapter [title Functions, number 4] @paragraph @pitch + @paragraph + A call works wherever an element does, even inside a sentence: + press {@key Ctrl} {@key K} to search. @row [spacing $unit, wrap] @stat [value 1, caption output file] @stat [value 0, caption lines of JavaScript, tone #10b981] @@ -160,9 +196,10 @@ @slot actions @link [color var(--brand), font-size 14] /edit Edit @paragraph - Children fill {@code @children}; {@code @slot} blocks fill slots by name. + Children fill {@code @children}; {@code @slot} blocks fill slots by name, + and a slot name the panel doesn't have is an error. @row [spacing $unit] - @badge [text scoped style] + @badge [text scoped style, dot] @badge [text same function] -- 5. Expressions --------------------------------------------------------- @@ -174,16 +211,9 @@ \$name inserts a value and \${...} any expression: ${uppercase($name)} has ${length($name)} letters and ${length($tags)} tags. @row [spacing $unit, wrap] - @each $shade in 0, 10, 20, 30 - @el [ - padding if($compact, 8, 16), - border-radius $unit, - color white, - background ${darken($--brand, $shade)} - ] - darken $shade% - @el [padding 16, border-radius $unit, background ${alpha($--brand, 0.2)}] alpha 0.2 - @el [padding 16, border-radius $unit, background ${mix($--brand, #f43f5e, 50)}] mix + @each $tag in $tags + @el [padding ${if($compact, 4, 8)} 12, border-radius $unit, background var(--subtle)] + ${capitalize($tag)}: ${length($tag)} letters @if contains($tags, css) @text [color var(--muted)] contains(\$tags, css) is true @@ -195,20 +225,20 @@ ] @data $nobody [] @chapter [title Control flow, number 6] - @table [border-collapse collapse, width 100%] + @table [border-collapse collapse, width 100%, @th:padding 8, @td:padding 8] @thead @tr [text-align left, color var(--muted), font-size 13] - @th [padding 8] # - @th [padding 8] Name - @th [padding 8] Role - @th [padding 8] Status + @th # + @th Name + @th Role + @th Status @tbody @each $person, $i in $team @tr [border-top 1 solid var(--line)] - @td [padding 8] ${$i + 1} - @td [padding 8, font-weight 600] $person.name - @td [padding 8] $person.role - @td [padding 8, color if($person.active, #10b981, var(--muted))] + @td ${$i + 1} + @td [font-weight 600] $person.name + @td $person.role + @td [if($person.active, color #10b981, color var(--muted))] @if $person.active Active @else @@ -225,7 +255,9 @@ @chapter [title HTML, number 7] @paragraph Every other element has its HTML name, and HTML attributes are written - {@code key=value}. + {@code key=value}. {@code @page} is the root element: its styles go on + {@code }, a column that fills the window, and its {@code key=value} + attributes on {@code }, like this page's {@code lang=en}. @row [spacing $gutter, wrap, align-items flex-start] @form [$card, spacing $unit, flex 1 1 280px, method=post] /subscribe @label [for=email, font-weight 600] Email @@ -245,7 +277,7 @@ @select [id=topic, name=topic, padding 10, border-radius 6] @option [value=news] News @option [value=releases] Releases - @button [type=submit, padding 10, border 0, border-radius 6, background var(--brand), color white, font-weight 700, cursor pointer, hover:background ${darken($--brand, 10)}] Subscribe + @button [type=submit, padding 10, border 0, border-radius 6, background var(--brand), color white, font-weight 700, cursor pointer, hover:background color-mix(in srgb, var(--brand), black 10%)] Subscribe @el [spacing $unit, flex 1 1 280px] @details [$card] @summary [font-weight 600, cursor pointer] What does this compile to? @@ -263,10 +295,9 @@ @figcaption [font-size 13, color var(--muted)] @figure and @figcaption -- Raw HTML is the escape hatch - @raw
+ @raw
- @footer [padding-block $gutter, color var(--muted), font-size 14, print:display none] - @row [spacing $unit] - @text © 2026 htmlang - @spacer - @link [hover:color var(--brand)] https://github.com/iko-soy/htmlang Source + @footer [flex-direction row, spacing $unit, padding-block $gutter, color var(--muted), font-size 14, print:display none] + @text © 2026 htmlang + @spacer + @link [hover:color var(--brand)] https://github.com/iko-soy/htmlang Source diff --git a/examples/demo.html b/examples/demo.html index 32ad87e..6d5f4bd 100644 --- a/examples/demo.html +++ b/examples/demo.html @@ -7,13 +7,12 @@ - - -
- -
- - + + +
+ +
+ + A one-page tour - -

+ +

Welcome to htmlang

- -

-A small layout language that compiles to - -one static HTML file . Structure starts with - + +

+A small layout language that compiles to + +one static HTML file . Structure starts with + @ , everything else is content.

- -
- -
- - + +
+ +
+ + 1. - -

+ +

Layout

- -

- - -@el stacks its children in a column and - -@row lays them out side by side. - -spacing is the gap between children.

- -
- -
+ +

+ + +@el stacks its children in a column and + +@row lays them out side by side. + +spacing is the gap between children. + +flex-direction row makes any column a row, also from a width up ( + +md:flex-direction row ), and the children's + +width fill follows it.

+ +
+ +
width fill
- -
+ +
shares the row
- -
+ +
width 160
- -
- - + +
+ + Logo - -
+ +
- - + + @spacer pushes this to the end
- -
- -
+ +
+ +
+ Stacked on a phone +
+ +
+ side by side from md up +
+
+ +
+ +
align-top
- -
+ +
center-y
- -
+ +
align-bottom
- -
- -
+ +
+ +
Cell 1
- -
+ +
Cell 2
- -
+ +
Cell 3
- -
+ +
Cell 4
- -
+ +
Cell 5
- -
+ +
Cell 6
- -
- -
- -
+ +
+ +
+ +
(behind)
- - + + @in-front and @behind layer content - -
- - + +
+ + painted on top
- -
- -
- - + +
+ +
+ + 2. - -

+ +

Text

- -

-Lines without an - -@ are text. Braces put elements inside a line: - -bold , - -italic , - -highlighted , - -a link , and - + +

+Lines without an + +@ are text. Braces put elements inside a line: + +bold , + +italic , + +important , H + +2 O, + +highlighted , + +a link , and + Ctrl+K .

- -

+ +

A backslash escapes: @ at the start of a line, $price, and {braces}.

- -
- - - - + + - -
+ +
Layout is code. Style is code. Everything should compose. - - + + — Anonymous
- -
- -
- - + +
+ +
+ + 3. - -

+ +

Style

- -

-Besides the layout attributes, every attribute is a CSS property with its CSS name and value. A bare number is pixels for lengths.

- -
- -
+ +

+Besides the layout attributes, every attribute is a CSS property with its CSS name and value. A bare number in a length, shadows included, is pixels, and \, keeps a comma in a value. A custom property (--name) is one too: set on an element, it holds for everything inside it. Prefixes stack, and one prefix before a [group] applies to each style in it. A selector prefix is a CSS pseudo-class or pseudo-element under its own name: hover:, first-child:, nth-child(odd):, has(...):, marker:. An element prefix styles every such element inside, at any depth: the table in chapter 6 pads all its cells with + +@td:padding 8 .

+ +
+ +
Any CSS property
- -
+ +
hover:
- -
- md: and dark: + +
+ md:[...] and dark:hover:
- -
+ +
+ custom properties +
+ +
before:
- -
    - -
  • -first: bolds this one
  • - -
  • -odd: shades every other row
  • - -
  • -children: pads them all
  • + +
      + +
    • + first-child: bolds this one +
    • + +
    • + nth-child(odd): shades every other row +
    • + +
    • + children: pads them all +
    • + +
    • + children:marker: colours the bullets +
    - -
    - - + +
    + + +
    + +

    +Colour maths is CSS too, so a variant follows its token wherever it is redefined:

    + +
    + +
    + color-mix … black 0% +
    + +
    + color-mix … black 10% +
    + +
    + color-mix … black 20% +
    + +
    + color-mix … black 30% +
    + +
    + rgb(from …) / 0.2 +
    + +
    + color-mix … 50% +
- -
- -
- - + +
+ +
+ + 4. - -

+ +

Functions

- -

-Functions are - -@let with a body. Their parameters are attributes, and a body can be text like this one.

- -
- -
- - + +

+Functions are + +@let @name with a body. Their parameters are listed and passed like attributes, a name alone passes true, and a body can be text like this one.

+ +

+A call works wherever an element does, even inside a sentence: press + +Ctrl + +K to search.

+ +
+ +
+ + 1 - - + + output file
- -
- - + +
+ + 0 - - + + lines of JavaScript
- -
- - + +
+ + ∞ - - + + components
- -
- -
- -

+ +
+ +
+ +

Named slots

- - - -

-Children fill - -@children ; - -@slot blocks fill slots by name.

- - + +

+Children fill + +@children ; + +@slot blocks fill slots by name, and a slot name the panel doesn't have is an error.

+ + Default footer
- -
- -
- - + +
+ +
+ + • scoped style
- -
- - -• + +
same function

- -
- -
- - + +
+ +
+ + 5. - -

+ +

Expressions

- -

+ +

$name inserts a value and ${...} any expression: HTMLANG has 7 letters and 3 tags.

- -
- -
- darken 0% -
- -
- darken 10% -
- -
- darken 20% -
- -
- darken 30% + +
+ +
+ Layout: 6 letters
- -
- alpha 0.2 + +
+ Css: 3 letters
- -
- mix + +
+ Static: 6 letters
- - + + contains($tags, css) is true
- -
- -
- - + +
+ +
+ + 6. - -

+ +

Control flow

- - - - - - - - "), + "{}", + out + ); + for name in [ + "text", + "button", + "label", + "summary", + "legend", + "figcaption", + "cite", + "dt", + "dd", + "th", + "mark", + "abbr", + "kbd", + "time", + "option", + "h6", + ] { + let out = compile(&format!("@{} a {{@text b}} c\n", name)); + assert!(out.contains("a b c"), "@{}: {}", name, out); + } + // Escapes and $names too, as before + let out = compile("@let x 5\n@h2 \\{@b\\} costs $x\n"); + assert!(out.contains(">{@b} costs 5"), "{}", out); +} + +#[test] +fn an_unknown_inline_element_in_an_argument_is_reported() { + let diagnostics = parse_diagnostics("@text Read {@lnk /x more}\n"); + let d = diagnostics + .iter() + .find(|d| d.code == "unknown-element") + .unwrap_or_else(|| panic!("{:?}", diagnostics)); + assert!(d.message.contains("did you mean @link"), "{}", d.message); +} + +#[test] +fn code_and_textarea_show_their_text_as_written() { + // Inline, on the line and in the lines under it + let out = compile("@paragraph\n Write {@code {@link /x y}} for a link.\n"); + assert!( + out.contains("Write {@link /x y} for a link.

"), + "{}", + out + ); + let out = compile("@code {@b x}\n"); + assert!(out.contains(">{@b x}"), "{}", out); + let out = compile("@code\n {@b x}\n {@i y}\n"); + assert!(out.contains(">{@b x}\n{@i y}"), "{}", out); + let out = compile("@pre > @code {@b x}\n"); + assert!(out.contains(">{@b x}"), "{}", out); + // Its misspellings aren't elements, so they aren't reported + let diagnostics = parse_diagnostics("@code {@lnk x}\n"); + assert!( + diagnostics.iter().all(|d| d.code != "unknown-element"), + "{:?}", + diagnostics + ); + // $names and escapes still work + let out = compile("@let v 2\n@code {@x} v$v \\}\n"); + assert!(out.contains(">{@x} v2 }"), "{}", out); + // A textarea keeps its lines, and shows braces as written + let out = compile("@textarea [aria-label=a]\n {@b one}\n two\n"); + assert!(out.contains(">{@b one}\ntwo"), "{}", out); + // Its text is on its line or in the block under it, not both + let diagnostics = parse_diagnostics("@textarea [aria-label=a] {@b one}\n two\n"); + assert!( + diagnostics.iter().any(|d| d.code == "unexpected-body"), + "{:?}", + diagnostics + ); +} + +#[test] +fn the_argument_is_the_first_line_of_the_element_s_layout() { + // In a row or column each line is a child, the argument first + let out = compile("@row [spacing 8] one\n two\n"); + assert!( + out.contains("onetwo"), + "{}", + out + ); + // In a text element the lines flow + let out = compile("@button [type=button] Read\n more\n"); + assert!(out.contains(">Read more"), "{}", out); + // Text that @each, @if or a function writes flows like a line + let out = compile( + "@let @word\n plain\n@h2 Start\n @each $x in a, b\n $x\n @if true\n yes\n @word\n", + ); + assert!(out.contains(">Start a b yes plain"), "{}", out); +} + +#[test] +fn lines_of_text_at_the_top_of_the_page_are_separate_lines() { + let out = compile("Read\nmore\n"); + assert_eq!(out, "Read\nmore"); + let out = compile("Read\n@text x\nmore\n"); + assert_eq!(out, "Readxmore"); +} + +#[test] +fn snapshot_html_elements() { + snapshot_test("html_elements"); +} + +#[test] +fn text_level_elements_are_text_with_no_css_of_their_own() { + let out = + compile("@paragraph\n {@b a}, {@i b}, {@strong c}{@br}\n {@small d} and H{@sub 2}O\n"); + assert!( + out.contains( + "a, b, c
d and H2O

" + ), + "{}", + out + ); + // On a line of its own, with the lines under it flowing + let out = compile("@strong Read\n this\n"); + assert!(out.ends_with("Read this"), "{}", out); + // Styles still work, and give it a class + let out = compile("@em [color red] x\n"); + assert!(out.contains("{color:red;}"), "{}", out); + assert!(out.contains("x"), "{}", out); + // Text has no gap: `spacing` is an error + let diags = parse_diagnostics("@strong [spacing 4] x\n"); + assert!( + diags + .iter() + .any(|d| d.code == "no-effect" && d.severity == htmlang::parser::Severity::Error), + "{:?}", + diags + ); +} + +#[test] +fn table_parts_and_option_groups_keep_html_s_layout() { + let out = compile( + "@table\n @caption Team\n @colgroup\n @col [span=2]\n @tfoot\n @tr\n @td Total\n", + ); + assert!( + out.contains("
+ + + + + + + + - - - - - - - - - - - - + + + + - - - - - - - - - - + + - - - - - - - - - - + + - - - - - -
# + + Name + + Role + + Status
+ +
1 + + Ada + + Engineering + + Active
+ +
2 + + Grace + + Design, research + + Active
+ +
3 + + Linus + + Operations + + Away
- -
- - + +
+ + 10 - - + + 8 - - + + 6 - - + + 4 - - + + 2 - - + + 0
- - + + @each … @else shows this for an empty list. - -
- -
- - + +
+ +
+ + 7. - -

+ +

HTML

- -

-Every other element has its HTML name, and HTML attributes are written - -key=value .

- -
- -
- -
-
- -
Ctrl
Team
Total
"), + "{}", + out + ); + // @optgroup's argument is its label + let out = compile("@select [aria-label=f]\n @optgroup Citrus\n @option Lemon\n"); + assert!( + out.contains(""), + "{}", + out + ); + // The void ones take no content + for src in ["@br x\n", "@col\n x\n", "@wbr\n @text x\n"] { + let diags = parse_diagnostics(src); + assert!( + diags.iter().any(|d| d.code == "unexpected-content"), + "{}: {:?}", + src, + diags + ); + } +} + +#[test] +fn new_flow_containers_are_columns() { + let out = compile("@menu [spacing 4]\n @li One\n@hgroup\n @h1 A\n B\n"); + assert!( + out.contains( + ":where(.hl-a){display:flex;flex-direction:column;margin:0;padding-left:0;list-style:none;}" + ) && out.contains(".hl-a{gap:4px;}"), + "{}", + out + ); + assert!( + out.contains("

A

B
"), + "{}", + out + ); + // Their HTML ends a

+ let diags = parse_diagnostics("@paragraph\n Text\n @menu\n @li x\n"); + assert!( + diags.iter().any(|d| d.code == "block-in-paragraph"), + "{:?}", + diags + ); +} + +#[test] +fn html_names_of_htmlang_s_own_elements_are_unknown_and_suggest_them() { + for (html, htmlang) in [ + ("a", "link"), + ("img", "image"), + ("span", "text"), + ("p", "paragraph"), + ("div", "el"), + ] { + for src in [format!("@{} x\n", html), format!("Read {{@{} x}}\n", html)] { + let diags = parse_diagnostics(&src); + let d = diags + .iter() + .find(|d| d.code == "unknown-element") + .unwrap_or_else(|| panic!("{}: {:?}", src, diags)); + assert_eq!(d.severity, htmlang::parser::Severity::Error); + assert!( + d.message.contains(&format!( + "did you mean @{}? (@{} writes <{}>)", + htmlang, htmlang, html + )), + "{}", + d.message + ); + assert_eq!(d.suggestion.as_deref(), Some(htmlang), "{}", src); + } + } + // A name that isn't on the list is unknown, not a new tag (`@data` + // on a line of its own is the directive) + for src in ["@el\n @template x\n", "@blink x\n", "Read {@data x}\n"] { + let diags = parse_diagnostics(src); + assert!( + diags.iter().any(|d| d.code == "unknown-element"), + "{}: {:?}", + src, + diags + ); + } + // Ordinary typos of the new names are suggested like any other + let diags = parse_diagnostics("@stong x\n"); + assert!( + diags + .iter() + .any(|d| d.message.contains("did you mean @strong?")), + "{:?}", + diags + ); +} + +#[test] +fn a_function_named_like_a_new_element_shadows_it() { + let result = + htmlang::parser::parse("@let @em\n @text [font-style italic]\n @children\n@em x\n"); + assert!( + result + .diagnostics + .iter() + .any(|d| d.code == "shadows-built-in"), + "{:?}", + result.diagnostics + ); +} + +#[test] +fn the_new_elements_attributes_are_known_html_attributes() { + // Written like a style, each gets "write `key=value`", not a CSS warning + for (src, key) in [ + ("@track [srclang en] a.vtt\n", "srclang"), + ("@area [shape rect, alt=x] /x\n", "shape"), + ("@area [coords 1, alt=x] /x\n", "coords"), + ] { + let diags = parse_diagnostics(src); + assert!( + diags + .iter() + .any(|d| d.code == "html-attribute-form" && d.message.contains(key)), + "{}: {:?}", + src, + diags + ); + assert!( + !diags.iter().any(|d| d.code == "unknown-attribute"), + "{}: {:?}", + src, + diags + ); + } +} + +#[test] +fn snapshot_layout_direction() { + snapshot_test("layout_direction"); +} + +#[test] +fn snapshot_leading_argument() { + snapshot_test("leading_argument"); +} + +/// The body of the rule for `selector` in `css`, which must be there. +fn rule<'a>(css: &'a str, selector: &str) -> &'a str { + let start = css + .find(&format!("{}{{", selector)) + .unwrap_or_else(|| panic!("no rule for {} in {}", selector, css)) + + selector.len() + + 1; + &css[start..start + css[start..].find('}').unwrap()] +} + +#[test] +fn flex_direction_decides_what_fill_means_for_the_children() { + // A column made a row: `width fill` takes the remaining width + let out = compile("@nav [flex-direction row]\n @el [width fill] a\n"); + assert!( + rule(&out, ".hl-b").contains("flex:1;min-width:0;"), + "{}", + out + ); + assert!(!rule(&out, ".hl-b").contains("width:100%"), "{}", out); + // A row made a column: `width fill` is the full width, not flex + let out = compile("@row [flex-direction column]\n @el [width fill] a\n"); + assert!(rule(&out, ".hl-b").contains("width:100%;"), "{}", out); + assert!(!rule(&out, ".hl-b").contains("flex:1"), "{}", out); + // `height fill` in a column made a row is the full height + let out = compile("@el [flex-direction row-reverse]\n @el [height fill] a\n"); + assert!(rule(&out, ".hl-b").contains("height:100%;"), "{}", out); + // `flex-flow` sets the direction too + let out = compile("@el [flex-flow row wrap]\n @el [width fill] a\n"); + assert!( + rule(&out, ".hl-b").contains("flex:1;min-width:0;"), + "{}", + out + ); + // A direction only the browser knows keeps the element's own + let out = compile("@el [flex-direction var(--dir)]\n @el [width fill] a\n"); + assert!(rule(&out, ".hl-b").contains("width:100%;"), "{}", out); +} + +#[test] +fn a_prefixed_direction_moves_the_children_s_words_with_it() { + let out = compile( + "@header [spacing 16, md:flex-direction row, md:align-items center]\n @text [font-weight 800] Launchpad\n @el [width fill]\n", + ); + // Full width while the header is a column... + assert!(rule(&out, ".hl-c").contains("width:100%;"), "{}", out); + // ...and the remaining width from md up, in the header's own block, + // keyed on its class + assert!( + out.contains( + "@media(min-width:768px){:where(.hl-a)>.hl-c{width:auto;flex:1;min-width:0;}.hl-a{flex-direction:row;align-items:center;}}" + ), + "{}", + out + ); + // Container and media conditions work the same way + let out = compile( + "@el [cq-md:flex-direction row, landscape:flex-direction row]\n @el [width fill] a\n", + ); + assert!( + out.contains( + "@container(min-width:768px){:where(.hl-a)>.hl-b{width:auto;flex:1;min-width:0;}" + ), + "{}", + out + ); + assert!( + out.contains( + "@media(orientation:landscape){:where(.hl-a)>.hl-b{width:auto;flex:1;min-width:0;}" + ), + "{}", + out + ); +} + +#[test] +fn a_direction_under_a_breakpoint_holds_at_the_wider_ones() { + // The row is a column from sm up, so the child's `lg:width fill` is + // the full width + let out = compile("@row [sm:flex-direction column]\n @el [width 200, lg:width fill] a\n"); + assert!( + out.contains("@media(min-width:1024px){.hl-b{width:100%;}}"), + "{}", + out + ); + // Back to a row at lg: the rule there undoes the column's + let out = + compile("@row [sm:flex-direction column, lg:flex-direction row]\n @el [width fill] a\n"); + assert!( + out.contains( + "@media(min-width:640px){:where(.hl-a)>.hl-b{flex:0 1 auto;min-width:auto;width:100%;}" + ), + "{}", + out + ); + assert!( + out.contains( + "@media(min-width:1024px){:where(.hl-a)>.hl-b{width:auto;flex:1;min-width:0;}" + ), + "{}", + out + ); +} + +#[test] +fn a_direction_under_a_state_prefix_leaves_the_children_s_words() { + // `hover:` isn't a block of its own: the children compile against the + // direction without a prefix + let out = compile("@el [hover:flex-direction row]\n @el [width fill] a\n"); + assert!(rule(&out, ".hl-b").contains("width:100%;"), "{}", out); + assert!(!out.contains(")>.hl-"), "{}", out); + // A grid has no direction, whatever flex-direction says + let out = compile("@grid [md:flex-direction row]\n @el [width fill] a\n"); + assert!(!out.contains(")>.hl-"), "{}", out); +} + +#[test] +fn center_and_align_are_auto_margins_in_any_parent() { + for parent in ["@el", "@row", "@grid", "@nav [flex-direction row]"] { + let out = compile(&format!( + "{}\n @el [center-x] a\n @el [center-y] b\n @el [align-left] c\n @el [align-right] d\n @el [align-top] e\n @el [align-bottom] f\n", + parent + )); + assert!(!out.contains("align-self"), "{}: {}", parent, out); + for margins in [ + "margin-left:auto;margin-right:auto;", + "margin-top:auto;margin-bottom:auto;", + "{margin-right:auto;}", + "{margin-left:auto;}", + "{margin-bottom:auto;}", + "{margin-top:auto;}", + ] { + assert!(out.contains(margins), "{}: {} in {}", parent, margins, out); + } + } +} + +#[test] +fn the_centred_column_needs_no_css_width() { + let out = compile("@section\n @el [width fill, max-width 800, center-x] a\n"); + assert!( + rule(&out, ".hl-b") + .contains("width:100%;max-width:800px;margin-left:auto;margin-right:auto;"), + "{}", + out + ); +} + +#[test] +fn shrink_fits_the_content_across_the_direction() { + // Across a column (and outside flex) the width fits the content... + for parent in ["@el", "@grid", "@row [flex-direction column]"] { + let out = compile(&format!("{}\n @el [width shrink] a\n", parent)); + assert!( + rule(&out, ".hl-b").contains("width:fit-content;"), + "{}: {}", + parent, + out + ); + } + // ...along a row it keeps the content's width + let out = compile("@row\n @el [width shrink] a\n"); + assert!(rule(&out, ".hl-b").contains("flex-shrink:0;"), "{}", out); + // The same for the height, the other way round + let out = compile("@row\n @el [height shrink] a\n"); + assert!( + rule(&out, ".hl-b").contains("height:fit-content;"), + "{}", + out + ); + let out = compile("@el\n @el [height shrink] a\n"); + assert!(rule(&out, ".hl-b").contains("flex-shrink:0;"), "{}", out); +} + +#[test] +fn a_layout_word_leaves_what_you_write_yourself() { + // Written before or after `width fill`, the element's own min-width wins + for attrs in ["min-width 200, width fill", "width fill, min-width 200"] { + let out = compile(&format!("@row\n @el [{}] a\n", attrs)); + assert!( + rule(&out, ".hl-b").contains("flex:1;min-width:200px;") + || rule(&out, ".hl-b").contains("min-width:200px;flex:1;"), + "{}: {}", + attrs, + out + ); + assert!(!out.contains("min-width:0"), "{}: {}", attrs, out); + } + // Also where the parent's direction changes + let out = compile("@el [md:flex-direction row]\n @el [width fill, min-width 200] a\n"); + assert!( + out.contains(":where(.hl-a)>.hl-b{width:auto;flex:1;}"), + "{}", + out + ); + // Only the last `width` counts + let out = compile("@row\n @el [width fill, width 300] a\n"); + assert!(!rule(&out, ".hl-b").contains("flex:1"), "{}", out); +} + +#[test] +fn a_direction_under_stacked_prefixes_moves_the_children() { + let out = compile( + "@row [md:flex-direction column, md:dark:flex-direction row]\n @el [width fill] A\n", + ); + assert!( + out.contains( + "@media(min-width:768px){:where(.hl-a)>.hl-b{flex:0 1 auto;min-width:auto;width:100%;}" + ), + "{}", + out + ); + assert!( + out.contains("@media(min-width:768px){@media(prefers-color-scheme:dark){:where(.hl-a)>.hl-b{width:auto;flex:1;min-width:0;}.hl-a{flex-direction:row;}}}"), + "{}", + out + ); +} + +#[test] +fn snapshot_prefixes_compose() { + snapshot_test("prefixes_compose"); +} + +#[test] +fn stacked_at_rules_are_one_condition_in_either_order() { + let out = compile("@el [md:dark:padding 8] a\n@el [dark:md:padding 8] b\n"); + // One class for both, in one nested block, the width outside + assert!( + out.contains("

a
"), + "{}", + out + ); + assert!( + out.contains( + "@media(min-width:768px){@media(prefers-color-scheme:dark){.hl-a{padding:8px;}}}" + ), + "{}", + out + ); +} + +#[test] +fn rules_are_written_in_one_order_whatever_the_source_order() { + let styles = [ + "padding 4", + "hover:color blue", + "dark:color white", + "md:padding 8", + "md:dark:padding 12", + "before:content \"x\"", + "children:opacity 1", + "hover:children:opacity 0.5", + ]; + let forward = compile(&format!("@el [{}]\n @el a\n", styles.join(", "))); + let mut reversed = styles; + reversed.reverse(); + let backward = compile(&format!("@el [{}]\n @el a\n", reversed.join(", "))); + assert_eq!(forward, backward); + // Plain, then selectors, then each block: `md:dark:` after `dark:` + let order = [ + ":where(.hl-a,.hl-b){display:flex;flex-direction:column;}", + ".hl-a{padding:4px;}", + ".hl-a:hover{color:blue;}", + ".hl-a::before{content:\"x\";}", + ":where(.hl-a)>*{opacity:1;}", + ":where(.hl-a:hover)>*{opacity:0.5;}", + "@media(min-width:768px){.hl-a{padding:8px;}}", + "@media(prefers-color-scheme:dark){.hl-a{color:white;}}", + "@media(min-width:768px){@media(prefers-color-scheme:dark){.hl-a{padding:12px;}}}", + ]; + let at: Vec = order + .iter() + .map(|rule| { + forward + .find(rule) + .unwrap_or_else(|| panic!("{}: {}", rule, forward)) + }) + .collect(); + assert!(at.windows(2).all(|w| w[0] < w[1]), "{:?}: {}", at, forward); +} + +#[test] +fn a_prefix_applies_to_a_group_a_bundle_or_the_branch_an_if_takes() { + let out = compile( + "@let card [padding 16, border-radius 8]\n@let on true\n\ + @el [md:[padding 32, font-size 20]] a\n\ + @el [md:$card] b\n\ + @el [hover:if($on, [color red, background blue], color gray)] c\n\ + @el [dark:[color white, hover:color gray]] d\n", + ); + for rule in [ + "@media(min-width:768px){.hl-a{padding:32px;font-size:20px;}.hl-b{padding:16px;border-radius:8px;}}", + ".hl-c:hover{color:red;background:blue;}", + "@media(prefers-color-scheme:dark){.hl-d{color:white;}.hl-d:hover{color:gray;}}", + ] { + assert!(out.contains(rule), "{}: {}", rule, out); + } + assert!(!out.contains("color:gray;}.hl-c"), "{}", out); +} + +#[test] +fn a_prefixed_group_or_bundle_holds_styles_only() { + for src in [ + "@el [md:[id=x, padding 4]]\n x\n", + "@let b [id=x, padding 4]\n@el [md:$b]\n x\n", + "@el [hover:[required]]\n x\n", + "@let on true\n@el [hover:if($on, [aria-current=page, color red])]\n x\n", + "@el [children:[width fill, color red]]\n x\n", + "@el [hover:[before:color red], before:[hover:color red]]\n x\n", + ] { + let d = parse_diagnostics(src); + let invalid = coded(&d, "invalid-prefix"); + assert_eq!(invalid.len(), 1, "{}: {:?}", src, d); + assert_eq!(errors(&d).len(), 1, "{}: {:?}", src, d); + } + let d = parse_diagnostics("@el [md:$b]\n x\n"); + assert_eq!(coded(&d, "undefined-variable").len(), 1, "{:?}", d); +} + +#[test] +fn the_branch_a_prefixed_if_doesn_t_take_is_checked_under_its_prefixes() { + for src in [ + "@let on true\n@el [hover:if($on, color red, id=x)]\n x\n", + "@let on false\n@el [hover:if($on, [id=x], color red)]\n x\n", + "@let on true\n@el [md:[if($on, color red, [aria-label=x])]]\n x\n", + "@let on true\n@row [children:if($on, color red, width fill)]\n x\n", + "@let on true\n@el [before:if($on, color red, hover:color blue)]\n x\n", + ] { + let d = parse_diagnostics(src); + assert_eq!(coded(&d, "invalid-prefix").len(), 1, "{}: {:?}", src, d); + assert_eq!(errors(&d).len(), 1, "{}: {:?}", src, d); + } +} + +#[test] +fn a_parameter_takes_no_prefix() { + let def = + "@let @card [title, pad 8, color blue]\n @el [padding $pad, color $color]\n $title\n"; + for call in [ + "@card [title A, md:pad 40]", + "@card [title A, md:[pad 40]]", + "@card [title A, md:title B]", + "@let b [pad 40]\n@card [title A, md:$b]", + ] { + let d = parse_diagnostics(&format!("{}{}\n", def, call)); + let invalid = coded(&d, "invalid-prefix"); + assert_eq!(invalid.len(), 1, "{}: {:?}", call, d); + assert!(invalid[0].message.contains("is a parameter"), "{:?}", d); + assert!(!d.iter().any(|d| d.code == "unknown-attribute"), "{:?}", d); + } + // A parameter named like a style is a style for the root under a prefix + let out = compile(&format!("{}@card [title A, hover:color red]\n", def)); + assert!(out.contains(":hover{color:red;}"), "{}", out); +} + +#[test] +fn a_style_takes_one_pseudo_element() { + let d = parse_diagnostics("@el [before:after:content \"x\"]\n x\n"); + let invalid = coded(&d, "invalid-prefix"); + assert_eq!(invalid.len(), 1, "{:?}", d); + assert!(invalid[0].suggestion.is_none(), "{:?}", d); + assert!( + invalid[0].message.contains("`before:` and `after:`"), + "{:?}", + d + ); +} + +#[test] +fn a_group_s_prefix_and_what_follows_the_group_are_checked() { + let d = parse_diagnostics("@el [hovr:[padding 4, color red]]\n x\n"); + let unknown = coded(&d, "unknown-prefix"); + assert_eq!(unknown.len(), 1, "{:?}", d); + assert_eq!(unknown[0].suggestion.as_deref(), Some("hover:")); + let d = parse_diagnostics("@el [md:[padding 4] extra]\n x\n"); + assert_eq!(coded(&d, "unexpected-argument").len(), 1, "{:?}", d); + // A group's members are checked like any attribute's, in code that + // doesn't run too + let d = parse_diagnostics("@if false\n @el [md:[colr red]]\n x\n"); + assert_eq!(coded(&d, "unknown-attribute").len(), 1, "{:?}", d); +} + +#[test] +fn a_child_s_own_style_wins_over_its_parent_s_children_style() { + let out = + compile("@row [children:opacity 1, hover:children:opacity 0.8]\n @el [opacity 0.5] a\n"); + // Both parent rules have no specificity, so the child's class wins, + // and the hovered one comes after the plain one + assert!( + out.contains(":where(.hl-a)>*{opacity:1;}:where(.hl-a:hover)>*{opacity:0.8;}"), + "{}", + out + ); + assert!(out.contains(".hl-b{opacity:0.5;}"), "{}", out); +} + +#[test] +fn a_parent_s_children_style_wins_over_a_child_s_defaults() { + // The defaults of an element's kind (a list item's column, a + // heading's margin) have no specificity and come first, so a parent's + // `children:` styles override them, as the child's own attributes + // override those + let out = compile( + "@ul [list-style disc, padding-inline-start 20, children:display list-item]\n @li [color red] a\n @li b\n", + ); + let defaults = out.find(":where(.hl-b,.hl-c){display:flex;flex-direction:column;}"); + let children = out.find(":where(.hl-a)>*{display:list-item;}"); + assert!( + defaults.is_some() && children.is_some() && defaults < children, + "{}", + out + ); + assert!(out.contains(".hl-b{color:red;}"), "{}", out); + // What the element sets itself is not in its defaults + assert!( + out.contains(":where(.hl-a){display:flex;flex-direction:column;margin:0;padding-left:0;}"), + "{}", + out + ); + let out = compile("@el [children:margin-bottom 8]\n @h2 A\n @paragraph B\n"); + let defaults = out.find(":where(.hl-b){margin:0;}"); + let children = out.find(":where(.hl-a)>*{margin-bottom:8px;}"); + assert!( + defaults.is_some() && children.is_some() && defaults < children, + "{}", + out + ); +} + +#[test] +fn an_html_attribute_name_may_hold_a_colon() { + let out = compile("@el [xml:lang=en, x-on:click=open, xlink:href=#a]\n x\n"); + assert!( + out.contains("xml:lang=\"en\" x-on:click=\"open\" xlink:href=\"#a\""), + "{}", + out + ); + let d = parse_diagnostics("@el [md:id=x]\n x\n"); + assert_eq!(coded(&d, "invalid-prefix").len(), 1, "{:?}", d); +} + +#[test] +fn elements_with_the_same_css_but_other_keyed_rules_get_two_classes() { + // In a column, `width fill` and `width 100%` are the same CSS, but only + // the first becomes flex where the parent turns into a row + let out = compile("@el [md:flex-direction row]\n @el [width fill] a\n @el [width 100%] b\n"); + assert!( + out.contains("
a
"), + "{}", + out + ); + assert!(out.contains(":where(.hl-a)>.hl-b{"), "{}", out); + assert!(!out.contains(":where(.hl-a)>.hl-c{"), "{}", out); +} + +#[test] +fn an_element_inside_text_is_not_a_child_of_the_row_around_it() { + // The inline @el's parent is the text, not the row + let out = compile("@el [md:flex-direction row]\n @paragraph\n a {@el [width fill] b}\n"); + assert!(!out.contains(")>.hl-"), "{}", out); +} + +// --------------------------------------------------------------------------- +// The leading argument: one rule for every element that has one +// --------------------------------------------------------------------------- + +/// Output with the errors allowed, for tests of what an error leaves. +fn compile_anyway(input: &str) -> (String, Vec) { + let result = htmlang::parser::parse(input); + let html = htmlang::codegen::generate_partial(&result.document); + (html, result.diagnostics) +} + +#[test] +fn the_first_token_fills_the_leading_attribute_and_the_rest_is_content() { + let html = compile("@form /subscribe Sign up\n"); + assert!( + html.contains(r#"
Sign up
"#), + "{}", + html + ); + let html = compile("@iframe [title=t] /y fallback\n@object report.pdf The report\n"); + assert!( + html.contains(r#""#), + "{}", + html + ); + assert!( + html.contains(r#"The report"#), + "{}", + html + ); + // The rest is text like any other: inline elements, escapes, $names + let html = compile("@let who you\n@link /a Read {@b this}, \\$5 for $who\n"); + assert!( + html.contains(r#"Read this, $5 for you"#), + "{}", + html + ); +} + +#[test] +fn the_leading_token_is_split_before_anything_is_filled_in() { + // A variable holding a space stays one token + let html = compile("@let u /a b\n@link $u Text\n@link ${u} Other\n"); + assert!( + html.contains(r#"Text"#), + "{}", + html + ); + assert!( + html.contains(r#"Other"#), + "{}", + html + ); + // `${...}` with spaces in it, and quoted text, are one token + let html = compile("@let n 2\n@link /page/${$n + 1} Next page\n@link \"/my page\" Mine\n"); + assert!( + html.contains(r#"Next page"#), + "{}", + html + ); + assert!( + html.contains(r#"Mine"#), + "{}", + html + ); + let html = compile("@image [alt=x] \"a photo.png\"\n"); + assert!( + html.contains(r#"x"#), + "{}", + html + ); + // A quoted variable gives its text + let html = compile("@let to \"/a b\"\n@link $to Go\n"); + assert!( + html.contains(r#"Go"#), + "{}", + html + ); +} + +#[test] +fn a_leading_argument_and_its_attribute_together_are_an_error() { + let (html, d) = compile_anyway("@link [href=/a] About\n"); + let found = coded(&d, "duplicate-attribute"); + assert_eq!(found.len(), 1, "{:?}", d); + let error = found[0]; + assert_eq!(error.severity, htmlang::parser::Severity::Error); + assert!( + error.message.contains("'About' as its href") && error.message.contains("`@link /a About`"), + "{}", + error.message + ); + assert_eq!(error.subject.as_deref(), Some("About")); + assert_eq!(error.column, Some(16)); + // The attribute is kept, once, and the text is the content + assert!( + html.contains(r#"About"#), + "{}", + html + ); + + for (src, word) in [ + ("@image [src=a.png, alt=x] b.png\n", "'b.png' as its src"), + ("@iframe [src=/x, title=t] /y\n", "'/y' as its src"), + ("@form [action=/a] /b\n x\n", "'/b' as its action"), + ("@script [src=a.js] b.js\n", "'b.js' as its src"), + ( + "@picture\n @source [srcset=a.webp] b.webp\n", + "'b.webp' as its srcset in @picture or src elsewhere", + ), + ( + "@video\n @source [src=a.mp4] b.mp4\n", + "'b.mp4' as its srcset", + ), + ] { + let d = parse_diagnostics(src); + let found = coded(&d, "duplicate-attribute"); + assert_eq!(found.len(), 1, "{}: {:?}", src, d); + assert!( + found[0].message.contains(word), + "{}: {}", + src, + found[0].message + ); + } + let (html, _) = compile_anyway("@image [src=a.png, alt=x] b.png\n"); + assert!( + html.contains(r#"x"#), + "{}", + html + ); +} + +#[test] +fn the_attribute_form_is_the_leading_argument() { + // One src, first, whichever way it is written + let html = compile("@image [alt=x, src=a.png]\n@image [alt=x] a.png\n"); + assert_eq!( + html.matches(r#"x"#) + .count(), + 2, + "{}", + html + ); + // No argument and no attribute: no empty src + let html = compile("@image [alt=x]\n"); + assert!(html.contains(r#"x"#), "{}", html); + // The text of a link without an argument is on the lines under it + let html = compile("@link [class=c, href=/b]\n Text on its own line\n"); + assert!( + html.contains(r#"Text on its own line"#), + "{}", + html + ); +} + +#[test] +fn words_after_the_argument_of_an_element_without_content_are_an_error() { + let (html, d) = compile_anyway("@image [alt=Logo] logo.png Our logo\n"); + let found = coded(&d, "unexpected-argument"); + assert_eq!(found.len(), 1, "{:?}", d); + assert!( + found[0].message.contains("'Our logo' would go nowhere"), + "{}", + found[0].message + ); + assert!(found[0].message.contains("alt="), "{}", found[0].message); + assert_eq!(found[0].column, Some(27)); + assert!( + html.contains(r#"Logo"#), + "{}", + html + ); + for src in [ + "@video\n @track [kind=captions] a.vtt English\n", + "@embed a.svg b\n", + "@picture\n @source a.webp 2x\n", + "@map\n @area [alt=x] /a b\n", + ] { + let d = parse_diagnostics(src); + assert_eq!( + coded(&d, "unexpected-argument").len(), + 1, + "{}: {:?}", + src, + d + ); + } +} + +#[test] +fn source_fills_srcset_in_a_picture_and_src_elsewhere() { + let html = compile( + "@let @art [file]\n @source [type=image/avif] $file\n@picture\n @source a.webp\n @art [file b.avif]\n @image [alt=x] c.jpg\n@video [aria-label=v]\n @source d.mp4\n", + ); + assert!(html.contains(r#""#), "{}", html); + assert!( + html.contains(r#""#), + "{}", + html + ); + assert!(html.contains(r#""#), "{}", html); +} + +#[test] +fn script_takes_its_src_as_its_leading_argument() { + let html = compile("@script [type=module, defer] app.js\n"); + assert!( + html.contains(r#""#), + "{}", + html + ); + // Every HTML attribute passes through + let html = + compile("@script [nonce=abc, data-x=1, class=s, id=i, crossorigin=anonymous] a.js\n"); + assert!( + html.contains( + r#""# + ), + "{}", + html + ); + // A src and a body: an error, whichever way the src is written + for src in [ + "@script app.js\n console.log(1)\n", + "@script [src=app.js]\n console.log(1)\n", + "@script [defer] app.js console.log(1)\n", + ] { + let d = parse_diagnostics(src); + let found = coded(&d, "unexpected-content"); + assert_eq!(found.len(), 1, "{}: {:?}", src, d); + assert!( + found[0] + .message + .contains("both a src ('app.js') and a body"), + "{}", + found[0].message + ); + } + // Without a src, the body is JavaScript, kept as written, also at the + // end of a chain + let html = compile("@el > @script\n if (a < b) { @notanelement(); }\n"); + assert!( + html.contains(""), + "{}", + html + ); +} + +#[test] +fn a_leading_attribute_passed_to_a_function_s_root_is_given_twice() { + let d = parse_diagnostics( + "@let @nav-link [label]\n @link /x $label\n@nav-link [label A, href=/y]\n@nav-link [label B, target=_blank]\n", + ); + let found = coded(&d, "duplicate-attribute"); + assert_eq!(found.len(), 1, "{:?}", d); + assert_eq!(found[0].line, 3); + assert!( + found[0].message.contains("'/x' as its href") && found[0].message.contains("@nav-link"), + "{}", + found[0].message + ); +} + +#[test] +fn a_word_after_an_optgroup_s_label_is_an_error() { + // It holds only the @option lines under it, so `fruits` would go nowhere + let d = + parse_diagnostics("@select [aria-label=f]\n @optgroup Citrus fruits\n @option Lemon\n"); + let found = coded(&d, "unexpected-argument"); + assert_eq!(found.len(), 1, "{:?}", d); + assert!(found[0].message.contains("`\"Citrus fruits\"`"), "{:?}", d); + // Quoted, it is one label + let html = + compile("@select [aria-label=f]\n @optgroup \"Citrus fruits\"\n @option Lemon\n"); + assert!( + html.contains(r#""#), + "{}", + html + ); + // Given twice, the suggestion has no content either + let d = parse_diagnostics("@select [aria-label=f]\n @optgroup [label=Stone] fruits\n"); + let found = coded(&d, "duplicate-attribute"); + assert_eq!(found.len(), 1, "{:?}", d); + assert!(found[0].message.contains("`@optgroup Stone`"), "{:?}", d); +} + +#[test] +fn the_one_way_form_of_a_duplicate_quotes_a_value_with_a_space() { + let d = parse_diagnostics("@link [href=/my page] About\n"); + let found = coded(&d, "duplicate-attribute"); + assert_eq!(found.len(), 1, "{:?}", d); + assert!( + found[0].message.contains("`@link \"/my page\" About`"), + "{:?}", + d + ); + let d = parse_diagnostics("@link [href=] About\n"); + assert!( + coded(&d, "duplicate-attribute")[0] + .message + .contains("`@link \"\" About`"), + "{:?}", + d + ); + // A quoted source isn't quoted twice in the hint + let d = parse_diagnostics("@image [alt=x] \"a b\" c\n"); + let found = coded(&d, "unexpected-argument"); + assert_eq!(found.len(), 1, "{:?}", d); + assert!(found[0].message.contains("`\"a b c\"`"), "{:?}", d); +} + +#[test] +fn an_element_under_script_is_not_dropped_silently() { + // `@script > @b x` would write ``: its body is code + let d = parse_diagnostics("@script [defer] > @b oops\n"); + let found = coded(&d, "unexpected-content"); + assert_eq!(found.len(), 1, "{:?}", d); + assert!(found[0].message.contains("@b would go nowhere"), "{:?}", d); + // Its code under it is fine + assert!(parse_diagnostics("@script\n @b(1)\n").is_empty()); +} + +#[test] +fn snapshot_code_samples() { + snapshot_test("code_samples"); +} + +#[test] +fn the_lines_under_code_are_a_verbatim_sample() { + // HTML-escaped, line breaks and indentation kept; `@`, `$`, `{@`, + // backslashes and `--` are text + let out = compile("@let v 2\n@pre > @code\n @b $v \\$v {@i x}\n -- & \\\\\n"); + assert!( + out.contains( + ">@b $v \\$v {@i x}\n -- <tag> & \\\\
" + ), + "{}", + out + ); + // Nothing in it is checked: no unknown element, no undefined variable + let diagnostics = parse_diagnostics("@code\n @nosuch $undefined {@lnk x}\n"); + assert!(diagnostics.is_empty(), "{:?}", diagnostics); + // A function that wraps @children in @pre shows the caller's sample + let out = compile("@let @sample\n @pre\n @children\n@sample\n @code\n a \n"); + assert!( + out.contains("
a <b>
"), + "{}", + out + ); + // The text on @code's line is still text with $names (a one-line + // sample can be templated), and the block is the same element + let out = compile("@let v 2\n@code cargo install htmlang@$v\n"); + assert!(out.contains(">cargo install htmlang@2
"), "{}", out); +} + +#[test] +fn readable_output_adds_no_whitespace_inside_pre_and_textarea() { + let doc = htmlang::parser::parse( + "@el\n @pre > @code\n a\n b\n @textarea [aria-label=x]\n c\n", + ) + .document; + let out = htmlang::codegen::generate_dev(&doc); + assert!( + out.contains(">a\n b\n"), + "{}", + out + ); + assert!(out.contains(">c\n"), "{}", out); +} + +// --- The page is the root element (@page styles ) --- + +#[test] +fn snapshot_page_root() { + snapshot_test("page_root"); +} + +#[test] +fn page_styles_go_on_body_and_html_attributes_on_html() { + let out = compile("@page [lang=en, dir=rtl, class=x, background #111, padding 8] T\n@text a"); + assert!( + out.contains(""), + "{}", + out + ); + assert!(out.contains(""), "{}", out); + assert!( + out.contains(".hl-a{background:#111;padding:8px;}"), + "{}", + out + ); + // The reset makes the column, so its class doesn't repeat it + assert!( + out.contains("body{margin:0;font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;min-height:100dvh}"), + "{}", + out + ); + assert!(!out.contains(".hl-a{display:flex"), "{}", out); +} + +#[test] +fn a_page_without_styles_has_a_plain_body() { + let out = compile("@page T\n@text a"); + assert!(out.contains(""), "{}", out); + assert!(out.contains("a"), "{}", out); +} + +#[test] +fn the_top_of_a_page_is_a_column() { + // Text lines are children of their own, and fill/center compile + // against the column is + let out = compile("@page T\nOne\nTwo\n@el [height fill, center-y] x"); + assert!( + out.contains("OneTwo"), + "{}", + out + ); + assert!(out.contains("flex:1;min-height:0;"), "{}", out); + // The top of a fragment still has no layout of its own + let fragment = compile("One\nTwo\n@el [height fill] x"); + assert!(fragment.contains("One\nTwo"), "{}", fragment); + assert!(fragment.contains("height:100%"), "{}", fragment); +} + +#[test] +fn a_page_s_direction_moves_its_children_s_fill() { + let out = compile("@page [flex-direction row] T\n@el [width fill] a\n@el b"); + assert!(out.contains(""), "{}", out); + assert!(out.contains(".hl-a{flex-direction:row;}"), "{}", out); + assert!(out.contains("flex:1;min-width:0;"), "{}", out); +} + +#[test] +fn page_attributes_are_checked_like_an_element_s() { + let d = parse_diagnostics("@page [lang en] T\n"); + assert_eq!(coded(&d, "html-attribute-form").len(), 1, "{:?}", d); + + let d = parse_diagnostics("@page [colr red] T\n"); + let found = coded(&d, "unknown-attribute"); + assert!( + found.len() == 1 && found[0].severity == htmlang::parser::Severity::Warning, + "{:?}", + d + ); + + let d = parse_diagnostics("@page [padding] T\n"); + assert_eq!(coded(&d, "missing-value").len(), 1, "{:?}", d); + + let d = parse_diagnostics("@page [hovr:color red] T\n"); + assert_eq!(coded(&d, "unknown-prefix").len(), 1, "{:?}", d); + + let d = parse_diagnostics("@page [favicn x.png] T\n"); + let found = coded(&d, "unknown-attribute"); + assert!( + found.len() == 1 + && found[0] + .message + .contains("unknown @page word 'favicn', did you mean 'favicon'?"), + "{:?}", + d + ); + + let d = parse_diagnostics("@page [favicon=x.png] T\n"); + let found = coded(&d, "parameter-form"); + assert!( + found.len() == 1 && found[0].suggestion.as_deref() == Some("favicon "), + "{:?}", + d + ); + + let d = parse_diagnostics("@page [favicon] T\n"); + assert_eq!(coded(&d, "missing-value").len(), 1, "{:?}", d); + + let d = parse_diagnostics("@page [inline] T\n"); + assert_eq!(coded(&d, "unexpected-argument").len(), 1, "{:?}", d); + + // Also where the @page doesn't run: a layout's, in its own file + let d = parse_diagnostics("@let @layout\n @page [lang en] Home\n @children\n"); + assert_eq!(coded(&d, "html-attribute-form").len(), 1, "{:?}", d); +} + +#[test] +fn a_page_s_attributes_can_come_from_a_layout_s_parameters() { + let out = compile( + "@let @layout [title, bg #fff]\n @page [lang=en, background $bg] $title\n @children\n@layout [title Home, bg #fafafa]\n @text a\n", + ); + assert!(out.contains("Home"), "{}", out); + assert!(out.contains(""), "{}", out); + assert!(out.contains(".hl-a{background:#fafafa;}"), "{}", out); +} + +#[test] +fn a_second_page_is_an_error() { + let d = parse_diagnostics("@page A\n@page B\n"); + let found = coded(&d, "duplicate-page"); + assert!( + found.len() == 1 + && found[0].line == 2 + && found[0].severity == htmlang::parser::Severity::Error + && found[0].message.contains("already has one, on line 1"), + "{:?}", + d + ); + // A layout called twice, and a layout's after the page's own + let layout = "@let @layout [title]\n @page $title\n @children\n"; + let d = parse_diagnostics(&format!( + "{}@layout [title A]\n x\n@layout [title B]\n y\n", + layout + )); + assert_eq!(coded(&d, "duplicate-page").len(), 1, "{:?}", d); + let d = parse_diagnostics(&format!("@page Mine\n{}@layout [title A]\n x\n", layout)); + assert_eq!(coded(&d, "duplicate-page").len(), 1, "{:?}", d); + // The same @page run twice says so + let d = parse_diagnostics("@each $i in 1..2\n @page T$i\n"); + let found = coded(&d, "duplicate-page"); + assert!( + found.len() == 1 && found[0].message.contains("runs a second time"), + "{:?}", + d + ); + // The first one stays + let result = htmlang::parser::parse("@page A\n@page B\n"); + assert_eq!(result.document.page.map(|p| p.title).as_deref(), Some("A")); + // One @page in each branch of an @if is one @page + let d = parse_diagnostics("@let wide true\n@if $wide\n @page A\n@else\n @page B\n"); + assert!(coded(&d, "duplicate-page").is_empty(), "{:?}", d); +} + +#[test] +fn the_same_meta_tag_is_written_once() { + let out = compile( + "@let @head-tags\n @meta description Same\n @meta og:title Same\n@page T\n@head-tags\n@head-tags\n", + ); + assert_eq!(out.matches("name=\"description\"").count(), 1, "{}", out); + assert_eq!(out.matches("property=\"og:title\"").count(), 1, "{}", out); + // Different values are different tags + let out = compile("@page T\n@meta keywords a\n@meta keywords b\n"); + assert_eq!(out.matches("name=\"keywords\"").count(), 2, "{}", out); +} + +#[test] +fn a_meta_viewport_replaces_the_usual_one() { + let out = compile("@page T\n@meta viewport width=1024\n"); + assert_eq!(out.matches("name=\"viewport\"").count(), 1, "{}", out); + assert!(out.contains("content=\"width=1024\""), "{}", out); +} + +#[test] +fn a_library_s_definitions_are_not_reported_unused() { + let lib = + "-- A layout\n@let @layout\n @page Home\n @children\n@let gap 8\n@let card [padding 8]\n"; + let d = parse_diagnostics(lib); + assert!(d.is_empty(), "{:?}", d); + // A page's own definitions still are + let d = parse_diagnostics("@page T\n@let gap 8\n"); + assert_eq!(coded(&d, "unused-variable").len(), 1, "{:?}", d); +} + +#[test] +fn page_words_are_checked_where_the_page_does_not_run() { + // A layout's @page in a library: `favicon=`, a favicon without its + // file and `inline` are reported without a call + let d = parse_diagnostics( + "@let @layout\n @page [favicon=x.png, inline, favicon] Home\n @children\n", + ); + assert_eq!(coded(&d, "parameter-form").len(), 1, "{:?}", d); + assert_eq!(coded(&d, "unexpected-argument").len(), 1, "{:?}", d); + assert_eq!(coded(&d, "missing-value").len(), 1, "{:?}", d); + // A file made from a parameter is given + let d = + parse_diagnostics("@let @layout [icon a.png]\n @page [favicon $icon] Home\n @children\n"); + assert!(d.is_empty(), "{:?}", d); +} + +#[test] +fn page_diagnostics_point_at_their_attribute() { + let src = "@page [favicon a.png, inline, favicon b.png] T\n"; + let d = parse_diagnostics(src); + let inline = coded(&d, "unexpected-argument"); + assert_eq!(inline.len(), 1, "{:?}", d); + assert_eq!(inline[0].column, src.find("inline"), "{:?}", d); + // The second favicon is the one that repeats + let twice = coded(&d, "duplicate-attribute"); + assert_eq!(twice.len(), 1, "{:?}", d); + assert_eq!(twice[0].column, src.rfind("favicon"), "{:?}", d); +} + +#[test] +fn an_included_library_s_unused_definitions_are_not_reported() { + let dir = std::env::temp_dir().join(format!("htmlang_test_library_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("layout.hl"), + "@let @layout [title]\n @page $title\n @children\n@let @card\n @children\n@let gap 8\n@let box [padding 8]\n", + ) + .unwrap(); + let result = htmlang::parser::parse_with_base( + "@include layout.hl\n@let mine 4\n@layout [title Home]\n x\n", + Some(&dir), + ); + let _ = std::fs::remove_dir_all(&dir); + let unused: Vec<_> = result + .diagnostics + .iter() + .filter(|d| d.code.starts_with("unused-")) + .collect(); + // Only the page's own definition is reported + assert!( + unused.len() == 1 && unused[0].message.contains("'$mine'"), + "{:?}", + result.diagnostics + ); +} + +#[test] +fn snapshot_one_css_path() { + snapshot_test("one_css_path"); +} + +#[test] +fn every_css_property_is_written_under_its_own_name() { + // The value as written, with pixels where the property takes a length; + // nothing is added or rewritten + let out = compile( + "@el [outline 1 dashed red, container card / inline-size, contain content, \ + width calc(100% - 2 * 8px), letter-spacing 2, flex-basis 200, gap 8]\n x\n", + ); + for declaration in [ + "outline:1px dashed red;", + "container:card / inline-size;", + "contain:content;", + "width:calc(100% - 2 * 8px);", + "letter-spacing:2px;", + "flex-basis:200px;", + "gap:8px;", + ] { + assert!(out.contains(declaration), "{}: {}", declaration, out); + } + // htmlang's own words still write what they mean + let out = compile("@grid [grid-cols 3, spacing 4]\n @el [col-span 2, line-clamp 2] x\n"); + assert!( + out.contains("grid-template-columns:repeat(3,1fr);") && out.contains("gap:4px;"), + "{}", + out + ); + assert!(out.contains("grid-column:span 2;"), "{}", out); + assert!( + out.contains("display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;"), + "{}", + out + ); +} + +#[test] +fn generated_classes_start_with_hl() { + let out = compile("@el [padding 1]\n @el [padding 2] x\n"); + assert!(out.contains(".hl-a{") && out.contains(".hl-b{"), "{}", out); + assert!( + out.contains(r#"
"#), + "{}", + out + ); + // A class of one's own with the prefix gets a warning and is kept + let (html, d) = compile_anyway("@el [class=card hl-a, padding 1] x\n"); + let found = coded(&d, "invalid-value"); + assert_eq!(found.len(), 1, "{:?}", d); + assert_eq!(found[0].severity, htmlang::parser::Severity::Warning); + assert_eq!(found[0].subject.as_deref(), Some("hl-a")); + assert!(found[0].message.contains("`hl-`"), "{}", found[0].message); + assert!(html.contains(r#"class="hl-a card hl-a""#), "{}", html); + // Other classes don't + assert!( + coded( + &parse_diagnostics("@el [class=html-x hl]\n"), + "invalid-value" + ) + .is_empty() + ); +} + +#[test] +fn a_scoped_style_s_class_never_meets_a_generated_one() { + // A function called `c` and the third generated class + let out = compile( + "@let @c\n @style\n & { color: red; }\n @el [padding 3] c\n\ + @el [padding 1] a\n@el [padding 2] b\n@c\n", + ); + assert!(out.contains(".hl-fn-c {"), "{}", out); + assert!(out.contains(r#"
"#), "{}", out); +} + +#[test] +fn element_defaults_stay_on_htmlang_s_elements() { + let src = "@h2 Title\n@ul\n @li Item\n@link /a A\n@image [alt=x] a.png\n\ + @markdown\n ## Heading\n\n - keeps its bullet and [its underline](/x)\n"; + for page in [true, false] { + let out = compile(&format!("{}{}", if page { "@page T\n" } else { "" }, src)); + // Each default is in the element's own class + assert!(out.contains(r#"

"#), "{}", out); + assert!( + out.contains("text-decoration:none;color:inherit;"), + "{}", + out + ); + assert!(out.contains(r#"Heading

\n
    \n
  • "), "{}", out); + assert!(out.contains(r#"its underline"#), "{}", out); + // A page resets its own body and box-sizing; a fragment only + // htmlang's elements + assert_eq!(out.contains("body{"), page, "{}", out); + assert_eq!(out.contains("*,*::before,*::after{"), page, "{}", out); + assert!( + page || out.starts_with( + r#"A photo of sunset \ No newline at end of file +AccessibilityA photo of sunset \ No newline at end of file diff --git a/tests/snapshots/alignment.html b/tests/snapshots/alignment.html index d4b8603..c1c9d2b 100644 --- a/tests/snapshots/alignment.html +++ b/tests/snapshots/alignment.html @@ -1 +1 @@ -Alignment
    Centered X
    Centered Y
    Left
    Right
    Top
    Bottom
    In row
    Top in row
    Bottom in row
    \ No newline at end of file +Alignment
    Centered X
    Centered Y
    Left
    Right
    Top
    Bottom
    In row
    Top in row
    Bottom in row
    \ No newline at end of file diff --git a/tests/snapshots/anchor_positioning.html b/tests/snapshots/anchor_positioning.html index b35f410..5633b23 100644 --- a/tests/snapshots/anchor_positioning.html +++ b/tests/snapshots/anchor_positioning.html @@ -1 +1 @@ -
    Hover me
    Tooltip content
    \ No newline at end of file +
    Hover me
    Tooltip content
    \ No newline at end of file diff --git a/tests/snapshots/animate_shorthand.html b/tests/snapshots/animate_shorthand.html index bcb1ef4..d898572 100644 --- a/tests/snapshots/animate_shorthand.html +++ b/tests/snapshots/animate_shorthand.html @@ -1 +1 @@ -Animate
    Faded in
    Slid up
    \ No newline at end of file +Animate
    Faded in
    Slid up
    \ No newline at end of file diff --git a/tests/snapshots/animations.html b/tests/snapshots/animations.html index c744d85..c995be8 100644 --- a/tests/snapshots/animations.html +++ b/tests/snapshots/animations.html @@ -1 +1 @@ -Animations
    Fading in
    Sliding up
    \ No newline at end of file +Animations
    Fading in
    Sliding up
    \ No newline at end of file diff --git a/tests/snapshots/arithmetic.html b/tests/snapshots/arithmetic.html index 076a61f..61323f0 100644 --- a/tests/snapshots/arithmetic.html +++ b/tests/snapshots/arithmetic.html @@ -1 +1 @@ -Arithmetic
    double is 20
    sum is 15
    diff is 70
    half is 50
    \ No newline at end of file +Arithmetic
    double is 20
    sum is 15
    diff is 70
    half is 50
    \ No newline at end of file diff --git a/tests/snapshots/attributes.html b/tests/snapshots/attributes.html index 211e48e..871b8dc 100644 --- a/tests/snapshots/attributes.html +++ b/tests/snapshots/attributes.html @@ -1 +1 @@ -Attributes
    Styled
    Aligned
    \ No newline at end of file +Attributes
    Styled
    Aligned
    \ No newline at end of file diff --git a/tests/snapshots/basic_elements.html b/tests/snapshots/basic_elements.html index 55c7ff9..f557a9b 100644 --- a/tests/snapshots/basic_elements.html +++ b/tests/snapshots/basic_elements.html @@ -1 +1 @@ -Basic Elements
    HelloWorld
    Inner

    Some text here

    Click me \ No newline at end of file +Basic Elements
    HelloWorld
    Inner

    Some text here

    Click me \ No newline at end of file diff --git a/tests/snapshots/chain_operator.html b/tests/snapshots/chain_operator.html index cf9cb32..3a0d271 100644 --- a/tests/snapshots/chain_operator.html +++ b/tests/snapshots/chain_operator.html @@ -1 +1 @@ -Chain
    Deep chain
    \ No newline at end of file +Chain
    Deep chain
    \ No newline at end of file diff --git a/tests/snapshots/clamp_css.html b/tests/snapshots/clamp_css.html index 820c514..9b7035a 100644 --- a/tests/snapshots/clamp_css.html +++ b/tests/snapshots/clamp_css.html @@ -1 +1 @@ -Clamp CSS Test
    Fluid typography and sizing
    \ No newline at end of file +Clamp CSS Test
    Fluid typography and sizing
    \ No newline at end of file diff --git a/tests/snapshots/code_samples.hl b/tests/snapshots/code_samples.hl new file mode 100644 index 0000000..a31f3c0 --- /dev/null +++ b/tests/snapshots/code_samples.hl @@ -0,0 +1,28 @@ +@page Code samples +-- The lines under @code are shown as written: HTML-escaped, with their +-- line breaks and indentation, and nothing in them is htmlang +@pre [padding 16] + @code + @el [padding 40] + shown as text for $5, {@b not bold} + -- not a comment + +-- @pre is an ordinary element, so a function can wrap a sample +@let @code-block + @pre [padding 16, background #f8fafc] + @children +@code-block + @code + cargo install --path . +@pre > @code + one + two + +-- The text on the line of a verbatim directive is its one-line body +@raw
    +@style .note { color: gray; } +@head + +@textarea [aria-label=Notes, rows=3] + First line + indented & kept diff --git a/tests/snapshots/code_samples.html b/tests/snapshots/code_samples.html new file mode 100644 index 0000000..5f42da3 --- /dev/null +++ b/tests/snapshots/code_samples.html @@ -0,0 +1,5 @@ +Code samples
    @el [padding 40]
    +  <b>shown as text</b> for $5, {@b not bold}
    +  -- not a comment
    cargo install --path .
    one
    +  two

    \ No newline at end of file diff --git a/tests/snapshots/color_scheme.html b/tests/snapshots/color_scheme.html index b2639b8..bdd80f9 100644 --- a/tests/snapshots/color_scheme.html +++ b/tests/snapshots/color_scheme.html @@ -1 +1 @@ -Color Scheme
    Adapts to system theme
    \ No newline at end of file +Color Scheme
    Adapts to system theme
    \ No newline at end of file diff --git a/tests/snapshots/colour_variants.hl b/tests/snapshots/colour_variants.hl new file mode 100644 index 0000000..6aa5c84 --- /dev/null +++ b/tests/snapshots/colour_variants.hl @@ -0,0 +1,12 @@ +-- Colour variants are CSS (color-mix() and relative colours) reading a +-- token with var(), so they follow the token when dark mode redefines it +@let --brand #3b82f6 +@let --radius 8px +@page [dark:--brand #60a5fa] Colour variants +@link [background var(--brand), border-radius var(--radius), hover:background color-mix(in srgb, var(--brand), black 8%)] /signup Sign up +@row + @each $shade in 0, 10, 20, 30 + @el [background color-mix(in srgb, var(--brand), black $shade%)] darken $shade% + @el [background color-mix(in srgb, var(--brand), white 20%)] lighten 20% + @el [background rgb(from var(--brand) r g b / 0.2)] alpha 0.2 + @el [background color-mix(in srgb, var(--brand), #f43f5e 50%)] mix diff --git a/tests/snapshots/colour_variants.html b/tests/snapshots/colour_variants.html new file mode 100644 index 0000000..e99fdc1 --- /dev/null +++ b/tests/snapshots/colour_variants.html @@ -0,0 +1 @@ +Colour variantsSign up
    darken 0%
    darken 10%
    darken 20%
    darken 30%
    lighten 20%
    alpha 0.2
    mix
    \ No newline at end of file diff --git a/tests/snapshots/computed_let.html b/tests/snapshots/computed_let.html index c941179..b2f2c9e 100644 --- a/tests/snapshots/computed_let.html +++ b/tests/snapshots/computed_let.html @@ -1 +1 @@ -Computed Let
    Base sizeLarge size
    \ No newline at end of file +Computed Let
    Base sizeLarge size
    \ No newline at end of file diff --git a/tests/snapshots/conditionals.html b/tests/snapshots/conditionals.html index dc643ea..3f39e2f 100644 --- a/tests/snapshots/conditionals.html +++ b/tests/snapshots/conditionals.html @@ -1 +1 @@ -Conditionals
    Dark mode
    VisibleThey differ \ No newline at end of file +Conditionals
    Dark mode
    VisibleThey differ \ No newline at end of file diff --git a/tests/snapshots/container_queries.hl b/tests/snapshots/container_queries.hl index 7d2ed90..3205a98 100644 --- a/tests/snapshots/container_queries.hl +++ b/tests/snapshots/container_queries.hl @@ -1,10 +1,10 @@ @page Container Test -@el [container, container-name sidebar] +@el [container-type inline-size, container-name sidebar] @el [width 200] @text Sidebar content -- Container query responsive children -@el [container, max-width 800, padding 20] +@el [container-type inline-size, max-width 800, padding 20] @el [background #f0f0f0, padding 16, cq-sm:padding 24, cq-lg:padding 32] @text Responsive to container width diff --git a/tests/snapshots/container_queries.html b/tests/snapshots/container_queries.html index 541aeed..3ea62e4 100644 --- a/tests/snapshots/container_queries.html +++ b/tests/snapshots/container_queries.html @@ -1 +1 @@ -Container Test
    Sidebar content
    Responsive to container width
    \ No newline at end of file +Container Test
    Sidebar content
    Responsive to container width
    \ No newline at end of file diff --git a/tests/snapshots/css_property.html b/tests/snapshots/css_property.html index 2ccad61..b74334a 100644 --- a/tests/snapshots/css_property.html +++ b/tests/snapshots/css_property.html @@ -1 +1 @@ -CSS Property Test
    Custom property element
    \ No newline at end of file +CSS Property Test
    Custom property element
    \ No newline at end of file diff --git a/tests/snapshots/css_shorthands.html b/tests/snapshots/css_shorthands.html index 593e892..0380f41 100644 --- a/tests/snapshots/css_shorthands.html +++ b/tests/snapshots/css_shorthands.html @@ -1 +1 @@ -CSS Shorthands
    This text will be truncated with an ellipsis

    This is a long paragraph that spans multiple lines and should be clamped at three lines.

    Blurred content
    Frosted glass effect
    Item 1
    Item 2
    Item 3
    Gradient background
    Angled gradient
    \ No newline at end of file +CSS Shorthands
    This text will be truncated with an ellipsis

    This is a long paragraph that spans multiple lines and should be clamped at three lines.

    Blurred content
    Frosted glass effect
    Item 1
    Item 2
    Item 3
    Gradient background
    Angled gradient
    \ No newline at end of file diff --git a/tests/snapshots/css_units.html b/tests/snapshots/css_units.html index 5e06dfa..05e2f0e 100644 --- a/tests/snapshots/css_units.html +++ b/tests/snapshots/css_units.html @@ -1 +1 @@ -CSS Units
    Hello
    Responsive padding
    Viewport units
    \ No newline at end of file +CSS Units
    Hello
    Responsive padding
    Viewport units
    \ No newline at end of file diff --git a/tests/snapshots/css_vars.hl b/tests/snapshots/css_vars.hl index 7c8fde2..6f94129 100644 --- a/tests/snapshots/css_vars.hl +++ b/tests/snapshots/css_vars.hl @@ -1,10 +1,10 @@ @page CSS Variables @let --primary #3b82f6 -@let --spacing 20 -@let --radius 8 +@let --spacing 20px +@let --radius 8px -@el [spacing $--spacing] - @el [background var(--primary), border-radius $--radius, padding $--spacing] +@el [spacing var(--spacing)] + @el [background var(--primary), border-radius var(--radius), padding var(--spacing)] @text [color white] Using CSS vars - @el [border 1 solid var(--primary), border-radius $--radius, padding $--spacing] + @el [border 1 solid var(--primary), border-radius var(--radius), padding var(--spacing)] @text [color var(--primary)] Border with var diff --git a/tests/snapshots/css_vars.html b/tests/snapshots/css_vars.html index c053b47..46d0416 100644 --- a/tests/snapshots/css_vars.html +++ b/tests/snapshots/css_vars.html @@ -1 +1 @@ -CSS Variables
    Using CSS vars
    Border with var
    \ No newline at end of file +CSS Variables
    Using CSS vars
    Border with var
    \ No newline at end of file diff --git a/tests/snapshots/custom_properties.hl b/tests/snapshots/custom_properties.hl new file mode 100644 index 0000000..0460950 --- /dev/null +++ b/tests/snapshots/custom_properties.hl @@ -0,0 +1,47 @@ +-- Custom properties are ordinary CSS properties: on any element, under any +-- prefix, with their values as written +@let --ink #0f172a +@let --surface white +@let --subtle #f8fafc +@let --radius 8px +@page [color var(--ink), background var(--surface), dark:--ink #e2e8f0, dark:--surface #0b1220, dark:--subtle #111a2e] Custom properties + +@section [padding 96 24, background var(--subtle)] + @article [padding 24, background var(--surface), border-radius var(--radius)] + Both follow dark mode, from the one place that sets it. + +-- A line of a list that starts with `--name` is a declaration +@el [ + -- the gap, wider on larger screens + --gap 16px, + md:--gap 24px, + spacing var(--gap) +] + @text One + @text Two + +-- Values are written as they are: a custom property has no type +@grid [--cols 3, --hue 200, grid-template-columns repeat(var(--cols), 1fr), background hsl(var(--hue) 80% 95%)] + @text a + @text b + @text c + +-- Under any prefix, and on any element +@link [--underline 2px, hover:--underline 4px, text-decoration-thickness var(--underline)] /about About +@row [children:--pad 4px, focus-within:--ring #3b82f6, has(img):--pad 8px, print:--pad 0] + @text [padding var(--pad)] x +@paragraph Some {@strong [--weight 800, font-weight var(--weight)] inline} text + +-- In a bundle, and forwarded by a call to its root +@let tinted [--tint #fef3c7, background var(--tint)] +@let @box + @el [padding 8] + @children +@box [$tinted, dark:--tint #422006] + Tinted + +-- Vendor-prefixed and SVG properties are written like any other +@el [-webkit-text-stroke 1px black, -webkit-tap-highlight-color transparent] + Stroked +@el [fill currentColor, stroke var(--ink), stroke-width 2, stroke-linecap round, stroke-dasharray 4 2] + Drawn diff --git a/tests/snapshots/custom_properties.html b/tests/snapshots/custom_properties.html new file mode 100644 index 0000000..abc6629 --- /dev/null +++ b/tests/snapshots/custom_properties.html @@ -0,0 +1 @@ +Custom properties
    Both follow dark mode, from the one place that sets it.
    OneTwo
    abc
    About
    x

    Some inline text

    Tinted
    Stroked
    Drawn
    \ No newline at end of file diff --git a/tests/snapshots/dark_mode.html b/tests/snapshots/dark_mode.html index 3ec33e5..b194b78 100644 --- a/tests/snapshots/dark_mode.html +++ b/tests/snapshots/dark_mode.html @@ -1 +1 @@ -Dark Mode
    Hello World
    Theme-aware card
    Not printed
    \ No newline at end of file +Dark Mode
    Hello World
    Theme-aware card
    Not printed
    \ No newline at end of file diff --git a/tests/snapshots/data_directive.html b/tests/snapshots/data_directive.html index 4641477..e50ff08 100644 --- a/tests/snapshots/data_directive.html +++ b/tests/snapshots/data_directive.html @@ -1 +1 @@ -Data TestMy SiteVersion: 1.0rusthtmlcss3 linksHomeAboutContact \ No newline at end of file +Data TestMy SiteVersion: 1.0rusthtmlcss3 linksHomeAboutContact \ No newline at end of file diff --git a/tests/snapshots/direction_attr.html b/tests/snapshots/direction_attr.html index caf65ba..1504f4d 100644 --- a/tests/snapshots/direction_attr.html +++ b/tests/snapshots/direction_attr.html @@ -1 +1 @@ -Direction
    This text should be right-to-left
    This text is explicitly left-to-right
    \ No newline at end of file +Direction
    This text should be right-to-left
    This text is explicitly left-to-right
    \ No newline at end of file diff --git a/tests/snapshots/each_destructuring.html b/tests/snapshots/each_destructuring.html index 343ee90..630d300 100644 --- a/tests/snapshots/each_destructuring.html +++ b/tests/snapshots/each_destructuring.html @@ -1 +1 @@ -Destructuring
    AliceAdmin
    BobUser
    CarolEditor
    \ No newline at end of file +Destructuring
    AliceAdmin
    BobUser
    CarolEditor
    \ No newline at end of file diff --git a/tests/snapshots/each_else.html b/tests/snapshots/each_else.html index b7ff4cc..a4658f2 100644 --- a/tests/snapshots/each_else.html +++ b/tests/snapshots/each_else.html @@ -1 +1 @@ -Each Else
    No items found.
    applebanana
    \ No newline at end of file +Each Else
    No items found.
    applebanana
    \ No newline at end of file diff --git a/tests/snapshots/each_index.html b/tests/snapshots/each_index.html index b904735..d08fdc1 100644 --- a/tests/snapshots/each_index.html +++ b/tests/snapshots/each_index.html @@ -1 +1 @@ -0: apple1: banana2: cherry \ No newline at end of file +0: apple1: banana2: cherry \ No newline at end of file diff --git a/tests/snapshots/each_range.html b/tests/snapshots/each_range.html index 72af64e..7f4cb58 100644 --- a/tests/snapshots/each_range.html +++ b/tests/snapshots/each_range.html @@ -1 +1 @@ -12345 \ No newline at end of file +12345 \ No newline at end of file diff --git a/tests/snapshots/each_step.html b/tests/snapshots/each_step.html index c466e20..2b03660 100644 --- a/tests/snapshots/each_step.html +++ b/tests/snapshots/each_step.html @@ -1 +1 @@ -Each Step
    Value: 0Value: 5Value: 10Value: 15Value: 20
    0
    25
    50
    75
    100
    Countdown: 10Countdown: 8Countdown: 6Countdown: 4Countdown: 2
    \ No newline at end of file +Each Step
    Value: 0Value: 5Value: 10Value: 15Value: 20
    0
    25
    50
    75
    100
    Countdown: 10Countdown: 8Countdown: 6Countdown: 4Countdown: 2
    \ No newline at end of file diff --git a/tests/snapshots/element_prefixes.hl b/tests/snapshots/element_prefixes.hl new file mode 100644 index 0000000..486d0f8 --- /dev/null +++ b/tests/snapshots/element_prefixes.hl @@ -0,0 +1,42 @@ +-- Element prefixes: every such element inside, at any depth +@page [@h2:margin-block-start 16] Element prefixes +@let --line #e5e7eb +@let team Ada, Grace, Linus + +-- A data table: the cells a loop writes need no attributes +@table [border-collapse collapse, width 100%, @th:[padding 8, text-align left], @td:padding 8, @tr:border-bottom 1 solid var(--line), @tr:nth-child(even):background #f8fafc] + @tr + @th Name + @th Role + @each $name in $team + @tr + @td $name + -- Its own attributes win over the table's + @td [padding 2] Engineer + +-- Markdown has no attributes of its own +@article [spacing 12, @h2:font-size 20, @code:[padding 1 4, background #f1f5f9], @link:hover:color #2563eb, md:@h2:font-size 24] + @markdown + ## Notes + + Run `htmlang check` before you [publish](/publish). + +-- The nearest element wins, whatever the order +@section [@td:padding 16, @td:color gray] + @table [@td:padding 4] + @tr + @td Inner + +-- Before the prefix: the element itself; after it: the elements inside +@nav [flex-direction row, spacing 12, hover:@link:text-decoration underline, @link:hover:color #2563eb, @link:before:content "→ "] + @link /a A + @link /b B + +-- A bundle and a group under the prefix, and the headings' default margin +@let muted [color #64748b, font-size 14] +@main [@paragraph:$muted, @ul:[padding-inline-start 20, list-style disc], @li:display list-item] + @h2 Heading + @paragraph Muted text + @ul + @li One + @li Two diff --git a/tests/snapshots/element_prefixes.html b/tests/snapshots/element_prefixes.html new file mode 100644 index 0000000..381d969 --- /dev/null +++ b/tests/snapshots/element_prefixes.html @@ -0,0 +1,3 @@ +Element prefixes
    NameRole
    AdaEngineer
    GraceEngineer
    LinusEngineer

    Notes

    +

    Run htmlang check before you publish.

    +
    Inner

    Heading

    Muted text

    • One
    • Two
    \ No newline at end of file diff --git a/tests/snapshots/else_if.html b/tests/snapshots/else_if.html index bd58c36..1bfe4cb 100644 --- a/tests/snapshots/else_if.html +++ b/tests/snapshots/else_if.html @@ -1 +1 @@ -Dark mode \ No newline at end of file +Dark mode \ No newline at end of file diff --git a/tests/snapshots/env_directive.html b/tests/snapshots/env_directive.html index 28d7978..b19bfe7 100644 --- a/tests/snapshots/env_directive.html +++ b/tests/snapshots/env_directive.html @@ -1 +1 @@ -Env Testdefault_val \ No newline at end of file +Env Testdefault_val \ No newline at end of file diff --git a/tests/snapshots/escapes_and_quotes.hl b/tests/snapshots/escapes_and_quotes.hl new file mode 100644 index 0000000..927b312 --- /dev/null +++ b/tests/snapshots/escapes_and_quotes.hl @@ -0,0 +1,19 @@ +@page Escapes \$5 \& quotes +@meta description "Commas, quotes and backslashes" +@let arrow "→ " +@let areas "head head" "side main" +@let price \$5 +@let @card [lede, mark "*"] + @el [before:content $mark, after:content " — $lede", padding 8] + $lede + +@el [transition opacity 0.3s\, transform 0.3s, font-family "Open Sans"\, sans-serif] + @text [before:content "\201C", after:content "\201D"] A quotation + @text [before:content $arrow] An arrow, with its quotes kept in CSS: $arrow + @el [display grid, grid-template-areas $areas] + @input [type=text, pattern=\d{3}-\d{4}, title=Costs $price, aria-label="Phone, US"] + @card [lede "Fast, simple"] + @card [lede Fast\, simple, mark $arrow] + @paragraph Braces {@mark \{and\}} brackets \[x\], a comma\, a quote \" and \\ + @each $item in one\, two, three + @text $item diff --git a/tests/snapshots/escapes_and_quotes.html b/tests/snapshots/escapes_and_quotes.html new file mode 100644 index 0000000..c21fdad --- /dev/null +++ b/tests/snapshots/escapes_and_quotes.html @@ -0,0 +1 @@ +Escapes $5 \& quotes
    A quotationAn arrow, with its quotes kept in CSS: →
    Fast, simple
    Fast, simple

    Braces {and} brackets [x], a comma, a quote " and \

    one, twothree
    \ No newline at end of file diff --git a/tests/snapshots/extra_css.html b/tests/snapshots/extra_css.html index f362068..eea3e63 100644 --- a/tests/snapshots/extra_css.html +++ b/tests/snapshots/extra_css.html @@ -1 +1 @@ -
    Hidden
    Also hidden
    Styled
    Typography
    Borders
    Wide
    Narrow
    \ No newline at end of file +
    Hidden
    Also hidden
    Styled
    Typography
    Borders
    Wide
    Narrow
    \ No newline at end of file diff --git a/tests/snapshots/fn_defaults.hl b/tests/snapshots/fn_defaults.hl index 9115e29..8200473 100644 --- a/tests/snapshots/fn_defaults.hl +++ b/tests/snapshots/fn_defaults.hl @@ -1,4 +1,4 @@ -@let badge $label $color=blue +@let @badge [label, color blue] @el [padding 4 8, background $color, border-radius 4] @text $label diff --git a/tests/snapshots/fn_defaults.html b/tests/snapshots/fn_defaults.html index d58ede8..38222c7 100644 --- a/tests/snapshots/fn_defaults.html +++ b/tests/snapshots/fn_defaults.html @@ -1 +1 @@ -
    Hello
    Warning
    \ No newline at end of file +
    Hello
    Warning
    \ No newline at end of file diff --git a/tests/snapshots/form_elements.html b/tests/snapshots/form_elements.html index c957de1..a13f307 100644 --- a/tests/snapshots/form_elements.html +++ b/tests/snapshots/form_elements.html @@ -1 +1 @@ -Form Elements
    \ No newline at end of file +Form Elements
    \ No newline at end of file diff --git a/tests/snapshots/fragment.html b/tests/snapshots/fragment.html index 0e4c536..d971ac9 100644 --- a/tests/snapshots/fragment.html +++ b/tests/snapshots/fragment.html @@ -1 +1 @@ -Fragment Test
    FirstSecondThird
    \ No newline at end of file +Fragment Test
    FirstSecondThird
    \ No newline at end of file diff --git a/tests/snapshots/function_calls.hl b/tests/snapshots/function_calls.hl new file mode 100644 index 0000000..fe3fe9e --- /dev/null +++ b/tests/snapshots/function_calls.hl @@ -0,0 +1,66 @@ +-- A function is called like an element: on its own line, in a chain, or +-- inline in text. +@let @key + @kbd [padding 1 6, border 1 solid #d1d5db, border-radius 4] + @children + +@paragraph + Press {@key Ctrl+K} to search, or {@key [background #f3f4f6] Esc} to close. + +-- Parameters bind by name; everything else goes to the root element. +@let @card [title, featured false] + @article [padding 16] + @h3 $title + @if $featured + @text Featured + @children +@card [title Plain, featured, padding 24, id=intro, center-x] + Body + +-- In a chain, and with a chain after it. +@el [padding 8] > @card [title Chained] + Child of the card +@card [title Linked] > @link /more More + +-- A body with several roots, or a text body, stands in its place. +@let @pair + @text [color #b91c1c] One + @text [color #1d4ed8] Two +@let @intro + htmlang is a layout language. + It compiles to {@text [font-weight bold] static HTML}. +@paragraph + Inline: {@pair}. In short, {@intro} +@row [spacing 8] > @pair + +-- A function in the content its caller passes is not recursion. +@let @box + @el [padding 4, border 1 solid #e5e7eb] + @children +@box + @box + Inner + {@key nested} in text + +-- A function may call itself under a condition, and a parameter passed a +-- record or a list gets all of it. +@data $menu [ + {"label": "Guide", "children": [ + {"label": "Install", "children": [{"label": "Linux"}, {"label": "macOS"}]}, + {"label": "Usage"} + ]}, + {"label": "Reference"} +] +@let @tree [items, depth 0] + @ul [padding-left 12] + @each $item in $items + @li + @text $item.label (level $depth) + @if $item.children + @tree [items $item.children, depth ${$depth + 1}] +@tree [items $menu] + +@data $post {"title": "Hello", "author": {"name": "Ada"}} +@let @byline [post] + @text $post.title by $post.author.name +@byline [post $post] diff --git a/tests/snapshots/function_calls.html b/tests/snapshots/function_calls.html new file mode 100644 index 0000000..9eed0cd --- /dev/null +++ b/tests/snapshots/function_calls.html @@ -0,0 +1 @@ +

    Press Ctrl+K to search, or Esc to close.

    Plain

    FeaturedBody

    Chained

    Child of the card

    Inline: One Two. In short, htmlang is a layout language. It compiles to static HTML.

    OneTwo
    Inner
    nested in text
    • Guide (level 0)
      • Install (level 1)
        • Linux (level 2)
        • macOS (level 2)
      • Usage (level 1)
    • Reference (level 0)
    Hello by Ada \ No newline at end of file diff --git a/tests/snapshots/function_definitions.hl b/tests/snapshots/function_definitions.hl new file mode 100644 index 0000000..c6b11f6 --- /dev/null +++ b/tests/snapshots/function_definitions.hl @@ -0,0 +1,35 @@ +-- A value, a bundle and a function: only the function is marked +@let gap = 8 * 2 +@let card [padding 20] +@let @panel [title] + @el [$card, spacing $gap] + @h3 $title + @children + +-- A default is everything after the parameter's name +@let @greeting [name, words Hello there, mark "!"] + @text $words, $name$mark + +-- A long parameter list continues onto the next lines +@let @badge [ + label, + tone #eff6ff, + size 12 +] + @el [background $tone, font-size $size] $label + +-- No brackets: no parameters +@let @rule + @hr + +-- One namespace: a @let gives the name a new meaning +@let accent [color red] +@let accent navy + +@panel [title Hello] + @greeting [name World] + @greeting [name you, words Goodbye] + @rule + @badge [label New] + @badge [label Old, tone #fef2f2, size 14] + @text [color $accent] Accent diff --git a/tests/snapshots/function_definitions.html b/tests/snapshots/function_definitions.html new file mode 100644 index 0000000..4462dea --- /dev/null +++ b/tests/snapshots/function_definitions.html @@ -0,0 +1 @@ +

    Hello

    Hello there, World!Goodbye, you!
    New
    Old
    Accent
    \ No newline at end of file diff --git a/tests/snapshots/functions.hl b/tests/snapshots/functions.hl index d2b03ed..7f78160 100644 --- a/tests/snapshots/functions.hl +++ b/tests/snapshots/functions.hl @@ -1,10 +1,10 @@ @page Functions -@let card $title +@let @card [title] @el [padding 20, border-radius 8] @text [font-weight bold] $title @children -@let wrapper +@let @wrapper @el [spacing 10] @children diff --git a/tests/snapshots/functions.html b/tests/snapshots/functions.html index 8d1b893..db5a61c 100644 --- a/tests/snapshots/functions.html +++ b/tests/snapshots/functions.html @@ -1 +1 @@ -Functions
    HelloContent inside card
    World
    \ No newline at end of file +Functions
    HelloContent inside card
    World
    \ No newline at end of file diff --git a/tests/snapshots/grid_areas.html b/tests/snapshots/grid_areas.html index 90bd6df..c83dbc9 100644 --- a/tests/snapshots/grid_areas.html +++ b/tests/snapshots/grid_areas.html @@ -1 +1 @@ -Grid Areas
    Header
    Sidebar
    Main content
    Footer
    \ No newline at end of file +Grid Areas
    Header
    Sidebar
    Main content
    Footer
    \ No newline at end of file diff --git a/tests/snapshots/has_pseudo.html b/tests/snapshots/has_pseudo.html index 6871212..c342785 100644 --- a/tests/snapshots/has_pseudo.html +++ b/tests/snapshots/has_pseudo.html @@ -1 +1 @@ -Has Pseudo
    Content
    Container
    \ No newline at end of file +Has Pseudo
    Content
    Container
    \ No newline at end of file diff --git a/tests/snapshots/hidden_attr.html b/tests/snapshots/hidden_attr.html index 1dabf0f..ff3d533 100644 --- a/tests/snapshots/hidden_attr.html +++ b/tests/snapshots/hidden_attr.html @@ -1 +1 @@ -Hidden Test
    This is hidden
    This is visible
    \ No newline at end of file +Hidden Test
    This is hidden
    This is visible
    \ No newline at end of file diff --git a/tests/snapshots/html_elements.hl b/tests/snapshots/html_elements.hl new file mode 100644 index 0000000..fc11324 --- /dev/null +++ b/tests/snapshots/html_elements.hl @@ -0,0 +1,42 @@ +@page HTML elements +@article [spacing 12] + @hgroup + @h1 Water + @paragraph A {@dfn molecule} of H{@sub 2}O + @paragraph + {@b Bold}, {@i italic}, {@strong important}, {@em stressed}{@br} + {@small fine print}, {@s gone}, {@u marked}, E = mc{@sup 2} + {@q quoted}, {@var x}, {@samp output}, {@bdi إيان}, {@bdo [dir=rtl] abc} + {@del [datetime=2026-01-01] old}{@ins new}, long{@wbr}word + {@ruby 漢{@rp (}{@rt kan}{@rp )}} + @paragraph [font-size 14] + {@strong [color #b91c1c] Warning:} styles still work. + @menu [spacing 4] + @li > @button [type=button] Copy + @li > @button [type=button] Paste + @table + @caption Team + @colgroup + @col [span=2] + @thead + @tr + @th Name + @th Role + @tbody + @tr + @td Ada + @td Engineer + @tfoot + @tr + @td [colspan=2] One person + @select [aria-label=Fruit] + @optgroup Citrus + @option Lemon + @option Lime + @video [controls] movie.mp4 + @track [kind=captions, srclang=en] captions.vtt + @embed [type=image/svg+xml] logo.svg + @object [type=application/pdf] report.pdf + @link report.pdf Download the report + @map [name=areas] + @area [shape=rect, coords=0\,0\,10\,10, alt=Corner] /corner diff --git a/tests/snapshots/html_elements.html b/tests/snapshots/html_elements.html new file mode 100644 index 0000000..5824991 --- /dev/null +++ b/tests/snapshots/html_elements.html @@ -0,0 +1 @@ +HTML elements

    Water

    A molecule of H2O

    Bold, italic, important, stressed
    fine print, gone, marked, E = mc2 quoted, x, output, إيان, abc oldnew, longword 漢(kan)

    Warning: styles still work.

  • Team
    NameRole
    AdaEngineer
    One person
    Download the reportCorner
    \ No newline at end of file diff --git a/tests/snapshots/if_expr.hl b/tests/snapshots/if_expr.hl index 60cda5c..8324104 100644 --- a/tests/snapshots/if_expr.hl +++ b/tests/snapshots/if_expr.hl @@ -1,12 +1,30 @@ @page If Expression @let active true @let theme dark +@let n 0 -@el [background if($active, blue, gray)] +-- A whole attribute: each branch is an attribute +@el [if($active, background blue, background gray)] Active element -@el [background if($active, #3b82f6, #999), color if($theme == dark, white, black)] +-- A value: an expression, whose CSS text is quoted when it isn't one word +@el [background ${if($active, #3b82f6, "var(--muted)")}, if($theme == dark, color white, color black)] Themed element -@el [padding if($active != true, 10, 20)] +@el [padding ${if($active != true, 10, 20)}] Inactive padding + +-- A group: one condition for several attributes +@el [if($active, [border 1 solid var(--line), font-weight 600, aria-current=page])] + Grouped attributes + +-- An empty branch leaves the attribute out; a branch can be a bundle +@el [if(not $active, padding 4), if($active, $truncate, [])] + Empty branch and a bundle + +-- Only the branch taken is evaluated +@text Ratio ${if($n != 0, 10 / $n, 0)}, ${$n == 0 or 10 / $n > 1} + +-- CSS's own if() is CSS, decided by the browser +@el [width if(media(width > 40em): 50%; else: 100%)] + CSS if() diff --git a/tests/snapshots/if_expr.html b/tests/snapshots/if_expr.html index 2a4a407..7c6827c 100644 --- a/tests/snapshots/if_expr.html +++ b/tests/snapshots/if_expr.html @@ -1 +1 @@ -If Expression
    Active element
    Themed element
    Inactive padding
    \ No newline at end of file +If Expression
    Active element
    Themed element
    Inactive padding
    Grouped attributes
    Empty branch and a bundle
    Ratio 0, true
    CSS if()
    \ No newline at end of file diff --git a/tests/snapshots/initial_letter.html b/tests/snapshots/initial_letter.html index d046939..875c608 100644 --- a/tests/snapshots/initial_letter.html +++ b/tests/snapshots/initial_letter.html @@ -1 +1 @@ -

    O nce upon a time in a land far away.

    \ No newline at end of file +

    O nce upon a time in a land far away.

    \ No newline at end of file diff --git a/tests/snapshots/inline_text.html b/tests/snapshots/inline_text.html index cf16801..dbe1198 100644 --- a/tests/snapshots/inline_text.html +++ b/tests/snapshots/inline_text.html @@ -1 +1 @@ -Inline Text

    This is bold and styled text.

    Multiple inline elements here.

    \ No newline at end of file +Inline Text

    This is bold and styled text.

    Multiple inline elements here.

    \ No newline at end of file diff --git a/tests/snapshots/lang_directive.hl b/tests/snapshots/lang_directive.hl index a8e4533..7e1c605 100644 --- a/tests/snapshots/lang_directive.hl +++ b/tests/snapshots/lang_directive.hl @@ -1,3 +1,3 @@ -@page [lang en] Lang Test +@page [lang=en] Lang Test @text Hello World diff --git a/tests/snapshots/lang_directive.html b/tests/snapshots/lang_directive.html index de3331a..ad9d7c5 100644 --- a/tests/snapshots/lang_directive.html +++ b/tests/snapshots/lang_directive.html @@ -1 +1 @@ -Lang TestHello World \ No newline at end of file +Lang TestHello World \ No newline at end of file diff --git a/tests/snapshots/layer_wrapping.html b/tests/snapshots/layer_wrapping.html index ed5a530..ce93253 100644 --- a/tests/snapshots/layer_wrapping.html +++ b/tests/snapshots/layer_wrapping.html @@ -1 +1 @@ -Layer Test
    Hello
    Hoverable
    \ No newline at end of file +Layer Test
    Hello
    Hoverable
    \ No newline at end of file diff --git a/tests/snapshots/layout_direction.hl b/tests/snapshots/layout_direction.hl new file mode 100644 index 0000000..ec1a2db --- /dev/null +++ b/tests/snapshots/layout_direction.hl @@ -0,0 +1,33 @@ +@page Layout direction +-- A column that becomes a row from md up: the children's layout words +-- follow it there +@header [spacing 16, md:flex-direction row, md:align-items center] + @text [font-weight 800] Launchpad + @el [width fill] + @nav [flex-direction row, spacing 20] + @link /features Features + @link /pricing Pricing + +-- The centred column: the full width up to 800px, in the middle +@section [padding 24] + @el [width fill, max-width 800, center-x, spacing 8] + @text [width shrink, background #eef2ff] Fits its text + @text [align-right] On the right + +-- A row that stacks from sm up and is a row again from lg up +@row [spacing 12, sm:flex-direction column, lg:flex-direction row] + @el [width fill, min-width 200] Keeps its own min-width + @el [width 240, lg:width fill] Fixed, then fills + @el [width shrink] Shrinks + +-- A direction under stacked prefixes: a column from md up, a row again +-- from md up in dark mode +@row [spacing 8, md:flex-direction column, md:dark:flex-direction row] + @el [width fill] A + @el B + +-- The direction of a media or container condition +@el [container-type inline-size] + @el [cq-md:flex-direction row, print:flex-direction row] + @el [width fill] One + @el [height fill] Two diff --git a/tests/snapshots/layout_direction.html b/tests/snapshots/layout_direction.html new file mode 100644 index 0000000..ebdc0a2 --- /dev/null +++ b/tests/snapshots/layout_direction.html @@ -0,0 +1 @@ +Layout direction
    Launchpad
    Fits its textOn the right
    Keeps its own min-width
    Fixed, then fills
    Shrinks
    A
    B
    One
    Two
    \ No newline at end of file diff --git a/tests/snapshots/layout_modes.hl b/tests/snapshots/layout_modes.hl new file mode 100644 index 0000000..43769cf --- /dev/null +++ b/tests/snapshots/layout_modes.hl @@ -0,0 +1,44 @@ +-- Every element has one layout. In a row, column or grid each line of +-- text is a child; in a text element the lines flow. + +-- A list is a column: `spacing` is the gap between its items +@ol [spacing 4] + @li First + @li Second + and more + +-- Text: the lines join with a space +@button [type=button] + Save + changes +@h2 Meet + htmlang +@td + a + b + +-- A layout element inside text is laid out inline, in a +@paragraph + Price: {@el [padding 2, background #fef3c7] $9} a month. +@h3 + Plans + @row [spacing 4] > @text New +@paragraph Grid: {@grid [grid-cols 2] {@text a} {@text b}} + +-- A semantic container in text keeps its element +@label + Name + @search [padding 2] > @input [type=search, aria-label=Name] + +-- Children compile against their parent's layout +@ul [spacing 2] + @li [center-x] Centered in a column + @li [width fill] Full width +@row + @el [width fill] A + @el [width fill] B + +-- Native: HTML's own layout; lines are separated by a line break +@pre + line one + line two diff --git a/tests/snapshots/layout_modes.html b/tests/snapshots/layout_modes.html new file mode 100644 index 0000000..d09c7fa --- /dev/null +++ b/tests/snapshots/layout_modes.html @@ -0,0 +1,2 @@ +
    1. First
    2. Secondand more

    Meet htmlang

    a b

    Price: $9 a month.

    Plans New

    Grid: a b

    • Centered in a column
    • Full width
    A
    B
    line one
    +line two
    \ No newline at end of file diff --git a/tests/snapshots/leading_argument.hl b/tests/snapshots/leading_argument.hl new file mode 100644 index 0000000..7ff570c --- /dev/null +++ b/tests/snapshots/leading_argument.hl @@ -0,0 +1,50 @@ +@page Leading argument +-- The first word after the attributes fills the element's leading +-- attribute; the rest of the text is its content +@let next 3 +@let spaced /docs/getting started +@let docs "/docs/a guide" +@let @nav-link [to, label] + @link [padding 4] $to $label +@nav [flex-direction row, spacing 12] + @link /about About {@b us} + @link [href=/contact, class=c] + Contact + @link $spaced Getting started + @link $docs The guide + @link /page/${$next + 1} Next page + @link "/my page" Mine + @nav-link [to /x, label X] +@form [method=post, spacing 8] /subscribe + @label [for=email] Email + @input [type=email, name=email, id=email] + @button [type=submit] Sign up +@form /search Search the site +@image [alt=Logo, width 120] logo.png +@image [alt=Team] "team photo.jpg" +@image [src=hero.jpg, alt=Hero] +-- @source's is srcset in @picture and src in @video +@picture + @source [type=image/avif] hero.avif + @source [srcset=hero-2x.webp 2x, type=image/webp] + @image [alt=Hero] hero.jpg +@video [controls, aria-label=Intro] intro.mp4 + @source [type=video/webm] intro.webm + @track [kind=captions, srclang=en] intro.vtt + Your browser doesn't play this video. +@audio [controls] song.mp3 +@iframe [title=Map, width fill, height 300] https://example.com/map +@select [aria-label=Fruit] + @optgroup "Citrus fruits" + @option Lemon +@embed [type=image/svg+xml] logo.svg +@object [type=application/pdf] report.pdf + The report +@map [name=m] + @area [shape=rect, coords=0\,0\,10\,10, alt=Corner] /corner +-- @script: every HTML attribute passes through +@script [type=module, nonce=abc, data-page=home, class=s] app.js +@script + document.body.dataset.ready = "yes" +@el > @script + if (a < b && c) { @notanelement(); } diff --git a/tests/snapshots/leading_argument.html b/tests/snapshots/leading_argument.html new file mode 100644 index 0000000..6ff8b0c --- /dev/null +++ b/tests/snapshots/leading_argument.html @@ -0,0 +1 @@ +Leading argument
    Search the site
    LogoTeamHeroHeroThe reportCorner
    \ No newline at end of file diff --git a/tests/snapshots/list_elements.html b/tests/snapshots/list_elements.html index 0dde565..73adbb8 100644 --- a/tests/snapshots/list_elements.html +++ b/tests/snapshots/list_elements.html @@ -1 +1 @@ -List Elements
    • First item
    • Second item
    • Third item
    1. Step one
    2. Step two
    3. Step three
    • Styled item
    \ No newline at end of file +List Elements
    • First item
    • Second item
    • Third item
    1. Step one
    2. Step two
    3. Step three
    • Styled item
    \ No newline at end of file diff --git a/tests/snapshots/loops.html b/tests/snapshots/loops.html index ad6dc1d..eb4276d 100644 --- a/tests/snapshots/loops.html +++ b/tests/snapshots/loops.html @@ -1 +1 @@ -Loops
    applebananacherry \ No newline at end of file +Loops
    applebananacherry \ No newline at end of file diff --git a/tests/snapshots/manifest_directive.html b/tests/snapshots/manifest_directive.html index d814351..3a1afc1 100644 --- a/tests/snapshots/manifest_directive.html +++ b/tests/snapshots/manifest_directive.html @@ -1 +1 @@ -My App
    Hello
    \ No newline at end of file +My App
    Hello
    \ No newline at end of file diff --git a/tests/snapshots/markdown_block.html b/tests/snapshots/markdown_block.html index 23c5601..5e3a881 100644 --- a/tests/snapshots/markdown_block.html +++ b/tests/snapshots/markdown_block.html @@ -1,4 +1,4 @@ -Markdown Test

    Hello World

    +Markdown Test

    Hello World

    This is a bold paragraph with italic text. Here is inline code and a link.

    • Item one
    • diff --git a/tests/snapshots/match_directive.html b/tests/snapshots/match_directive.html index 7657840..2b00ce3 100644 --- a/tests/snapshots/match_directive.html +++ b/tests/snapshots/match_directive.html @@ -1 +1 @@ -Match
      Dark theme
      \ No newline at end of file +Match
      Dark theme
      \ No newline at end of file diff --git a/tests/snapshots/media_elements.html b/tests/snapshots/media_elements.html index 4f4a6b3..648a642 100644 --- a/tests/snapshots/media_elements.html +++ b/tests/snapshots/media_elements.html @@ -1 +1 @@ -Media Elements \ No newline at end of file +Media Elements \ No newline at end of file diff --git a/tests/snapshots/media_extended.html b/tests/snapshots/media_extended.html index ef2fe36..601206d 100644 --- a/tests/snapshots/media_extended.html +++ b/tests/snapshots/media_extended.html @@ -1 +1 @@ -Media Extended
      Wide screen layout
      Respects motion preferences
      Animation with motion safety
      Orientation-aware padding
      \ No newline at end of file +Media Extended
      Wide screen layout
      Respects motion preferences
      Animation with motion safety
      Orientation-aware padding
      \ No newline at end of file diff --git a/tests/snapshots/meta_head.html b/tests/snapshots/meta_head.html index 4d44460..0e40d17 100644 --- a/tests/snapshots/meta_head.html +++ b/tests/snapshots/meta_head.html @@ -1 +1 @@ -My Site
      Hello
      \ No newline at end of file +My Site
      Hello
      \ No newline at end of file diff --git a/tests/snapshots/mixin_spread.hl b/tests/snapshots/mixin_spread.hl index d996140..ba083ee 100644 --- a/tests/snapshots/mixin_spread.hl +++ b/tests/snapshots/mixin_spread.hl @@ -7,4 +7,4 @@ Hello mixin world @el [$card-style, border 1 solid #ccc] - Using $define syntax too + Using \$define syntax too diff --git a/tests/snapshots/mixin_spread.html b/tests/snapshots/mixin_spread.html index 3b074be..e945afc 100644 --- a/tests/snapshots/mixin_spread.html +++ b/tests/snapshots/mixin_spread.html @@ -1 +1 @@ -Mixin Test
      Hello mixin world
      Using $define syntax too
      \ No newline at end of file +Mixin Test
      Hello mixin world
      Using $define syntax too
      \ No newline at end of file diff --git a/tests/snapshots/named_slots.hl b/tests/snapshots/named_slots.hl index f056b10..de588ca 100644 --- a/tests/snapshots/named_slots.hl +++ b/tests/snapshots/named_slots.hl @@ -1,4 +1,4 @@ -@let layout +@let @layout @el [padding 20] @slot header @text Default Header diff --git a/tests/snapshots/named_slots.html b/tests/snapshots/named_slots.html index 5337935..258db82 100644 --- a/tests/snapshots/named_slots.html +++ b/tests/snapshots/named_slots.html @@ -1 +1 @@ -
      Custom HeaderMain bodyFooter content
      \ No newline at end of file +
      Custom HeaderMain bodyFooter content
      \ No newline at end of file diff --git a/tests/snapshots/names_and_values.hl b/tests/snapshots/names_and_values.hl new file mode 100644 index 0000000..7ae2505 --- /dev/null +++ b/tests/snapshots/names_and_values.hl @@ -0,0 +1,18 @@ +-- Names are checked and values pass through to the CSS as written +@page Names and values +@data $post {"title": "Hello", "author": {"name": "Ada"}} +@el [max-width none, z-index auto, opacity 50%, color rebeccapurple, width fit-content] + Values are the browser's to judge +@el [--gap 12px, gap var(--gap), -webkit-tap-highlight-color transparent, hover:--gap 16px] + Custom and vendor-prefixed properties, as written +@el [width if(media(width > 40em): 50%; else: 100%), background url(data:image/gif;base64,R0lGODlhAQABAAAAACw=)] + A `;` inside parentheses is CSS's own +@el [before:content "a;b{}", padding $post.gap, margin-top ${default($post.gap, 8)}] + $post.title by $post.author.name$post.editor.name +@row + @el [width fill] Fills the row +@el + @el [width fill] The full width of a column +@image [width=800, width 200, alt=A photo] photo.jpg +@el [hidden] Hidden +@script [src=app.js, type=module, data-page=home, defer] diff --git a/tests/snapshots/names_and_values.html b/tests/snapshots/names_and_values.html new file mode 100644 index 0000000..16dfec4 --- /dev/null +++ b/tests/snapshots/names_and_values.html @@ -0,0 +1 @@ +Names and values
      Values are the browser's to judge
      Custom and vendor-prefixed properties, as written
      A `;` inside parentheses is CSS's own
      Hello by Ada
      Fills the row
      The full width of a column
      A photo \ No newline at end of file diff --git a/tests/snapshots/new_css.html b/tests/snapshots/new_css.html index ab3ebd8..1fa181a 100644 --- a/tests/snapshots/new_css.html +++ b/tests/snapshots/new_css.html @@ -1 +1 @@ -New CSS
      Margins
      Filtered
      LeftRight
      Second
      Background
      Shadowed
      Longtext
      \ No newline at end of file +New CSS
      Margins
      Filtered
      LeftRight
      Second
      Background
      Shadowed
      Longtext
      \ No newline at end of file diff --git a/tests/snapshots/new_css_attrs.hl b/tests/snapshots/new_css_attrs.hl index d8a427a..8a9ede3 100644 --- a/tests/snapshots/new_css_attrs.hl +++ b/tests/snapshots/new_css_attrs.hl @@ -3,7 +3,7 @@ @el [aspect-ratio 16/9, width 400, background #eee] @text Widescreen -@el [outline 2 red, padding 10] +@el [outline 2 solid red, padding 10] @text Outlined @el [padding-inline 20, padding-block 10] diff --git a/tests/snapshots/new_css_attrs.html b/tests/snapshots/new_css_attrs.html index 790d2d7..828e7e8 100644 --- a/tests/snapshots/new_css_attrs.html +++ b/tests/snapshots/new_css_attrs.html @@ -1 +1 @@ -CSS Attrs
      Widescreen
      Outlined
      Logical padding
      Logical margin
      Snap target
      \ No newline at end of file +CSS Attrs
      Widescreen
      Outlined
      Logical padding
      Logical margin
      Snap target
      \ No newline at end of file diff --git a/tests/snapshots/new_css_properties.html b/tests/snapshots/new_css_properties.html index 82c2178..badcda6 100644 --- a/tests/snapshots/new_css_properties.html +++ b/tests/snapshots/new_css_properties.html @@ -1 +1 @@ -New CSS Properties
      Overflow
      Overlay
      • One
      • Two
      Cell
      Decorated
      Centered
      Self centered
      Smooth scroll
      \ No newline at end of file +New CSS Properties
      Overflow
      Overlay
      • One
      • Two
      Cell
      Decorated
      Centered
      Self centered
      Smooth scroll
      \ No newline at end of file diff --git a/tests/snapshots/new_css_properties_2.html b/tests/snapshots/new_css_properties_2.html index 8c458b5..74974fd 100644 --- a/tests/snapshots/new_css_properties_2.html +++ b/tests/snapshots/new_css_properties_2.html @@ -1 +1 @@ -New CSS Properties 2
      Triangle
      Blended
      Background blend
      Vertical text
      Multi-column layout content here

      Indented first line of the paragraph.

      A very long word that might need hyphenation in narrow containers.

      Grow
      No shrink
      Fixed basis
      Isolated stacking
      Centered grid content
      With background image
      \ No newline at end of file +New CSS Properties 2
      Triangle
      Blended
      Background blend
      Vertical text
      Multi-column layout content here

      Indented first line of the paragraph.

      A very long word that might need hyphenation in narrow containers.

      Grow
      No shrink
      Fixed basis
      Isolated stacking
      Centered grid content
      With background image
      \ No newline at end of file diff --git a/tests/snapshots/new_css_properties_3.html b/tests/snapshots/new_css_properties_3.html index 3d9c1b7..f01402a 100644 --- a/tests/snapshots/new_css_properties_3.html +++ b/tests/snapshots/new_css_properties_3.html @@ -1 +1 @@ -New CSS Properties 3
      Semi-bold textOblique textA heading that wraps nicely
      Optimized for animation
      No touch scrolling
      Middle aligned
      Contained element
      Offset for fixed header
      Lazy rendered content
      Scroll padding container
      \ No newline at end of file +New CSS Properties 3
      Semi-bold textOblique textA heading that wraps nicely
      Optimized for animation
      No touch scrolling
      Middle aligned
      Contained element
      Offset for fixed header
      Lazy rendered content
      Scroll padding container
      \ No newline at end of file diff --git a/tests/snapshots/new_css_properties_4.html b/tests/snapshots/new_css_properties_4.html index 06f2911..48a1c11 100644 --- a/tests/snapshots/new_css_properties_4.html +++ b/tests/snapshots/new_css_properties_4.html @@ -1 +1 @@ -Styled underline
      Multi-column layout content
      \ No newline at end of file +Styled underline
      Multi-column layout content
      \ No newline at end of file diff --git a/tests/snapshots/new_directives.html b/tests/snapshots/new_directives.html index 5c3d452..3e27a35 100644 --- a/tests/snapshots/new_directives.html +++ b/tests/snapshots/new_directives.html @@ -5,4 +5,4 @@ "@type": "WebPage", "name": "My Site" } -
      Hello with custom fonts
      \ No newline at end of file +
      Hello with custom fonts
      \ No newline at end of file diff --git a/tests/snapshots/new_elements.html b/tests/snapshots/new_elements.html index 482b473..c569092 100644 --- a/tests/snapshots/new_elements.html +++ b/tests/snapshots/new_elements.html @@ -1 +1 @@ -New Elements

      Click to expandHidden content here
      To be or not to beShakespeare
      A code example
      A code example
      console.log("hello")
      \ No newline at end of file +New Elements

      Click to expandHidden content here
      To be or not to beShakespeare
      A code example
      A code example
      console.log("hello")
      \ No newline at end of file diff --git a/tests/snapshots/new_elements_2.html b/tests/snapshots/new_elements_2.html index 08f88b7..953686b 100644 --- a/tests/snapshots/new_elements_2.html +++ b/tests/snapshots/new_elements_2.html @@ -1 +1 @@ -New Elements 2Dialog TitleDialog content here
      Term One
      Definition of term one
      Term Two
      Definition of term two
      Personal Info
      A photoThis is {@mark highlighted} textPress {@kbd Ctrl+C} to copyHTML \ No newline at end of file +New Elements 2Dialog TitleDialog content here
      Term One
      Definition of term one
      Term Two
      Definition of term two
      Personal Info
      A photoThis is highlighted textPress Ctrl+C to copyHTML \ No newline at end of file diff --git a/tests/snapshots/new_elements_3.html b/tests/snapshots/new_elements_3.html index e898dbc..a2d4474 100644 --- a/tests/snapshots/new_elements_3.html +++ b/tests/snapshots/new_elements_3.html @@ -1 +1 @@ -New Elements 342 \ No newline at end of file +New Elements 342 \ No newline at end of file diff --git a/tests/snapshots/new_elements_4.html b/tests/snapshots/new_elements_4.html index 89f0c04..9424458 100644 --- a/tests/snapshots/new_elements_4.html +++ b/tests/snapshots/new_elements_4.html @@ -1 +1 @@ -New Elements 4
      First
      Second
      Third
      Overlay
      Left
      Right
      Helpful text
      \ No newline at end of file +New Elements 4
      First
      Second
      Third
      Overlay
      Left
      Right
      Helpful text
      \ No newline at end of file diff --git a/tests/snapshots/new_elements_6.html b/tests/snapshots/new_elements_6.html index 40b8cb9..acc9781 100644 --- a/tests/snapshots/new_elements_6.html +++ b/tests/snapshots/new_elements_6.html @@ -1 +1 @@ -
      John Doejohn@example.com
      \ No newline at end of file +
      John Doejohn@example.com
      \ No newline at end of file diff --git a/tests/snapshots/new_html_attrs.html b/tests/snapshots/new_html_attrs.html index 40948fc..63aaa3a 100644 --- a/tests/snapshots/new_html_attrs.html +++ b/tests/snapshots/new_html_attrs.html @@ -1 +1 @@ -HTML Attrs
      \ No newline at end of file +HTML Attrs
      \ No newline at end of file diff --git a/tests/snapshots/new_pseudos.html b/tests/snapshots/new_pseudos.html index 9cb9a2e..f78162c 100644 --- a/tests/snapshots/new_pseudos.html +++ b/tests/snapshots/new_pseudos.html @@ -1 +1 @@ -Already visited
      Conditional content
      \ No newline at end of file +Already visited
      Conditional content
      \ No newline at end of file diff --git a/tests/snapshots/no_page.html b/tests/snapshots/no_page.html index 2471beb..21095b3 100644 --- a/tests/snapshots/no_page.html +++ b/tests/snapshots/no_page.html @@ -1 +1 @@ -
      Fragment without @page
      \ No newline at end of file +
      Fragment without @page
      \ No newline at end of file diff --git a/tests/snapshots/nth_pseudo.hl b/tests/snapshots/nth_pseudo.hl index eab8279..fa0c3e9 100644 --- a/tests/snapshots/nth_pseudo.hl +++ b/tests/snapshots/nth_pseudo.hl @@ -3,11 +3,11 @@ -- nth-child styling @el [spacing 4] @each $i in 1..5 - @el [padding 8, nth:3:background #3b82f6, nth:3:color white] + @el [padding 8, nth-child(3):background #3b82f6, nth-child(3):color white] @text Item $i -- nth-child with formula @row [spacing 4, wrap] @each $i in 1..8 - @el [padding 12, background #f0f0f0, nth:2n:background #e0e0e0] + @el [padding 12, background #f0f0f0, nth-child(2n):background #e0e0e0] @text $i diff --git a/tests/snapshots/nth_pseudo.html b/tests/snapshots/nth_pseudo.html index 9de14de..8901ac2 100644 --- a/tests/snapshots/nth_pseudo.html +++ b/tests/snapshots/nth_pseudo.html @@ -1 +1 @@ -Nth Pseudo
      Item 1
      Item 2
      Item 3
      Item 4
      Item 5
      1
      2
      3
      4
      5
      6
      7
      8
      \ No newline at end of file +Nth Pseudo
      Item 1
      Item 2
      Item 3
      Item 4
      Item 5
      1
      2
      3
      4
      5
      6
      7
      8
      \ No newline at end of file diff --git a/tests/snapshots/og_directive.html b/tests/snapshots/og_directive.html index 1da09ec..3a47946 100644 --- a/tests/snapshots/og_directive.html +++ b/tests/snapshots/og_directive.html @@ -1 +1 @@ -OG TestHello \ No newline at end of file +OG TestHello \ No newline at end of file diff --git a/tests/snapshots/one_css_path.hl b/tests/snapshots/one_css_path.hl new file mode 100644 index 0000000..ea18658 --- /dev/null +++ b/tests/snapshots/one_css_path.hl @@ -0,0 +1,14 @@ +@page One CSS path +@input [type=email, aria-label=Email, focus:outline 2 solid var(--brand)] +@el [outline 2 red, container card / inline-size, contain content] + An outline without a style draws none +@el [width calc(100% - 2 * 8px), content-visibility auto, line-clamp 2] + Values are CSS's +@h2 Defaults stay on htmlang's elements +@ul + @li No marker +@link /home Home +@markdown + - keeps its bullet and [its underline](/x) +@dialog A closed dialog stays hidden +@el [hidden] So does a hidden element diff --git a/tests/snapshots/one_css_path.html b/tests/snapshots/one_css_path.html new file mode 100644 index 0000000..52c219c --- /dev/null +++ b/tests/snapshots/one_css_path.html @@ -0,0 +1,4 @@ +One CSS path
      An outline without a style draws none
      Values are CSS's

      Defaults stay on htmlang's elements

      • No marker
      Home +A closed dialog stays hidden \ No newline at end of file diff --git a/tests/snapshots/page_root.hl b/tests/snapshots/page_root.hl new file mode 100644 index 0000000..bfcf295 --- /dev/null +++ b/tests/snapshots/page_root.hl @@ -0,0 +1,15 @@ +-- The page is the root element: @page's styles style , its +-- key=value attributes go on , and favicon is its own word. +@page [lang=en, dir=ltr, class=site, favicon missing-icon.png, color var(--ink), font-family Georgia\, serif, spacing 8, dark:background #0b1220, dark:color #e2e8f0, md:padding 16] The page is the root +@let --ink #0f172a +@meta description One page +@meta description One page + +-- The top level is a column, like the children of an @el +A line of text +@text [background #fef3c7] Inline elements stack too +@link /about About +@header [padding 16] Header +@main [height fill, padding 16] + @el [center-y, center-x] In the middle of the rest of the window +@footer [padding 16] Footer diff --git a/tests/snapshots/page_root.html b/tests/snapshots/page_root.html new file mode 100644 index 0000000..6318896 --- /dev/null +++ b/tests/snapshots/page_root.html @@ -0,0 +1 @@ +The page is the rootA line of textInline elements stack tooAbout
      Header
      In the middle of the rest of the window
      Footer
      \ No newline at end of file diff --git a/tests/snapshots/parameters.hl b/tests/snapshots/parameters.hl new file mode 100644 index 0000000..16a7732 --- /dev/null +++ b/tests/snapshots/parameters.hl @@ -0,0 +1,31 @@ +-- Parameters are declared the way a call passes them +@let brand #eff6ff +@let @card [title, tone $brand, heading "About $title", mark "→ ", note ${if($title, set, unset)}] + @article [padding 16, background $tone, before:content $mark] + @h3 $heading + @text $note + @children + +@card [title Full, tone #f9fafb] +@card [title Plain] + Children. +-- A default sees the names visible where @card is defined: this later +-- @let doesn't change it +@let brand #fef3c7 +@card [title "A, b and \$c"] + +-- A flag: false unless the call names it +@let @post-card [post, featured false, number ""] + @article [padding ${if($featured, 24, 8)}] + @if $featured + @text Featured + @if $number + @text $number. + @h2 $post + +@post-card [post First, featured, number 1] +@post-card [post Second] +@post-card [post Third, featured false] + +-- Attributes that aren't parameters style the root +@post-card [post Styled, background #f8fafc, id=styled] diff --git a/tests/snapshots/parameters.html b/tests/snapshots/parameters.html new file mode 100644 index 0000000..7390422 --- /dev/null +++ b/tests/snapshots/parameters.html @@ -0,0 +1 @@ +

      About Full

      set

      About Plain

      setChildren.

      About A, b and $c

      set
      Featured1.

      First

      Second

      Third

      Styled

      \ No newline at end of file diff --git a/tests/snapshots/popover_api.html b/tests/snapshots/popover_api.html index afbd0b6..afc3d22 100644 --- a/tests/snapshots/popover_api.html +++ b/tests/snapshots/popover_api.html @@ -1 +1 @@ -Popover Demo
      This is a popover panel
      \ No newline at end of file +Popover Demo
      This is a popover panel
      \ No newline at end of file diff --git a/tests/snapshots/positional.html b/tests/snapshots/positional.html index e09d80f..35bb298 100644 --- a/tests/snapshots/positional.html +++ b/tests/snapshots/positional.html @@ -1 +1 @@ -Positional
      X
      Y
      \ No newline at end of file +Positional
      X
      Y
      \ No newline at end of file diff --git a/tests/snapshots/prefixes_compose.hl b/tests/snapshots/prefixes_compose.hl new file mode 100644 index 0000000..d6e47ad --- /dev/null +++ b/tests/snapshots/prefixes_compose.hl @@ -0,0 +1,17 @@ +-- Prefixes stack, and one prefix covers a group, a bundle or an if() +@let card [padding 16, border 1 solid #e5e7eb, border-radius 8] +@let active true +@page Prefixes compose +@el [dark:md:padding 8, padding 4, md:dark:color white, dark:color #e5e7eb, md:padding 6, hover:color blue] + Written out of order, emitted in one order +@el [md:[padding 32, font-size 20], dark:[background #1e3a8a, hover:background #1e40af]] + Groups +@section [spacing 16, md:$card, hover:if($active, [background #eef2ff, color #3730a3])] + A bundle and an if() +@row [spacing 4, children:opacity 1, hover:children:opacity 0.8, children:hover:color red, children:nth-child(odd):background #f3f4f6] + @el [opacity 0.5] Own opacity wins + @el Two +@el [before:content "→ ", hover:before:color blue, md:hover:after:content "!"] + Pseudo-elements last +@el [xml:lang=en, x-on:click=open] + @el [md:dark:[padding 12, hover:[color red, cq-md:color green]]] Nested groups diff --git a/tests/snapshots/prefixes_compose.html b/tests/snapshots/prefixes_compose.html new file mode 100644 index 0000000..4db1685 --- /dev/null +++ b/tests/snapshots/prefixes_compose.html @@ -0,0 +1 @@ +Prefixes compose
      Written out of order, emitted in one order
      Groups
      A bundle and an if()
      Own opacity wins
      Two
      Pseudo-elements last
      Nested groups
      \ No newline at end of file diff --git a/tests/snapshots/pseudo_elements.html b/tests/snapshots/pseudo_elements.html index 1801e51..9457a8e 100644 --- a/tests/snapshots/pseudo_elements.html +++ b/tests/snapshots/pseudo_elements.html @@ -1 +1 @@ -Pseudo Elements
      Item with arrow prefix
      Completed item
      No pseudo element
      \ No newline at end of file +Pseudo Elements
      Item with arrow prefix
      Completed item
      No pseudo element
      \ No newline at end of file diff --git a/tests/snapshots/pseudo_states.html b/tests/snapshots/pseudo_states.html index d328633..b581b12 100644 --- a/tests/snapshots/pseudo_states.html +++ b/tests/snapshots/pseudo_states.html @@ -1 +1 @@ -Pseudo States
      Hover me
      \ No newline at end of file +Pseudo States
      Hover me
      \ No newline at end of file diff --git a/tests/snapshots/pseudo_states_extended.hl b/tests/snapshots/pseudo_states_extended.hl index 590a486..84acc9f 100644 --- a/tests/snapshots/pseudo_states_extended.hl +++ b/tests/snapshots/pseudo_states_extended.hl @@ -7,8 +7,8 @@ @input [type=text, placeholder:color #999] -@el [first:border-top 0, last:border-bottom 0] +@el [first-child:border-top 0, last-child:border-bottom 0] @row [spacing 10] - @el [odd:background #f5f5f5, even:background white, padding 10] + @el [nth-child(odd):background #f5f5f5, nth-child(even):background white, padding 10] Item diff --git a/tests/snapshots/pseudo_states_extended.html b/tests/snapshots/pseudo_states_extended.html index 4dc02d5..33f2f46 100644 --- a/tests/snapshots/pseudo_states_extended.html +++ b/tests/snapshots/pseudo_states_extended.html @@ -1 +1 @@ -Pseudo States Extended
      Item
      \ No newline at end of file +Pseudo States Extended
      Item
      \ No newline at end of file diff --git a/tests/snapshots/px_rule.hl b/tests/snapshots/px_rule.hl new file mode 100644 index 0000000..19f5e61 --- /dev/null +++ b/tests/snapshots/px_rule.hl @@ -0,0 +1,20 @@ +-- A bare number in a length is pixels; everything else is as written +@page Pixels +@el [padding 20, box-shadow 0 2 4 rgba(0,0,0,.1), flex 1 1 240px, grid-column 1 / 3, initial-letter 3] + Lengths get px, numbers inside rgba() and number-valued properties don't +@el [box-shadow 0 1 3 rgba(0,0,0,0.1)\, inset 0 0 0 1 #e5e7eb, text-shadow 0 1 2 black] + Every shadow in a list +@grid [grid-template-columns 200 1fr, grid-auto-rows minmax(80px, auto), gap 10 20, row-gap 4] + Tracks and gaps +@el [position absolute, inset 0 10, translate 10 20, transform-origin 0 0] + Position +@el [background-position 0 0\, 10 10, background-size 20 20, object-position 50% 10] + Positions and sizes +@el [border-inline 1 solid red, border 1 solid rgb(255 128 0), border-radius 8 / 4, border-spacing 2 4] + Borders +@el [outline 2 dashed red, outline-offset 2, hover:outline none] + Outlines +@el [border-image-width 2, opacity 0.5, z-index 2, line-height 1.5, font-weight 700, aspect-ratio 16 / 9, order 1, --cols 3] + Numbers stay numbers, and custom properties are as written +@el [width calc(100% - 20px), margin 0 auto, padding 0 4, font-size 1.2em] + Functions, zeros and units diff --git a/tests/snapshots/px_rule.html b/tests/snapshots/px_rule.html new file mode 100644 index 0000000..175f432 --- /dev/null +++ b/tests/snapshots/px_rule.html @@ -0,0 +1 @@ +Pixels
      Lengths get px, numbers inside rgba() and number-valued properties don't
      Every shadow in a list
      Tracks and gaps
      Position
      Positions and sizes
      Borders
      Outlines
      Numbers stay numbers, and custom properties are as written
      Functions, zeros and units
      \ No newline at end of file diff --git a/tests/snapshots/raw_html.html b/tests/snapshots/raw_html.html index d8d652c..53e6e12 100644 --- a/tests/snapshots/raw_html.html +++ b/tests/snapshots/raw_html.html @@ -1 +1 @@ -Raw HTML
      Before raw
      Raw HTML content
      After raw
      \ No newline at end of file +Raw HTML
      Before raw
      Raw HTML content
      After raw
      \ No newline at end of file diff --git a/tests/snapshots/repeat_directive.html b/tests/snapshots/repeat_directive.html index ea25904..2586f59 100644 --- a/tests/snapshots/repeat_directive.html +++ b/tests/snapshots/repeat_directive.html @@ -1 +1 @@ -Item 0Item 1Item 2 \ No newline at end of file +Item 0Item 1Item 2 \ No newline at end of file diff --git a/tests/snapshots/responsive.html b/tests/snapshots/responsive.html index 717ffd0..d051bd5 100644 --- a/tests/snapshots/responsive.html +++ b/tests/snapshots/responsive.html @@ -1 +1 @@ -Responsive
      Responsive text
      \ No newline at end of file +Responsive
      Responsive text
      \ No newline at end of file diff --git a/tests/snapshots/responsive_images.html b/tests/snapshots/responsive_images.html index 7c4ddef..2209f3a 100644 --- a/tests/snapshots/responsive_images.html +++ b/tests/snapshots/responsive_images.html @@ -1 +1 @@ -Responsive ImagesHero photoSmall icon \ No newline at end of file +Responsive ImagesHero photoSmall icon \ No newline at end of file diff --git a/tests/snapshots/scope_css.html b/tests/snapshots/scope_css.html index 943523c..20b44b8 100644 --- a/tests/snapshots/scope_css.html +++ b/tests/snapshots/scope_css.html @@ -1 +1 @@ -Scope Test
      Hello
      \ No newline at end of file +Scope Test
      Hello
      \ No newline at end of file diff --git a/tests/snapshots/script_element.html b/tests/snapshots/script_element.html index bc6c2dd..023cbe7 100644 --- a/tests/snapshots/script_element.html +++ b/tests/snapshots/script_element.html @@ -1,4 +1,4 @@ - \ No newline at end of file diff --git a/tests/snapshots/scroll_driven_animations.html b/tests/snapshots/scroll_driven_animations.html index 05f70ac..3fab7f9 100644 --- a/tests/snapshots/scroll_driven_animations.html +++ b/tests/snapshots/scroll_driven_animations.html @@ -1 +1 @@ -Scroll Animation Test
      Animated on scroll
      \ No newline at end of file +Scroll Animation Test
      Animated on scroll
      \ No newline at end of file diff --git a/tests/snapshots/selection_pseudo.html b/tests/snapshots/selection_pseudo.html index acb4423..4167b70 100644 --- a/tests/snapshots/selection_pseudo.html +++ b/tests/snapshots/selection_pseudo.html @@ -1 +1 @@ -Selection Pseudo

      Select this text to see custom selection colors.

      Red selectionGreen selection
      \ No newline at end of file +Selection Pseudo

      Select this text to see custom selection colors.

      Red selectionGreen selection
      \ No newline at end of file diff --git a/tests/snapshots/semantic_elements.html b/tests/snapshots/semantic_elements.html index 094dd3c..49b973e 100644 --- a/tests/snapshots/semantic_elements.html +++ b/tests/snapshots/semantic_elements.html @@ -1 +1 @@ -Semantic Elements
      My Site
      Article Title

      Some article content here

      Section Title

      Section content

      Copyright 2024
      \ No newline at end of file +Semantic Elements
      My Site
      Article Title

      Some article content here

      Section Title

      Section content

      Copyright 2024
      \ No newline at end of file diff --git a/tests/snapshots/sizing.html b/tests/snapshots/sizing.html index f15b5cf..7fbf156 100644 --- a/tests/snapshots/sizing.html +++ b/tests/snapshots/sizing.html @@ -1 +1 @@ -Sizing
      Fill width
      Fixed 100
      Shrink
      Fill height
      Fixed 50
      \ No newline at end of file +Sizing
      Fill width
      Fixed 100
      Shrink
      Fill height
      Fixed 50
      \ No newline at end of file diff --git a/tests/snapshots/slots_and_children.hl b/tests/snapshots/slots_and_children.hl new file mode 100644 index 0000000..3d9bf42 --- /dev/null +++ b/tests/snapshots/slots_and_children.hl @@ -0,0 +1,59 @@ +-- @children and @slot NAME, each with fallback content +@let @card [title] + @article [padding 16, spacing 8] + @h3 $title + @children + @text [color gray] Nothing yet. + @slot footer + @text [font-size 12] No footer + +@card [title Empty] +@card [title Text] Just a line. +@card [title Full] + The body. + @slot footer + @text Updated today + +-- A @slot block under @if or @each at the call fills the slot too +@let show true +@card [title Conditional] + @if $show + @slot footer + @text Shown + @each $n in 1..2 + Line $n + +-- A chain ends in the block that fills a slot +@card [title Chained] > @slot footer + @text From a chain + +-- A slot declared under @if in the body +@let @panel [compact false] + @section + @children + @if not $compact + @slot actions + @text No actions + +@panel + Wide panel +@panel [compact] + @slot actions + @text Hidden, as the body says + Compact panel + +-- A body passes on what its function got +@let @frame + @card [title Framed] + @el [padding 8] + @children + @slot footer + @slot footer + @text Frame footer + +@frame + Framed content +@frame + Framed again + @slot footer + @text Own footer diff --git a/tests/snapshots/slots_and_children.html b/tests/snapshots/slots_and_children.html new file mode 100644 index 0000000..9521be7 --- /dev/null +++ b/tests/snapshots/slots_and_children.html @@ -0,0 +1 @@ +

      Empty

      Nothing yet.No footer

      Text

      Just a line.No footer

      Full

      The body.Updated today

      Conditional

      Line 1Line 2Shown

      Chained

      Nothing yet.From a chain
      Wide panelNo actions
      Compact panel

      Framed

      Framed content
      Frame footer

      Framed

      Framed again
      Own footer
      \ No newline at end of file diff --git a/tests/snapshots/starting_style.html b/tests/snapshots/starting_style.html index 368d09c..ff1a50f 100644 --- a/tests/snapshots/starting_style.html +++ b/tests/snapshots/starting_style.html @@ -1 +1 @@ -Starting Style Test
      Hello
      \ No newline at end of file +Starting Style Test
      Hello
      \ No newline at end of file diff --git a/tests/snapshots/stress_deeply_nested.hl b/tests/snapshots/stress_deeply_nested.hl index 581ee10..3af5a4a 100644 --- a/tests/snapshots/stress_deeply_nested.hl +++ b/tests/snapshots/stress_deeply_nested.hl @@ -2,14 +2,14 @@ @let --primary #3b82f6 @let --border #e2e8f0 -@let --radius 8 +@let --radius 8px -@let deep-card $title - @el [background white, dark:background #1e293b, border-radius $--radius, padding 16, border 1 solid $--border] +@let @deep-card [title] + @el [background white, dark:background #1e293b, border-radius var(--radius), padding 16, border 1 solid var(--border)] @text [font-weight bold, font-size 16] $title @children -@let inner-panel $label +@let @inner-panel [label] @el [background #f8fafc, dark:background #0f172a, border-radius 6, padding 12, margin-top 8] @text [font-size 13, color #64748b] $label @children @@ -19,7 +19,7 @@ @el [background #f0f9ff, border-radius 12, padding 20, border 1 solid #bae6fd] @text [font-weight bold, font-size 20, color #0369a1] Level 1 - App Shell @row [spacing 16, margin-top 16] - @el [background white, border-radius 8, padding 16, border 1 solid $--border, width fill] + @el [background white, border-radius 8, padding 16, border 1 solid var(--border), width fill] @text [font-weight bold, font-size 16, color #1e293b] Level 2 - Sidebar @el [spacing 8, margin-top 12] @el [background #f1f5f9, border-radius 6, padding 12] @@ -55,7 +55,7 @@ @el [padding 8 12, hover:background #e2e8f0, border-radius 4] @text [font-size 13, color #64748b] $item - @el [background white, border-radius 8, padding 16, border 1 solid $--border, width fill] + @el [background white, border-radius 8, padding 16, border 1 solid var(--border), width fill] @text [font-weight bold, font-size 16, color #1e293b] Level 2 - Main Content @el [spacing 12, margin-top 12] @deep-card [title Level 3 - Dashboard Card] @@ -86,9 +86,9 @@ @deep-card [title Level 3 - Form Card] @inner-panel [label Level 4 - Form Section] @el [spacing 8, margin-top 8] - @el [padding 8, border 1 solid $--border, border-radius 4] + @el [padding 8, border 1 solid var(--border), border-radius 4] @text [font-size 12, color #64748b, margin-bottom 4] Level 5 - Field Group - @input [type=text, placeholder=Name, padding 6 10, border-radius 4, border 1 solid $--border, width fill] + @input [type=text, placeholder=Name, padding 6 10, border-radius 4, border 1 solid var(--border), width fill] @el [margin-top 4, padding 4 8, background #fef2f2, border-radius 4] @text [font-size 11, color #991b1b] Level 6 - Validation Error @el [margin-top 2] @@ -96,9 +96,9 @@ @el [margin-top 1, padding 2 4, background #fee2e2, border-radius 2] @text [font-size 9, color #b91c1c] Level 8 - Error Code - @el [padding 8, border 1 solid $--border, border-radius 4] + @el [padding 8, border 1 solid var(--border), border-radius 4] @text [font-size 12, color #64748b, margin-bottom 4] Level 5 - Select Group - @select [padding 6 10, border-radius 4, border 1 solid $--border, width fill] + @select [padding 6 10, border-radius 4, border 1 solid var(--border), width fill] @option [value=a] Option A @option [value=b] Option B @option [value=c] Option C @@ -108,12 +108,12 @@ @el [spacing 6, margin-top 8] @let top-categories Electronics,Clothing,Books @each $cat in $top-categories - @el [padding 8, border 1 solid $--border, border-radius 4, hover:background #f8fafc] + @el [padding 8, border 1 solid var(--border), border-radius 4, hover:background #f8fafc] @text [font-size 14, font-weight bold, color #1e293b] Level 5 - $cat @el [spacing 4, margin-top 6, margin-left 12] @el [padding 6, background #f8fafc, border-radius 4] @row [spacing 8, align-items center] - @el [width 8, height 8, border-radius 2, background $--primary] + @el [width 8, height 8, border-radius 2, background var(--primary)] @text [font-size 12, color #475569] Level 6 - Subcategory @el [margin-top 4, margin-left 16] @text [font-size 11, color #94a3b8] Level 7 - Item @@ -123,16 +123,16 @@ @el [background #fefce8, border-radius 12, padding 20, border 1 solid #fde68a, margin-top 20] @text [font-weight bold, font-size 20, color #854d0e] Level 1 - Settings Panel @el [spacing 12, margin-top 16] - @el [background white, border-radius 8, padding 16, border 1 solid $--border] + @el [background white, border-radius 8, padding 16, border 1 solid var(--border)] @text [font-weight bold, font-size 16, color #1e293b] Level 2 - Account Settings @el [spacing 8, margin-top 12] @el [background #f8fafc, border-radius 6, padding 12] @text [font-size 14, color #475569] Level 3 - Profile Section - @el [margin-top 8, padding 10, border 1 solid $--border, border-radius 6] + @el [margin-top 8, padding 10, border 1 solid var(--border), border-radius 6] @text [font-size 13, color #1e293b] Level 4 - Avatar Upload @el [margin-top 6, width 80, height 80, border-radius 50%, background #e2e8f0, display flex, align-items center, justify-content center] @text [font-size 24, color #94a3b8, font-weight bold] Level 5 - Initials - @el [position absolute, bottom 0, right 0, width 24, height 24, border-radius 50%, background $--primary, display flex, align-items center, justify-content center] + @el [position absolute, bottom 0, right 0, width 24, height 24, border-radius 50%, background var(--primary), display flex, align-items center, justify-content center] @text [font-size 10, color white] Level 6 - Edit Icon @el [margin-top 1] @text [font-size 8, color #bfdbfe] Level 7 - Tooltip @@ -143,26 +143,26 @@ @text [font-size 14, color #475569] Level 3 - Preferences @el [spacing 6, margin-top 8] @each $pref in Language,Timezone,Currency - @row [spacing 8, align-items center, padding 6 8, border 1 solid $--border, border-radius 4] + @row [spacing 8, align-items center, padding 6 8, border 1 solid var(--border), border-radius 4] @text [font-size 13, color #1e293b, width fill] Level 4 - $pref @el [padding 4 12, background #f1f5f9, border-radius 4] @text [font-size 12, color #64748b] Level 5 - Current Value - @el [background white, border-radius 8, padding 16, border 1 solid $--border] + @el [background white, border-radius 8, padding 16, border 1 solid var(--border)] @text [font-weight bold, font-size 16, color #1e293b] Level 2 - Notification Preferences @el [spacing 6, margin-top 12] @let channels Email,Push,SMS,Slack @each $channel in $channels - @row [spacing 8, align-items center, padding 8, border-bottom 1 solid $--border] + @row [spacing 8, align-items center, padding 8, border-bottom 1 solid var(--border)] @text [font-size 14, color #1e293b, width fill] $channel Notifications - @el [width 36, height 20, border-radius 10, background $--primary, padding 2] + @el [width 36, height 20, border-radius 10, background var(--primary), padding 2] @el [width 16, height 16, border-radius 50%, background white, margin-left 16] @el [background #fdf2f8, border-radius 12, padding 20, border 1 solid #fbcfe8, margin-top 20] @text [font-weight bold, font-size 20, color #9d174d] Level 1 - Monitoring @row [spacing 16, margin-top 16] @each $service in API,Database,Cache,Queue - @el [background white, border-radius 8, padding 12, border 1 solid $--border, width fill] + @el [background white, border-radius 8, padding 12, border 1 solid var(--border), width fill] @text [font-weight bold, font-size 14, color #1e293b] Level 2 - $service @el [margin-top 8, background #f0fdf4, border-radius 6, padding 8] @text [font-size 12, color #166534] Level 3 - Healthy diff --git a/tests/snapshots/stress_deeply_nested.html b/tests/snapshots/stress_deeply_nested.html index bd6b58e..4d18676 100644 --- a/tests/snapshots/stress_deeply_nested.html +++ b/tests/snapshots/stress_deeply_nested.html @@ -1 +1 @@ -Deeply Nested Stress Test
      Level 1 - App Shell
      Level 2 - Sidebar
      Level 3 - Nav Group
      Level 4 - Nav Item
      Level 5 - Badge
      Level 6 - Badge Detail
      Level 7 - Sub Detail
      Level 8 - Deepest Badge
      Level 4 - Second Nav Item
      Level 5 - Status
      Level 6 - Online
      Level 6 - Away
      Level 3 - Second Group
      Settings
      Profile
      Security
      Level 2 - Main Content
      Level 3 - Dashboard Card
      Level 4 - Chart Area
      Level 5 - Metric A
      Level 6 - Value
      Level 7 - Trend
      Level 8 - Spark Dot
      Level 5 - Metric B
      Level 6 - Value
      Level 7 - Change
      Level 8 - Delta
      Level 3 - Form Card
      Level 4 - Form Section
      Level 5 - Field Group
      Level 6 - Validation Error
      Level 7 - Error Detail
      Level 8 - Error Code
      Level 5 - Select Group
      Level 3 - Nested Lists Card
      Level 4 - Category Browser
      Level 5 - Electronics
      Level 6 - Subcategory
      Level 7 - Item
      Level 8 - Variant
      Level 5 - Clothing
      Level 6 - Subcategory
      Level 7 - Item
      Level 8 - Variant
      Level 5 - Books
      Level 6 - Subcategory
      Level 7 - Item
      Level 8 - Variant
      Level 1 - Settings Panel
      Level 2 - Account Settings
      Level 3 - Profile Section
      Level 4 - Avatar Upload
      Level 5 - Initials
      Level 6 - Edit Icon
      Level 7 - Tooltip
      Level 8 - Tooltip Arrow
      Level 3 - Preferences
      Level 4 - Language
      Level 5 - Current Value
      Level 4 - Timezone
      Level 5 - Current Value
      Level 4 - Currency
      Level 5 - Current Value
      Level 2 - Notification Preferences
      Email Notifications
      Push Notifications
      SMS Notifications
      Slack Notifications
      Level 1 - Monitoring
      Level 2 - API
      Level 3 - Healthy
      Level 4 - Uptime 99.9%
      Level 5 - Response 42ms
      Level 6 - p99 120ms
      Level 7 - Throughput 1.2k/s
      Level 8 - Peak 2.4k/s
      Level 2 - Database
      Level 3 - Healthy
      Level 4 - Uptime 99.9%
      Level 5 - Response 42ms
      Level 6 - p99 120ms
      Level 7 - Throughput 1.2k/s
      Level 8 - Peak 2.4k/s
      Level 2 - Cache
      Level 3 - Healthy
      Level 4 - Uptime 99.9%
      Level 5 - Response 42ms
      Level 6 - p99 120ms
      Level 7 - Throughput 1.2k/s
      Level 8 - Peak 2.4k/s
      Level 2 - Queue
      Level 3 - Healthy
      Level 4 - Uptime 99.9%
      Level 5 - Response 42ms
      Level 6 - p99 120ms
      Level 7 - Throughput 1.2k/s
      Level 8 - Peak 2.4k/s
      \ No newline at end of file +Deeply Nested Stress Test
      Level 1 - App Shell
      Level 2 - Sidebar
      Level 3 - Nav Group
      Level 4 - Nav Item
      Level 5 - Badge
      Level 6 - Badge Detail
      Level 7 - Sub Detail
      Level 8 - Deepest Badge
      Level 4 - Second Nav Item
      Level 5 - Status
      Level 6 - Online
      Level 6 - Away
      Level 3 - Second Group
      Settings
      Profile
      Security
      Level 2 - Main Content
      Level 3 - Dashboard Card
      Level 4 - Chart Area
      Level 5 - Metric A
      Level 6 - Value
      Level 7 - Trend
      Level 8 - Spark Dot
      Level 5 - Metric B
      Level 6 - Value
      Level 7 - Change
      Level 8 - Delta
      Level 3 - Form Card
      Level 4 - Form Section
      Level 5 - Field Group
      Level 6 - Validation Error
      Level 7 - Error Detail
      Level 8 - Error Code
      Level 5 - Select Group
      Level 3 - Nested Lists Card
      Level 4 - Category Browser
      Level 5 - Electronics
      Level 6 - Subcategory
      Level 7 - Item
      Level 8 - Variant
      Level 5 - Clothing
      Level 6 - Subcategory
      Level 7 - Item
      Level 8 - Variant
      Level 5 - Books
      Level 6 - Subcategory
      Level 7 - Item
      Level 8 - Variant
      Level 1 - Settings Panel
      Level 2 - Account Settings
      Level 3 - Profile Section
      Level 4 - Avatar Upload
      Level 5 - Initials
      Level 6 - Edit Icon
      Level 7 - Tooltip
      Level 8 - Tooltip Arrow
      Level 3 - Preferences
      Level 4 - Language
      Level 5 - Current Value
      Level 4 - Timezone
      Level 5 - Current Value
      Level 4 - Currency
      Level 5 - Current Value
      Level 2 - Notification Preferences
      Email Notifications
      Push Notifications
      SMS Notifications
      Slack Notifications
      Level 1 - Monitoring
      Level 2 - API
      Level 3 - Healthy
      Level 4 - Uptime 99.9%
      Level 5 - Response 42ms
      Level 6 - p99 120ms
      Level 7 - Throughput 1.2k/s
      Level 8 - Peak 2.4k/s
      Level 2 - Database
      Level 3 - Healthy
      Level 4 - Uptime 99.9%
      Level 5 - Response 42ms
      Level 6 - p99 120ms
      Level 7 - Throughput 1.2k/s
      Level 8 - Peak 2.4k/s
      Level 2 - Cache
      Level 3 - Healthy
      Level 4 - Uptime 99.9%
      Level 5 - Response 42ms
      Level 6 - p99 120ms
      Level 7 - Throughput 1.2k/s
      Level 8 - Peak 2.4k/s
      Level 2 - Queue
      Level 3 - Healthy
      Level 4 - Uptime 99.9%
      Level 5 - Response 42ms
      Level 6 - p99 120ms
      Level 7 - Throughput 1.2k/s
      Level 8 - Peak 2.4k/s
      \ No newline at end of file diff --git a/tests/snapshots/stress_large.hl b/tests/snapshots/stress_large.hl index b75f14d..aab35e5 100644 --- a/tests/snapshots/stress_large.hl +++ b/tests/snapshots/stress_large.hl @@ -8,10 +8,10 @@ @let --light #f8fafc @let --gray #64748b @let --border #e2e8f0 -@let --radius 8 -@let --gap 16 -@let --gap-sm 8 -@let --gap-lg 24 +@let --radius 8px +@let --gap 16px +@let --gap-sm 8px +@let --gap-lg 24px @style @keyframes fade-in { @@ -23,91 +23,91 @@ 0%{transform:translateY(-10px);opacity:0}100%{transform:translateY(0);opacity:1} } -@let icon-button $label $color=var(--primary) - @button [padding 8 16, background $color, hover:background $--dark, color white, border-radius 6, border none, cursor pointer, transition all 0.2s, font-size 14] +@let @icon-button [label, color var(--primary)] + @button [padding 8 16, background $color, hover:background var(--dark), color white, border-radius 6, border none, cursor pointer, transition all 0.2s, font-size 14] $label -@let badge $label $variant=blue +@let @badge [label, variant blue] @el [display inline-block, padding 2 8, background $variant, color white, border-radius 12, font-size 12] @text $label -@let card $title - @el [background white, dark:background #1e293b, border-radius $--radius, padding $--gap, border 1 solid $--border, dark:border 1 solid #334155, hover:box-shadow 0 4 12 rgba(0,0,0,0.1), transition all 0.2s, animation fade-in 0.3s ease] - @text [font-weight bold, font-size 18, color $--dark, dark:color white] $title +@let @card [title] + @el [background white, dark:background #1e293b, border-radius var(--radius), padding var(--gap), border 1 solid var(--border), dark:border 1 solid #334155, hover:box-shadow 0 4 12 rgba(0,0,0,0.1), transition all 0.2s, animation fade-in 0.3s ease] + @text [font-weight bold, font-size 18, color var(--dark), dark:color white] $title @el [margin-top 8] @children -@let stat-card $label $value $change - @el [background white, dark:background #1e293b, border-radius $--radius, padding $--gap, border 1 solid $--border, dark:border 1 solid #334155] - @text [font-size 14, color $--gray] $label - @text [font-weight bold, font-size 28, color $--dark, dark:color white, margin-top 4] $value - @text [font-size 12, color $--secondary, margin-top 2] $change +@let @stat-card [label, value, change] + @el [background white, dark:background #1e293b, border-radius var(--radius), padding var(--gap), border 1 solid var(--border), dark:border 1 solid #334155] + @text [font-size 14, color var(--gray)] $label + @text [font-weight bold, font-size 28, color var(--dark), dark:color white, margin-top 4] $value + @text [font-size 12, color var(--secondary), margin-top 2] $change -@let nav-link $url $label $active=false +@let @nav-link [url, label, active false] @if $active == true - @link [color $--primary, font-weight bold, padding 8 12, background #eff6ff, dark:background #1e3a5f, border-radius 6] $url $label + @link [color var(--primary), font-weight bold, padding 8 12, background #eff6ff, dark:background #1e3a5f, border-radius 6] $url $label @else - @link [color $--gray, hover:color $--primary, padding 8 12, hover:background #f1f5f9, dark:hover:background #334155, border-radius 6, transition all 0.15s] $url $label + @link [color var(--gray), hover:color var(--primary), padding 8 12, hover:background #f1f5f9, dark:hover:background #334155, border-radius 6, transition all 0.15s] $url $label -@let sidebar-section $title - @el [margin-bottom $--gap-lg] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase, letter-spacing 1, margin-bottom $--gap-sm] $title +@let @sidebar-section [title] + @el [margin-bottom var(--gap-lg)] + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase, letter-spacing 1, margin-bottom var(--gap-sm)] $title @children -@let avatar $initials $size=32 - @el [width $size, height $size, border-radius 50%, background $--primary, color white, display flex, align-items center, justify-content center, font-size 14, font-weight bold] +@let @avatar [initials, size 32] + @el [width $size, height $size, border-radius 50%, background var(--primary), color white, display flex, align-items center, justify-content center, font-size 14, font-weight bold] @text $initials -@let table-row $col1 $col2 $col3 $col4 - @row [spacing 0, padding 12 16, border-bottom 1 solid $--border, dark:border-bottom 1 solid #334155, hover:background #f8fafc, dark:hover:background #1e293b] +@let @table-row [col1, col2, col3, col4] + @row [spacing 0, padding 12 16, border-bottom 1 solid var(--border), dark:border-bottom 1 solid #334155, hover:background #f8fafc, dark:hover:background #1e293b] @el [width 200] @text [font-size 14] $col1 @el [width 150] - @text [font-size 14, color $--gray] $col2 + @text [font-size 14, color var(--gray)] $col2 @el [width 120] @text [font-size 14] $col3 @el [width fill] - @text [font-size 14, color $--gray] $col4 + @text [font-size 14, color var(--gray)] $col4 -@let breadcrumb-item $url $label $is-last=false +@let @breadcrumb-item [url, label, is-last false] @if $is-last == true - @text [color $--dark, dark:color white, font-size 14] $label + @text [color var(--dark), dark:color white, font-size 14] $label @else @row [spacing 8] - @link [color $--gray, hover:color $--primary, font-size 14] $url $label - @text [color $--border, font-size 14] / + @link [color var(--gray), hover:color var(--primary), font-size 14] $url $label + @text [color var(--border), font-size 14] / -@let alert $message $type=info +@let @alert [message, type info] @if $type == info - @el [background #eff6ff, border 1 solid #bfdbfe, border-radius $--radius, padding 12 16] + @el [background #eff6ff, border 1 solid #bfdbfe, border-radius var(--radius), padding 12 16] @text [color #1e40af, font-size 14] $message @else if $type == success - @el [background #f0fdf4, border 1 solid #bbf7d0, border-radius $--radius, padding 12 16] + @el [background #f0fdf4, border 1 solid #bbf7d0, border-radius var(--radius), padding 12 16] @text [color #166534, font-size 14] $message @else if $type == warning - @el [background #fffbeb, border 1 solid #fde68a, border-radius $--radius, padding 12 16] + @el [background #fffbeb, border 1 solid #fde68a, border-radius var(--radius), padding 12 16] @text [color #92400e, font-size 14] $message @else - @el [background #fef2f2, border 1 solid #fecaca, border-radius $--radius, padding 12 16] + @el [background #fef2f2, border 1 solid #fecaca, border-radius var(--radius), padding 12 16] @text [color #991b1b, font-size 14] $message -@let progress-bar $percent $color=var(--primary) +@let @progress-bar [percent, color var(--primary)] @el [width fill, height 8, background #e2e8f0, dark:background #334155, border-radius 4, overflow hidden] @el [width $percent%, height fill, background $color, border-radius 4] -@let tag $label - @el [display inline-block, padding 4 10, background #f1f5f9, dark:background #334155, color $--gray, dark:color #94a3b8, border-radius 4, font-size 12, margin 2] +@let @tag [label] + @el [display inline-block, padding 4 10, background #f1f5f9, dark:background #334155, color var(--gray), dark:color #94a3b8, border-radius 4, font-size 12, margin 2] @text $label -@let notification-dot - @el [width 8, height 8, border-radius 50%, background $--danger] +@let @notification-dot + @el [width 8, height 8, border-radius 50%, background var(--danger)] -@let divider - @el [width fill, height 1, background $--border, dark:background #334155, margin 8 0] +@let @divider + @el [width fill, height 1, background var(--border), dark:background #334155, margin 8 0] -@let toggle $checked=false +@let @toggle [checked false] @if $checked == true - @el [width 40, height 22, border-radius 11, background $--primary, padding 2, cursor pointer, transition all 0.2s] + @el [width 40, height 22, border-radius 11, background var(--primary), padding 2, cursor pointer, transition all 0.2s] @el [width 18, height 18, border-radius 50%, background white, margin-left 18, transition all 0.2s] @else @el [width 40, height 22, border-radius 11, background #cbd5e1, dark:background #475569, padding 2, cursor pointer, transition all 0.2s] @@ -121,13 +121,13 @@ @row [spacing 0, min-height 100vh] - @el [width 260, background white, dark:background #0f172a, border-right 1 solid $--border, dark:border-right 1 solid #1e293b, padding $--gap, spacing $--gap-sm] + @el [width 260, background white, dark:background #0f172a, border-right 1 solid var(--border), dark:border-right 1 solid #1e293b, padding var(--gap), spacing var(--gap-sm)] @row [spacing 12, padding 8, align-items center] @avatar [initials HL, size 36] @el [spacing 2] - @text [font-weight bold, font-size 16, color $--dark, dark:color white] htmlang - @text [font-size 12, color $--gray] Dashboard v2.0 + @text [font-weight bold, font-size 16, color var(--dark), dark:color white] htmlang + @text [font-size 12, color var(--gray)] Dashboard v2.0 @divider @@ -151,58 +151,58 @@ @sidebar-section [title Shortcuts] @el [spacing 4] @row [spacing 8, align-items center] - @el [width 6, height 6, border-radius 50%, background $--secondary] - @text [font-size 13, color $--gray] Online (12) + @el [width 6, height 6, border-radius 50%, background var(--secondary)] + @text [font-size 13, color var(--gray)] Online (12) @row [spacing 8, align-items center] - @el [width 6, height 6, border-radius 50%, background $--accent] - @text [font-size 13, color $--gray] Away (3) + @el [width 6, height 6, border-radius 50%, background var(--accent)] + @text [font-size 13, color var(--gray)] Away (3) @row [spacing 8, align-items center] @el [width 6, height 6, border-radius 50%, background #cbd5e1] - @text [font-size 13, color $--gray] Offline (5) + @text [font-size 13, color var(--gray)] Offline (5) - @el [margin-top auto, padding 12, background #f1f5f9, dark:background #1e293b, border-radius $--radius] + @el [margin-top auto, padding 12, background #f1f5f9, dark:background #1e293b, border-radius var(--radius)] @row [spacing 8, align-items center] @avatar [initials JD, size 28] @el [spacing 0] - @text [font-size 13, font-weight bold, color $--dark, dark:color white] Jane Doe - @text [font-size 11, color $--gray] jane@example.com + @text [font-size 13, font-weight bold, color var(--dark), dark:color white] Jane Doe + @text [font-size 11, color var(--gray)] jane@example.com - @el [width fill, background $--light, dark:background #0f172a] + @el [width fill, background var(--light), dark:background #0f172a] - @header [padding 12 24, background white, dark:background #0f172a, border-bottom 1 solid $--border, dark:border-bottom 1 solid #1e293b] + @header [padding 12 24, background white, dark:background #0f172a, border-bottom 1 solid var(--border), dark:border-bottom 1 solid #1e293b] @row [spacing 16, align-items center] @el [spacing 2] @row [spacing 8] @breadcrumb-item [url /, label Home] @breadcrumb-item [url /dashboard, label Dashboard, is-last true] - @text [font-weight bold, font-size 22, color $--dark, dark:color white] Dashboard Overview + @text [font-weight bold, font-size 22, color var(--dark), dark:color white] Dashboard Overview @el [width fill] @row [spacing 12, align-items center] - @input [type=search, placeholder=Search..., padding 8 12, border-radius 6, border 1 solid $--border, dark:border 1 solid #334155, dark:background #1e293b, dark:color white, width 240, focus:border 1 solid $--primary, transition all 0.2s] + @input [type=search, placeholder=Search..., padding 8 12, border-radius 6, border 1 solid var(--border), dark:border 1 solid #334155, dark:background #1e293b, dark:color white, width 240, focus:border 1 solid var(--primary), transition all 0.2s] @el [position relative] @icon-button [label Alerts] @el [position absolute, top -2, right -2] @notification-dot - @icon-button [label Settings, color $--gray] + @icon-button [label Settings, color var(--gray)] - @el [padding $--gap-lg, overflow-y auto] + @el [padding var(--gap-lg), overflow-y auto] @alert [message Welcome back! You have 3 new notifications and 2 pending tasks., type info] - @row [spacing $--gap, margin-top $--gap-lg, sm:flex-direction column, lg:flex-direction row] - @stat-card [label Total Revenue, value $48,295, change +12.5% from last month] - @stat-card [label Active Users, value 2,847, change +8.2% from last week] + @row [spacing var(--gap), margin-top var(--gap-lg), sm:flex-direction column, lg:flex-direction row] + @stat-card [label Total Revenue, value $48\,295, change +12.5% from last month] + @stat-card [label Active Users, value 2\,847, change +8.2% from last week] @stat-card [label Conversion Rate, value 3.24%, change +0.4% from yesterday] @stat-card [label Open Tickets, value 142, change -5.1% from last month] - @row [spacing $--gap, margin-top $--gap-lg] + @row [spacing var(--gap), margin-top var(--gap-lg)] @card [title Revenue Overview] @el [height 200, background #f8fafc, dark:background #0f172a, border-radius 6, margin-top 12, display flex, align-items flex-end, padding 16, gap 8] @each $height in 40,65,45,80,55,70,90,60,75,85,50,95 - @el [width fill, height $height%, background $--primary, hover:background #2563eb, border-radius 4 4 0 0, transition all 0.2s, min-width 20] + @el [width fill, height $height%, background var(--primary), hover:background #2563eb, border-radius 4 4 0 0, transition all 0.2s, min-width 20] @card [title Activity Feed] @el [spacing 12, margin-top 12] @@ -210,25 +210,25 @@ @row [spacing 12, align-items center, padding 8, hover:background #f8fafc, dark:hover:background #1e293b, border-radius 6] @avatar [initials $member, size 32] @el [spacing 2, width fill] - @text [font-size 14, font-weight bold, color $--dark, dark:color white] $member - @text [font-size 12, color $--gray] Updated project files - @text [font-size 11, color $--gray] 2h ago + @text [font-size 14, font-weight bold, color var(--dark), dark:color white] $member + @text [font-size 12, color var(--gray)] Updated project files + @text [font-size 11, color var(--gray)] 2h ago - @row [spacing $--gap, margin-top $--gap-lg, sm:flex-direction column, lg:flex-direction row] + @row [spacing var(--gap), margin-top var(--gap-lg), sm:flex-direction column, lg:flex-direction row] - @el [width fill, spacing $--gap] + @el [width fill, spacing var(--gap)] @card [title Project Status] @el [spacing 0, margin-top 12] - @row [spacing 0, padding 8 16, background #f8fafc, dark:background #0f172a, border-bottom 1 solid $--border] + @row [spacing 0, padding 8 16, background #f8fafc, dark:background #0f172a, border-bottom 1 solid var(--border)] @el [width 200] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase] Project + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase] Project @el [width 150] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase] Status + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase] Status @el [width 120] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase] Progress + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase] Progress @el [width fill] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase] Due Date + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase] Due Date @table-row [col1 Project Alpha, col2 In Progress, col3 75%, col4 Dec 15 2025] @table-row [col1 Project Beta, col2 Review, col3 90%, col4 Nov 30 2025] @@ -241,8 +241,8 @@ @each $project in $projects @el [spacing 4] @row [spacing 0] - @text [font-size 13, color $--dark, dark:color white, width fill] $project - @text [font-size 13, color $--gray] 67% + @text [font-size 13, color var(--dark), dark:color white, width fill] $project + @text [font-size 13, color var(--gray)] 67% @progress-bar [percent 67, color var(--primary)] @card [title Recent Activity Log] @@ -251,11 +251,11 @@ @each $entry, $i in $log-entries @row [spacing 12, padding 8 12, align-items center] @el [width 24, height 24, border-radius 50%, background #eff6ff, dark:background #1e3a5f, display flex, align-items center, justify-content center] - @text [font-size 10, color $--primary, font-weight bold] $i - @text [font-size 14, color $--dark, dark:color white, width fill] $entry - @text [font-size 11, color $--gray] just now + @text [font-size 10, color var(--primary), font-weight bold] $i + @text [font-size 14, color var(--dark), dark:color white, width fill] $entry + @text [font-size 11, color var(--gray)] just now - @el [width 340, sm:width fill, spacing $--gap] + @el [width 340, sm:width fill, spacing var(--gap)] @card [title Quick Actions] @el [spacing 8, margin-top 12] @@ -270,9 +270,9 @@ @row [spacing 10, align-items center, padding 6 8, hover:background #f1f5f9, dark:hover:background #1e293b, border-radius 6, transition all 0.15s] @avatar [initials $member, size 28] @el [spacing 0, width fill] - @text [font-size 13, font-weight bold, color $--dark, dark:color white] $member - @text [font-size 11, color $--gray] Developer - @el [width 8, height 8, border-radius 50%, background $--secondary] + @text [font-size 13, font-weight bold, color var(--dark), dark:color white] $member + @text [font-size 11, color var(--gray)] Developer + @el [width 8, height 8, border-radius 50%, background var(--secondary)] @card [title Tags] @row [spacing 0, wrap, margin-top 8] @@ -293,104 +293,104 @@ @el [spacing 0, margin-top 12] @let notifications New deployment completed,PR review requested,Build failed on staging,Meeting in 30 minutes,Subscription renewal due @each $note in $notifications - @row [spacing 10, padding 10 12, border-bottom 1 solid $--border, dark:border-bottom 1 solid #334155, align-items center] + @row [spacing 10, padding 10 12, border-bottom 1 solid var(--border), dark:border-bottom 1 solid #334155, align-items center] @notification-dot - @text [font-size 13, color $--dark, dark:color white, width fill] $note - @text [font-size 11, color $--gray] now + @text [font-size 13, color var(--dark), dark:color white, width fill] $note + @text [font-size 11, color var(--gray)] now @card [title Settings] @el [spacing 12, margin-top 12] @row [spacing 0, align-items center] - @text [font-size 14, color $--dark, dark:color white, width fill] Dark Mode + @text [font-size 14, color var(--dark), dark:color white, width fill] Dark Mode @toggle [checked true] @row [spacing 0, align-items center] - @text [font-size 14, color $--dark, dark:color white, width fill] Notifications + @text [font-size 14, color var(--dark), dark:color white, width fill] Notifications @toggle [checked true] @row [spacing 0, align-items center] - @text [font-size 14, color $--dark, dark:color white, width fill] Auto-save + @text [font-size 14, color var(--dark), dark:color white, width fill] Auto-save @toggle [checked false] @row [spacing 0, align-items center] - @text [font-size 14, color $--dark, dark:color white, width fill] Compact View + @text [font-size 14, color var(--dark), dark:color white, width fill] Compact View @toggle [checked false] - @el [margin-top $--gap-lg] + @el [margin-top var(--gap-lg)] @card [title Data Table] @el [spacing 0, margin-top 12] @row [spacing 16, margin-bottom 12, align-items center] - @input [type=search, placeholder=Filter results..., padding 8 12, border-radius 6, border 1 solid $--border, width 240] + @input [type=search, placeholder=Filter results..., padding 8 12, border-radius 6, border 1 solid var(--border), width 240] @el [width fill] @row [spacing 8] @each $cat in $categories - @button [padding 6 12, border-radius 4, border 1 solid $--border, background white, dark:background #1e293b, hover:background #f1f5f9, color $--gray, hover:color $--dark, font-size 13, cursor pointer, transition all 0.15s] $cat + @button [padding 6 12, border-radius 4, border 1 solid var(--border), background white, dark:background #1e293b, hover:background #f1f5f9, color var(--gray), hover:color var(--dark), font-size 13, cursor pointer, transition all 0.15s] $cat - @row [spacing 0, padding 8 16, background #f8fafc, dark:background #0f172a, border-bottom 2 solid $--border, border-radius 6 6 0 0] + @row [spacing 0, padding 8 16, background #f8fafc, dark:background #0f172a, border-bottom 2 solid var(--border), border-radius 6 6 0 0] @el [width 60] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase] ID + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase] ID @el [width 200] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase] Name + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase] Name @el [width 150] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase] Category + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase] Category @el [width 120] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase] Status + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase] Status @el [width fill] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase] Updated + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase] Updated @each $status in $statuses @each $project in $projects - @row [spacing 0, padding 10 16, border-bottom 1 solid $--border, dark:border-bottom 1 solid #334155, hover:background #f8fafc, dark:hover:background #1e293b, cursor pointer, transition all 0.1s] + @row [spacing 0, padding 10 16, border-bottom 1 solid var(--border), dark:border-bottom 1 solid #334155, hover:background #f8fafc, dark:hover:background #1e293b, cursor pointer, transition all 0.1s] @el [width 60] - @text [font-size 13, color $--gray] #42 + @text [font-size 13, color var(--gray)] #42 @el [width 200] @row [spacing 8, align-items center] @avatar [initials $project, size 24] - @text [font-size 14, color $--dark, dark:color white] $project + @text [font-size 14, color var(--dark), dark:color white] $project @el [width 150] - @text [font-size 13, color $--gray] $status + @text [font-size 13, color var(--gray)] $status @el [width 120] @badge [label $status, variant var(--secondary)] @el [width fill] - @text [font-size 13, color $--gray] 2 hours ago + @text [font-size 13, color var(--gray)] 2 hours ago - @row [spacing 16, padding 12 16, align-items center, border-top 1 solid $--border] - @text [font-size 13, color $--gray] Showing 1-20 of 142 results + @row [spacing 16, padding 12 16, align-items center, border-top 1 solid var(--border)] + @text [font-size 13, color var(--gray)] Showing 1-20 of 142 results @el [width fill] @row [spacing 4] - @button [padding 6 10, border-radius 4, border 1 solid $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] Prev - @button [padding 6 10, border-radius 4, border 1 solid $--primary, background $--primary, color white, font-size 13, cursor pointer] 1 - @button [padding 6 10, border-radius 4, border 1 solid $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] 2 - @button [padding 6 10, border-radius 4, border 1 solid $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] 3 - @button [padding 6 10, border-radius 4, border 1 solid $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] Next - - @footer [padding $--gap-lg 24, background white, dark:background #0f172a, border-top 1 solid $--border, dark:border-top 1 solid #1e293b] - @row [spacing $--gap-lg, sm:flex-direction column, lg:flex-direction row] + @button [padding 6 10, border-radius 4, border 1 solid var(--border), background white, dark:background #1e293b, color var(--gray), font-size 13, cursor pointer] Prev + @button [padding 6 10, border-radius 4, border 1 solid var(--primary), background var(--primary), color white, font-size 13, cursor pointer] 1 + @button [padding 6 10, border-radius 4, border 1 solid var(--border), background white, dark:background #1e293b, color var(--gray), font-size 13, cursor pointer] 2 + @button [padding 6 10, border-radius 4, border 1 solid var(--border), background white, dark:background #1e293b, color var(--gray), font-size 13, cursor pointer] 3 + @button [padding 6 10, border-radius 4, border 1 solid var(--border), background white, dark:background #1e293b, color var(--gray), font-size 13, cursor pointer] Next + + @footer [padding var(--gap-lg) 24, background white, dark:background #0f172a, border-top 1 solid var(--border), dark:border-top 1 solid #1e293b] + @row [spacing var(--gap-lg), sm:flex-direction column, lg:flex-direction row] @el [spacing 8, width fill] - @text [font-weight bold, font-size 14, color $--dark, dark:color white] htmlang Dashboard - @text [font-size 12, color $--gray] Built with htmlang - a declarative HTML language - @text [font-size 11, color $--gray] Copyright 2025 htmlang contributors + @text [font-weight bold, font-size 14, color var(--dark), dark:color white] htmlang Dashboard + @text [font-size 12, color var(--gray)] Built with htmlang - a declarative HTML language + @text [font-size 11, color var(--gray)] Copyright 2025 htmlang contributors @el [spacing 4] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase, margin-bottom 4] Product - @link [color $--gray, hover:color $--primary, font-size 13] /features Features - @link [color $--gray, hover:color $--primary, font-size 13] /pricing Pricing - @link [color $--gray, hover:color $--primary, font-size 13] /changelog Changelog - @link [color $--gray, hover:color $--primary, font-size 13] /roadmap Roadmap + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase, margin-bottom 4] Product + @link [color var(--gray), hover:color var(--primary), font-size 13] /features Features + @link [color var(--gray), hover:color var(--primary), font-size 13] /pricing Pricing + @link [color var(--gray), hover:color var(--primary), font-size 13] /changelog Changelog + @link [color var(--gray), hover:color var(--primary), font-size 13] /roadmap Roadmap @el [spacing 4] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase, margin-bottom 4] Resources - @link [color $--gray, hover:color $--primary, font-size 13] /docs Documentation - @link [color $--gray, hover:color $--primary, font-size 13] /api API Reference - @link [color $--gray, hover:color $--primary, font-size 13] /guides Guides - @link [color $--gray, hover:color $--primary, font-size 13] /examples Examples + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase, margin-bottom 4] Resources + @link [color var(--gray), hover:color var(--primary), font-size 13] /docs Documentation + @link [color var(--gray), hover:color var(--primary), font-size 13] /api API Reference + @link [color var(--gray), hover:color var(--primary), font-size 13] /guides Guides + @link [color var(--gray), hover:color var(--primary), font-size 13] /examples Examples @el [spacing 4] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase, margin-bottom 4] Company - @link [color $--gray, hover:color $--primary, font-size 13] /about About - @link [color $--gray, hover:color $--primary, font-size 13] /blog Blog - @link [color $--gray, hover:color $--primary, font-size 13] /careers Careers - @link [color $--gray, hover:color $--primary, font-size 13] /contact Contact + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase, margin-bottom 4] Company + @link [color var(--gray), hover:color var(--primary), font-size 13] /about About + @link [color var(--gray), hover:color var(--primary), font-size 13] /blog Blog + @link [color var(--gray), hover:color var(--primary), font-size 13] /careers Careers + @link [color var(--gray), hover:color var(--primary), font-size 13] /contact Contact @el [spacing 4] - @text [font-weight bold, font-size 12, color $--gray, text-transform uppercase, margin-bottom 4] Legal - @link [color $--gray, hover:color $--primary, font-size 13] /privacy Privacy Policy - @link [color $--gray, hover:color $--primary, font-size 13] /terms Terms of Service - @link [color $--gray, hover:color $--primary, font-size 13] /cookies Cookie Policy + @text [font-weight bold, font-size 12, color var(--gray), text-transform uppercase, margin-bottom 4] Legal + @link [color var(--gray), hover:color var(--primary), font-size 13] /privacy Privacy Policy + @link [color var(--gray), hover:color var(--primary), font-size 13] /terms Terms of Service + @link [color var(--gray), hover:color var(--primary), font-size 13] /cookies Cookie Policy diff --git a/tests/snapshots/stress_large.html b/tests/snapshots/stress_large.html index 36952ff..cc6c919 100644 --- a/tests/snapshots/stress_large.html +++ b/tests/snapshots/stress_large.html @@ -1 +1 @@ -Stress Test Large Document
      HL
      htmlangDashboard v2.0
      Shortcuts
      Online (12)
      Away (3)
      Offline (5)
      JD
      Jane Doejane@example.com
      Dashboard
      Dashboard Overview
      Welcome back! You have 3 new notifications and 2 pending tasks.
      Total Revenue$48+12.5% from last month
      Active Users2+8.2% from last week
      Conversion Rate3.24%+0.4% from yesterday
      Open Tickets142-5.1% from last month
      Revenue Overview
      Activity Feed
      Alice
      AliceUpdated project files
      2h ago
      Bob
      BobUpdated project files
      2h ago
      Carol
      CarolUpdated project files
      2h ago
      Dave
      DaveUpdated project files
      2h ago
      Eve
      EveUpdated project files
      2h ago
      Frank
      FrankUpdated project files
      2h ago
      Grace
      GraceUpdated project files
      2h ago
      Hank
      HankUpdated project files
      2h ago
      Project Status
      Project
      Status
      Progress
      Due Date
      Project Alpha
      In Progress
      75%
      Dec 15 2025
      Project Beta
      Review
      90%
      Nov 30 2025
      Project Gamma
      Planning
      20%
      Jan 20 2026
      Project Delta
      Completed
      100%
      Oct 10 2025
      Project Epsilon
      On Hold
      45%
      Feb 28 2026
      Progress Breakdown
      Alpha67%
      Beta67%
      Gamma67%
      Delta67%
      Epsilon67%
      Recent Activity Log
      0
      Deployed v2.3.1 to productionjust now
      1
      Merged feature/auth-redesignjust now
      2
      Closed issue #482: Login timeoutjust now
      3
      Updated CI/CD pipeline configjust now
      4
      Added unit tests for payment modulejust now
      5
      Reviewed PR #156: API refactorjust now
      6
      Fixed memory leak in worker pooljust now
      7
      Released hotfix 2.3.2just now
      Quick Actions
      Team Members
      Alice
      AliceDeveloper
      Bob
      BobDeveloper
      Carol
      CarolDeveloper
      Dave
      DaveDeveloper
      Eve
      EveDeveloper
      Frank
      FrankDeveloper
      Grace
      GraceDeveloper
      Hank
      HankDeveloper
      Tags
      frontend
      backend
      devops
      design
      mobile
      security
      performance
      testing
      docs
      infra
      ux
      api
      Notifications
      New deployment completednow
      PR review requestednow
      Build failed on stagingnow
      Meeting in 30 minutesnow
      Subscription renewal duenow
      Settings
      Dark Mode
      Notifications
      Auto-save
      Compact View
      Data Table
      ID
      Name
      Category
      Status
      Updated
      #42
      Alpha
      Alpha
      active
      active
      2 hours ago
      #42
      Beta
      Beta
      active
      active
      2 hours ago
      #42
      Gamma
      Gamma
      active
      active
      2 hours ago
      #42
      Delta
      Delta
      active
      active
      2 hours ago
      #42
      Epsilon
      Epsilon
      active
      active
      2 hours ago
      #42
      Alpha
      Alpha
      pending
      pending
      2 hours ago
      #42
      Beta
      Beta
      pending
      pending
      2 hours ago
      #42
      Gamma
      Gamma
      pending
      pending
      2 hours ago
      #42
      Delta
      Delta
      pending
      pending
      2 hours ago
      #42
      Epsilon
      Epsilon
      pending
      pending
      2 hours ago
      #42
      Alpha
      Alpha
      completed
      completed
      2 hours ago
      #42
      Beta
      Beta
      completed
      completed
      2 hours ago
      #42
      Gamma
      Gamma
      completed
      completed
      2 hours ago
      #42
      Delta
      Delta
      completed
      completed
      2 hours ago
      #42
      Epsilon
      Epsilon
      completed
      completed
      2 hours ago
      #42
      Alpha
      Alpha
      archived
      archived
      2 hours ago
      #42
      Beta
      Beta
      archived
      archived
      2 hours ago
      #42
      Gamma
      Gamma
      archived
      archived
      2 hours ago
      #42
      Delta
      Delta
      archived
      archived
      2 hours ago
      #42
      Epsilon
      Epsilon
      archived
      archived
      2 hours ago
      Showing 1-20 of 142 results
      \ No newline at end of file +Stress Test Large Document
      HL
      htmlangDashboard v2.0
      Shortcuts
      Online (12)
      Away (3)
      Offline (5)
      JD
      Jane Doejane@example.com
      Dashboard
      Dashboard Overview
      Welcome back! You have 3 new notifications and 2 pending tasks.
      Total Revenue$48,295+12.5% from last month
      Active Users2,847+8.2% from last week
      Conversion Rate3.24%+0.4% from yesterday
      Open Tickets142-5.1% from last month
      Revenue Overview
      Activity Feed
      Alice
      AliceUpdated project files
      2h ago
      Bob
      BobUpdated project files
      2h ago
      Carol
      CarolUpdated project files
      2h ago
      Dave
      DaveUpdated project files
      2h ago
      Eve
      EveUpdated project files
      2h ago
      Frank
      FrankUpdated project files
      2h ago
      Grace
      GraceUpdated project files
      2h ago
      Hank
      HankUpdated project files
      2h ago
      Project Status
      Project
      Status
      Progress
      Due Date
      Project Alpha
      In Progress
      75%
      Dec 15 2025
      Project Beta
      Review
      90%
      Nov 30 2025
      Project Gamma
      Planning
      20%
      Jan 20 2026
      Project Delta
      Completed
      100%
      Oct 10 2025
      Project Epsilon
      On Hold
      45%
      Feb 28 2026
      Progress Breakdown
      Alpha67%
      Beta67%
      Gamma67%
      Delta67%
      Epsilon67%
      Recent Activity Log
      0
      Deployed v2.3.1 to productionjust now
      1
      Merged feature/auth-redesignjust now
      2
      Closed issue #482: Login timeoutjust now
      3
      Updated CI/CD pipeline configjust now
      4
      Added unit tests for payment modulejust now
      5
      Reviewed PR #156: API refactorjust now
      6
      Fixed memory leak in worker pooljust now
      7
      Released hotfix 2.3.2just now
      Quick Actions
      Team Members
      Alice
      AliceDeveloper
      Bob
      BobDeveloper
      Carol
      CarolDeveloper
      Dave
      DaveDeveloper
      Eve
      EveDeveloper
      Frank
      FrankDeveloper
      Grace
      GraceDeveloper
      Hank
      HankDeveloper
      Tags
      frontend
      backend
      devops
      design
      mobile
      security
      performance
      testing
      docs
      infra
      ux
      api
      Notifications
      New deployment completednow
      PR review requestednow
      Build failed on stagingnow
      Meeting in 30 minutesnow
      Subscription renewal duenow
      Settings
      Dark Mode
      Notifications
      Auto-save
      Compact View
      Data Table
      ID
      Name
      Category
      Status
      Updated
      #42
      Alpha
      Alpha
      active
      active
      2 hours ago
      #42
      Beta
      Beta
      active
      active
      2 hours ago
      #42
      Gamma
      Gamma
      active
      active
      2 hours ago
      #42
      Delta
      Delta
      active
      active
      2 hours ago
      #42
      Epsilon
      Epsilon
      active
      active
      2 hours ago
      #42
      Alpha
      Alpha
      pending
      pending
      2 hours ago
      #42
      Beta
      Beta
      pending
      pending
      2 hours ago
      #42
      Gamma
      Gamma
      pending
      pending
      2 hours ago
      #42
      Delta
      Delta
      pending
      pending
      2 hours ago
      #42
      Epsilon
      Epsilon
      pending
      pending
      2 hours ago
      #42
      Alpha
      Alpha
      completed
      completed
      2 hours ago
      #42
      Beta
      Beta
      completed
      completed
      2 hours ago
      #42
      Gamma
      Gamma
      completed
      completed
      2 hours ago
      #42
      Delta
      Delta
      completed
      completed
      2 hours ago
      #42
      Epsilon
      Epsilon
      completed
      completed
      2 hours ago
      #42
      Alpha
      Alpha
      archived
      archived
      2 hours ago
      #42
      Beta
      Beta
      archived
      archived
      2 hours ago
      #42
      Gamma
      Gamma
      archived
      archived
      2 hours ago
      #42
      Delta
      Delta
      archived
      archived
      2 hours ago
      #42
      Epsilon
      Epsilon
      archived
      archived
      2 hours ago
      Showing 1-20 of 142 results
      \ No newline at end of file diff --git a/tests/snapshots/string_interpolation.html b/tests/snapshots/string_interpolation.html index 7934203..7a2f98f 100644 --- a/tests/snapshots/string_interpolation.html +++ b/tests/snapshots/string_interpolation.html @@ -1 +1 @@ -InterpolationHello WorldAPI Users \ No newline at end of file +InterpolationHello WorldAPI Users \ No newline at end of file diff --git a/tests/snapshots/style_block.html b/tests/snapshots/style_block.html index d50017c..be4e6d2 100644 --- a/tests/snapshots/style_block.html +++ b/tests/snapshots/style_block.html @@ -1 +1 @@ -Style Test
      Custom styled
      \ No newline at end of file +Style Test
      Custom styled
      \ No newline at end of file diff --git a/tests/snapshots/subgrid_css.html b/tests/snapshots/subgrid_css.html index afcb709..0070fd6 100644 --- a/tests/snapshots/subgrid_css.html +++ b/tests/snapshots/subgrid_css.html @@ -1 +1 @@ -
      First
      SecondThird
      \ No newline at end of file +
      First
      SecondThird
      \ No newline at end of file diff --git a/tests/snapshots/table_elements.html b/tests/snapshots/table_elements.html index 0daf9a7..6e27fa9 100644 --- a/tests/snapshots/table_elements.html +++ b/tests/snapshots/table_elements.html @@ -1 +1 @@ -Table Elements
      NameAgeCity
      Alice30New York
      Bob25London
      \ No newline at end of file +Table Elements
      NameAgeCity
      Alice30New York
      Bob25London
      \ No newline at end of file diff --git a/tests/snapshots/text_everywhere.hl b/tests/snapshots/text_everywhere.hl new file mode 100644 index 0000000..b555be8 --- /dev/null +++ b/tests/snapshots/text_everywhere.hl @@ -0,0 +1,41 @@ +-- Every element's text is read like any line of text: inline elements, +-- escapes and $names work in it. Its layout decides how its lines combine. +@let name htmlang + +-- Headings, list items, cells, buttons and labels +@h2 Welcome to {@text [color #3b82f6] $name} +@ul + @li Read {@link /docs the docs} +@table > @tr + @td Press {@kbd Ctrl} + @th \{not an element\} +@button [type=button] Save {@text [font-weight bold] now} +@label Name {@abbr [title=required] *} + +-- A row or column: each line is a child +@row [spacing 8] + one + two + +-- A text element: lines flow, whatever writes them +@button [type=button] + Read + more +@paragraph + Items: + @each $x in a, b + $x + @if true + and done. + +-- @code and @textarea show their text as written +@paragraph + Write {@code {@link /x y}} for a link to {@code $name}. +@code {@b not bold} +@textarea [aria-label=Notes] + {@b line one} + line two + +-- At the top of the page, two lines of text are two lines +Top +level diff --git a/tests/snapshots/text_everywhere.html b/tests/snapshots/text_everywhere.html new file mode 100644 index 0000000..6516c21 --- /dev/null +++ b/tests/snapshots/text_everywhere.html @@ -0,0 +1,3 @@ +

      Welcome to htmlang

      Press Ctrl{not an element}
      onetwo

      Items: a b and done.

      Write {@link /x y} for a link to htmlang.

      {@b not bold}Top +level \ No newline at end of file diff --git a/tests/snapshots/translations_i18n.html b/tests/snapshots/translations_i18n.html index cc96490..00dda59 100644 --- a/tests/snapshots/translations_i18n.html +++ b/tests/snapshots/translations_i18n.html @@ -1 +1 @@ -
      HelloGoodbye
      \ No newline at end of file +
      HelloGoodbye
      \ No newline at end of file diff --git a/tests/snapshots/unless_directive.html b/tests/snapshots/unless_directive.html index 671030d..bd85a7e 100644 --- a/tests/snapshots/unless_directive.html +++ b/tests/snapshots/unless_directive.html @@ -1 +1 @@ -UnlessThis shows because show is false \ No newline at end of file +UnlessThis shows because show is false \ No newline at end of file diff --git a/tests/snapshots/values_and_scope.hl b/tests/snapshots/values_and_scope.hl new file mode 100644 index 0000000..e0bfcb6 --- /dev/null +++ b/tests/snapshots/values_and_scope.hl @@ -0,0 +1,62 @@ +-- Values are typed, and a name holds a whole value +@let fruits apple, banana, cherry +@let tagline "Fast, simple" +@let motto small\, fast +@let rgb red,green,blue +@el + @text ${length($fruits)} fruits: $fruits + @text $tagline (${length($tagline)}), $motto (${length($motto)}) + @text $rgb prints as written: ${length($rgb)} items + @text ${100 / 3} ${0.1 + 0.2} ${7 / 2} + +-- Ranges, in @each and inside ${...} +@row [spacing 4] + @each $n in ${reverse(1..5)} + @text $n +@row [spacing 4] + @each $n, $i in 0..20 step 10 + @text $i:$n +@let from 2 +@let to 4 +@text ${length($from..$to)} numbers: ${$from..$to} + +-- Items written with commas: quotes and \, keep one text +@ul + @each $item in "a, b", c\, d, e + @li $item + +-- A record goes to a parameter whole, whatever its name +@data $posts [ + {"title": "First", "tags": ["css", "html"]}, + {"title": "Second", "tags": []} +] +@let @post-card [post] + @article [spacing 4] + @h3 $post.title + @if $post.tags + @text ${length($post.tags)} tags: $post.tags + @else + @text No tags +@each $p in $posts + @post-card [post $p] + +-- Rebinding replaces the whole value +@data $x ["a", "b"] +@let x hello, world, there +@text ${length($x)}: $x.2 + +-- Truthiness is defined once +@let zero 0.0 +@let flag false +@let quoted "0" +@text ${if($zero, yes, no)} ${if($flag, yes, no)} ${if($quoted, yes, no)} ${if(no, yes, no)} + +-- Every definition is visible to the end of its block +@let gap 8 +@el [spacing $gap] + @let gap 16 + @el [padding $gap] Sixteen +@el [padding $gap] Eight +@each $i in 1, 2 + @let square = $i * $i + @text $square diff --git a/tests/snapshots/values_and_scope.html b/tests/snapshots/values_and_scope.html new file mode 100644 index 0000000..aab667d --- /dev/null +++ b/tests/snapshots/values_and_scope.html @@ -0,0 +1 @@ +
      3 fruits: apple, banana, cherryFast, simple (12), small, fast (11)red,green,blue prints as written: 3 items33.3333 0.3 3.5
      54321
      0:01:102:20
      3 numbers: 2, 3, 4
      • a, b
      • c, d
      • e

      First

      2 tags: css, html

      Second

      No tags
      3: thereno no yes yes
      Sixteen
      Eight
      14 \ No newline at end of file diff --git a/tests/snapshots/variable_filters.html b/tests/snapshots/variable_filters.html index 85765cf..c489d8a 100644 --- a/tests/snapshots/variable_filters.html +++ b/tests/snapshots/variable_filters.html @@ -1 +1 @@ -Variable Filters
      HELLO WORLDhello worldHello world11dlrow ollehhello...hi
      \ No newline at end of file +Variable Filters
      HELLO WORLDhello worldHello world11dlrow ollehhello...hi
      \ No newline at end of file diff --git a/tests/snapshots/variable_slots.hl b/tests/snapshots/variable_slots.hl new file mode 100644 index 0000000..c44fbb7 --- /dev/null +++ b/tests/snapshots/variable_slots.hl @@ -0,0 +1,18 @@ +@page Variables fill slots +@let lang fr +@let size 12 +@let fonts Inter, sans-serif +@let label Docs +@let link-style [color #2563eb, text-decoration underline] +@data $post {"title": "Hello {@b there} $lang", "tags": ["a", "b"]} + +@el [padding ${size}px, font-family $fonts, spacing 4] + @text locales/$lang.json + @link [$link-style, aria-label=$label] /docs/$lang Read the $label + @paragraph $post.title, $5 and $$ + @each $tag in $post.tags + @text #$tag + @each $tag in $post.missing + @text never + @if $post.draft + @text never diff --git a/tests/snapshots/variable_slots.html b/tests/snapshots/variable_slots.html new file mode 100644 index 0000000..bd2babf --- /dev/null +++ b/tests/snapshots/variable_slots.html @@ -0,0 +1 @@ +Variables fill slots
      locales/fr.jsonRead the Docs

      Hello {@b there} $lang, $5 and $$

      #a#b
      \ No newline at end of file diff --git a/tests/snapshots/variables_defines.html b/tests/snapshots/variables_defines.html index 6ff37df..c9f2c8f 100644 --- a/tests/snapshots/variables_defines.html +++ b/tests/snapshots/variables_defines.html @@ -1 +1 @@ -Variables
      Hello
      World
      \ No newline at end of file +Variables
      Hello
      World
      \ No newline at end of file diff --git a/tests/snapshots/view_transitions.html b/tests/snapshots/view_transitions.html index 54d7eeb..aeacaba 100644 --- a/tests/snapshots/view_transitions.html +++ b/tests/snapshots/view_transitions.html @@ -1 +1 @@ -View Transitions
      Hero element
      Sidebar element
      \ No newline at end of file +View Transitions
      Hero element
      Sidebar element
      \ No newline at end of file diff --git a/tests/snapshots/with_directive.html b/tests/snapshots/with_directive.html index 520cf14..221e9d7 100644 --- a/tests/snapshots/with_directive.html +++ b/tests/snapshots/with_directive.html @@ -1 +1 @@ -Alice says Hello \ No newline at end of file +Alice says Hello \ No newline at end of file