Skip to content

web — HTML + validated CSS in one nimony block

▶️ Try aoughwl/web live in the Playground — clones the repo into the in-browser IDE, no install.

web is a declarative DSL that builds a typed HTML tree and, inline, validates every style declaration against the MDN value grammar, lowering each style: block to a single scoped class. It sits at the top of the presentation stack: the web: block is a nimony compiler plugin that lowers to builder calls over html (the typed element tree) and css (MDN-grammar validation). For nimony / Nim 3.0.

Status — Works today for server-side / static output: HTML rendering plus a de-duplicated scoped stylesheet with full MDN validation of every declaration. component takes typed parameters, composes with other components, and styles by Style VALUE; for/if control flow and runtime children work in both web: and component blocks. No client-side reactivity or event binding yet — those are out of scope for this release.

Quickstart

nim
import web

let page = web:
  box:                       # <div>  (`div` is a nimony keyword — `box` spells it)
    attr:
      id: "hero"
    style:
      color: red
      padding: 10.px 20.px
    h1 "Hello, nimony"
    p "HTML + validated CSS, one block."
    ul:
      li "one"
      li "two"

echo render(page)            # <div id="hero" class="c…"><h1>Hello, nimony</h1>…</div>
echo renderStylesheet()      # .c…{color:red;padding:10px 20px}
for e in styleErrors(): echo e   # declarations that failed MDN validation

Components

component gives a tree parameters. It lowers to an ordinary proc … : HTML, so a component is called, composed and type-checked like any other procedure — there is no component runtime, registry, or lifecycle.

nim
proc defaultTheme(): Style =
  declare("color", "black") & declare("padding", "10px")

component card(title: string):
  input children: HTML                 # a parameter
  input footer: string = "(no footer)" # …with a default
  let theme = defaultTheme()           # ordinary locals — any nimony statement
  let highlight = true

  box:                                 # no marker: the tree is the tail of the body,
    @id "card"                         # starting at the first tree-shaped statement
    h1 title
    children                           # spliced — `children` is HTML, not text
    if highlight:                      # control flow works INSIDE an element, for
      @style theme:                    # directives as well as for children
        color: red                     # overrides the theme, keeps its padding
    for i in 1 ..< 3:
      p $i & ". item"
    small footer

let kids = web:
  p "a spliced child"

let page = card(title = "Hello", children = kids)
echo render(page)
# <div id="card" class="c9fb024aa"><h1>Hello</h1><p>a spliced child</p>
#  <p>1. item</p><p>2. item</p><small>(no footer)</small></div>
echo renderStylesheet()
# .c9fb024aa{color:red;padding:10px}      <- theme's black overridden, padding kept

Forms

formmeaning
input x: T / input x: T = da parameter; a default requires this form
component card(x: T):parameters in the header — no defaults (see below)
@name valuean attribute; the ident is kebab-cased (@dataIddata-id)
@namea boolean attribute (@disabled), rendered bare
@style s / @style s: + blockattach a Style value, optionally overridden
@style: + blockan anonymous inline style
tag "text" / tag: + blockan element
anything elsea value — appended according to its type

Styling is by value

A Style is an ordered set of validated declarations, merged right-wins by &. So theme & declare("color", "red") is the theme with one property replaced and every other property intact, and @style theme: with an override block is exactly that merge. A theme is therefore ordinary data — defaultTheme() is just a proc returning a Style, and it can be built, passed as an input, and overridden per element. Literal and computed styles alike are MDN-validated and lowered to one content-addressed class, so equal styles share a class however they were built.

Children are typed, not syntactic

Every child is emitted verbatim into webAppend, which is overloaded on string, HTMLNode and HTML. What a child means is therefore decided by its type: h1 title renders text and children splices a tree, with no syntax distinguishing the two and no type inspection in the plugin.

Elements versus components

A call is an element iff its name is a real HTML tag, asked of html's element registry rather than a tag list copied into the DSL. Everything else is a call to a component, so the two compose with one syntax.

A bare identifier is never an element. title, label, footer, data, form, summary, time and code are all real tags and among the most ordinary parameter names there are; reading a bare title as <title> would silently swallow the input of any component that named one that way. A component whose tree begins with a spliced value rather than an element marks it with an explicit web: block.

Two constraints worth knowing

A header parameter list cannot carry a default: component card(title: string = "x") is parsed as a call, and a call argument may not be name: T = default. That is a parser rule, not a style preference, and it is why defaulted inputs live on input lines.

A component that accepts children names that input children, because a block of children passed at a call site lowers to a named argument children = … (Nim forbids a positional argument after a named one, so a trailing positional slot would not compose).

There is no @style when cond: form — if cond: @style x already expresses it, since control flow inside an element lowers into the same accumulator.

The DSL

web: is the same DSL without the proc wrapper — reach for it when you just want an HTML value in an ordinary variable. Both surfaces are lowered by one engine (web/deps/weblower), so they cannot drift apart.

Inside a web: block each line is one of five forms:

formexamplelowers to
tag: + indented blocksection:an element with those children
tag "text" (command form)h1 "Hi"an element with one text child
bare string"raw text"a text node
attr: block of name: valueattr: / id: "x"attributes on the enclosing element
style: block of prop: valuestyle: / color: redinline styles → validated → one scoped class

style: blocks are the point. Property idents are kebab-cased (fontSizefont-size); values are rendered from the node tree — 10.px10px, 10.px 20.px10px 20px, rgb(1, 2, 3)rgb(1,2,3), pct/percent%. Each concatenated "prop:value;…" block is validated against the MDN value-definition grammar (clamp(), &lt;color&gt;, &lt;length&gt;{1,4}, function arities, …). Invalid declarations still render, but are collected in styleErrors(). Identical style blocks are content-addressed (FNV-1a) and share a single scoped class, so renderStylesheet() emits each unique rule once, in first-seen order.

