diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5e6c415 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the action will run. +on: + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [ master ] + pull_request: + branches: [ master ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + container: nimlang/nim + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + # Runs a single command using the runners shell + - name: Tests + run: | + nimble develop -y + nim c -r tests/tester.nim diff --git a/.gitignore b/.gitignore index acb4ec1..842e140 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ nimcache/ karax/tools/karun *.code-workspace *.exe +app.js +app.html diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6e03ea9..0000000 --- a/.travis.yml +++ /dev/null @@ -1,20 +0,0 @@ -sudo: false -language: c -os: linux -install: - - git clone https://github.com/nim-lang/nim - - cd nim - - git clone --depth 1 https://github.com/nim-lang/csources.git - - cd csources - - sh build.sh - - cd ../ - - bin/nim c koch - - ./koch boot -d:release - - ./koch tools - - cd .. -before_script: - - set -e - - export PATH=$(pwd)/nim/bin:$(pwd):$PATH -script: - - nimble develop - - nim c -r tests/tester.nim diff --git a/examples/button.nim b/examples/button.nim index bd49dc3..d86fa82 100644 --- a/examples/button.nim +++ b/examples/button.nim @@ -1,4 +1,3 @@ - include karax / prelude var lines: seq[kstring] = @[] diff --git a/examples/carousel/carousel.html b/examples/carousel/carousel.html index 464f960..500e73a 100644 --- a/examples/carousel/carousel.html +++ b/examples/carousel/carousel.html @@ -1,15 +1,15 @@ - - Carousel app - + + Carousel app + - + -
+
- + - + diff --git a/examples/hellostyle.nim b/examples/hellostyle.nim new file mode 100644 index 0000000..716c2f0 --- /dev/null +++ b/examples/hellostyle.nim @@ -0,0 +1,17 @@ +include karax / prelude +import karax / vstyles + +proc createDom(): VNode = + result = buildHtml(tdiv): + tdiv(style = style(StyleAttr.color, "red".cstring)): + text "red" + tdiv(style = style(StyleAttr.color, "blue")): + text "blue" + # explicit `kstring` required for varargs overload + tdiv(style = style((fontStyle, "italic".kstring), (color, "orange".kstring))): + text "italic orange" + # can use a string directly + tdiv(style = "font-style: oblique; color: pink".toCss): + text "oblique pink" + +setRenderer createDom diff --git a/examples/hellouniverse.nim b/examples/hellouniverse.nim index 9d6ebc6..8343d07 100644 --- a/examples/hellouniverse.nim +++ b/examples/hellouniverse.nim @@ -1,10 +1,9 @@ - include karax / prelude import random proc createDom(): VNode = result = buildHtml(tdiv): - if random(100) <= 50: + if rand(100) <= 50: text "Hello World!" else: text "Hello Universe" diff --git a/examples/login.nim b/examples/login.nim index 642f573..0350e23 100644 --- a/examples/login.nim +++ b/examples/login.nim @@ -1,4 +1,3 @@ - include karax / prelude from sugar import `=>` import karax / errors @@ -18,7 +17,7 @@ const proc validateNotEmpty(field: kstring): proc () = result = proc () = let x = getVNodeById(field) - if x.text.isNil or x.text == "": + if x.text == "": errors.setError(field, field & " must not be empty") else: errors.setError(field, "") @@ -28,7 +27,7 @@ var loggedIn: bool proc loginDialog(): VNode = result = buildHtml(tdiv): if not loggedIn: - loginField("Name :", username, "input", validateNotEmpty) + loginField("Name: ", username, "input", validateNotEmpty) loginField("Password: ", password, "password", validateNotEmpty) button(onclick = () => (loggedIn = true), disabled = errors.disableOnError()): text "Login" diff --git a/examples/mediaplayer/playerapp.html b/examples/mediaplayer/playerapp.html index 7800d32..a45a2e8 100644 --- a/examples/mediaplayer/playerapp.html +++ b/examples/mediaplayer/playerapp.html @@ -1,15 +1,15 @@ - - Player app - + + Player app + - + -
+
- + - + diff --git a/examples/scrollapp/scrollapp.html b/examples/scrollapp/scrollapp.html index f7ca085..44d657e 100644 --- a/examples/scrollapp/scrollapp.html +++ b/examples/scrollapp/scrollapp.html @@ -1,15 +1,15 @@ - - Todo app - + + Todo app + - + -
+
- + - + diff --git a/examples/todoapp/todoapp.html b/examples/todoapp/todoapp.html index bec9d65..ab3daa7 100644 --- a/examples/todoapp/todoapp.html +++ b/examples/todoapp/todoapp.html @@ -1,16 +1,16 @@ - - Todo app - - + + Todo app + + - + -
+
- + - + diff --git a/examples/toychat.nim b/examples/toychat.nim index 66c4cd0..11ec7a4 100644 --- a/examples/toychat.nim +++ b/examples/toychat.nim @@ -1,4 +1,3 @@ - include login const @@ -12,7 +11,7 @@ var allMessages: seq[TextMessage] = @[] proc doSendMessage() = let inputField = getVNodeById(message) - allMessages.add(TextMessage(name: "you", content: inputField.text)) + allMessages.add(TextMessage(name: "you", content: inputField.getInputText)) inputField.setInputText "" proc main(): VNode = diff --git a/karax.nimble b/karax.nimble index c6259f6..8712a6d 100644 --- a/karax.nimble +++ b/karax.nimble @@ -1,14 +1,15 @@ # Package -version = "1.1.1" +version = "1.2.1" author = "Andreas Rumpf" description = "Karax is a framework for developing single page applications in Nim." license = "MIT" # Dependencies -requires "nim >= 0.16.1" - +requires "nim >= 0.18.0" +requires "ws" +requires "dotenv" skipDirs = @["examples", "experiments", "tests"] bin = @["karax/tools/karun"] diff --git a/karax/compact.nim b/karax/compact.nim index 3363802..4e5dd6d 100644 --- a/karax/compact.nim +++ b/karax/compact.nim @@ -61,7 +61,7 @@ proc newname*(n: NimNode): NimNode = n[1] = newname(n[1]) result = n elif n.kind == nnkSym: - result = ident($n.symbol) + result = ident(n.strVal) else: result = n diff --git a/karax/karax.nim b/karax/karax.nim index 84ab6f5..2246676 100644 --- a/karax/karax.nim +++ b/karax/karax.nim @@ -7,9 +7,8 @@ export kdom.Event, kdom.Blob when defined(nimNoNil): {.experimental: "notnil".} -proc kout*[T](x: T) {.importc: "console.log", varargs, deprecated.} - ## the preferred way of debugging karax applications. Now deprecated, - ## you can now use ``system.echo`` instead. +proc kout*[T](x: T) {.importc: "console.log", varargs.} + ## The preferred way of debugging karax applications. type PatchKind = enum @@ -38,6 +37,7 @@ type toFocus: Node toFocusV: VNode renderId: int + rendering: bool patches: seq[Patch] # we reuse this to save allocations patchLen: int patchesV: seq[PatchV] @@ -154,7 +154,9 @@ proc getVNodeById*(id: cstring; kxi: KaraxInstance = kxi): VNode = proc toDom*(n: VNode; useAttachedNode: bool; kxi: KaraxInstance = nil): Node = if useAttachedNode: - if n.dom != nil: return n.dom + if n.dom != nil: + if n.id != nil: kxi.byId[n.id] = n + return n.dom if n.kind == VNodeKind.text: result = document.createTextNode(n.text) attach n @@ -383,6 +385,8 @@ proc addPatchV(kxi: KaraxInstance; parent: VNode; pos: int; newChild: VNode) = proc moveDom(dest, src: VNode) = dest.dom = src.dom src.dom = nil + if dest.id != nil: + kxi.byId[dest.id] = dest assert dest.len == src.len for i in 0.. """ - html = """ + +const html = """ @@ -23,33 +18,81 @@ const $1 $2 - -
- + +
+ +$3 """ +const websocket = """ + +""" proc exec(cmd: string) = if os.execShellCmd(cmd) != 0: quit "External command failed: " & cmd -proc build(name: string, rest: string, selectedCss: string, run: bool) = +proc build(rest: string, selectedCss: string, run: bool, watch: bool) = echo("Building...") - exec("nim js --out:" & name & ".js " & rest) - let dest = name & ".html" - writeFile(dest, html % [name, selectedCss]) - if run: openDefaultBrowser(dest) + let cmd = "nim js --out:" & "app" & ".js " & rest + if watch: + discard os.execShellCmd(cmd) + else: + exec cmd + let dest = "app" & ".html" + let script = if watch: websocket else: "" + writeFile(dest, html % ["app", selectedCss, script]) + if run: openDefaultBrowser("http://localhost:8080") + +proc watchBuild(filePath: string, selectedCss: string, rest: string) {.thread.} = + var files: Table[string, Time] = {"path": getLastModificationTime(".")}.toTable + while true: + sleep(300) + for path in walkDirRec("."): + if ".git" in path: + continue + var (_, _, ext) = splitFile(path) + if ext in [".scss",".sass",".less",".styl",".pcss",".postcss"]: + continue + if files.hasKey(path): + if files[path] != getLastModificationTime(path): + echo("File changed: " & path) + build(rest,selectedCss, false, true) + files[path] = getLastModificationTime(path) + else: + if absolutePath(path) in [absolutePath("app" & ".js"),absolutePath("app" & ".html")]: + continue + files[path] = getLastModificationTime(path) + +proc serve(){.thread.} = + serveStatic() proc main = var op = initOptParser() var rest = op.cmdLineRest var file = "" var run = false - var selectedCss = "" var watch = false - var files: Table[string, Time] = {"path": getLastModificationTime(".")}.toTable - + var selectedCss = "" while true: op.next() case op.kind @@ -59,8 +102,11 @@ proc main = run = true rest = rest.replace("--run ") of "css": - selectedCss = css - rest = rest.replace("--css ") + if op.val != "": + selectedCss = readFile(op.val) + else: + selectedCss = css + rest = rest.substr(rest.find(" ")) else: discard of cmdShortOption: if op.key == "r": @@ -73,25 +119,11 @@ proc main = of cmdEnd: break if file.len == 0: quit "filename expected" - let name = file.splitFile.name - build(name, rest, selectedCss, run) + if run: + spawn serve() if watch: - # TODO: launch http server - while true: - sleep(300) - for path in walkDirRec("."): - if ".git" in path: - continue - if files.hasKey(path): - if files[path] != getLastModificationTime(path): - echo("File changed: " & path) - build(name, rest, selectedCss, run) - files[path] = getLastModificationTime(path) - else: - files[path] = getLastModificationTime(path) + spawn watchBuild(file, selectedCss, rest) + build(rest,selectedCss, run, watch) + sync() main() - - - - diff --git a/karax/tools/karun.nims b/karax/tools/karun.nims new file mode 100644 index 0000000..8983496 --- /dev/null +++ b/karax/tools/karun.nims @@ -0,0 +1 @@ +switch("threads", "on") \ No newline at end of file diff --git a/karax/tools/static_server.nim b/karax/tools/static_server.nim new file mode 100644 index 0000000..eeddf4f --- /dev/null +++ b/karax/tools/static_server.nim @@ -0,0 +1,141 @@ +import + std/[net, os, strutils, uri, mimetypes, asyncnet, asyncdispatch, md5, + logging, httpcore, asyncfile, asynchttpserver, tables, times] +from cgi import decodeUrl +import ws, dotenv + +var logger = newConsoleLogger() +addHandler(logger) + +when defined(release): + setLogFilter(lvlError) + +type + RawHeaders* = seq[tuple[key, val: string]] + +proc toStr(headers: RawHeaders): string = + $newHttpHeaders(headers) + +proc send(request: Request, code: HttpCode, headers: RawHeaders, + body: string): Future[void] = + return request.respond(code, body, newHttpHeaders(headers)) + +proc statusContent(request: Request, status: HttpCode, content: string, + headers: RawHeaders): Future[void] = + try: + result = send(request, status, headers, content) + debug(" ", status, " ", toStr(headers)) + except: + error("Could not send response: ", osErrorMsg(osLastError())) + +proc sendStaticIfExists(req: Request, paths: seq[string]): Future[HttpCode] {.async.} = + result = Http200 + let mimes = newMimetypes() + for p in paths: + if fileExists(p): + if fpOthersRead notin getFilePermissions(p): + return Http403 + let fileSize = getFileSize(p) + let extPos = searchExtPos(p) + let mimetype = mimes.getMimetype( + if extPos >= 0: p.substr(extPos + 1) + else: "") + if fileSize < 10_000_000: # 10 mb + var file = readFile(p) + var hashed = getMD5(file) + # If the user has a cached version of this file and it matches our + # version, let them use it + if req.headers.getOrDefault("If-None-Match") == hashed: + await req.statusContent(Http304, "", default(RawHeaders)) + else: + await req.statusContent(Http200, file, @{ + "Content-Type": mimetype, + "ETag": hashed + }) + else: + let headers = @{ + "Content-Type": mimetype, + "Content-Length": $fileSize + } + await req.statusContent(Http200, "", headers) + var fileStream = newFutureStream[string]("sendStaticIfExists") + var file = openAsync(p, fmRead) + # Let `readToStream` write file data into fileStream in the + # background. + asyncCheck file.readToStream(fileStream) + # The `writeFromStream` proc will complete once all the data in the + # `bodyStream` has been written to the file. + while true: + let (hasValue, value) = await fileStream.read() + if hasValue: + await req.client.send(value) + else: + break + file.close() + return + # If we get to here then no match could be found. + return Http404 + +proc handleFileRequest(req: Request): Future[HttpCode] {.async.} = + # Find static file. + var reqPath = cgi.decodeUrl(req.url.path) + var staticDir = getEnv("staticDir") # it's assumed a relative dir + var status = Http400 + var path = staticDir / reqPath + normalizePathEnd(path, false) + if dirExists(path): + status = await sendStaticIfExists(req, @[path / "index.html", path / "index.htm"]) + else: + status = await sendStaticIfExists(req, @[path]) + return status + +proc handleWs(req: Request) {.async.} = + var ws = await newWebSocket(req) + await ws.send("Welcome to simple echo server") + + var files: Table[string, Time] = {"path": getLastModificationTime(".")}.toTable + let watchedFiles = [absolutePath "app.js", absolutePath "app.html"] + for path in watchedFiles: + files[path] = getLastModificationTime(path) + + while ws.readyState == Open: + await sleepAsync(500) + var changed = false + for path in watchedFiles: + if files[path] != getLastModificationTime(path): + changed = true + files[path] = getLastModificationTime(path) + if changed: + await ws.send("refresh") + changed = false + +proc serveStatic*() = + if fileExists("static.env"): + var env: DotEnv + env = initDotEnv(getCurrentDir(), "static.env") + env.overload() + else: + putEnv("staticDir", "assets/") + + var server = newAsyncHttpServer() + proc cb(req: Request) {.gcsafe, async.} = + if req.url.path == "/ws": + await handleWs(req) + if req.url.path == "/": + await req.respond(Http200, readFile "app.html") + elif req.url.path == "/app.js": + let file = absolutePath("app" & ".js") + if not file.fileExists: + error(file, " does not exist!") + if fpUserRead notin os.getFilePermissions(file): + error("Could not read ", file, "!") + await req.respond(Http200, readFile(file)) + else: + let status = await handleFileRequest(req) + if status != Http200: + await req.respond(status, "") + + waitFor server.serve(Port(8080), cb) + +when isMainModule: + serveStatic() diff --git a/karax/vdom.nim b/karax/vdom.nim index 3f44673..8fd654e 100644 --- a/karax/vdom.nim +++ b/karax/vdom.nim @@ -35,7 +35,19 @@ type mark, ruby, rt, rp, bdi, dbo, span, br, wbr, ins, del, img, iframe, embed, `object` = "object", param, video, audio, source, track, canvas, map, - area, svg, math, path, circle + area, math, + + # SVG elements, see: https://www.w3.org/TR/SVG2/eltindex.html + animate, animateMotion, animateTransform, circle, clipPath, defs, desc, + `discard` = "discard", ellipse, feBlend, feColorMatrix, feComponentTransfer, + feComposite, feConvolveMatrix, feDiffuseLighting, feDisplacementMap, + feDistantLight, feDropShadow, feFlood, feFuncA, feFuncB, feFuncG, feFuncR, + feGaussianBlur, feImage, feMerge, feMergeNode, feMorphology, feOffset, + fePointLight, feSpecularLighting, feSpotLight, feTile, feTurbulence, + filter, foreignObject, g, image, line, linearGradient, marker, mask, + metadata, mpath, path, pattern, polygon, polyline, radialGradient, rect, + `set` = "set", stop, svg, switch, symbol, txt = "text", textPath, tspan, + unknown, use, view, table, caption, colgroup, col, tbody, thead, tfoot, tr, td, th, @@ -97,6 +109,13 @@ type ## passed (useful for on-the-fly text completions) onload, # img + ontransitioncancel, + ontransitionend, + ontransitionrun, + ontransitionstart, + + onwheel # fires when the user rotates a wheel button on a pointing device. + macro buildLookupTables(): untyped = var a = newTree(nnkBracket) for i in low(VNodeKind)..high(VNodeKind): @@ -298,6 +317,12 @@ proc sameAttrs*(a, b: VNode): bool = proc addEventListener*(n: VNode; event: EventKind; handler: EventHandler) = n.events.add((event, handler, nil)) +when kstring is cstring: + proc len(a: kstring): int = + # xxx: maybe move where kstring is defined + # without this, `n.field.len` fails on js (non web) platform + if a == nil: 0 else: a.len + template toStringAttr(field) = if n.field.len > 0: result.add " " & astToStr(field) & " = " & $n.field diff --git a/karax/vstyles.nim b/karax/vstyles.nim index e2fca7d..7097aee 100644 --- a/karax/vstyles.nim +++ b/karax/vstyles.nim @@ -1,5 +1,9 @@ +##[ +see examples/hellostyle.nim +]## -import macros, kbase +import std/[macros, strutils] +import kbase when defined(js): import kdom, jdict @@ -234,7 +238,10 @@ proc eq*(a, b: VStyle): bool = if a[i] != b[i]: return false return true -proc setAttr(s: VStyle; a, value: kstring) {.noSideEffect.} = +proc setAttr*(s: VStyle; a, value: kstring) {.noSideEffect.} = + ## inserts (a, value) in sorted order of key `a` + # worst case quadratic complexity (if given styles in reverse order), hopefully + # not a concern assuming small cardinal var i = 0 while i < s.len: if s[i] == a: @@ -243,8 +250,8 @@ proc setAttr(s: VStyle; a, value: kstring) {.noSideEffect.} = elif s[i] > a: s.add "" s.add "" - # insertion point here: - for j in countdown(s.len-1, i, 2): + # insertion point here, shift all remaining pairs by 2 indexes + for j in countdown(s.len-1, i+3, 2): s[j] = s[j-2] s[j-1] = s[j-3] s[i] = a @@ -289,6 +296,24 @@ proc style*(a: StyleAttr; val: kstring): VStyle {.noSideEffect.} = result[] = @[] result.setAttr a, val +proc toCss*(a: string): VStyle = + ##[ + See example in hellostyle.nim + Allows passing a css string directly, eg: + tdiv(style = style((fontStyle, "italic".kstring), (color, "orange".kstring))): discard + tdiv(style = "font-style: oblique; color: pink".toCss): discard + ]## + when defined(js): + result = newJSeq[cstring]() + else: + new(result) + result[] = @[] + for ai in a.split(";"): + var ai = ai.strip + if ai.len == 0: continue + let aj = ai.strip.split(":", maxsplit=1) + result.setAttr(aj[0], aj[1]) + when defined(js): proc setStyle(d: Style; key, val: cstring) {.importcpp: "#[#] = #", noSideEffect.} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..d8a3814 --- /dev/null +++ b/readme.md @@ -0,0 +1,394 @@ +![karax](https://user-images.githubusercontent.com/22755228/117183486-482b2a00-ade0-11eb-88e6-d8eeb28951ca.png) + +![Github Actions](https://img.shields.io/github/workflow/status/karaxnim/karax/CI?style=for-the-badge) ![GitHub issues](https://img.shields.io/github/issues-raw/karaxnim/karax?style=for-the-badge) ![GitHub](https://img.shields.io/github/license/karaxnim/karax?style=for-the-badge) ![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/karaxnim/karax?sort=semver&style=for-the-badge) ![https://nim-lang.org](https://img.shields.io/badge/nim-powered-ffc200?style=for-the-badge) + +# Karax +Karax is a framework for developing single page applications in Nim. + +## Install + +To use Karax you must have nim installed. You can follow the instructions [here](https://nim-lang.org/install.html). + +Then you can install karax through nimble: +``nimble install karax`` + +## Try Karax +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`` + +It uses a virtual DOM like React, but is much smaller than the existing +frameworks plus of course it's written in Nim for Nim. No external +dependencies! And thanks to Nim's whole program optimization only what +is used ends up in the generated JavaScript code. + + +## Goals + + +- 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. + + + +## Hello World + + +The simplest Karax program looks like this: + +```nim + +include karax / prelude + +proc createDom(): VNode = + result = buildHtml(tdiv): + text "Hello World!" + +setRenderer createDom +``` + +Since ``div`` is a keyword in Nim, karax choose to use ``tdiv`` instead +here. ``tdiv`` produces a ``
`` 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 +embeds and invokes the generated JavaScript code:: + +``nim c karax/tools/karun`` +``karax/tools/karun -r helloworld.nim`` + +Via ``-d:debugKaraxDsl`` we can have a look at the produced Nim code by +``buildHtml``: + +```nim + +let tmp1 = tree(VNodeKind.tdiv) +add(tmp1, text "Hello World!") +tmp1 +``` +(I shortened the IDs for better readability.) + +Ok, so ``buildHtml`` introduces temporaries and calls ``add`` for the tree +construction so that it composes with all of Nim's control flow constructs: + + +```nim + +include karax / prelude +import random + +proc createDom(): VNode = + result = buildHtml(tdiv): + if rand(100) <= 50: + text "Hello World!" + else: + text "Hello Universe" + +randomize() +setRenderer createDom + +``` +Produces: + +```nim + +let tmp1 = tree(VNodeKind.tdiv) +if rand(100) <= 50: + add(tmp1, text "Hello World!") +else: + add(tmp1, text "Hello Universe") +tmp1 +``` + +## Event model + +Karax does not change the DOM's event model much, here is a program +that writes "Hello simulated universe" on a button click: + +```nim + +include karax / prelude +# alternatively: import karax / [kbase, vdom, kdom, vstyles, karax, karaxdsl, jdict, jstrutils, jjson] + +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 +string"; for the JS target that is an immutable JavaScript string) which +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 ``kstring`` +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: + +```nim + +include karax / prelude +from sugar 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 +``` + +The ``buildHtml`` macro produces this code for us: + +```nim + +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 ``()`` any +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: + +```nim + +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)): +``` + +## DOM diffing + +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 ``setRenderer``. +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. + + +## Form validation +Most applications these days have some "login" +mechanism consisting of ``username`` and ``password`` and +a ``login`` button. The login button should only be clickable +if ``username`` and ``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 +returns a ``VNode``: + +```nim + +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 +logic. The ``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 +captures the ``field`` parameter: + +```nim + +proc validateNotEmpty(field: kstring): proc () = + result = proc () = + let x = getVNodeById(field).getInputText + if x.isNil or x == "": + 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 ()`` +or ``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: + + +```nim + +# 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](https://github.com/karaxnim/karax/blob/master/examples/login.nim).) + +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: + +```nim + +setError username, username & " must not be empty" +setError password, password & " must not be empty" +``` +There are likely more elegant solutions to this problem. + +## Routing + + +For routing ``setRenderer`` can be called with a callback that takes a parameter of +type ``RouterData``. Here is the relevant excerpt from the famous "Todo App" example: + +```nim + +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](https://github.com/karaxnim/karax/blob/master/examples/todoapp/todoapp.nim).) + +## Server Side HTML Rendering + +Karax can also be used to render HTML on the server. Only a subset of +modules can be used since there is no JS interpreter. + +```nim + +import karax / [karaxdsl, vdom] + +const places = @["boston", "cleveland", "los angeles", "new orleans"] + +proc render*(): string = + let vnode = buildHtml(tdiv(class = "mt-3")): + h1: text "My Web Page" + p: text "Hello world" + ul: + for place in places: + li: text place + dl: + dt: text "Can I use Karax for client side single page apps?" + dd: text "Yes" + + dt: text "Can I use Karax for server side HTML rendering?" + dd: text "Yes" + result = $vnode + +echo render() +``` +## Generate HTML with event handlers + +If you are writing a static site generator or do server-side HTML rendering +via ``nim c``, you may want to override ``addEventHandler`` when using event +handlers to avoid compiler complaints. + +Here's an example of auto submit a dropdown when a value is selected: + +```nim + +template kxi(): int = 0 +template addEventHandler(n: VNode; k: EventKind; action: string; kxi: int) = + n.setAttr($k, action) + +let + names = @["nim", "c", "python"] + selected_name = request.params.getOrDefault("name") + hello = buildHtml(html): + form(`method` = "get"): + select(name="name", onchange="this.form.submit()"): + for name in names: + if name == selected_name: + option(selected = ""): text name + else: + option: text name +``` + +## License +MIT License. See [here](https://github.com/karaxnim/karax/blob/master/LICENSE.txt). diff --git a/readme.rst b/readme.rst deleted file mode 100644 index 4b8cec2..0000000 --- a/readme.rst +++ /dev/null @@ -1,355 +0,0 @@ -Karax – Single page applications in Nim |travis| -================================================ - -Karax is a framework for developing single page applications in Nim. - -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 - -It uses a virtual DOM like React, but is much smaller than the existing -frameworks plus of course it's written in Nim for Nim. No external -dependencies! And thanks to Nim's whole program optimization only what -is used ends up in the generated JavaScript code. - - -Goals -===== - -- 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. - -.. |travis| image:: https://travis-ci.org/pragmagic/karax.svg?branch=master - :target: https://travis-ci.org/pragmagic/karax - - -Hello World -=========== - -The simplest Karax program looks like this: - -.. code-block:: nim - - include karax / prelude - - proc createDom(): VNode = - result = buildHtml(tdiv): - text "Hello World!" - - setRenderer createDom - - -Since ``div`` is a keyword in Nim, karax choose to use ``tdiv`` instead -here. ``tdiv`` produces a ``
`` 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 -embeds and invokes the generated JavaScript code:: - - nim c karax/tools/karun - karax/tools/karun -r helloworld.nim - -Via ``-d:debugKaraxDsl`` we can have a look at the produced Nim code by -``buildHtml``: - -.. code-block:: nim - - let tmp1 = tree(VNodeKind.tdiv) - add(tmp1, text "Hello World!") - tmp1 - -(I shortened the IDs for better readability.) - -Ok, so ``buildHtml`` introduces temporaries and calls ``add`` for the tree -construction so that it composes with all of Nim's control flow constructs: - - -.. code-block:: nim - - 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 - - -Produces: - -.. code-block:: nim - - let tmp1 = tree(VNodeKind.tdiv) - if random(100) <= 50: - add(tmp1, text "Hello World!") - else: - add(tmp1, text "Hello Universe") - tmp1 - - -Event model -=========== - -Karax does not change the DOM's event model much, here is a program -that writes "Hello simulated universe" on a button click: - -.. code-block:: nim - - include karax / prelude - # alternatively: import karax / [kbase, vdom, kdom, vstyles, karax, karaxdsl, jdict, jstrutils, jjson] - - 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 -string"; for the JS target that is an immutable JavaScript string) which -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 ``kstring`` -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: - -.. code-block:: nim - - include karax / prelude - from sugar 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 - - -The ``buildHtml`` macro produces this code for us: - -.. code-block:: nim - - 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 ``()`` any -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: - -.. code-block:: nim - - 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)): - - -DOM diffing -=========== - -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 ``setRenderer``. -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. - - -Form validation -=============== - -Most applications these days have some "login" -mechanism consisting of ``username`` and ``password`` and -a ``login`` button. The login button should only be clickable -if ``username`` and ``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 -returns a ``VNode``: - -.. code-block:: nim - - 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 -logic. The ``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 -captures the ``field`` parameter: - -.. code-block:: nim - - 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 ()`` -or ``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: - - -.. code-block:: nim - - # 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: - -.. code-block:: nim - - setError username, username & " must not be empty" - setError password, password & " must not be empty" - -There are likely more elegant solutions to this problem. - - -Routing -======= - -For routing ``setRenderer`` can be called with a callback that takes a parameter of -type ``RouterData``. Here is the relevant excerpt from the famous "Todo App" example: - -.. code-block:: nim - - 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 `_.) - - -Server Side HTML Rendering -========================== - -Karax can also be used to render HTML on the server. Only a subset of -modules can be used since there is no JS interpreter. - -.. code-block:: nim - - import karax / [karaxdsl, vdom] - - const places = @["boston", "cleveland", "los angeles", "new orleans"] - - proc render*(): string = - let vnode = buildHtml(tdiv(class = "mt-3")): - h1: text "My Web Page" - p: text "Hello world" - ul: - for place in places: - li: text place - dl: - dt: text "Can I use Karax for client side single page apps?" - dd: text "Yes" - - dt: text "Can I use Karax for server side HTML rendering?" - dd: text "Yes" - result = $vnode - - echo render()