Karax is a framework for developing single page applications in Nim. The API is reasonably stable and version 1 should arrive anytime soon now.
To try it out, run:
cd ~/projects # Insert your favourite directory for projects nimble develop karax # This will clone Karax and create a link to it in ~/.nimble cd karax cd examples/todoapp nim js todoapp.nim open todoapp.html cd ../.. cd examples/mediaplayer nim js playerapp.nim open playerapp.html
- Leverage Nim's macro system to produce a framework that allows for the development of applications that are boilerplate free.
- Keep it small, keep it fast, keep it flexible.
The simplest Karax program looks like this:
include karax / prelude proc createDom(): VNode = result = buildHtml(tdiv): text "Hello World!" setRenderer createDom
div is a keyword in Nim, karax choose to use
tdiv produces a
<div> virtual DOM node.
As you can see, karax comes with its own
buildHtml DSL for convenient
construction of (virtual) DOM trees (of type
VNode). Karax provides
a tiny build tool called
karun that generates the HTML boilerplate code that
nim c karax/tools/karun karax/tools/karun -r helloworld.nim
-d:debugKaraxDsl we can have a look at the produced Nim code by
let tmp1 = tree(VNodeKind.tdiv) add(tmp1, text "Hello World!") tmp1
(I shortened the IDs for better readability.)
buildHtml introduces temporaries and calls
add for the tree
construction so that it composes with all of Nim's control flow constructs:
include karax / prelude import random proc createDom(): VNode = result = buildHtml(tdiv): if random(100) <= 50: text "Hello World!" else: text "Hello Universe" randomize() setRenderer createDom
let tmp1 = tree(VNodeKind.tdiv) if random(100) <= 50: add(tmp1, text "Hello World!") else: add(tmp1, text "Hello Universe") tmp1
Karax does not change the DOM's event model much, here is a program that writes "Hello simulated universe" on a button click:
include karax / prelude var lines: seq[kstring] = @ proc createDom(): VNode = result = buildHtml(tdiv): button: text "Say hello!" proc onclick(ev: Event; n: VNode) = lines.add "Hello simulated universe" for x in lines: tdiv: text x setRenderer createDom
kstring is Karax's alias for
cstring (which stands for "compatible
is preferred for efficiency on the JS target. However, on the native targets
kstring is mapped to
string for efficiency. The DSL for HTML
construction is also avaible for the native targets (!) and the
abstraction helps to deal with these conflicting requirements.
Karax's DSL is quite flexible when it comes to event handlers, so the following syntax is also supported:
include karax / prelude from future import `=>` var lines: seq[kstring] = @ proc createDom(): VNode = result = buildHtml(tdiv): button(onclick = () => lines.add "Hello simulated universe"): text "Say hello!" for x in lines: tdiv: text x setRenderer createDom
buildHtml macro produces this code for us:
let tmp2 = tree(VNodeKind.tdiv) let tmp3 = tree(VNodeKind.button) addEventHandler(tmp3, EventKind.onclick, () => lines.add "Hello simulated universe", kxi) add(tmp3, text "Say hello!") add(tmp2, tmp3) for x in lines: let tmp4 = tree(VNodeKind.tdiv) add(tmp4, text x) add(tmp2, tmp4) tmp2
As the examples grow larger it becomes more and more visible of what a DSL that composes with the builtin Nim control flow constructs buys us. Once you have tasted this power there is no going back and languages without AST based macro system simply don't cut it anymore.
Attaching data to an event handler
Since the type of an event handler is
(ev: Event; n: VNode) or
additional data that should be passed to the event handler needs to be
done via Nim's closures. In general this means a pattern like this:
proc menuAction(menuEntry: kstring): proc() = result = proc() = echo "clicked ", menuEntry proc buildMenu(menu: seq[kstring]): VNode = result = buildHtml(tdiv): for m in menu: nav(class="navbar is-primary"): tdiv(class="navbar-brand"): a(class="navbar-item", onclick = menuAction(m)):
Ok, so now we have seen DOM creation and event handlers. But how does
Karax actually keep the DOM up to date? The trick is that every event
handler is wrapped in a helper proc that triggers a redraw operation
that calls the renderer that you initially passed to
So a new virtual DOM is created and compared against the previous
virtual DOM. This comparison produces a patch set that is then applied
to the real DOM the browser uses internally. This process is called
"virtual DOM diffing" and other frameworks, most notably Facebook's
React, do quite similar things. The virtual DOM is faster to create
and manipulate than the real DOM so this approach is quite efficient.
Most applications these days have some "login"
mechanism consisting of
login button. The login button should only be clickable
password are not empty. An error
message should be shown as long as one input field is empty.
To create new UI elements we write a
loginField proc that
proc loginField(desc, field, class: kstring; validator: proc (field: kstring): proc ()): VNode = result = buildHtml(tdiv): label(`for` = field): text desc input(class = class, id = field, onchange = validator(field))
We use the
karax / errors module to help with this error
errors module is mostly a mapping from strings to
strings but it turned out that the logic is tricky enough to warrant
a library solution.
validateNotEmpty returns a closure that
proc validateNotEmpty(field: kstring): proc () = result = proc () = let x = getVNodeById(field) if x.text.isNil or x.text == "": errors.setError(field, field & " must not be empty") else: errors.setError(field, "")
This indirection is required because
event handlers in Karax need to have the type
proc (ev: Event; n: VNode). The errors module also
gives us a handy
disableOnError helper. It returns
"disabled" if there are errors. Now we have all the
pieces together to write our login dialog:
# some consts in order to prevent typos: const username = kstring"username" password = kstring"password" var loggedIn: bool proc loginDialog(): VNode = result = buildHtml(tdiv): if not loggedIn: loginField("Name :", username, "input", validateNotEmpty) loginField("Password: ", password, "password", validateNotEmpty) button(onclick = () => (loggedIn = true), disabled = errors.disableOnError()): text "Login" p: text errors.getError(username) p: text errors.getError(password) else: p: text "You are now logged in." setRenderer loginDialog
(Full example here.)
This code still has a bug though, when you run it, the
login button is not
disabled until some input fields are validated! This is easily fixed,
at initialization we have to do:
There are likely more elegant solutions to this problem.
setRenderer can be called with a callback that takes a parameter of
RouterData. Here is the relevant excerpt from the famous "Todo App" example:
proc createDom(data: RouterData): VNode = if data.hashPart == "#/": filter = all elif data.hashPart == "#/completed": filter = completed elif data.hashPart == "#/active": filter = active result = buildHtml(tdiv(class="todomvc-wrapper")): section(class = "todoapp"): ... setRenderer createDom
(Full example here.)