Language spec
A web language that compiles to JavaScript. Files are .art. Expressions are JavaScript; only the structure is new.
Declarations (top level) #
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,modelnames,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);createmay omit the field. - Files:
photo: File? max=2000000 accept="image/*"(max in bytes). Pass the File fromfile pickedstraight tocreate/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 itparams.id(String) andquery.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 Mainpicks one;layout Docs layout Maingoes inside Main). Links to the current page getaria-current="page".link "x" to="/path"andnavigate("/path")change pages without reloading.notify("Saved", "success")shows a short message (info,success,danger).page Admin "/admin" requires admin(orrequires login) only shows to them (others go to/loginor/).setTheme("dark" | "light" | "auto"),theme().
Imports: use #
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) #
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,computedandfnwritten outside any component are shared by all of them (state cart: Item[] = []: one cart for every page).- Assigning to a
stateupdates 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:
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 #
api users: User // REST at /api/users: validated against the model, data stored- The model needs an
IDfield (ifcreateomits it, the server assigns it). - Relations:
author: User(ortags: Tag[]) in a stored model stores the id; writes take the row or its id, reads return the row (post.author.name; deeper withlist({ include: ["post.author"] })),where: { author: id }filters. Deleting a referenced row fails unless the field iscascade(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;searchmatches text fields);count({ where, search }). Insidedatathey 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 as0,getasnull(T?).users.loading(until the first response),users.error(message ornull),users.reload().... livealso reloads when someone else writes.awaitandtry { } catch (e) { }work as in JS;e.messageexplains a validation error (e.details.fieldnames the field;e.statusis 409 when auniquevalue is taken).- Access:
api notes: Note loginneeds a session;privatealso scopes rows per user (the model needsowner: ID, filled in);readonlyafter it (api orders: Order private readonly): clients only read, server fns write;admin: anyone reads, admins write (accounts needrole: String; the first account is "admin", later ones "user"). auth users(the model needsemail: Emailandpassword: 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 callsauth.resetPassword(query.token, password). Withverified: Boolin the model, sign-up emails/verify-email?token=...(auth.verifyEmail(query.token)).server fn name(a, b) { ... }runs on the server; call it asserver.name(a, b)(also indata). Inside:db.<api>(noawait, not scoped per user),me(logged-in user ornull),fail("message", status?)andawait email(to, subject, text).server job cleanup every "1h" { ... }(s m h d): on the server, withdb,fail,email.
View #
One line per element: tag content prop=value flag -> action { children }
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))
}
}
}| Element | Content | -> fires on | Props | Flags |
|---|---|---|---|---|
text | text | — | bold muted small large danger | |
title | text | — | muted small large | |
button | text | click | disabled | primary danger small |
input | state to bind (two-way) | Enter | placeholder type disabled label | required |
textarea | state to bind | — | placeholder rows disabled label | required |
select | state to bind | change | options placeholder disabled label | |
radio tabs | state to bind | change | options label | |
checkbox | Bool state | change | label disabled | |
file | state (File, or list with multiple) | change | accept label disabled | multiple |
modal | Bool state (open) | — | gap pad align justify | |
image | src | — | alt width height | |
video audio | src | — | video: width height poster | controls autoplay loop muted |
link | text | — | to href target | muted |
badge | text | — | primary success danger | |
icon | Lucide name ("check" "trash" "edit" "search" "user" "home"...) | — | size label | muted primary success danger |
spinner divider | — | — | ||
canvas | — | — | width height | |
row column card | — | — | gap pad align justify | row: wrap |
grid | — | — | gap pad align justify cols | |
form | — | submit | gap pad align justify | |
list > item | item: text | item: click | item: muted | |
table > tr > th td | th/td: text | tr: click | td: muted |
- All take
class style id role aria-* data-*(button "Menu" aria-expanded=open); texts and containers taketag=for the HTML element (title "Plans" tag=h1,column tag=nav; default:titleis an h2,texta span); a field withoutlabel=is named by itsplaceholder.style { .box { ... } }in a component: CSS only for its elements..cssfiles 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=4andpad=4: 1 unit = 4px.align=start|center|end|stretch.justify=start|center|end|between|around.cols=3.type=text|number|email|password|checkbox|date. Withtype=checkbox,inputbinds 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 whileopenis true (Esc or the backdrop set it to false).item,th,td,linktake text and/or{ children }(link to="/p/1" { card { ... } }).- Prop values: literal, name,
a.b, call, or( expression )in parentheses. - Component:
Name prop=value. ChildrenCard { ... }go where it putsslot;slot headeris filled byCard { header { ... } }.onPick: Fn(User)types a callback (onPick=(u => ...)getsu: User). - Other events:
on:<event>=statementwithevent: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(...)andapi.x.get(id)areT?: use?.field,?? valueorif x { }.- Inside
if x { },if x != null,x && ...,x ? ... : ...or afterif !x { return },xis no longer null. - Objects passed as a
modelneed 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.