on v2026.9.25235210 help sign in
htmlang/DESIGN.md
498 lines 17 kB history raw
1# htmlang (.hl)
3A minimalist layout language inspired by elm-ui that compiles to static HTML.
5## Principles
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.
16## Syntax
18### Structure
20```
21@element [attributes] argument
22 children
23```
25Children are indented under their parent. Attributes are comma-separated inside
26`[...]` and may span several lines:
28```
29@el [
30 padding 20,
31 background white,
32 rounded 8,
33 box-shadow 0 2px 4px rgba(0,0,0,0.1)
35 Content
36```
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 "Inter, sans-serif"`.
44### Text
46Any line that doesn't start with `@` or `[` is text. Text after an element's
47attributes is its content, and `{...}` puts elements inside a line of text:
49```
50@paragraph
51 This is {@text [bold] important} and this is a {@link https://example.com link}.
52@text [bold, size 24, color #333] Hello world
53@section [padding 8] Text after the attributes is content too.
54```
56### Comments
58`--` at the start of a line begins a comment. Comments must be on their own
59line: `--` later in a line is ordinary text.
61```
62-- this is a comment
63@row [spacing 10]
64 -- todo: add nav items
65```
67### Shorthands
69A line starting with `[` is an anonymous `@el`. `>` chains single-child
70elements on one line; the last element in the chain gets the indented children.
72```
73[padding 20, background white]
74 Hello
76@el [padding 16, background blue, rounded 8] > @link https://example.com
77 @text [color white] Get Started
78```
80### Raw content
82`@raw """ ... """` is pasted into the output verbatim.
84```
85@raw """
86<div class="custom-widget"></div>
87"""
88```
90## Elements
92Layout elements set up flexbox; the rest map to the HTML element of the same
93name.
95| Element | Output | Purpose |
96|---|---|---|
97| `@row` | div, flex row | Horizontal layout |
98| `@column` | div, flex column | Vertical layout |
99| `@el` | div, flex column | Generic container |
100| `@grid` | div, grid | Grid container (use `grid-cols`) |
101| `@stack` | div, position relative | Children layered on top of each other |
102| `@in-front` / `@behind` | div, absolute | Overlay layers filling the parent (see below) |
103| `@text` | span | Styled inline text |
104| `@paragraph` | p | Flowing text with inline elements |
105| `@link url` | a | Link; text after the URL becomes its content |
106| `@image src` | img | Image |
107| `@h1` … `@h6` | h1 … h6 | Headings |
109Semantic containers, all laid out as columns: `@nav`, `@header`, `@footer`,
110`@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@form`,
111`@details` / `@summary`, `@dialog`, `@figure` / `@figcaption`, `@blockquote` /
112`@cite`, `@fieldset` / `@legend`, `@noscript`.
114Content: `@list` / `@item` (`@list [ordered]` for `<ol>`), `@dl` / `@dt` /
115`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
116`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
117`@canvas`, `@iframe src`, `@video src`, `@audio src`, `@picture` / `@source`,
118`@script`, `@fragment` (children without a wrapper).
120Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
121`@label`, `@datalist`. `@form URL` sets the form's `action`.
123```
124@list [ordered]
125 @item First
126 @item Second
127@form [method=post] /subscribe
128 @label [for=email] Email
129 @input [type=email, name=email, id=email, required]
130 @button [type=submit] Send
131@details [open]
132 @summary Question
133 @text The answer.
134```
136### `@in-front` / `@behind`
138Overlay layers, like elm-ui's `inFront` and `behind`. Their children fill the
139parent's bounds; the parent automatically becomes a positioning context
140(`position: relative; isolation: isolate`). An explicit `position` on the parent
141wins.
143```
144@el [width 200, height 200, background blue]
145 @text [color white] Main content
146 @in-front
147 @text [color yellow] Painted on top
148 @behind
149 @el [background red, width fill, height fill]
150```
152### Standard library
154These components are written in htmlang (`std.hl`) and available in every file.
155Use them like elements; your own `@let` with the same name takes precedence.
157| Component | Purpose |
158|---|---|
159| `@badge` | Small pill for counts and statuses |
160| `@tag` | Label with slightly rounded corners |
161| `@chip` | Outlined, fully rounded label |
162| `@avatar` | Circular frame for an image or initials |
163| `@spacer` | Takes up the remaining space in a row or column |
164| `@tooltip [tip TEXT]` | Text that shows `TEXT` when hovered |
165| `@carousel` | Horizontally scrolling row that snaps to each child |
166| `@breadcrumb` | Breadcrumb trail of `@item`s |
167| `$skeleton`, `$truncate`, `$no-scrollbar` | Attribute bundles: loading placeholder, one-line ellipsis, hidden scrollbars |
169```
170@row [spacing 8, align-items center]
171 @badge [background #ef4444, color white] 3
172 @chip Rust
173 @spacer
174 @tooltip [tip Opens in a new tab] Help
175@breadcrumb
176 @item > @link / Home
177 @item Docs
178@el [$skeleton, height 20, width fill]
179```
181## Attributes
183### Layout and sizing
185| Attribute | Effect |
186|---|---|
187| `spacing N` | Gap between children |
188| `padding N` / `padding Y X` / `padding T R B L` | Padding (also `padding-x`, `padding-y`) |
189| `margin ...` | Margin, same forms as padding (also `margin-x`, `margin-y`) |
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 |
196| `hidden` | `display: none` |
198### Style
200| Attribute | Effect |
201|---|---|
202| `border N COLOR` (also `border-top` etc.) | Solid border |
203| `rounded N` | Border radius |
204| `bold`, `italic`, `underline` | Text style |
205| `size N` | Font size |
206| `font NAME` | Font family |
207| `line-clamp N` | Cut text off after N lines |
209**Any standard CSS property** can also be used as an attribute, with the same
210name and value: `background red`, `opacity 0.5`, `cursor pointer`,
211`z-index 10`, `margin-top 16`, `grid-template-areas "a b"`, and so on.
213**Units.** For lengths, a bare number is pixels (`padding 20` is `20px`).
214Values with a unit, keywords and CSS functions are passed through: `width 50%`,
215`margin 0 auto`, `max-width min(100%, 800px)`.
217### HTML attributes
219Write HTML attributes as `key=value`: `id=main`, `class=note`, `href=/about`,
220`type=email`, `alt=Logo`, `target=_blank`, `aria-label=Close menu`,
221`data-id=42`. Any name works. Booleans are written bare: `required`,
222`disabled`, `checked`, `open`, `sandbox`. A style and an HTML attribute may
223share a name without clashing:
225```
226@image [width=800, width 200, alt=A photo] photo.jpg
227@select [size=4, size 18]
228 @option One
229```
231### State and media prefixes
233Prefix a style attribute to apply it conditionally:
235| Prefix | Applies |
236|---|---|
237| `hover:`, `active:`, `focus:`, `focus-visible:`, `focus-within:`, `disabled:`, `checked:`, `visited:`, `target:`, `valid:`, `invalid:`, `empty:`, `placeholder:`, `selection:` | In that state |
238| `first:`, `last:`, `odd:`, `even:`, `nth:EXPR:` | By position among siblings |
239| `children:` | To each direct child |
240| `before:`, `after:` | On the `::before` / `::after` pseudo-element (with `content`) |
241| `has(SELECTOR):` | When the element contains a match |
242| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640–1536px) |
243| `cq-sm:` … `cq-2xl:` | Container queries |
244| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Media conditions |
246```
247@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
248 @text [color white] Click me
249@row [spacing 4, children:flex 1, nth:2n:background #f3f4f6]
250 @el A
251 @el B
252@el [before:content "→ ", before:color red]
253 Item with an arrow
254```
256### Conditional attributes
258`if(CONDITION, A, B)` picks `A` or `B` by the condition. It works as a value or
259as a whole attribute, and an empty choice (the `B` can be left out) leaves the
260attribute out.
262```
263@let active true
264@el [background if($active, blue, gray), if($active, bold), padding if($active, 12)]
265 Conditionally styled
266```
268## Variables and functions
270`@let` defines everything reusable. What it defines depends on its shape:
272```
273-- A value, used as $primary
274@let primary #3b82f6
275-- A computed value: `=` makes it an expression
276@let gap = 8 * 2
277-- A quoted string, with $variables interpolated
278@let greeting "Hello $primary"
279-- An attribute bundle, used as [$card]
280@let card [padding 20, background white, rounded 8]
281-- A function: a @let with an indented body
282@let panel $title $tone=neutral
283 @el [$card]
284 @text [bold] $title
285 @children
287@panel [title Welcome]
288 Body text goes into @children.
289@el [$card, background #f9fafb]
290 Attributes after a bundle override it.
291```
293A function is called like an element:
295- Its parameters are passed as attributes; parameters with `=default` may be
296 omitted.
297- Other attributes style its root element, so `@panel [title Hi, padding 40]`
298 works like styling a built-in element.
299- Text after the attributes and indented children replace `@children`, and a
300 caller's `@slot name` block replaces `@slot name` (the slot's own children are
301 the default).
302- An `@style` block at the top of the body is scoped to the function: its rules
303 only apply inside the function's output.
305```
306@let note $kind=info
307 @style
308 .title { font-weight: bold; }
309 @el [padding 12, rounded 6, background #eff6ff]
310 @text [class=title] $kind
311 @children
313@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
314```
316A multi-line string uses triple quotes; its indented lines are the value:
318```
319@let intro """
320 First line
321 Second line
322 """
323@text $intro
324```
326`@let --name value` also emits a CSS custom property, used as `$--name` and
327as `var(--name)` in the generated CSS:
329```
330@let --brand #3b82f6
331@el [background $--brand] Themed
332```
334### Checks
336`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
337warning.
339## Expressions
341Conditions and computed values (`@let x = ...`) are expressions:
343| | |
344|---|---|
345| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
346| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
347| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains`, `starts-with`, `ends-with` |
348| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
349| Choice | `if(CONDITION, A, B)` |
350| Text functions | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)`, `reverse(s)`, `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` |
351| Color functions | `lighten(c, pct)`, `darken(c, pct)`, `alpha(c, a)`, `mix(c1, c2, pct)` |
353Variables are looked up while evaluating, so a value containing `==` or spaces
354is still one value. An invalid expression is a compile error.
356In text and attribute values, `$name` inserts a variable and `${EXPR}` inserts
357the value of any expression:
359```
360@let name htmlang
361@let base #3b82f6
362@text [color ${darken($base, 10)}] ${uppercase($name)} has ${length($name)} letters
363```
365## Control flow
367All control flow runs at compile time.
369```
370@let items apple, banana, cherry
371@let count 3
373@if $count > 2 and not $hidden
374 @text Many
375@else if $count == 0
376 @text None
377@else
378 @text Few
380@each $item in $items
381 @text $_index: $item
382@else
383 @text The list is empty.
385@each $i in 1..10 step 3
386 @text $i
388@each $label, $url in Home /, About /about
389 @link $url $label
390```
392A range counts down when its start is greater than its end.
394## Files and data
396| Directive | Effect |
397|---|---|
398| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
399| `@include lib.hl as ui` | Take only its definitions, named `ui.name` |
400| `@data file.json` / `@data $name file.json` | Load JSON values as variables (`$name.key`) |
401| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
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):
410```
411-- layout.hl
412@let layout
413 @page My Site
414 @column [max-width 800, center-x]
415 @slot header
416 @text Default header
417 @children
418```
420```
421-- page.hl
422@include layout.hl
423@layout
424 @slot header
425 @text About us
426 @paragraph This fills @children.
427```
429## Page and head
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`.
437```
438@page [lang en, favicon /favicon.png] My Site
439@meta description A small site
440@meta og:title My Site
441```
443## CSS
445- `@style` holds raw CSS, including at-rules such as `@scope`, `@property` and
446 `@starting-style`.
447- `@keyframes name` takes htmlang attribute syntax (`from [opacity 0]`) or raw
448 CSS.
449- Generated rules live in `@layer htmlang` (after a `hl-reset` layer), so any
450 CSS in `@style` or `@raw` overrides them.
452```
453@keyframes fade-in
454 from [opacity 0]
455 to [opacity 1]
456@style
457 .note { color: gray; }
458@el [animation fade-in 0.3s ease, class=note] Fades in
459```
461## Upgrading older files
463`htmlang upgrade [dir|file]` rewrites removed syntax; the compiler reports each
464removed form with its replacement.
466| Removed | Use instead |
467|---|---|
468| `@fn`, `@define`, `@mixin`, `@component` | `@let` (function, bundle; `@style` in a function body is scoped) |
469| `@unless COND` | `@if not COND` |
470| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
471| `grid` (attribute) | `@grid`, or `display grid` |
472| `@switch`, `@match` | `@if $x == a` / `@else if` / `@else` |
473| `@use "file" names`, `@import file` | `@include file` |
474| `@collection`, `@env`, `@translations`, `@fetch` | `@data $name SOURCE` (glob, `env:NAME`, or a JSON file per locale) |
475| `@theme` | `@let --name value` lines |
476| `@json-ld`, `@font-face`, `@manifest` | Raw HTML in `@head`, CSS in `@style` |
477| `@breakpoint`, `@deprecated` | A media query in `@style`; nothing |
478| `@svg [attrs] file.svg` | `@image [inline, attrs] file.svg` |
479| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
480| `@with $x as y` | `@let y $x` |
481| `@extends layout.hl`, `@layout file` | `@include layout.hl` and a call to the layout, now a function (both files are converted) |
482| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
483| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
484| `@og KEY VALUE` | `@meta og:KEY VALUE` |
485| `@debug`, `@log` | `@warn` |
486| `@defer` | Its content, directly |
487| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@column`, `@paragraph`, `@image`, `@item`, `@button`, `@list`, `@hr`, `@option` |
488| `type email`, `id main` (HTML attributes written as styles) | `type=email`, `id=main` |
489| `skeleton`, `no-scrollbar`, `gradient A B` | `$skeleton`, `$no-scrollbar`, `background linear-gradient(A, B)` |
490| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
491| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
492| `COND ? A : B` | `if(COND, A, B)` |
493| `key if COND` (in attributes) | `if(COND, key)` |
494| `$a ~ " " ~ $b` | `"$a $b"` |
495| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
496| `...$bundle` | `$bundle` |
497| `animate`, `inset-area` | `animation`, `position-area` |
498| `$x\|uppercase`, `$c\|darken:10` (filters) | `${uppercase($x)}`, `${darken($c, 10)}` |