Reference

Language spec

MarkdownEdit on GitHub

A web language that compiles to JavaScript. Files are .art. Expressions are JavaScript; only the structure is new.

Declarations (top level) #

text
model User {
  id: ID
  name: String
  bio: String?
  tags: String[]
}

component UserCard(user: User, onDelete: Fn, big: Bool = false) {
  ...members and view
}

page Users "/users" {
  ...members and view
}
  • Types: String Number Bool ID Email Date Fn Any File, model names, T[] list, T? optional (may be null).
  • Field rules, enforced by the server: name: String min=2 max=50 (length; for a Number, its value; for a list, its size), code: String match="^[A-Z]{3}$", email: Email unique.
  • Defaults: stock: Number = 0 (a literal); create may omit the field.
  • Files: photo: File? max=2000000 accept="image/*" (max in bytes). Pass the File from file picked straight to create/update: it's uploaded and stored as { url, name, type, size } (image post.photo.url).
  • Changing a stored model needs no migration code; a new required field needs a default (or ?); a renamed one: title: String was="name".
  • page Product "/products/:id": a component with a route; inside it params.id (String) and query.tab (from ?tab=). page NotFound "*" catches unknown paths. Without a route: /lowercase-name.
  • meta title="..." description="..." image="/og.png" in a page sets its title, description and Open Graph tags.
  • layout Main { ... slot ... } wraps pages and stays mounted while they change (the only top-level layout applies to every page; page X "/x" layout Main picks one; layout Docs layout Main goes inside Main). Links to the current page get aria-current="page". link "x" to="/path" and navigate("/path") change pages without reloading. notify("Saved", "success") shows a short message (info, success, danger). page Admin "/admin" requires admin (or requires login) only shows to them (others go to /login or /). setTheme("dark" | "light" | "auto"), theme().

Imports: use #

text
use "date-fns" { format, addDays as plus } // npm package (install it with npm first)
use "canvas-confetti" as confetti       // default export
use "./lib/money.ts" { toUSD }          // your own JS/TS module: the way out for anything not built in
  • Imported names work in every component and server fn, typed Any.

Members (inside component/page, before the view) #

text
state count = 0                  // reactive; type inferred
state users: User[] = []         // explicit type
computed total = count * 2       // derived; assigning it overrides it until count changes
fn add(x) {                      // function; body = JS statements
  if x == "" { return }
  users.push({ id: crypto.randomUUID(), name: x, tags: [] })
}
  • state, computed and fn written outside any component are shared by all of them (state cart: Item[] = []: one cart for every page).
  • Assigning to a state updates the UI: count++, name = "x", users.push(u), user.name = "x", also through a fn parameter (fn sell(p) { p.stock-- }).
  • A component that assigns its prop (items = items.filter(...)) changes the parent's state: pass a state (List items=items).
  • No hooks, setters or manual dependencies.
  • Statements: expression, let x = ..., if cond { } else { }, for x in xs { }, while cond { }, return, try { } catch (e) { } finally { }.
  • For DOM libraries (charts, maps), timers and subscriptions:
text
ref box                          // the element marked `canvas ref=box` (set before mount runs)
ref chart                        // a ref also holds any value that isn't state
mount {                          // once, when the view is in the page
  chart = new Chart(box, { data: points })
  cleanup { chart.destroy() }    // on unmount
}
effect {                         // re-runs when the states it reads change
  document.title = `${count} items`
}

Backend: api and data #

text
api users: User                    // REST at /api/users: validated against the model, data stored
  • The model needs an ID field (if create omits it, the server assigns it).
  • Relations: author: User (or tags: Tag[]) in a stored model stores the id; writes take the row or its id, reads return the row (post.author.name; deeper with list({ include: ["post.author"] })), where: { author: id } filters. Deleting a referenced row fails unless the field is cascade (post: Post cascade).
  • Typed client in any component: api.users.list(query?), count(query?), get(id), create(obj), update(id, changes), remove(id).
  • Query: list({ where: { active: true }, search: "pan", sort: "-price", limit: 20, offset: 40 }) (- = descending; search matches text fields); count({ where, search }). Inside data they re-run when the states they use change (offset: page * 20).
  • data users = api.users.list() loads on mount and reloads by itself after any write, login or logout. A list starts as [], a count as 0, get as null (T?). users.loading (until the first response), users.error (message or null), users.reload(). ... live also reloads when someone else writes.
  • await and try { } catch (e) { } work as in JS; e.message explains a validation error (e.details.field names the field; e.status is 409 when a unique value is taken).
  • Access: api notes: Note login needs a session; private also scopes rows per user (the model needs owner: ID, filled in); readonly after it (api orders: Order private readonly): clients only read, server fns write; admin: anyone reads, admins write (accounts need role: String; the first account is "admin", later ones "user").
  • auth users (the model needs email: Email and password: String): auth.signup(obj), auth.login(email, password), auth.logout(), auth.logoutAll(), data me = auth.me() (T?). auth users with google, github: auth.loginWith("google").
  • auth.requestReset(email) emails a link to /reset-password?token=..., a page that calls auth.resetPassword(query.token, password). With verified: Bool in the model, sign-up emails /verify-email?token=... (auth.verifyEmail(query.token)).
  • server fn name(a, b) { ... } runs on the server; call it as server.name(a, b) (also in data). Inside: db.<api> (no await, not scoped per user), me (logged-in user or null), fail("message", status?) and await email(to, subject, text).
  • server job cleanup every "1h" { ... } (s m h d): on the server, with db, fail, email.