Keyword tags. div and object are reserved words in nimony, so the DSL spells them box and obj. Every other element uses its real tag name.

API

Everything from html is re-exported, so render, the HTML/HTMLNode types, and the element constructors are available without a second import.

The DSL macros

symbolsignaturewhat it does
webtemplate web(body: untyped): HTMLCompiler plugin (deps/web_plugin). Lowers the declarative HTML+CSS block to an HTML value. May contain for/if/while control flow, @ directives, Style values and arbitrary runtime children.
componenttemplate component(name, body: untyped)Compiler plugin (deps/component_plugin). The same DSL wrapped in a generated proc name(<inputs>): HTML, so a component takes typed parameters and composes with other components. input lines and a header parameter list both become proc parameters.
dumpWebtemplate dumpWeb(name, body: untyped)Debug aid (deps/dump_plugin): lowers a block to echo "<its parse tree>", so a DSL shape can be discovered from the parser instead of guessed.

Output

symbolsignaturewhat it does
renderproc render(n: HTMLNode): string / proc render(nodes: seq[HTMLNode]): stringThe HTML string for a node or a whole fragment. Re-exported from html; HTML is seq[HTMLNode], so render(page) renders the top-level web: result.
renderStylesheetproc renderStylesheet(): stringEvery unique scoped rule the style: blocks compiled to, one .class{…} per line, in first-seen order.
styleErrorsproc styleErrors(): seq[string]The declarations that failed MDN validation, each formatted "prop: value — reason". Empty when every style validated.

Runtime builders (what the plugins chain)

These are the lowering targets the DSL emits; you rarely call them by hand, but they are public and can be used to assemble a tree programmatically.

symbolsignaturewhat it does
webElproc webEl(tag: string): HTMLNodeA new empty element node for tag.
webTextproc webText(s: string): HTMLNodeA text node.
webChildproc webChild(n, c: HTMLNode): HTMLNodeAppends child c to n, returns n (chainable).
webAppendproc webAppend(n: HTMLNode, x: string | HTMLNode | HTML): HTMLNodeAppends a child chosen by type: a string becomes a text node, an HTMLNode is spliced as-is, an HTML fragment splices all of its nodes. The lowering target for every child.
webAttrproc webAttr(n: HTMLNode, name, value: string): HTMLNodeSets attribute name=value on n, returns n.
webFlagproc webFlag(n: HTMLNode, name: string): HTMLNodeSets a boolean attribute (rendered bare, no ="…") — what @disabled lowers to.
webStyleValproc webStyleVal(n: HTMLNode, s: Style): HTMLNodeAttaches a Style VALUE — what @style lowers to. Routed through webStyle, so a computed style gets the same validation, class and dedup as a literal block.
webStyleproc webStyle(n: HTMLNode, decls: string): HTMLNodeValidates "prop:value;…", lowers it to one content-addressed class, merges that class onto n (preserving any existing class), records new rules for renderStylesheet() and any failures for styleErrors().
webFragproc webFrag(): HTMLAn empty fragment (@[]) — the accumulator a web: block grows.
webAddproc webAdd(f: HTML, n: HTMLNode): HTMLAppends node n to fragment f, returns the grown fragment.

Re-exported from html

The HTMLNode object model and its constructors come through export html:

symbolsignaturewhat it does
HTMLtype HTML = seq[HTMLNode]A fragment: the top-level result of a web: block.
HTMLNodetype HTMLNode = ref object (variant over HTMLNodeKind)An element (hnElement: tag, attrs, children) or a leaf (hnText/hnComment/hnRaw: text).
elproc el(tag: string, …): HTMLNodeElement constructor (three overloads: tag only, tag+children, tag+attrs+children).
text / comment / rawNodeproc(s: string): HTMLNodeText, <!-- … -->, and verbatim-unescaped nodes.
add / setAttrproc add(parent, child: HTMLNode) / proc setAttr(node: HTMLNode, name, value: string)Mutating tree/attribute helpers.

Design notes

  • Validation is compile-adjacent, not blocking. A bad declaration never stops rendering — it is emitted as-is and surfaced through styleErrors(), mirroring the nimony house style of status-based reporting over exceptions.
  • Content-addressed classes. The scoped class name is "c" + an 8-hex FNV-1a hash of the declaration string, so structurally identical style: blocks collapse to one rule and one class automatically.
  • Two plugins, ONE engine. web: and component: both call deps/weblower; the only difference is the wrapper (an expression versus a generated proc). They are the same language by construction rather than by discipline, so a feature added to one cannot go missing from the other.
  • The lowering is accumulator-shaped. Every element becomes a block expression over a mutable local (var e = webEl("div"); e = webAppend(e, …); e). That is what lets for and if work inside an element — for @ directives as much as for children, since a branch is not a special case but the same append statements under the original condition. A chained-expression lowering (webChild(webChild(…))) can express neither. The accumulator lives in a non-global frame, so a half-built tree is never snapshotted.
  • Meaning comes from types, not syntax. Children are emitted verbatim into the overloaded webAppend, so the plugin never decides whether a value is text or a tree — the type checker does, at the call site, with real diagnostics.
  • Global rule/error state. renderStylesheet() and styleErrors() read process-global registries populated as style: blocks compile; they accumulate across every web:/ component in the module.

Requirements

  • nimony / Nim 3.0 toolchain.
  • html — the typed HTML5 element tree (re-exported).
  • css — MDN-grammar CSS value validation (validateValue).
  • plugin — the web:/component blocks are nimony compiler plugins authored with it.

All three are declared in web.nimble.

aoughwl — self-hosted platform for things n stuff. Contact / Support on Discord for access to the private backends.