diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 5e6c415..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,31 +0,0 @@ -# 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 842e140..acb4ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,3 @@ nimcache/ karax/tools/karun *.code-workspace *.exe -app.js -app.html diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..6e03ea9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +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 d86fa82..bd49dc3 100644 --- a/examples/button.nim +++ b/examples/button.nim @@ -1,3 +1,4 @@ + include karax / prelude var lines: seq[kstring] = @[] diff --git a/examples/carousel/carousel.html b/examples/carousel/carousel.html index 500e73a..464f960 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 deleted file mode 100644 index 716c2f0..0000000 --- a/examples/hellostyle.nim +++ /dev/null @@ -1,17 +0,0 @@ -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 8343d07..9d6ebc6 100644 --- a/examples/hellouniverse.nim +++ b/examples/hellouniverse.nim @@ -1,9 +1,10 @@ + include karax / prelude import random proc createDom(): VNode = result = buildHtml(tdiv): - if rand(100) <= 50: + if random(100) <= 50: text "Hello World!" else: text "Hello Universe" diff --git a/examples/login.nim b/examples/login.nim index 0350e23..642f573 100644 --- a/examples/login.nim +++ b/examples/login.nim @@ -1,3 +1,4 @@ + include karax / prelude from sugar import `=>` import karax / errors @@ -17,7 +18,7 @@ const proc validateNotEmpty(field: kstring): proc () = result = proc () = let x = getVNodeById(field) - if x.text == "": + if x.text.isNil or x.text == "": errors.setError(field, field & " must not be empty") else: errors.setError(field, "") @@ -27,7 +28,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 a45a2e8..7800d32 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 44d657e..f7ca085 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 ab3daa7..bec9d65 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 11ec7a4..66c4cd0 100644 --- a/examples/toychat.nim +++ b/examples/toychat.nim @@ -1,3 +1,4 @@ + include login const @@ -11,7 +12,7 @@ var allMessages: seq[TextMessage] = @[] proc doSendMessage() = let inputField = getVNodeById(message) - allMessages.add(TextMessage(name: "you", content: inputField.getInputText)) + allMessages.add(TextMessage(name: "you", content: inputField.text)) inputField.setInputText "" proc main(): VNode = diff --git a/karax.nimble b/karax.nimble index 8712a6d..c6259f6 100644 --- a/karax.nimble +++ b/karax.nimble @@ -1,15 +1,14 @@ # Package -version = "1.2.1" +version = "1.1.1" author = "Andreas Rumpf" description = "Karax is a framework for developing single page applications in Nim." license = "MIT" # Dependencies -requires "nim >= 0.18.0" -requires "ws" -requires "dotenv" +requires "nim >= 0.16.1" + skipDirs = @["examples", "experiments", "tests"] bin = @["karax/tools/karun"] diff --git a/karax/compact.nim b/karax/compact.nim index 4e5dd6d..3363802 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.strVal) + result = ident($n.symbol) else: result = n diff --git a/karax/karax.nim b/karax/karax.nim index 2246676..84ab6f5 100644 --- a/karax/karax.nim +++ b/karax/karax.nim @@ -7,8 +7,9 @@ export kdom.Event, kdom.Blob when defined(nimNoNil): {.experimental: "notnil".} -proc kout*[T](x: T) {.importc: "console.log", varargs.} - ## The preferred way of debugging karax applications. +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. type PatchKind = enum @@ -37,7 +38,6 @@ type toFocus: Node toFocusV: VNode renderId: int - rendering: bool patches: seq[Patch] # we reuse this to save allocations patchLen: int patchesV: seq[PatchV] @@ -154,9 +154,7 @@ proc getVNodeById*(id: cstring; kxi: KaraxInstance = kxi): VNode = proc toDom*(n: VNode; useAttachedNode: bool; kxi: KaraxInstance = nil): Node = if useAttachedNode: - if n.dom != nil: - if n.id != nil: kxi.byId[n.id] = n - return n.dom + if n.dom != nil: return n.dom if n.kind == VNodeKind.text: result = document.createTextNode(n.text) attach n @@ -385,8 +383,6 @@ 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.. """ - -const html = """ + html = """ @@ -18,81 +23,33 @@ const html = """ $1 $2 - -
- -$3 + +
+ """ -const websocket = """ - -""" proc exec(cmd: string) = if os.execShellCmd(cmd) != 0: quit "External command failed: " & cmd -proc build(rest: string, selectedCss: string, run: bool, watch: bool) = +proc build(name: string, rest: string, selectedCss: string, run: bool) = echo("Building...") - 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() + exec("nim js --out:" & name & ".js " & rest) + let dest = name & ".html" + writeFile(dest, html % [name, selectedCss]) + if run: openDefaultBrowser(dest) proc main = var op = initOptParser() var rest = op.cmdLineRest var file = "" var run = false - var watch = false var selectedCss = "" + var watch = false + var files: Table[string, Time] = {"path": getLastModificationTime(".")}.toTable + while true: op.next() case op.kind @@ -102,11 +59,8 @@ proc main = run = true rest = rest.replace("--run ") of "css": - if op.val != "": - selectedCss = readFile(op.val) - else: - selectedCss = css - rest = rest.substr(rest.find(" ")) + selectedCss = css + rest = rest.replace("--css ") else: discard of cmdShortOption: if op.key == "r": @@ -119,11 +73,25 @@ proc main = of cmdEnd: break if file.len == 0: quit "filename expected" - if run: - spawn serve() + let name = file.splitFile.name + build(name, rest, selectedCss, run) if watch: - spawn watchBuild(file, selectedCss, rest) - build(rest,selectedCss, run, watch) - sync() + # 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) main() + + + + diff --git a/karax/tools/karun.nims b/karax/tools/karun.nims deleted file mode 100644 index 8983496..0000000 --- a/karax/tools/karun.nims +++ /dev/null @@ -1 +0,0 @@ -switch("threads", "on") \ No newline at end of file diff --git a/karax/tools/static_server.nim b/karax/tools/static_server.nim deleted file mode 100644 index eeddf4f..0000000 --- a/karax/tools/static_server.nim +++ /dev/null @@ -1,141 +0,0 @@ -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 8fd654e..3f44673 100644 --- a/karax/vdom.nim +++ b/karax/vdom.nim @@ -35,19 +35,7 @@ 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, 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, + area, svg, math, path, circle table, caption, colgroup, col, tbody, thead, tfoot, tr, td, th, @@ -109,13 +97,6 @@ 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): @@ -317,12 +298,6 @@ 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 7097aee..e2fca7d 100644 --- a/karax/vstyles.nim +++ b/karax/vstyles.nim @@ -1,9 +1,5 @@ -##[ -see examples/hellostyle.nim -]## -import std/[macros, strutils] -import kbase +import macros, kbase when defined(js): import kdom, jdict @@ -238,10 +234,7 @@ proc eq*(a, b: VStyle): bool = if a[i] != b[i]: return false return true -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 +proc setAttr(s: VStyle; a, value: kstring) {.noSideEffect.} = var i = 0 while i < s.len: if s[i] == a: @@ -250,8 +243,8 @@ proc setAttr*(s: VStyle; a, value: kstring) {.noSideEffect.} = elif s[i] > a: s.add "" s.add "" - # insertion point here, shift all remaining pairs by 2 indexes - for j in countdown(s.len-1, i+3, 2): + # insertion point here: + for j in countdown(s.len-1, i, 2): s[j] = s[j-2] s[j-1] = s[j-3] s[i] = a @@ -296,24 +289,6 @@ 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 deleted file mode 100644 index d8a3814..0000000 --- a/readme.md +++ /dev/null @@ -1,394 +0,0 @@ -![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 new file mode 100644 index 0000000..4b8cec2 --- /dev/null +++ b/readme.rst @@ -0,0 +1,355 @@ +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()