View #

One line per element: tag content prop=value flag -> action { children }

text
column gap=4 align=center {
  title "Users"
  text `Total: ${total}` muted
  input draft placeholder="Name" -> add(draft)
  button "Add" primary -> add(draft)
  if users.length == 0 {
    text "Empty"
  } else {
    for u, i in users {
      UserCard user=u onDelete=(id => users = users.filter(x => x.id != id))
    }
  }
}
ElementContent-> fires onPropsFlags
texttext—bold muted small large danger
titletext—muted small large
buttontextclickdisabledprimary danger small
inputstate to bind (two-way)Enterplaceholder type disabled labelrequired
textareastate to bind—placeholder rows disabled labelrequired
selectstate to bindchangeoptions placeholder disabled label
radio tabsstate to bindchangeoptions label
checkboxBool statechangelabel disabled
filestate (File, or list with multiple)changeaccept label disabledmultiple
modalBool state (open)—gap pad align justify
imagesrc—alt width height
video audiosrc—video: width height postercontrols autoplay loop muted
linktext—to href targetmuted
badgetext—primary success danger
iconLucide name ("check" "trash" "edit" "search" "user" "home"...)—size labelmuted primary success danger
spinner divider——
canvas——width height
row column card——gap pad align justifyrow: wrap
grid——gap pad align justify cols
form—submitgap pad align justify
list > itemitem: textitem: clickitem: muted
table > tr > th tdth/td: texttr: clicktd: muted
  • All take class style id role aria-* data-* (button "Menu" aria-expanded=open); texts and containers take tag= for the HTML element (title "Plans" tag=h1, column tag=nav; default: title is an h2, text a span); a field without label= is named by its placeholder. style { .box { ... } } in a component: CSS only for its elements. .css files in the project are bundled; theme: :root { --a-primary: #e11d48; --a-radius: 4px; --a-font: Inter } (also --a-bg --a-fg --a-surface --a-border --a-muted --a-danger --a-success).
  • Conditional flag: text t.title muted=t.done.
  • gap=4 and pad=4: 1 unit = 4px. align=start|center|end|stretch. justify=start|center|end|between|around. cols=3.
  • type=text|number|email|password|checkbox|date. With type=checkbox, input binds a Bool.
  • options=["S", "M"] or objects (value/id, label/name); the state gets the option's value. label="Email" adds a visible label. modal open { ... } shows while open is true (Esc or the backdrop set it to false).
  • item, th, td, link take text and/or { children } (link to="/p/1" { card { ... } }).
  • Prop values: literal, name, a.b, call, or ( expression ) in parentheses.
  • Component: Name prop=value. Children Card { ... } go where it puts slot; slot header is filled by Card { header { ... } }. onPick: Fn(User) types a callback (onPick=(u => ...) gets u: User).
  • Other events: on:<event>=statement with event: card on:mouseenter=(hover = true), input q on:keydown=(event.key == "Escape" ? q = "" : null).
  • for p in products key p.id { }: rows matched by key keep their DOM and focus.
  • Responsive: grid cols=1 md:cols=3 lg:gap=6 (sm md lg xl = 640/768/1024/1280px; cols gap pad).
  • Multi-statement action: -> { a(); b = 1 }.

Tests #

test "adds a task" { then one step per line, }; art test runs them in a simulated browser. Steps: open "/path", see "x", notSee "x", click "Label" [n], link "Label", fill "Placeholder" "value", press "Placeholder" "Enter", select 0 "Option", check 0.

Expressions #

JavaScript: literals, /regex/, `template ${x}`, a.b, a?.b, a[i], f(x), x => x * 2, { a, ...b, [key]: 1 }, [...xs], ? :, ??, &&, ||. == and != compile to === and !==. A line starting with ?, :, ., &&, || or ?? continues the previous one. JS globals work (Math JSON Date crypto fetch localStorage...).

Rules the compiler checks #

  • list.find(...) and api.x.get(id) are T?: use ?.field, ?? value or if x { }.
  • Inside if x { }, if x != null, x && ..., x ? ... : ... or after if !x { return }, x is no longer null.
  • Objects passed as a model need every non-optional field and no extra fields.
  • Unknown names, elements, props and flags → error with a suggestion.

Changing existing code #

Answer with a ``patch `` block instead of rewriting files: replace Todos/column/title with the new code indented below it; also insert before|after <path>, append <path>, remove <path>, set Todos/column gap=6, add (paths: Component/tag/tag[n], Component.member, Model.field`). The full format is in SPEC-EDIT.md.