3A minimalist layout language inspired by elm-ui that compiles to static HTML.
7- `@` means structure, bare lines mean content.
8- Layout is explicit and compositional: every element declares its own layout
9 role, and there is no CSS cascade to reason about.
10- Output is one self-contained HTML file with embedded CSS (flexbox), and no
11 JavaScript unless you write some.
13Every example block in this file is compiled by the test suite
14(`tests/docs.rs`), so the examples stay correct.
21@element [attributes] argument
25Children are indented under their parent. Attributes are comma-separated inside
26`[...]` and may span several lines:
33 box-shadow 0 2px 4px rgba(0,0,0,0.1)
38An attribute is either a **style**, written `key value` (`padding 20`,
39`color red`), or an **HTML attribute**, written `key=value` (`id=main`,
40`type=email`, `aria-label=Close`). Boolean HTML attributes are written bare
41(`required`, `disabled`, `open`). A comma inside `(...)` or `"..."` does not
42split attributes, so a font stack is written `font-family "Inter, sans-serif"`.
46Any line that doesn't start with `@` is text. Text after an element's
47attributes is its content, and `{...}` puts elements inside a line of text:
51 This is {@text [font-weight bold] important} and this is a {@link https://example.com link}.
52@text [font-weight bold, font-size 24, color #333] Hello world
53@section [padding 8] Text after the attributes is content too.
58`--` at the start of a line begins a comment. Comments must be on their own
59line: `--` later in a line is ordinary text.
64 -- todo: add nav items
69`>` chains single-child elements on one line; the last element in the chain
70gets the indented children.
73@el [padding 16, background blue, border-radius 8] > @link https://example.com
74 @text [color white] Get Started
79`@raw` pastes HTML into the output verbatim: the rest of its line, or its
80indented block. The bodies of `@style`, `@head`, `@script` and `@markdown` are
81verbatim too (a `--` line in them is not a comment).
84@raw <hr class="fancy">
86 <div class="custom-widget">
87 <span>Hand-written HTML</span>
93The layout and text elements come from elm-ui; every other element has its
94HTML name. Every container lays its children out in a column, except `@row`.
96| Element | Output | Purpose |
98| `@row` | div, flex row | Horizontal layout |
99| `@el` | div, flex column | The container: children laid out top to bottom |
100| `@grid` | div, grid | Grid container (use `grid-cols`) |
101| `@in-front` / `@behind` | div, absolute | Overlay layers filling the parent (see below) |
102| `@text` | span | Styled inline text |
103| `@paragraph` | p | Flowing text with inline elements |
104| `@link url` | a | Link; text after the URL becomes its content |
105| `@image src` | img | Image |
106| `@h1` … `@h6` | h1 … h6 | Headings |
108Semantic containers, all laid out as columns: `@nav`, `@header`, `@footer`,
109`@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@form`,
110`@details` / `@summary`, `@dialog`, `@figure` / `@figcaption`, `@blockquote` /
111`@cite`, `@fieldset` / `@legend`, `@noscript`.
113Content: `@ul` / `@ol` / `@li`, `@dl` / `@dt` /
114`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
115`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
116`@canvas`, `@iframe src`, `@video src`, `@audio src`, `@picture` / `@source`,
117`@script`, `@fragment` (children without a wrapper).
119Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
120`@label`, `@datalist`. `@form URL` sets the form's `action`.
126@form [method=post] /subscribe
127 @label [for=email] Email
128 @input [type=email, name=email, id=email, required]
129 @button [type=submit] Send
135### `@in-front` / `@behind`
137Overlay layers, like elm-ui's `inFront` and `behind`. Their children fill the
138parent's bounds; the parent automatically becomes a positioning context
139(`position: relative; isolation: isolate`). An explicit `position` on the parent
143@el [width 200, height 200, background blue]
144 @text [color white] Main content
146 @text [color yellow] Painted on top
148 @el [background red, width fill, height fill]
153These components are written in htmlang (`std.hl`) and available in every file.
154Use them like elements; your own `@let` with the same name takes precedence.
156| Component | Purpose |
158| `@badge` | Small pill for counts and statuses |
159| `@tag` | Label with slightly rounded corners |
160| `@chip` | Outlined, fully rounded label |
161| `@avatar` | Circular frame for an image or initials |
162| `@spacer` | Takes up the remaining space in a row or column |
163| `@tooltip [tip TEXT]` | Text that shows `TEXT` when hovered |
164| `@carousel` | Horizontally scrolling row that snaps to each child |
165| `@breadcrumb` | Breadcrumb trail of `@li`s |
166| `$skeleton`, `$truncate`, `$no-scrollbar` | Attribute bundles: loading placeholder, one-line ellipsis, hidden scrollbars |
169@row [spacing 8, align-items center]
170 @badge [background #ef4444, color white] 3
173 @tooltip [tip Opens in a new tab] Help
177@el [$skeleton, height 20, width fill]
184htmlang's own attributes are about layout, in the spirit of elm-ui: how an
185element sits in its row or column.
187| Attribute | Effect |
189| `spacing N` | Gap between children |
190| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
191| `height fill` / `height N` / `height shrink` | Same for height |
192| `center-x`, `center-y` | Center within the parent |
193| `align-left`, `align-right`, `align-top`, `align-bottom` | Align within the parent |
194| `wrap` | Let a row wrap |
195| `grid-cols N`, `grid-rows N`, `col-span N`, `row-span N` | Grid layout |
199Everything else is CSS: **any standard CSS property** is an attribute, with
200the same name and value: `padding 20`, `font-weight bold`, `font-size 18`,
201`border 1 solid #e5e7eb`, `border-radius 8`, `background red`,
202`display none`, `grid-template-areas "a b"`, and so on. `line-clamp N` also
203adds the `-webkit-box` declarations browsers still need to cut text off after
206**Units.** For lengths, a bare number is pixels (`padding 20` is `20px`,
207`border 1 solid red` is `1px solid red`). Values with a unit, keywords and CSS
208functions are passed through: `width 50%`, `margin 0 auto`,
209`max-width min(100%, 800px)`.
213Write HTML attributes as `key=value`: `id=main`, `class=note`, `href=/about`,
214`type=email`, `alt=Logo`, `target=_blank`, `aria-label=Close menu`,
215`data-id=42`. Any name works. Booleans are written bare: `required`,
216`disabled`, `checked`, `open`, `sandbox`. A style and an HTML attribute may
217share a name without clashing:
220@image [width=800, width 200, alt=A photo] photo.jpg
221@select [size=4, font-size 18]
225### State and media prefixes
227Prefix a style attribute to apply it conditionally:
231| `hover:`, `active:`, `focus:`, `focus-visible:`, `focus-within:`, `disabled:`, `checked:`, `visited:`, `target:`, `valid:`, `invalid:`, `empty:`, `placeholder:`, `selection:` | In that state |
232| `first:`, `last:`, `odd:`, `even:`, `nth:EXPR:` | By position among siblings |
233| `children:` | To each direct child |
234| `before:`, `after:` | On the `::before` / `::after` pseudo-element (with `content`) |
235| `has(SELECTOR):` | When the element contains a match |
236| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640–1536px) |
237| `cq-sm:` … `cq-2xl:` | Container queries |
238| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Media conditions |
241@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
242 @text [color white] Click me
243@row [spacing 4, children:flex 1, nth:2n:background #f3f4f6]
246@el [before:content "→ ", before:color red]
250### Conditional attributes
252`if(CONDITION, A, B)` picks `A` or `B` by the condition. It works as a value or
253as a whole attribute, and an empty choice (the `B` can be left out) leaves the
258@el [background if($active, blue, gray), if($active, font-weight bold), padding if($active, 12)]
262## Variables and functions
264`@let` defines everything reusable. What it defines depends on its shape:
267-- A value, used as $primary
269-- A computed value: `=` makes it an expression
271-- A quoted string, with $variables interpolated
272@let greeting "Hello $primary"
273-- An attribute bundle, used as [$card]
274@let card [padding 20, background white, border-radius 8]
275-- A function: a @let with an indented body
276@let panel $title $tone=neutral
278 @text [font-weight bold] $title
281@panel [title Welcome]
282 Body text goes into @children.
283@el [$card, background #f9fafb]
284 Attributes after a bundle override it.
287A function is called like an element:
289- Its parameters are passed as attributes; parameters with `=default` may be
291- Other attributes style its root element, so `@panel [title Hi, padding 40]`
292 works like styling a built-in element.
293- Text after the attributes and indented children replace `@children`, and a
294 caller's `@slot name` block replaces `@slot name` (the slot's own children are
296- An `@style` block at the top of the body is scoped to the function: its rules
297 only apply inside the function's output.
302 .title { font-weight: bold; }
303 @el [padding 12, border-radius 6, background #eff6ff]
304 @text [class=title] $kind
307@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
310A multi-line string uses triple quotes; its indented lines are the value:
320`@let --name value` also emits a CSS custom property, used as `$--name` and
321as `var(--name)` in the generated CSS:
325@el [background $--brand] Themed
330Conditions and computed values (`@let x = ...`) are expressions:
334| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
335| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
336| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains` (in text, or as an item of a list), `starts-with`, `ends-with` |
337| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
338| Choice | `if(CONDITION, A, B)` |
339| Text functions | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)` (a list's items, or a text's characters), `reverse(s)`, `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` |
340| Color functions | `lighten(c, pct)`, `darken(c, pct)`, `alpha(c, a)`, `mix(c1, c2, pct)` |
342Variables are looked up while evaluating, so a value containing `==` or spaces
343is still one value. An invalid expression is a compile error.
345In text and attribute values, `$name` inserts a variable and `${EXPR}` inserts
346the value of any expression:
351@text [color ${darken($base, 10)}] ${uppercase($name)} has ${length($name)} letters
356All control flow runs at compile time.
359@let items apple, banana, cherry
362@if $count > 2 and not $hidden
369@each $item, $i in $items
372 @text The list is empty.
374@each $n in 1..10 step 3
378`@each $item in LIST` loops over a list: a list loaded with `@data`, text
379split on commas, or a range (which counts down when its start is greater than
380its end). An optional second variable is the index, from 0. An item loaded from
381JSON can be a record, whose fields are `$item.key`:
385 {"label": "About us", "url": "/about"},
386 {"label": "Blog", "url": "/blog"}
389 @each $link in $links
390 @link $link.url $link.label
391@text ${length($links)} links
396| Directive | Effect |
398| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
399| `@data $name file.json` | Load JSON values as variables (`$name.key`; arrays are lists) |
400| `@data $name [...]` / `@data $name {...}` | Inline JSON (an array may span lines) |
401| `@data $name dir/*.json` | A list with one record per file, in name order; `$item.file` is the file's name |
402| `@data $name env:NAME [default]` | Read an environment variable |
403| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
404| `@image [inline] file.svg` | Inline an SVG file (`width`, `height`, `color`, `class=`, `id=` apply to it) |
406A layout is an ordinary function in its own file. It marks where content goes
407with `@slot name` (named blocks, with default content) and `@children`
408(everything in the call outside `@slot` blocks):
414 @el [max-width 800, center-x]
426 @paragraph This fills @children.
431`@page TITLE` produces a full HTML document; without it the output is a fragment.
432Its attributes set `lang`, `favicon`, `canonical` and `base`. `@meta NAME VALUE`
433adds a meta tag (`og:` names become Open Graph `property` tags), and
434`@head` holds any other raw HTML for the `<head>` (fonts, JSON-LD, a manifest
435link). Translations are a JSON file per locale: `@data $t locales/$lang.json`.
438@page [lang en, favicon /favicon.png] My Site
439@meta description A small site
440@meta og:title My Site
445- `@style` holds raw CSS, including at-rules such as `@keyframes`, `@scope`,
446 `@property` and `@starting-style`.
447- Generated rules live in `@layer htmlang` (after a `hl-reset` layer), so any
448 CSS in `@style` or `@raw` overrides them.
456 .note { color: gray; }
457@el [animation fade-in 0.3s ease, class=note] Fades in
460## Upgrading older files
462`htmlang upgrade [dir|file]` rewrites removed syntax; the compiler reports each
463removed form with its replacement.
465| Removed | Use instead |
467| `@fn`, `@define`, `@mixin`, `@component` | `@let` (function, bundle; `@style` in a function body is scoped) |
468| `@unless COND` | `@if not COND` |
469| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
470| `grid` (attribute) | `@grid`, or `display grid` |
471| `@switch`, `@match` | `@if $x == a` / `@else if` / `@else` |
472| `@use "file" names`, `@import file` | `@include file` |
473| `@collection`, `@env`, `@translations`, `@fetch` | `@data $name SOURCE` (glob, `env:NAME`, or a JSON file per locale) |
474| `@theme` | `@let --name value` lines |
475| `@json-ld`, `@font-face`, `@manifest` | Raw HTML in `@head`, CSS in `@style` |
476| `@breakpoint`, `@deprecated` | A media query in `@style`; nothing |
477| `@svg [attrs] file.svg` | `@image [inline, attrs] file.svg` |
478| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
479| `@with $x as y` | `@let y $x` |
480| `@extends layout.hl`, `@layout file` | `@include layout.hl` and a call to the layout, now a function (both files are converted) |
481| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
482| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
483| `@og KEY VALUE` | `@meta og:KEY VALUE` |
484| `@assert`, `@warn`, `@debug`, `@log` | Nothing (the lines are removed) |
485| `@defer` | Its content, directly |
486| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@el`, `@paragraph`, `@image`, `@li`, `@button`, `@ul`, `@hr`, `@option` |
487| `type email`, `id main` (HTML attributes written as styles) | `type=email`, `id=main` |
488| `skeleton`, `no-scrollbar`, `gradient A B` | `$skeleton`, `$no-scrollbar`, `background linear-gradient(A, B)` |
489| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
490| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
491| `COND ? A : B` | `if(COND, A, B)` |
492| `key if COND` (in attributes) | `if(COND, key)` |
493| `$a ~ " " ~ $b` | `"$a $b"` |
494| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
495| `...$bundle` | `$bundle` |
496| `bold`, `italic`, `underline`, `hidden` | `font-weight bold`, `font-style italic`, `text-decoration underline`, `display none` |
497| `size`, `font`, `rounded`, `padding-x/y`, `margin-x/y` | `font-size`, `font-family`, `border-radius`, `padding-inline/block`, `margin-inline/block` |
498| `border N COLOR` (solid implied) | `border N solid COLOR` |
499| `@each $a, $b in A x, B y` (items split on spaces) | Records: `@data $rows [{"a": "A", "b": "x"}, ...]` and `@each $row in $rows` with `$row.a` |
500| `$_index` | `@each $item, $i in ...` |
501| `$posts._count`, `$posts._keys`, `$posts.STEM.key` | `length($posts)`; nothing; `@each $post in $posts` with `$post.file` |
502| `@raw """ ... """` | `@raw` with an indented body |
503| `@keyframes NAME` with `from [opacity 0]` | The `@keyframes` rule in `@style` |
504| `@include lib.hl as ui` | `@include lib.hl`, without the `ui.` prefix |
505| `@data file.json` (keys as `$key`) | `@data $name file.json` (keys as `$name.key`) |
506| `[attrs]` alone on a line | `@el [attrs]` |
507| `animate`, `inset-area` | `animation`, `position-area` |
508| `$x\|uppercase`, `$c\|darken:10` (filters) | `${uppercase($x)}`, `${darken($c, 10)}` |
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list