carlos / todomvc-bakeoff Public
feat(nim): TodoMVC in Nim with asynchttpserver and static SQLite
Server-rendered TodoMVC that works without JavaScript, with a small ES module that posts the same forms and swaps in server-rendered fragments. Static linux-arm64 build through zig cc (musl).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+1098
−0
apps/nim/AGENTS.md+35 −0apps/nim/DEPS+2 −0apps/nim/Makefile+70 −0apps/nim/README.md+92 −0apps/nim/src/app.nim+236 −0apps/nim/src/page.nimf+75 −0apps/nim/src/store.nim+208 −0apps/nim/src/views.nim+30 −0apps/nim/static/app.css+62 −0apps/nim/static/app.js+94 −0apps/nim/tests/test_app.nim+181 −0apps/nim/todo.nimble+13 −0
diff --git a/apps/nim/AGENTS.md b/apps/nim/AGENTS.md| new file mode 100644 |
| index 0000000..467cb44 |
| --- /dev/null |
| +++ b/apps/nim/AGENTS.md |
| @@ -0,0 +1,35 @@ |
| +# 🤖 AGENTS.md: todo-nim |
| + |
| +The bakeoff's TodoMVC, written in Nim, deployed to Carloku as `todo-nim` |
| +on account `bfj` (`https://todo-nim.bfj.oncarlos.com`). |
| + |
| +## The one rule |
| + |
| +**The page works with JavaScript off.** Every action is a plain HTML form |
| +that posts and gets a 303 back. JavaScript only makes the same requests |
| +without a page load and swaps in the HTML the server renders. If a change |
| +needs JavaScript to function, it's the wrong change. |
| + |
| +## Tenancy |
| + |
| +- **One instance for the whole app.** Every visitor shares one process |
| + and one SQLite file. |
| +- **Each visitor's list is kept separately by an anonymous cookie.** The |
| + first visit gets a random 128-bit value in an `HttpOnly; SameSite=Lax` |
| + cookie (`Secure` on Carloku). Every query is scoped to that list, and an |
| + id from someone else's list answers 404. A cookie the app doesn't |
| + recognise gets a fresh, empty list. |
| +- **No sign-in.** Losing the cookie means losing the list. |
| +- **Trust class C.** Todo titles are stored as plain text in SQLite, not |
| + encrypted. That's a deliberate choice: the data is low-value, has no |
| + owner identity attached, and a todo list doesn't justify key handling. |
| + |
| +## Working here |
| + |
| +- Source is in `src/`. `make test` runs the unit tests, `make build` the |
| + local binary; the README has the rest. |
| +- The CSS is `shared/base.css` and `shared/index.css`, embedded at compile |
| + time and served byte for byte. Don't copy or edit them here. |
| +- Migrations only add. Append a new entry to `migrations` in |
| + `src/store.nim`; never edit or remove one that has shipped. |
| +- Bind `127.0.0.1` only. Scratch goes in `$TMPDIR`. |
diff --git a/apps/nim/DEPS b/apps/nim/DEPS| new file mode 100644 |
| index 0000000..e221fdb |
| --- /dev/null |
| +++ b/apps/nim/DEPS |
| @@ -0,0 +1,2 @@ |
| +db_connector |
| +sqlite |
diff --git a/apps/nim/Makefile b/apps/nim/Makefile| new file mode 100644 |
| index 0000000..00dca3a |
| --- /dev/null |
| +++ b/apps/nim/Makefile |
| @@ -0,0 +1,70 @@ |
| +# TodoMVC in Nim. See README.md. |
| +SHELL := bash |
| +REPO := $(abspath ../..) |
| +ZIGCC := $(REPO)/scripts/zigcc-aarch64-musl |
| +CACHE := $(CURDIR)/.cache |
| + |
| +VERSION ?= $(shell git rev-parse --short HEAD) |
| +SQLITE := sqlite-amalgamation-3540100 |
| +SQLITE_URL := https://sqlite.org/2026/$(SQLITE).zip |
| +SQLITE_SHA256 := 0a9c8e34352dad46006aa82836d5383bff9dc6bc9ce5a60553c05aa30e6ad1f9 |
| +SQLITE_C := $(CACHE)/sqlite/sqlite3.c |
| +NIMBLE_DIR := $(CACHE)/nimble |
| +DEPS_STAMP := $(NIMBLE_DIR)/.installed |
| + |
| +# Every library is compiled from source into a nimcache under .cache/. |
| +NIMFLAGS := --hints:off -d:release --opt:speed \ |
| + --noNimblePath $$(for p in $(NIMBLE_DIR)/pkgs2/*/; do echo --path:$$p; done) \ |
| + --dynlibOverride:sqlite3 -d:sqliteSource=$(SQLITE_C) \ |
| + -d:appVersion=$(VERSION) |
| +ARM64FLAGS := --cpu:arm64 --os:linux --cc:clang \ |
| + --clang.exe=$(ZIGCC) --clang.linkerexe=$(ZIGCC) --passL:-static --passL:-s |
| + |
| +SOURCES := $(wildcard src/*.nim src/*.nimf static/*) $(REPO)/shared/base.css $(REPO)/shared/index.css |
| + |
| +.PHONY: build build-arm64 test clean clean-deps deps bump unbump deploy |
| + |
| +build: dist/app |
| +build-arm64: dist/app-linux-arm64 |
| +deps: $(DEPS_STAMP) $(SQLITE_C) |
| + |
| +dist/app: $(SOURCES) deps .FORCE |
| + nim c $(NIMFLAGS) --nimcache:$(CACHE)/nimcache-host -o:$@ src/app.nim |
| + |
| +dist/app-linux-arm64: $(SOURCES) deps .FORCE |
| + nim c $(NIMFLAGS) $(ARM64FLAGS) --nimcache:$(CACHE)/nimcache-arm64 -o:$@ src/app.nim |
| + |
| +test: deps |
| + nim c -r $(NIMFLAGS) -d:testing --nimcache:$(CACHE)/nimcache-test -o:$(CACHE)/test_app tests/test_app.nim |
| + |
| +$(DEPS_STAMP): todo.nimble |
| + source $(REPO)/scripts/env.sh && nimble --nimbleDir:$(NIMBLE_DIR) install --depsOnly -y |
| + touch $@ |
| + |
| +$(SQLITE_C): |
| + mkdir -p $(CACHE)/dl $(CACHE)/sqlite |
| + [ -f $(CACHE)/dl/$(SQLITE).zip ] || curl -sSfL -o $(CACHE)/dl/$(SQLITE).zip $(SQLITE_URL) |
| + echo "$(SQLITE_SHA256) $(CACHE)/dl/$(SQLITE).zip" | sha256sum -c --quiet |
| + unzip -q -o -j $(CACHE)/dl/$(SQLITE).zip '$(SQLITE)/sqlite3.c' '$(SQLITE)/sqlite3.h' -d $(CACHE)/sqlite |
| + touch $@ |
| + |
| +clean: |
| + rm -rf dist |
| + |
| +# Compiled output only: downloaded sources (.cache/nimble, .cache/dl, |
| +# .cache/sqlite) stay, so the next build compiles every library again. |
| +clean-deps: clean |
| + rm -rf $(CACHE)/nimcache-host $(CACHE)/nimcache-arm64 $(CACHE)/nimcache-test $(CACHE)/test_app |
| + |
| +bump: |
| + sed -i 's/serverName = "todo-nim"/serverName = "todo-nim bumped"/' src/app.nim |
| + |
| +unbump: |
| + sed -i 's/serverName = "todo-nim bumped"/serverName = "todo-nim"/' src/app.nim |
| + |
| +deploy: |
| + source $(REPO)/scripts/env.sh && carlos deploy --account bfj --app todo-nim \ |
| + --target linux-arm64 --no-prompt --message "$${MESSAGE:-Deploy todo-nim $(VERSION)}" \ |
| + dist/app-linux-arm64 |
| + |
| +.FORCE: |
diff --git a/apps/nim/README.md b/apps/nim/README.md| new file mode 100644 |
| index 0000000..634a667 |
| --- /dev/null |
| +++ b/apps/nim/README.md |
| @@ -0,0 +1,92 @@ |
| +# 🤖 todo-nim |
| + |
| +TodoMVC in Nim for the CARLOS bakeoff: server-rendered, works with |
| +JavaScript off, and keeps each visitor's list in SQLite. It runs at |
| +[https://todo-nim.bfj.oncarlos.com](https://todo-nim.bfj.oncarlos.com). |
| + |
| +## The stack, and why |
| + |
| +- **Nim 2.2, compiled to C.** Nim builds a small native binary, and its C |
| + backend makes the static linux-arm64 build easy: `zig cc` targeting |
| + musl acts as the C compiler and linker, and the result is a 1.7 MB |
| + static executable that starts in milliseconds. |
| +- **HTTP: the standard library's `asynchttpserver`**, on `asyncdispatch`. |
| + A todo list doesn't need more than a single-threaded event loop, and the |
| + standard library means no framework to pin. The popular alternatives |
| + don't fit this platform: Mummy and httpbeast (Jester's server) only |
| + listen on TCP, and Carloku hands the app a unix socket. |
| + `asynchttpserver` doesn't take a unix socket either, but it's a small |
| + gap: `unixServer` in `src/app.nim` binds the socket itself and gives it |
| + to the server through `std/importutils.privateAccess`. The standard |
| + library's docs call this server basic; it has no request timeouts or |
| + keep-alive limits. Carloku's edge sits in front of it, and that's an |
| + acceptable trade for an app this size. |
| +- **Routing: a `case` on the path.** Six actions and four pages don't need |
| + a router. |
| +- **Templates: Nim's built-in `stdtmpl` source filter** (`src/page.nimf`). |
| + It compiles the template into an ordinary Nim proc, so the markup is |
| + type-checked and there's nothing to load at run time. Titles go through |
| + `xmltree.escape`. |
| +- **SQLite: `db_connector`'s `sqlite3` binding** (the database module that |
| + moved out of Nim's standard library) over the **SQLite 3.54.1 |
| + amalgamation, compiled into the binary.** The binding normally loads |
| + `libsqlite3.so`; `--dynlibOverride:sqlite3` turns that off so it links |
| + against the compiled-in copy. `src/store.nim` prepares every statement |
| + once and binds parameters, so no user input is ever spliced into SQL. |
| + WAL mode, `synchronous = NORMAL`, and numbered migrations that only add |
| + (`PRAGMA user_version` records how far the file has got). |
| +- **JavaScript: one plain ES module, no framework** (`static/app.js`). It |
| + sends the same form POSTs with an `X-Fragment: 1` header, and the server |
| + answers with the re-rendered list section instead of a 303. Rendering |
| + stays in one place, the server. |
| +- **The CSS** is `shared/base.css` and `shared/index.css`, embedded at |
| + compile time with `staticRead` and served byte for byte. |
| + `static/app.css` adds the no-JavaScript controls and hides them once |
| + JavaScript is running. |
| + |
| +Lists are anonymous: the first visit gets a cookie holding 128 random bits |
| +from the OS (`std/sysrand`), and every query is scoped to it. See |
| +`AGENTS.md` for the tenancy declaration. |
| + |
| +## Build, run, test, deploy |
| + |
| +Put the toolchains on `PATH` first: `source scripts/env.sh` from the repo |
| +root. The first build fetches `db_connector` into `.cache/nimble` and the |
| +SQLite amalgamation into `.cache/dl`, checking it against a pinned SHA-256. |
| + |
| +``` |
| +make build # dist/app for this machine |
| +make build-arm64 # dist/app-linux-arm64, static (zig cc, musl) |
| +make test # unit tests: the store, and the routes with fake requests |
| +make clean # removes dist/ |
| +make clean-deps # also removes the compiled C under .cache/nimcache-* |
| +``` |
| + |
| +Run it locally and drive the shared behaviour suite against it: |
| + |
| +``` |
| +dist/app --listen 127.0.0.1:8080 --db $TMPDIR/nim.db |
| +scripts/test-app.sh http://127.0.0.1:8080 $TMPDIR/nim-suite.json |
| +``` |
| + |
| +On Carloku it runs as `app --socket <path> --db <path>`, reads |
| +`CARLOS_HOST` for its origin, and sets `Secure` on its cookie. It exits |
| +cleanly on SIGTERM. |
| + |
| +`/api/version` reports the git short SHA the binary was built from, so |
| +commit before building what you deploy. Then: |
| + |
| +``` |
| +scripts/boot-check.sh dist/app-linux-arm64 |
| +make deploy MESSAGE="what changed" |
| +``` |
| + |
| +## Layout |
| + |
| +| Path | What | |
| +|---|---| |
| +| `src/app.nim` | Flags, the server, routes and cookies | |
| +| `src/store.nim` | SQLite: schema, migrations, every query | |
| +| `src/views.nim`, `src/page.nimf` | The page and the list fragment | |
| +| `static/app.js`, `static/app.css` | The JavaScript layer and the no-JS controls | |
| +| `tests/test_app.nim` | Unit tests | |
diff --git a/apps/nim/src/app.nim b/apps/nim/src/app.nim| new file mode 100644 |
| index 0000000..b94bc8c |
| --- /dev/null |
| +++ b/apps/nim/src/app.nim |
| @@ -0,0 +1,236 @@ |
| +## TodoMVC on CARLOS: the HTTP server and its routes. |
| +## |
| +## app --socket <path> --db <path> (on Carloku) |
| +## app --listen 127.0.0.1:<port> --db <path> (local runs and tests) |
| + |
| +import std/[asyncdispatch, asynchttpserver, asyncnet, cookies, importutils, options, |
| + nativesockets, net, os, parseopt, posix, strutils, strtabs, uri] |
| +import store, views |
| + |
| +const |
| + appVersion {.strdefine.} = "dev" |
| + # `make bump` edits this string so the next build has to recompile this file. |
| + serverName = "todo-nim" |
| + cookieName = "todo_list" |
| + baseCss = staticRead("../../../shared/base.css") |
| + indexCss = staticRead("../../../shared/index.css") |
| + appCss = staticRead("../static/app.css") |
| + appJs = staticRead("../static/app.js") |
| + |
| +type |
| + Config = object |
| + socket, listen, db: string |
| + |
| + App = ref object |
| + store: Store |
| + origin: string ## the only Origin a POST may carry |
| + secure: bool ## served over https, so the cookie gets `Secure` |
| + |
| + Reply = object |
| + code: HttpCode |
| + body: string |
| + headers: seq[(string, string)] |
| + |
| +# Requests |
| + |
| +proc header(req: Request, name: string): string = |
| + if req.headers.hasKey(name): $req.headers[name] else: "" |
| + |
| +proc parseFilter(s: string): Filter = |
| + case s |
| + of "active": fActive |
| + of "completed": fCompleted |
| + else: fAll |
| + |
| +proc formFields(req: Request): StringTableRef = |
| + result = newStringTable(modeCaseSensitive) |
| + for (k, v) in decodeQuery(req.body): |
| + result[k] = v |
| + |
| +proc queryParam(req: Request, name: string): string = |
| + for (k, v) in decodeQuery(req.url.query): |
| + if k == name: return v |
| + |
| +# Responses |
| + |
| +proc reply(code: HttpCode, body = "", contentType = "text/plain; charset=utf-8"): Reply = |
| + Reply(code: code, body: body, headers: @{"Content-Type": contentType}) |
| + |
| +proc html(body: string): Reply = |
| + result = reply(Http200, body, "text/html; charset=utf-8") |
| + result.headers.add ("Cache-Control", "no-store") |
| + |
| +proc asset(body, contentType: string): Reply = |
| + result = reply(Http200, body, contentType) |
| + result.headers.add ("Cache-Control", "public, max-age=86400") |
| + |
| +proc seeOther(path: string): Reply = |
| + result = reply(Http303) |
| + result.headers.add ("Location", path) |
| + |
| +# The visitor's list |
| + |
| +proc listFor(app: App, req: Request, r: var Reply): string = |
| + ## The list the visitor's cookie names, or a new one with a cookie that |
| + ## says so. Garbled and unknown cookies get a new list too. |
| + let jar = parseCookies(req.header("Cookie")) |
| + result = jar.getOrDefault(cookieName) |
| + if not app.store.listExists(result): |
| + result = app.store.createList() |
| + let cookie = setCookie(cookieName, result, path = "/", noName = true, |
| + secure = app.secure, httpOnly = true, maxAge = some(400 * 24 * 3600), |
| + sameSite = Lax) |
| + r.headers.add ("Set-Cookie", cookie) |
| + |
| +proc view(app: App, list: string, filter: Filter, editing = 0'i64): View = |
| + let (total, completed) = app.store.counts(list) |
| + View(todos: app.store.todos(list, filter), total: total, completed: completed, |
| + filter: filter, editing: editing, version: appVersion) |
| + |
| +# Routes |
| + |
| +proc page(app: App, req: Request, filter: Filter): Reply = |
| + var r: Reply |
| + let list = app.listFor(req, r) |
| + let editing = try: parseBiggestInt(req.queryParam("edit")) except ValueError: 0 |
| + result = html(renderPage(app.view(list, filter, editing))) |
| + result.headers.add r.headers |
| + |
| +proc action(app: App, req: Request, path: string): Reply = |
| + ## A form POST: change the list, then 303 back to the filter, or render the |
| + ## changed list as a fragment when the page's JavaScript asks for one. |
| + let origin = req.header("Origin") |
| + if origin != "" and origin != app.origin: |
| + return reply(Http403, "Cross-origin request refused\n") |
| + var r: Reply |
| + let list = app.listFor(req, r) |
| + let form = req.formFields() |
| + let filter = parseFilter(form.getOrDefault("filter")) |
| + let parts = path.split('/') # "", "todos", <id or verb>, [verb] |
| + var found = true |
| + if parts.len == 2: |
| + let title = form.getOrDefault("title").strip() |
| + if title.len > 0: app.store.add(list, title) |
| + elif parts.len == 3 and parts[2] == "toggle-all": |
| + app.store.toggleAll(list) |
| + elif parts.len == 3 and parts[2] == "clear-completed": |
| + app.store.clearCompleted(list) |
| + elif parts.len == 4: |
| + let id = try: parseBiggestInt(parts[2]) except ValueError: -1 |
| + case parts[3] |
| + of "toggle": found = app.store.toggle(list, id) |
| + of "destroy": found = app.store.destroy(list, id) |
| + of "edit": |
| + let title = form.getOrDefault("title").strip() |
| + found = if title.len > 0: app.store.rename(list, id, title) |
| + else: app.store.destroy(list, id) |
| + else: return reply(Http404, "Not found\n") |
| + else: |
| + return reply(Http404, "Not found\n") |
| + result = |
| + if not found: reply(Http404, "No such item in your list\n") |
| + elif req.header("X-Fragment") == "1": |
| + html(renderMain(app.view(list, filter))) |
| + else: seeOther(filterPath(filter)) |
| + result.headers.add r.headers |
| + |
| +proc route(app: App, req: Request): Reply = |
| + let path = req.url.path |
| + case req.reqMethod |
| + of HttpGet: |
| + case path |
| + of "/": app.page(req, fAll) |
| + of "/active": app.page(req, fActive) |
| + of "/completed": app.page(req, fCompleted) |
| + of "/healthz": reply(Http200, "ok\n") |
| + of "/api/version": reply(Http200, appVersion) |
| + of "/static/base.css": asset(baseCss, "text/css; charset=utf-8") |
| + of "/static/index.css": asset(indexCss, "text/css; charset=utf-8") |
| + of "/static/app.css": asset(appCss, "text/css; charset=utf-8") |
| + of "/static/app.js": asset(appJs, "text/javascript; charset=utf-8") |
| + else: reply(Http404, "Not found\n") |
| + of HttpPost: |
| + if path == "/todos" or path.startsWith("/todos/"): app.action(req, path) |
| + else: reply(Http404, "Not found\n") |
| + else: |
| + reply(Http405, "Method not allowed\n") |
| + |
| +proc handle(app: App, req: Request) {.async.} = |
| + var r: Reply |
| + try: |
| + r = app.route(req) |
| + except CatchableError as e: |
| + stderr.writeLine "error: ", req.reqMethod, " ", req.url.path, ": ", e.msg |
| + r = reply(Http500, "Internal error\n") |
| + r.headers.add ("Server", serverName) |
| + # asynchttpserver sets Content-Length from the body, in bytes. |
| + await req.respond(r.code, r.body, newHttpHeaders(r.headers)) |
| + |
| +# Startup and shutdown |
| + |
| +const usage = "usage: app (--socket <path> | --listen 127.0.0.1:<port>) --db <path>" |
| + |
| +proc parseArgs(): Config = |
| + # Every flag takes a value; listing one with none makes `--flag value` work. |
| + var p = initOptParser(commandLineParams(), longNoVal = @["help"]) |
| + for kind, key, val in p.getopt(): |
| + if kind != cmdLongOption: |
| + quit "unexpected argument: " & key, 2 |
| + case key |
| + of "socket": result.socket = val |
| + of "listen": result.listen = val |
| + of "db": result.db = val |
| + of "help": quit usage, 0 |
| + else: quit "unknown flag --" & key, 2 |
| + if result.db == "" or (result.socket == "") == (result.listen == ""): |
| + quit usage, 2 |
| + |
| +proc unixServer(path: string): AsyncHttpServer = |
| + ## asynchttpserver only listens on TCP; hand it a unix socket instead. |
| + privateAccess(AsyncHttpServer) |
| + result = newAsyncHttpServer() |
| + if fileExists(path) or symlinkExists(path): removeFile(path) |
| + let sock = newAsyncSocket(Domain.AF_UNIX, SockType.SOCK_STREAM, Protocol.IPPROTO_IP) |
| + sock.bindUnix(path) |
| + sock.listen() |
| + result.socket = sock |
| + result.maxFDs = try: maxDescriptors() except OSError: nimMaxDescriptorsFallback |
| + |
| +proc main() = |
| + let cfg = parseArgs() |
| + let app = App(store: store.open(cfg.db)) |
| + var server: AsyncHttpServer |
| + if cfg.socket != "": |
| + server = unixServer(cfg.socket) |
| + app.origin = "https://" & getEnv("CARLOS_HOST") |
| + app.secure = true |
| + else: |
| + let i = cfg.listen.rfind(':') |
| + let host = if i < 0: "" else: cfg.listen[0 ..< i] |
| + let port = try: parseInt(cfg.listen[i + 1 .. ^1]) except ValueError: -1 |
| + if host != "127.0.0.1" or port notin 1..65535: |
| + quit "--listen must be 127.0.0.1:<port>", 2 |
| + server = newAsyncHttpServer() |
| + server.listen(Port(port), host) |
| + app.origin = "http://" & cfg.listen |
| + |
| + proc stop(fd: AsyncFD): bool = |
| + server.close() |
| + if cfg.socket != "": discard tryRemoveFile(cfg.socket) |
| + app.store.close() |
| + quit 0 |
| + addSignal(SIGTERM.int, stop) |
| + addSignal(SIGINT.int, stop) |
| + |
| + stderr.writeLine "todo-nim ", appVersion, " listening on ", |
| + (if cfg.socket != "": cfg.socket else: cfg.listen) |
| + proc serve() {.async.} = |
| + while true: |
| + if server.shouldAcceptRequest(): |
| + await server.acceptRequest(proc (req: Request): Future[void] = app.handle(req)) |
| + else: |
| + await sleepAsync(10) |
| + waitFor serve() |
| + |
| +when isMainModule and not defined(testing): |
| + main() |
diff --git a/apps/nim/src/page.nimf b/apps/nim/src/page.nimf| new file mode 100644 |
| index 0000000..4ed3f5a |
| --- /dev/null |
| +++ b/apps/nim/src/page.nimf |
| @@ -0,0 +1,75 @@ |
| +#? stdtmpl(subsChar = '$', metaChar = '#') |
| +#proc renderMain*(v: View): string = |
| +# result = "" |
| +# let f = $v.filter |
| +# if v.total > 0: |
| + <section class="main"> |
| + <input id="toggle-all" class="toggle-all" type="checkbox"${checkedIf(v.total == v.completed)}> |
| + <label for="toggle-all">Mark all as complete</label> |
| + <form class="inline" method="post" action="/todos/toggle-all"><input type="hidden" name="filter" value="$f"><button class="toggle-all-button" data-action="toggle-all">Mark all as complete</button></form> |
| + <ul class="todo-list"> |
| +# for t in v.todos: |
| +# let editing = t.id == v.editing |
| +# let title = h(t.title) |
| + <li${classes(t.completed, editing)} data-id="${t.id}"> |
| + <div class="view"> |
| + <input class="toggle" type="checkbox"${checkedIf(t.completed)}> |
| + <label>$title</label> |
| + <form class="inline" method="post" action="/todos/${t.id}/toggle"><input type="hidden" name="filter" value="$f"><button class="toggle-button" data-action="toggle">${if t.completed: "Mark as active" else: "Mark as complete"}</button></form> |
| + <a class="edit-link" data-action="edit" href="${filterPath(v.filter)}?edit=${t.id}">edit</a> |
| + <form class="inline" method="post" action="/todos/${t.id}/destroy"><input type="hidden" name="filter" value="$f"><button class="destroy" aria-label="Delete"></button></form> |
| + </div> |
| +# if editing: |
| + <form class="inline" method="post" action="/todos/${t.id}/edit"><input type="hidden" name="filter" value="$f"><input class="edit" name="title" value="$title" autofocus></form> |
| +# else: |
| + <input class="edit" value="$title"> |
| +# end if |
| + </li> |
| +# end for |
| + </ul> |
| + </section> |
| + <footer class="footer"> |
| + <span class="todo-count"><strong>${v.total - v.completed}</strong> ${if v.total - v.completed == 1: "item" else: "items"} left</span> |
| + <ul class="filters"> |
| +# for (filter, name) in [(fAll, "All"), (fActive, "Active"), (fCompleted, "Completed")]: |
| + <li> |
| + <a${if filter == v.filter: " class=\"selected\"" else: ""} href="${filterPath(filter)}">$name</a> |
| + </li> |
| +# end for |
| + </ul> |
| +# if v.completed > 0: |
| + <form class="inline" method="post" action="/todos/clear-completed"><input type="hidden" name="filter" value="$f"><button class="clear-completed">Clear completed</button></form> |
| +# end if |
| + </footer> |
| +# end if |
| +#end proc |
| +# |
| +#proc renderPage*(v: View): string = |
| +# result = "" |
| +<!doctype html> |
| +<html lang="en"> |
| + <head> |
| + <meta charset="utf-8"> |
| + <meta name="viewport" content="width=device-width, initial-scale=1"> |
| + <title>TodoMVC</title> |
| + <script>document.documentElement.classList.add("js")</script> |
| + <link rel="stylesheet" href="/static/base.css"> |
| + <link rel="stylesheet" href="/static/index.css"> |
| + <link rel="stylesheet" href="/static/app.css?v=${h(v.version)}"> |
| + <script type="module" src="/static/app.js?v=${h(v.version)}"></script> |
| + </head> |
| + <body> |
| + <section class="todoapp" data-filter="${v.filter}"> |
| + <header class="header"> |
| + <h1>todos</h1> |
| + <form class="inline" method="post" action="/todos"><input type="hidden" name="filter" value="${v.filter}"><input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"${if v.editing == 0: " autofocus" else: ""}></form> |
| + </header> |
| +${renderMain(v)} </section> |
| + <footer class="info"> |
| + <p>Double-click to edit a todo</p> |
| + <p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · Nim</p> |
| + <p>Part of <a href="http://todomvc.com">TodoMVC</a></p> |
| + </footer> |
| + </body> |
| +</html> |
| +#end proc |
diff --git a/apps/nim/src/store.nim b/apps/nim/src/store.nim| new file mode 100644 |
| index 0000000..e64d3ea |
| --- /dev/null |
| +++ b/apps/nim/src/store.nim |
| @@ -0,0 +1,208 @@ |
| +## SQLite storage: one file, WAL mode, every query scoped to one list. |
| +## |
| +## The binding is `db_connector/sqlite3`; the SQLite amalgamation is compiled |
| +## into the binary here, and the build passes `--dynlibOverride:sqlite3` so |
| +## the binding links against it instead of loading `libsqlite3.so`. |
| + |
| +import std/[sysrand, strutils, times] |
| +import db_connector/sqlite3 |
| + |
| +const |
| + sqliteSource {.strdefine.} = "../.cache/sqlite/sqlite3.c" |
| + sqliteFlags = "-O2 -DSQLITE_THREADSAFE=0 -DSQLITE_DQS=0 " & |
| + "-DSQLITE_DEFAULT_MEMSTATUS=0 -DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1 " & |
| + "-DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_OMIT_DEPRECATED " & |
| + "-DSQLITE_OMIT_SHARED_CACHE -DSQLITE_LIKE_DOESNT_MATCH_BLOBS" |
| + |
| +{.compile(sqliteSource, sqliteFlags).} |
| + |
| +type |
| + StoreError* = object of CatchableError |
| + |
| + Todo* = object |
| + id*: int64 |
| + title*: string |
| + completed*: bool |
| + |
| + Store* = ref object |
| + db: PSqlite3 |
| + stmts: seq[PStmt] |
| + |
| + Stmt = enum |
| + sListExists, sCreateList, sTodos, sAdd, sToggle, sRename, sDestroy, |
| + sCountAll, sCountCompleted, sSetAll, sClearCompleted |
| + |
| +const |
| + # Migrations only ever add. Append; never edit or remove a shipped entry. |
| + migrations = [ |
| + """ |
| + CREATE TABLE lists ( |
| + id TEXT PRIMARY KEY, |
| + created_at INTEGER NOT NULL |
| + ) WITHOUT ROWID; |
| + CREATE TABLE todos ( |
| + id INTEGER PRIMARY KEY, |
| + list_id TEXT NOT NULL REFERENCES lists(id), |
| + title TEXT NOT NULL, |
| + completed INTEGER NOT NULL DEFAULT 0, |
| + created_at INTEGER NOT NULL |
| + ); |
| + CREATE INDEX todos_by_list ON todos(list_id, id); |
| + """, |
| + ] |
| + |
| + sql: array[Stmt, string] = [ |
| + sListExists: "SELECT 1 FROM lists WHERE id = ?1", |
| + sCreateList: "INSERT INTO lists (id, created_at) VALUES (?1, ?2)", |
| + sTodos: "SELECT id, title, completed FROM todos WHERE list_id = ?1 " & |
| + "AND (?2 = 0 OR completed = ?3) ORDER BY id", |
| + sAdd: "INSERT INTO todos (list_id, title, created_at) VALUES (?1, ?2, ?3)", |
| + sToggle: "UPDATE todos SET completed = 1 - completed " & |
| + "WHERE list_id = ?1 AND id = ?2", |
| + sRename: "UPDATE todos SET title = ?3 WHERE list_id = ?1 AND id = ?2", |
| + sDestroy: "DELETE FROM todos WHERE list_id = ?1 AND id = ?2", |
| + sCountAll: "SELECT count(*) FROM todos WHERE list_id = ?1", |
| + sCountCompleted: "SELECT count(*) FROM todos WHERE list_id = ?1 AND completed = 1", |
| + sSetAll: "UPDATE todos SET completed = ?2 WHERE list_id = ?1", |
| + sClearCompleted: "DELETE FROM todos WHERE list_id = ?1 AND completed = 1", |
| + ] |
| + |
| +proc check(s: Store, rc: int32): int32 {.discardable.} = |
| + if rc notin [SQLITE_OK, SQLITE_ROW, SQLITE_DONE]: |
| + raise newException(StoreError, $errmsg(s.db)) |
| + rc |
| + |
| +proc execScript(s: Store, script: string) = |
| + var err: cstring |
| + if exec(s.db, script.cstring, nil, nil, err) != SQLITE_OK: |
| + let msg = $err |
| + free(err) |
| + raise newException(StoreError, msg) |
| + |
| +proc scalar(s: Store, query: string): int64 = |
| + var st: PStmt |
| + s.check prepare_v2(s.db, query.cstring, -1, st, nil) |
| + try: |
| + if step(st) == SQLITE_ROW: result = column_int64(st, 0) |
| + finally: |
| + discard finalize(st) |
| + |
| +proc schemaVersion*(s: Store): int = s.scalar("PRAGMA user_version").int |
| + |
| +proc migrate(s: Store) = |
| + for i in s.schemaVersion ..< migrations.len: |
| + s.execScript("BEGIN;\n" & migrations[i] & "\nPRAGMA user_version = " & |
| + $(i + 1) & ";\nCOMMIT;") |
| + |
| +proc open*(path: string): Store = |
| + result = Store() |
| + if sqlite3.open(path.cstring, result.db) != SQLITE_OK: |
| + raise newException(StoreError, "cannot open " & path) |
| + discard busy_timeout(result.db, 5000) |
| + result.execScript("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; " & |
| + "PRAGMA foreign_keys = ON;") |
| + result.migrate() |
| + result.stmts = newSeq[PStmt](Stmt.high.ord + 1) |
| + for k in Stmt: |
| + result.check prepare_v2(result.db, sql[k].cstring, -1, result.stmts[k.ord], nil) |
| + |
| +proc close*(s: Store) = |
| + for st in s.stmts: discard finalize(st) |
| + s.stmts.setLen(0) |
| + discard sqlite3.close(s.db) |
| + |
| +# Statements are prepared once and reused: bind, step, then reset. |
| + |
| +type Arg = object |
| + case isText: bool |
| + of true: text: string |
| + of false: num: int64 |
| + |
| +proc toArg(v: string): Arg = Arg(isText: true, text: v) |
| +proc toArg(v: int64): Arg = Arg(isText: false, num: v) |
| +proc toArg(v: int): Arg = Arg(isText: false, num: v.int64) |
| +proc toArg(v: bool): Arg = Arg(isText: false, num: ord(v).int64) |
| + |
| +proc bound(s: Store, k: Stmt, args: openArray[Arg]): PStmt = |
| + result = s.stmts[k.ord] |
| + discard reset(result) |
| + discard clear_bindings(result) |
| + for i, a in args: |
| + let n = int32(i + 1) |
| + if a.isText: |
| + s.check bind_text(result, n, a.text.cstring, a.text.len.int32, SQLITE_TRANSIENT) |
| + else: |
| + s.check bind_int64(result, n, a.num) |
| + |
| +proc exec(s: Store, k: Stmt, args: varargs[Arg, toArg]): int = |
| + ## Runs a statement that returns no rows; answers the rows it changed. |
| + let st = s.bound(k, args) |
| + try: |
| + s.check step(st) |
| + result = changes(s.db).int |
| + finally: |
| + discard reset(st) |
| + |
| +proc count(s: Store, k: Stmt, args: varargs[Arg, toArg]): int = |
| + let st = s.bound(k, args) |
| + try: |
| + if s.check(step(st)) == SQLITE_ROW: result = column_int64(st, 0).int |
| + finally: |
| + discard reset(st) |
| + |
| +proc now(): int64 = getTime().toUnix() |
| + |
| +# Lists |
| + |
| +proc newListId*(): string = |
| + ## 128 random bits from the OS, as 32 hex characters. |
| + for b in urandom(16): result.add b.toHex(2).toLowerAscii |
| + |
| +proc isWellFormedListId*(id: string): bool = |
| + id.len == 32 and id.allCharsInSet({'0'..'9', 'a'..'f'}) |
| + |
| +proc listExists*(s: Store, id: string): bool = |
| + isWellFormedListId(id) and s.count(sListExists, id) == 1 |
| + |
| +proc createList*(s: Store): string = |
| + result = newListId() |
| + discard s.exec(sCreateList, result, now()) |
| + |
| +# Todos |
| + |
| +type Filter* = enum |
| + fAll = "all", fActive = "active", fCompleted = "completed" |
| + |
| +proc todos*(s: Store, list: string, filter = fAll): seq[Todo] = |
| + let st = s.bound(sTodos, [toArg(list), toArg(filter != fAll), |
| + toArg(filter == fCompleted)]) |
| + try: |
| + while s.check(step(st)) == SQLITE_ROW: |
| + result.add Todo(id: column_int64(st, 0), title: $column_text(st, 1), |
| + completed: column_int64(st, 2) != 0) |
| + finally: |
| + discard reset(st) |
| + |
| +proc counts*(s: Store, list: string): tuple[total, completed: int] = |
| + (s.count(sCountAll, list), s.count(sCountCompleted, list)) |
| + |
| +proc add*(s: Store, list, title: string) = |
| + discard s.exec(sAdd, list, title, now()) |
| + |
| +proc toggle*(s: Store, list: string, id: int64): bool = |
| + ## False when `id` isn't in `list`. |
| + s.exec(sToggle, list, id) == 1 |
| + |
| +proc rename*(s: Store, list: string, id: int64, title: string): bool = |
| + s.exec(sRename, list, id, title) == 1 |
| + |
| +proc destroy*(s: Store, list: string, id: int64): bool = |
| + s.exec(sDestroy, list, id) == 1 |
| + |
| +proc toggleAll*(s: Store, list: string) = |
| + ## Completes every item, or reopens them all if all were already done. |
| + let (total, completed) = s.counts(list) |
| + discard s.exec(sSetAll, list, total == 0 or completed < total) |
| + |
| +proc clearCompleted*(s: Store, list: string) = |
| + discard s.exec(sClearCompleted, list) |
diff --git a/apps/nim/src/views.nim b/apps/nim/src/views.nim| new file mode 100644 |
| index 0000000..e84bc49 |
| --- /dev/null |
| +++ b/apps/nim/src/views.nim |
| @@ -0,0 +1,30 @@ |
| +## HTML rendering. The markup is TodoMVC's own template (shared/reference.html) |
| +## plus the forms and links that make it work without JavaScript. |
| + |
| +import std/xmltree |
| +import store |
| + |
| +type View* = object |
| + todos*: seq[Todo] ## the items the filter shows |
| + total*, completed*: int ## counts over the whole list |
| + filter*: Filter |
| + editing*: int64 ## the item being edited without JavaScript, or 0 |
| + version*: string |
| + |
| +proc h(s: string): string = xmltree.escape(s) |
| + |
| +proc filterPath*(f: Filter): string = |
| + case f |
| + of fAll: "/" |
| + of fActive: "/active" |
| + of fCompleted: "/completed" |
| + |
| +proc checkedIf(b: bool): string = (if b: " checked" else: "") |
| + |
| +proc classes(completed, editing: bool): string = |
| + if completed and editing: " class=\"completed editing\"" |
| + elif completed: " class=\"completed\"" |
| + elif editing: " class=\"editing\"" |
| + else: "" |
| + |
| +include "page.nimf" |
diff --git a/apps/nim/static/app.css b/apps/nim/static/app.css| new file mode 100644 |
| index 0000000..5ca055a |
| --- /dev/null |
| +++ b/apps/nim/static/app.css |
| @@ -0,0 +1,62 @@ |
| +/* The controls that work without JavaScript, laid over TodoMVC's own. With |
| + JavaScript on (html.js) they're hidden and the page is the reference. */ |
| + |
| +form.inline { |
| + display: contents; |
| +} |
| + |
| +.toggle-button, |
| +.toggle-all-button { |
| + position: absolute; |
| + z-index: 1; |
| + padding: 0; |
| + border: 0; |
| + background: none; |
| + font-size: 0; |
| + cursor: pointer; |
| +} |
| + |
| +.todo-list li .toggle-button { |
| + top: 0; |
| + bottom: 0; |
| + left: 0; |
| + width: 40px; |
| + height: 40px; |
| + margin: auto 0; |
| +} |
| + |
| +.toggle-all-button { |
| + top: -65px; |
| + left: 0; |
| + width: 45px; |
| + height: 65px; |
| +} |
| + |
| +.todo-list li .edit-link { |
| + position: absolute; |
| + top: 0; |
| + right: 54px; |
| + bottom: 0; |
| + height: 20px; |
| + margin: auto 0; |
| + font-size: 14px; |
| + line-height: 20px; |
| + color: #949494; |
| + text-decoration: none; |
| +} |
| + |
| +.todo-list li .edit-link:hover, |
| +.todo-list li .edit-link:focus { |
| + color: #484848; |
| + text-decoration: underline; |
| +} |
| + |
| +html:not(.js) .todo-list li label { |
| + padding-right: 90px; |
| +} |
| + |
| +.js .toggle-button, |
| +.js .toggle-all-button, |
| +.js .edit-link { |
| + display: none; |
| +} |
diff --git a/apps/nim/static/app.js b/apps/nim/static/app.js| new file mode 100644 |
| index 0000000..03e8fc1 |
| --- /dev/null |
| +++ b/apps/nim/static/app.js |
| @@ -0,0 +1,94 @@ |
| +// Progressive enhancement for the server-rendered TodoMVC. Every action is |
| +// the same form POST the page makes without JavaScript; with the |
| +// `X-Fragment: 1` header the server answers with the re-rendered |
| +// `section.main` and `footer.footer` instead of a 303, and we swap them in. |
| + |
| +const app = document.querySelector('.todoapp'); |
| +const filter = app.dataset.filter; |
| +const newTodo = app.querySelector('.new-todo'); |
| + |
| +// Requests run one at a time, so responses apply in the order they were made. |
| +let queue = Promise.resolve(); |
| + |
| +function post(action, fields) { |
| + const body = new URLSearchParams({ ...fields, filter }); |
| + const run = async () => { |
| + const res = await fetch(action, { |
| + method: 'POST', |
| + body, |
| + headers: { 'X-Fragment': '1' }, |
| + credentials: 'same-origin', |
| + }); |
| + // The item is gone (another tab removed it): show the list as it is. |
| + if (!res.ok) return location.reload(); |
| + render(await res.text()); |
| + }; |
| + queue = queue.then(run, run); |
| + return queue; |
| +} |
| + |
| +function render(html) { |
| + app.querySelector('.main')?.remove(); |
| + app.querySelector('.footer')?.remove(); |
| + app.querySelector('.header').insertAdjacentHTML('afterend', html); |
| +} |
| + |
| +function itemOf(el) { |
| + return el.closest('.todo-list li'); |
| +} |
| + |
| +// Forms: adding, deleting and clearing completed. |
| +app.addEventListener('submit', (e) => { |
| + const form = e.target; |
| + e.preventDefault(); |
| + const fields = Object.fromEntries(new FormData(form)); |
| + delete fields.filter; |
| + post(form.getAttribute('action'), fields); |
| + if (form.contains(newTodo)) newTodo.value = ''; |
| +}); |
| + |
| +app.addEventListener('change', (e) => { |
| + if (e.target.matches('.toggle')) { |
| + post(`/todos/${itemOf(e.target).dataset.id}/toggle`, {}); |
| + } else if (e.target.matches('.toggle-all')) { |
| + post('/todos/toggle-all', {}); |
| + } |
| +}); |
| + |
| +// Editing: double-click a label; Enter or blur saves, Escape cancels. |
| +app.addEventListener('dblclick', (e) => { |
| + if (!e.target.matches('.todo-list label')) return; |
| + const li = itemOf(e.target); |
| + const input = li.querySelector('.edit'); |
| + li.classList.add('editing'); |
| + input.value = e.target.textContent; |
| + input.focus(); |
| + input.setSelectionRange(input.value.length, input.value.length); |
| +}); |
| + |
| +function finishEditing(input, save) { |
| + const li = itemOf(input); |
| + if (!li?.classList.contains('editing')) return; |
| + li.classList.remove('editing'); |
| + // A page opened from an edit link (?edit=<id>) is done with it now. |
| + if (location.search) history.replaceState(null, '', location.pathname); |
| + if (save) { |
| + post(`/todos/${li.dataset.id}/edit`, { title: input.value }); |
| + } else { |
| + input.value = li.querySelector('label').textContent; |
| + } |
| +} |
| + |
| +app.addEventListener('keydown', (e) => { |
| + if (!e.target.matches('.todo-list .edit')) return; |
| + if (e.key === 'Enter') { |
| + e.preventDefault(); |
| + finishEditing(e.target, true); |
| + } else if (e.key === 'Escape') { |
| + finishEditing(e.target, false); |
| + } |
| +}); |
| + |
| +app.addEventListener('focusout', (e) => { |
| + if (e.target.matches('.todo-list .edit')) finishEditing(e.target, true); |
| +}); |
diff --git a/apps/nim/tests/test_app.nim b/apps/nim/tests/test_app.nim| new file mode 100644 |
| index 0000000..537e066 |
| --- /dev/null |
| +++ b/apps/nim/tests/test_app.nim |
| @@ -0,0 +1,181 @@ |
| +## Unit tests: the store on its own, then the routes with fake requests. |
| +## Run with `make test`. |
| + |
| +import std/[unittest, os, tempfiles, strutils, uri] |
| +include "../src/app.nim" |
| + |
| +let dir = createTempDir("todo-nim-test", "") |
| +var dbCount = 0 |
| + |
| +proc freshStore(): Store = |
| + inc dbCount |
| + store.open(dir / ("t" & $dbCount & ".db")) |
| + |
| +suite "store": |
| + test "keeps each list to itself": |
| + let s = freshStore() |
| + let a = s.createList() |
| + let b = s.createList() |
| + s.add(a, "mine") |
| + let id = s.todos(a)[0].id |
| + check s.todos(b).len == 0 |
| + check not s.toggle(b, id) |
| + check not s.rename(b, id, "theirs") |
| + check not s.destroy(b, id) |
| + check s.todos(a) == @[Todo(id: id, title: "mine", completed: false)] |
| + |
| + test "filters, toggles all and clears completed": |
| + let s = freshStore() |
| + let l = s.createList() |
| + for t in ["a", "b", "c"]: s.add(l, t) |
| + check s.toggle(l, s.todos(l)[1].id) |
| + check s.todos(l, fActive).len == 2 |
| + check s.todos(l, fCompleted).len == 1 |
| + s.toggleAll(l) |
| + check s.counts(l) == (total: 3, completed: 3) |
| + s.toggleAll(l) |
| + check s.counts(l) == (total: 3, completed: 0) |
| + discard s.toggle(l, s.todos(l)[0].id) |
| + s.clearCompleted(l) |
| + check s.todos(l).len == 2 |
| + |
| + test "keeps any Unicode and long titles exactly": |
| + let s = freshStore() |
| + let l = s.createList() |
| + let long = "x".repeat(10_000) |
| + s.add(l, "naïve 🚀 日本語 <b>") |
| + s.add(l, long) |
| + check s.todos(l)[0].title == "naïve 🚀 日本語 <b>" |
| + check s.todos(l)[1].title == long |
| + |
| + test "reopening runs no migration twice and keeps data": |
| + let path = dir / "reopen.db" |
| + var s = store.open(path) |
| + let l = s.createList() |
| + s.add(l, "kept") |
| + s.close() |
| + s = store.open(path) |
| + check s.todos(l)[0].title == "kept" |
| + check s.schemaVersion == 1 |
| + check fileExists(path & "-wal") |
| + s.close() |
| + |
| + test "list ids are 128 random bits": |
| + let a = newListId() |
| + check a.len == 32 and isWellFormedListId(a) |
| + check a != newListId() |
| + check not isWellFormedListId("%%%") |
| + |
| +# Routes |
| + |
| +let app = App(store: freshStore(), origin: "http://127.0.0.1:9", secure: false) |
| + |
| +proc request(meth: HttpMethod, target: string, body = "", |
| + headers: openArray[(string, string)] = []): Request = |
| + Request(reqMethod: meth, url: parseUri(target), body: body, |
| + headers: newHttpHeaders(headers)) |
| + |
| +proc headerOf(r: Reply, name: string): string = |
| + for (k, v) in r.headers: |
| + if k.cmpIgnoreCase(name) == 0: return v |
| + |
| +proc cookieOf(r: Reply): string = |
| + r.headerOf("Set-Cookie").split(';')[0] |
| + |
| +proc post(target, body: string, cookie: string, |
| + extra: openArray[(string, string)] = []): Reply = |
| + app.route(request(HttpPost, target, body, @{"Cookie": cookie} & @extra)) |
| + |
| +suite "routes": |
| + test "the first visit gets a cookie and an empty page": |
| + let r = app.route(request(HttpGet, "/")) |
| + check r.code == Http200 |
| + let c = r.headerOf("Set-Cookie") |
| + check c.startsWith("todo_list=") and "HttpOnly" in c and "SameSite=Lax" in c |
| + check "Secure" notin c |
| + check "<section class=\"main\">" notin r.body |
| + check "Created by <a href=\"https://bakeoff.bfj.oncarlos.com\">the CARLOS bakeoff</a> · Nim" in r.body |
| + |
| + test "a garbled or unknown cookie gets a fresh list": |
| + for bad in ["%%%", "0123456789abcdef0123456789abcdef"]: |
| + let r = app.route(request(HttpGet, "/", headers = {"Cookie": "todo_list=" & bad})) |
| + check r.code == Http200 |
| + check r.cookieOf() != "todo_list=" & bad |
| + |
| + test "actions answer 303 back to the filter": |
| + let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| + for (filter, path) in [("all", "/"), ("active", "/active"), |
| + ("completed", "/completed"), ("bogus", "/"), ("", "/")]: |
| + let r = post("/todos", "title=x&filter=" & filter, cookie) |
| + check r.code == Http303 |
| + check r.headerOf("Location") == path |
| + |
| + test "titles are trimmed, blank ones ignored, and shown as text": |
| + let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| + discard post("/todos", "title=" & encodeUrl(" ") & "&filter=all", cookie) |
| + discard post("/todos", "title=" & encodeUrl(" <img src=x> & \"q\" "), cookie) |
| + let page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body |
| + check page.count("<li data-id=") == 1 |
| + check "<label><img src=x> & "q"</label>" in page |
| + |
| + test "another list's id answers 404 and changes nothing": |
| + let mine = app.route(request(HttpGet, "/")).cookieOf() |
| + let theirs = app.route(request(HttpGet, "/")).cookieOf() |
| + discard post("/todos", "title=mine", mine) |
| + let list = mine.split('=')[1] |
| + let id = app.store.todos(list)[0].id |
| + for verb in ["toggle", "destroy", "edit"]: |
| + check post("/todos/" & $id & "/" & verb, "title=x", theirs).code == Http404 |
| + check post("/todos/nope/toggle", "", mine).code == Http404 |
| + check app.store.todos(list)[0] == Todo(id: id, title: "mine", completed: false) |
| + |
| + test "a foreign Origin is refused; our own and none are allowed": |
| + let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| + check post("/todos", "title=a", cookie, {"Origin": "https://evil.example"}).code == Http403 |
| + check post("/todos", "title=a", cookie, {"Origin": "http://127.0.0.1:9"}).code == Http303 |
| + check post("/todos", "title=a", cookie).code == Http303 |
| + |
| + test "editing: the edit view, renaming, and blank deletes": |
| + let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| + discard post("/todos", "title=old", cookie) |
| + let list = cookie.split('=')[1] |
| + let id = $app.store.todos(list)[0].id |
| + let page = app.route(request(HttpGet, "/active?edit=" & id, |
| + headers = {"Cookie": cookie})).body |
| + check "<li class=\"editing\" data-id=\"" & id & "\">" in page |
| + check "action=\"/todos/" & id & "/edit\"" in page |
| + discard post("/todos/" & id & "/edit", "title=" & encodeUrl(" new "), cookie) |
| + check app.store.todos(list)[0].title == "new" |
| + discard post("/todos/" & id & "/edit", "title=+++", cookie) |
| + check app.store.todos(list).len == 0 |
| + |
| + test "the page's JavaScript gets a fragment instead of a redirect": |
| + let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| + let r = post("/todos", "title=one&filter=active", cookie, {"X-Fragment": "1"}) |
| + check r.code == Http200 |
| + check r.body.strip.startsWith("<section class=\"main\">") |
| + check "<strong>1</strong> item left" in r.body |
| + check "<a class=\"selected\" href=\"/active\">" in r.body |
| + |
| + test "counter, toggle-all state and clear-completed": |
| + let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| + discard post("/todos", "title=a", cookie) |
| + discard post("/todos", "title=b", cookie) |
| + var page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body |
| + check "<strong>2</strong> items left" in page |
| + check "clear-completed" notin page.split("<footer class=\"footer\">")[1].split("</footer>")[0] |
| + discard post("/todos/toggle-all", "", cookie) |
| + page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body |
| + check "<strong>0</strong> items left" in page |
| + check "class=\"toggle-all\" type=\"checkbox\" checked" in page |
| + check "<button class=\"clear-completed\">Clear completed</button>" in page |
| + |
| + test "static files, health and version": |
| + check app.route(request(HttpGet, "/static/base.css")).body == readFile("../../shared/base.css") |
| + check app.route(request(HttpGet, "/static/index.css")).body == readFile("../../shared/index.css") |
| + check app.route(request(HttpGet, "/healthz")).code == Http200 |
| + check app.route(request(HttpGet, "/api/version")).body == appVersion |
| + check app.route(request(HttpGet, "/nope")).code == Http404 |
| + |
| +app.store.close() |
| +removeDir(dir) |
diff --git a/apps/nim/todo.nimble b/apps/nim/todo.nimble| new file mode 100644 |
| index 0000000..b3f9232 |
| --- /dev/null |
| +++ b/apps/nim/todo.nimble |
| @@ -0,0 +1,13 @@ |
| +# Package |
| + |
| +version = "0.1.0" |
| +author = "CARLOS bakeoff" |
| +description = "TodoMVC, server-rendered, on CARLOS" |
| +license = "MIT" |
| +srcDir = "src" |
| +bin = @["app"] |
| + |
| +# Dependencies |
| + |
| +requires "nim >= 2.2.0" |
| +requires "db_connector#29450a2063970712422e1ab857695c12d80112a6" |