changeset 26:7e10cace67a3

scid: add basic mustache support
author David Demelier <markand@malikania.fr>
date Tue, 02 Aug 2022 13:24:13 +0200
parents c40f98360ac9
children dae2de19ca5d
files .clang Makefile config.mk extern/LICENSE.libmustache4c.txt extern/VERSION.libmustache4c.txt extern/libmustache4c/mustache.c extern/libmustache4c/mustache.h lib/db.c lib/db.h lib/types.h lib/util.c scid/http.c scid/main.c scid/page-api-jobresults.c scid/page-api-jobs.c scid/page-api-projects.c scid/page-api-todo.c scid/page-api-workers.c scid/page-index.c scid/page-index.h scid/page-static.c scid/page-static.h scid/page.c scid/page.h scid/scid.c scid/scid.h sql/init.sql sql/job-list.sql sql/jobresult-list-by-job-group.sql sql/jobresult-list-by-job.sql sql/jobresult-list-by-worker.sql themes/bulma/fragments/footer.html themes/bulma/fragments/header.html themes/bulma/pages/index.html
diffstat 34 files changed, 2206 insertions(+), 185 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.clang	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,1 @@
+-Ilib
--- a/Makefile	Mon Jul 25 21:22:13 2022 +0200
+++ b/Makefile	Tue Aug 02 13:24:13 2022 +0200
@@ -21,26 +21,30 @@
 include config.mk
 
 LIBSCI=                 lib/libsci.a
-LIBSCI_SRCS=            extern/libsqlite/sqlite3.c      \
-                        lib/apic.c                      \
-                        lib/db.c                        \
-                        lib/log.c                       \
-                        lib/strlcpy.c                   \
-                        lib/strtonum.c                  \
-                        lib/types.c                     \
+LIBSCI_SRCS=            extern/libsqlite/sqlite3.c \
+                        lib/apic.c \
+                        lib/db.c \
+                        lib/log.c \
+                        lib/strlcpy.c \
+                        lib/strtonum.c \
+                        lib/types.c \
                         lib/util.c
 LIBSCI_OBJS=            ${LIBSCI_SRCS:.c=.o}
 LIBSCI_DEPS=            ${LIBSCI_SRCS:.c=.d}
 
-SQL_SRCS=               sql/init.sql                    \
-                        sql/job-add.sql                 \
-                        sql/job-todo.sql                \
-                        sql/jobresult-add.sql           \
-                        sql/project-find.sql            \
-                        sql/project-list.sql            \
-                        sql/project-save.sql            \
-                        sql/worker-find.sql             \
-                        sql/worker-list.sql             \
+SQL_SRCS=               sql/init.sql \
+                        sql/job-add.sql \
+                        sql/job-list.sql \
+                        sql/job-todo.sql \
+                        sql/jobresult-add.sql \
+                        sql/jobresult-list-by-job.sql \
+                        sql/jobresult-list-by-job-group.sql \
+                        sql/jobresult-list-by-worker.sql \
+                        sql/project-find.sql \
+                        sql/project-list.sql \
+                        sql/project-save.sql \
+                        sql/worker-find.sql \
+                        sql/worker-list.sql \
                         sql/worker-save.sql
 SQL_OBJS=               ${SQL_SRCS:.sql=.h}
 
@@ -50,13 +54,17 @@
 SCICTL_DEPS=            ${SCICTL_SRCS:.c=.d}
 
 SCID=                   scid/scid
-SCID_SRCS=              scid/http.c                     \
-                        scid/main.c                     \
-                        scid/page-api-jobresults.c      \
-                        scid/page-api-jobs.c            \
-                        scid/page-api-projects.c        \
-                        scid/page-api-todo.c            \
-                        scid/page-api-workers.c         \
+SCID_SRCS=              extern/libmustache4c/mustache.c \
+                        scid/http.c \
+                        scid/main.c \
+                        scid/scid.c \
+                        scid/page-api-jobresults.c \
+                        scid/page-api-jobs.c \
+                        scid/page-api-projects.c \
+                        scid/page-api-todo.c \
+                        scid/page-api-workers.c \
+                        scid/page-index.c \
+                        scid/page-static.c \
                         scid/page.c
 SCID_OBJS=              ${SCID_SRCS:.c=.o}
 SCID_DEPS=              ${SCID_SRCS:.c=.d}
@@ -88,6 +96,7 @@
 INCS=                   -Iextern/libsqlite \
                         -Iextern/libutlist \
                         -Iextern/libgreatest \
+                        -Iextern/libmustache4c \
                         -Ilib \
                         -I.
 DEFS=                   -DVARDIR=\"${VARDIR}\" \
--- a/config.mk	Mon Jul 25 21:22:13 2022 +0200
+++ b/config.mk	Tue Aug 02 13:24:13 2022 +0200
@@ -1,5 +1,5 @@
 CC=             cc
-CFLAGS=         -g -O0 -Wall -Wextra
+CFLAGS=         -g -O0 -Wall -Wextra -fsanitize=address
 #CFLAGS=         -Wall -Wextra -fsanitize=address,undefined -g -O0
 #LDFLAGS=        -fsanitize=address,undefined
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/extern/LICENSE.libmustache4c.txt	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,22 @@
+
+# The MIT License (MIT)
+
+Copyright © 2017 Martin Mitáš
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the “Software”),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/extern/VERSION.libmustache4c.txt	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,1 @@
+1d6b25ac1ef93cf3cf032684d4689b2a7789e41f
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/extern/libmustache4c/mustache.c	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,1167 @@
+/*
+ * Mustache4C
+ * (http://github.com/mity/mustache4c)
+ *
+ * Copyright (c) 2017 Martin Mitas
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+ * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+#include "mustache.h"
+
+#include <errno.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/types.h>  /* for size_t */
+
+
+#ifdef _MSC_VER
+    /* MSVC does not understand "inline" when building as pure C (not C++).
+     * However it understands "__inline" */
+    #ifndef __cplusplus
+        #define inline __inline
+    #endif
+#endif
+
+
+#define MUSTACHE_DEFAULTOPENER      "{{"
+#define MUSTACHE_DEFAULTCLOSER      "}}"
+#define MUSTACHE_MAXOPENERLENGTH    32
+#define MUSTACHE_MAXCLOSERLENGTH    32
+
+
+/**********************
+ *** Growing Buffer ***
+ **********************/
+
+typedef struct MUSTACHE_BUFFER {
+    uint8_t* data;
+    size_t n;
+    size_t alloc;
+} MUSTACHE_BUFFER;
+
+static inline void
+mustache_buffer_free(MUSTACHE_BUFFER* buf)
+{
+    free(buf->data);
+}
+
+static int
+mustache_buffer_insert(MUSTACHE_BUFFER* buf, size_t off, const void* data, size_t n)
+{
+    if(buf->n + n > buf->alloc) {
+        size_t new_alloc = (buf->n + n) * 2;
+        uint8_t* new_data;
+
+        new_data = (uint8_t*) realloc(buf->data, new_alloc);
+        if(new_data == NULL)
+            return -1;
+
+        buf->data = new_data;
+        buf->alloc = new_alloc;
+    }
+
+    if(off < buf->n)
+        memmove(buf->data + off + n, buf->data + off, buf->n - off);
+
+    memcpy(buf->data + off, data, n);
+    buf->n += n;
+    return 0;
+}
+
+static inline int
+mustache_buffer_append(MUSTACHE_BUFFER* buf, const void* data, size_t n)
+{
+    return mustache_buffer_insert(buf, (size_t) buf->n, data, n);
+}
+
+static int
+mustache_buffer_insert_num(MUSTACHE_BUFFER* buf, size_t off, uint64_t num)
+{
+    uint8_t tmp[16];
+    size_t n = 0;
+
+    tmp[15 - n++] = num & 0x7f;
+
+    while(1) {
+        num = num >> 7;
+        if(num == 0)
+            break;
+        tmp[15 - n++] = 0x80 | (num & 0x7f);
+    }
+
+    return mustache_buffer_insert(buf, off, tmp+16-n, n);
+}
+
+static inline int
+mustache_buffer_append_num(MUSTACHE_BUFFER* buf, uint64_t num)
+{
+    return mustache_buffer_insert_num(buf, buf->n, num);
+}
+
+static uint64_t
+mustache_decode_num(const uint8_t* data, size_t off, size_t* p_off)
+{
+    uint64_t num = 0;
+
+    while(data[off] >= 0x80) {
+        num |= (data[off++] & 0x7f);
+        num = num << 7;
+    }
+
+    num |= data[off++];
+    *p_off = off;
+    return num;
+}
+
+
+/****************************
+ *** Stack Implementation ***
+ ****************************/
+
+typedef MUSTACHE_BUFFER MUSTACHE_STACK;
+
+static inline void
+mustache_stack_free(MUSTACHE_STACK* stack)
+{
+    mustache_buffer_free(stack);
+}
+
+static inline int
+mustache_stack_is_empty(MUSTACHE_STACK* stack)
+{
+    return (stack->n == 0);
+}
+
+static inline int
+mustache_stack_push(MUSTACHE_STACK* stack, uintptr_t item)
+{
+    return mustache_buffer_append(stack, &item, sizeof(uintptr_t));
+}
+
+static inline uintptr_t
+mustache_stack_peek(MUSTACHE_STACK* stack)
+{
+    return *((uintptr_t*)(stack->data + (stack->n - sizeof(uintptr_t))));
+}
+
+static inline uintptr_t
+mustache_stack_pop(MUSTACHE_STACK* stack)
+{
+    uintptr_t item = mustache_stack_peek(stack);
+    stack->n -= sizeof(uintptr_t);
+    return item;
+}
+
+
+/***************************
+ *** Parsing & Compiling ***
+ ***************************/
+
+#define MUSTACHE_ISANYOF2(ch, ch1, ch2)            ((ch) == (ch1) || (ch) == (ch2))
+#define MUSTACHE_ISANYOF4(ch, ch1, ch2, ch3, ch4)  ((ch) == (ch1) || (ch) == (ch2) || (ch) == (ch3) || (ch) == (ch4))
+
+#define MUSTACHE_ISWHITESPACE(ch)   MUSTACHE_ISANYOF4((ch), ' ', '\t', '\v', '\f')
+#define MUSTACHE_ISNEWLINE(ch)      MUSTACHE_ISANYOF2((ch), '\r', '\n')
+
+/* Keep in sync with MUSTACHE_ERR_xxx constants. */
+static const char* mustache_err_messages[] = {
+    "Success.",
+    "Tag opener has no closer.",
+    "Tag closer has no opener.",
+    "Tag closer is incompatible with its opener.",
+    "Tag has no name.",
+    "Tag name is invalid.",
+    "Section-opening tag has no closer.",
+    "Section-closing tag has no opener.",
+    "Name of section-closing tag does not match corresponding section-opening tag.",
+    "The section-opening is located here.",
+    "Invalid specification of delimiters."
+};
+
+/* For the given template, we construct list of MUSTACHE_TAGINFO structures.
+ * Along the way, we also check for any parsing errors and report them
+ * to the app.
+ */
+
+typedef enum MUSTACHE_TAGTYPE {
+    MUSTACHE_TAGTYPE_NONE = 0,
+    MUSTACHE_TAGTYPE_DELIM,             /* {{=@ @=}} */
+    MUSTACHE_TAGTYPE_COMMENT,           /* {{! comment }} */
+    MUSTACHE_TAGTYPE_VAR,               /* {{ var }} */
+    MUSTACHE_TAGTYPE_VERBATIMVAR,       /* {{{ var }}} */
+    MUSTACHE_TAGTYPE_VERBATIMVAR2,      /* {{& var }} */
+    MUSTACHE_TAGTYPE_OPENSECTION,       /* {{# section }} */
+    MUSTACHE_TAGTYPE_OPENSECTIONINV,    /* {{^ section }} */
+    MUSTACHE_TAGTYPE_CLOSESECTION,      /* {{/ section }} */
+    MUSTACHE_TAGTYPE_CLOSESECTIONINV,
+    MUSTACHE_TAGTYPE_PARTIAL,           /* {{> partial }} */
+    MUSTACHE_TAGTYPE_INDENT      /* for internal purposes. */
+} MUSTACHE_TAGTYPE;
+
+typedef struct MUSTACHE_TAGINFO {
+    MUSTACHE_TAGTYPE type;
+    size_t line;
+    size_t col;
+    size_t beg;
+    size_t end;
+    size_t name_beg;
+    size_t name_end;
+} MUSTACHE_TAGINFO;
+
+static void
+mustache_parse_error(int err_code, const char* msg,
+                    unsigned line, unsigned column, void* parser_data)
+{
+    /* noop */
+    (void)err_code;
+    (void)msg;
+    (void)line;
+    (void)column;
+    (void)parser_data;
+}
+
+static int
+mustache_is_std_closer(const char* closer, size_t closer_len)
+{
+    size_t off;
+
+    for(off = 0; off < closer_len; off++) {
+        if(closer[off] != '}')
+            return 0;
+    }
+
+    return 1;
+}
+
+static int
+mustache_validate_tagname(const char* tagname, size_t size)
+{
+    size_t off;
+
+    if(size == 1  &&  tagname[0] == '.')
+        return 0;
+
+    /* Verify there is no whitespace and that '.' is used only as a delimiter
+     * of non-empty tokens. */
+    if(tagname[0] == '.'  ||  tagname[size-1] == '.')
+        return -1;
+    for(off = 0; off < size; off++) {
+        if(MUSTACHE_ISWHITESPACE(tagname[off]))
+            return -1;
+        if(tagname[off] == '.'  &&  off+1 < size  &&  tagname[off+1] == '.')
+            return -1;
+    }
+
+    return 0;
+}
+
+static int
+mustache_validate_sections(const char* templ_data, MUSTACHE_BUFFER* tags_buffer,
+                           const MUSTACHE_PARSER* parser, void* parser_data)
+{
+    MUSTACHE_TAGINFO* tags = (MUSTACHE_TAGINFO*) tags_buffer->data;
+    unsigned n_tags = tags_buffer->n / sizeof(MUSTACHE_TAGINFO);
+    unsigned i;
+    MUSTACHE_STACK section_stack = { 0 };
+    MUSTACHE_TAGINFO* opener;
+    int n_errors = 0;
+    int ret = -1;
+
+    for(i = 0; i < n_tags; i++) {
+        switch(tags[i].type) {
+        case MUSTACHE_TAGTYPE_OPENSECTION:
+        case MUSTACHE_TAGTYPE_OPENSECTIONINV:
+            if(mustache_stack_push(&section_stack, (uintptr_t) &tags[i]) != 0)
+                goto err;
+            break;
+
+        case MUSTACHE_TAGTYPE_CLOSESECTION:
+        case MUSTACHE_TAGTYPE_CLOSESECTIONINV:
+            if(mustache_stack_is_empty(&section_stack)) {
+                parser->parse_error(MUSTACHE_ERR_DANGLINGSECTIONCLOSER,
+                        mustache_err_messages[MUSTACHE_ERR_DANGLINGSECTIONCLOSER],
+                        (unsigned)tags[i].line, (unsigned)tags[i].col,
+                        parser_data);
+                n_errors++;
+            } else {
+                opener = (MUSTACHE_TAGINFO*) mustache_stack_pop(&section_stack);
+
+                if(opener->name_end - opener->name_beg != tags[i].name_end - tags[i].name_beg  ||
+                   strncmp(templ_data + opener->name_beg,
+                           templ_data + tags[i].name_beg,
+                           opener->name_end - opener->name_beg) != 0)
+                {
+                    parser->parse_error(MUSTACHE_ERR_SECTIONNAMEMISMATCH,
+                            mustache_err_messages[MUSTACHE_ERR_SECTIONNAMEMISMATCH],
+                            (unsigned)tags[i].line, (unsigned)tags[i].col,
+                            parser_data);
+                    parser->parse_error(MUSTACHE_ERR_SECTIONOPENERHERE,
+                            mustache_err_messages[MUSTACHE_ERR_SECTIONOPENERHERE],
+                            (unsigned)opener->line, (unsigned)opener->col,
+                            parser_data);
+                    n_errors++;
+                }
+
+                if(opener->type == MUSTACHE_TAGTYPE_OPENSECTIONINV)
+                    tags[i].type = MUSTACHE_TAGTYPE_CLOSESECTIONINV;
+            }
+            break;
+
+        default:
+            break;
+        }
+    }
+
+    if(!mustache_stack_is_empty(&section_stack)) {
+        while(!mustache_stack_is_empty(&section_stack)) {
+            opener = (MUSTACHE_TAGINFO*) mustache_stack_pop(&section_stack);
+
+            parser->parse_error(MUSTACHE_ERR_DANGLINGSECTIONOPENER,
+                    mustache_err_messages[MUSTACHE_ERR_DANGLINGSECTIONOPENER],
+                    (unsigned)opener->line, (unsigned)opener->col,
+                    parser_data);
+            n_errors++;
+        }
+    }
+
+    if(n_errors == 0)
+        ret = 0;
+
+err:
+    mustache_stack_free(&section_stack);
+    return ret;
+}
+
+static int
+mustache_parse_delimiters(const char* delim_spec, size_t size,
+                          char* opener, size_t* p_opener_len,
+                          char* closer, size_t* p_closer_len)
+{
+    size_t opener_beg, opener_end;
+    size_t closer_beg, closer_end;
+
+    opener_beg = 0;
+
+    opener_end = opener_beg;
+    while(opener_end < size) {
+        if(MUSTACHE_ISWHITESPACE(delim_spec[opener_end]))
+            break;
+        if(delim_spec[opener_end] == '=')
+            return -1;
+        opener_end++;
+    }
+    if(opener_end <= opener_beg  ||  opener_end - opener_beg > MUSTACHE_MAXOPENERLENGTH)
+        return -1;
+
+    closer_beg = opener_end;
+    while(closer_beg < size) {
+        if(!MUSTACHE_ISWHITESPACE(delim_spec[closer_beg]))
+            break;
+        closer_beg++;
+    }
+    if(closer_beg <= opener_end)
+        return -1;
+
+    closer_end = closer_beg;
+    while(closer_end < size) {
+        if(MUSTACHE_ISWHITESPACE(delim_spec[closer_end]))
+            return -1;
+        closer_end++;
+    }
+    if(closer_end <= closer_beg  ||   closer_end - closer_beg > MUSTACHE_MAXCLOSERLENGTH)
+        return -1;
+    if(closer_end != size)
+        return -1;
+
+    memcpy(opener, delim_spec + opener_beg, opener_end - opener_beg);
+    *p_opener_len = opener_end - opener_beg;
+    memcpy(closer, delim_spec + closer_beg, closer_end - closer_beg);
+    *p_closer_len = closer_end - closer_beg;
+    return 0;
+}
+
+static int
+mustache_parse(const char* templ_data, size_t templ_size,
+               const MUSTACHE_PARSER* parser, void* parser_data,
+               MUSTACHE_TAGINFO** p_tags, unsigned* p_n_tags)
+{
+    int n_errors = 0;
+    char opener[MUSTACHE_MAXOPENERLENGTH] = MUSTACHE_DEFAULTOPENER;
+    char closer[MUSTACHE_MAXCLOSERLENGTH] = MUSTACHE_DEFAULTCLOSER;
+    size_t opener_len;
+    size_t closer_len;
+    size_t off = 0;
+    size_t line = 1;
+    size_t col = 1;
+    MUSTACHE_TAGINFO current_tag;
+    MUSTACHE_BUFFER tags = { 0 };
+
+    /* If this template will ever be used as a partial, it may inherit an
+     * extra indentation from parent template, so we mark every line beginning
+     * with the dummy tag for further processing in mustache_compile(). */
+    if(off < templ_size) {
+        current_tag.type = MUSTACHE_TAGTYPE_INDENT;
+        current_tag.beg = off;
+        current_tag.end = off;
+        if(mustache_buffer_append(&tags, &current_tag, sizeof(MUSTACHE_TAGINFO)) != 0)
+            goto err;
+    }
+
+    current_tag.type = MUSTACHE_TAGTYPE_NONE;
+
+    opener_len = strlen(MUSTACHE_DEFAULTOPENER);
+    closer_len = strlen(MUSTACHE_DEFAULTCLOSER);
+
+    while(off < templ_size) {
+        int is_opener, is_closer;
+
+        is_opener = (off + opener_len <= templ_size  &&  memcmp(templ_data+off, opener, opener_len) == 0);
+        is_closer = (off + closer_len <= templ_size  &&  memcmp(templ_data+off, closer, closer_len) == 0);
+        if(is_opener && is_closer) {
+            /* Opener and closer may be defined to be the same string.
+             * Consider for example "{{=@ @=}}".
+             * Determine the real meaning from current parser state:
+             */
+            if(current_tag.type == MUSTACHE_TAGTYPE_NONE)
+                is_closer = 0;
+            else
+                is_opener = 0;
+        }
+
+        if(is_opener) {
+            /* Handle tag opener "{{" */
+
+            if(current_tag.type != MUSTACHE_TAGTYPE_NONE  &&  current_tag.type != MUSTACHE_TAGTYPE_COMMENT) {
+                /* Opener after some previous opener??? */
+                parser->parse_error(MUSTACHE_ERR_DANGLINGTAGOPENER,
+                        mustache_err_messages[MUSTACHE_ERR_DANGLINGTAGOPENER],
+                        (unsigned)current_tag.line, (unsigned)current_tag.col,
+                        parser_data);
+                n_errors++;
+                current_tag.type = MUSTACHE_TAGTYPE_NONE;
+            }
+
+            current_tag.line = line;
+            current_tag.col = col;
+            current_tag.beg = off;
+            off += opener_len;
+
+            if(off < templ_size) {
+                switch(templ_data[off]) {
+                case '=':   current_tag.type = MUSTACHE_TAGTYPE_DELIM; off++; break;
+                case '!':   current_tag.type = MUSTACHE_TAGTYPE_COMMENT; off++; break;
+                case '{':   current_tag.type = MUSTACHE_TAGTYPE_VERBATIMVAR; off++; break;
+                case '&':   current_tag.type = MUSTACHE_TAGTYPE_VERBATIMVAR2; off++; break;
+                case '#':   current_tag.type = MUSTACHE_TAGTYPE_OPENSECTION; off++; break;
+                case '^':   current_tag.type = MUSTACHE_TAGTYPE_OPENSECTIONINV; off++; break;
+                case '/':   current_tag.type = MUSTACHE_TAGTYPE_CLOSESECTION; off++; break;
+                case '>':   current_tag.type = MUSTACHE_TAGTYPE_PARTIAL; off++; break;
+                default:    current_tag.type = MUSTACHE_TAGTYPE_VAR; break;
+                }
+            }
+
+            while(off < templ_size  &&  MUSTACHE_ISWHITESPACE(templ_data[off]))
+                off++;
+            current_tag.name_beg = off;
+
+            col += current_tag.name_beg - current_tag.beg;
+        } else if(is_closer  &&  current_tag.type == MUSTACHE_TAGTYPE_NONE) {
+            /* Invalid closer. */
+            parser->parse_error(MUSTACHE_ERR_DANGLINGTAGCLOSER,
+                    mustache_err_messages[MUSTACHE_ERR_DANGLINGTAGCLOSER],
+                    (unsigned) line, (unsigned) col, parser_data);
+            n_errors++;
+            off++;
+            col++;
+        } else if(is_closer) {
+            /* Handle tag closer "}}" */
+
+            current_tag.name_end = off;
+            off += closer_len;
+            col += closer_len;
+            if(current_tag.type == MUSTACHE_TAGTYPE_VERBATIMVAR) {
+                /* Eat the extra '}'. Note it may be after the found
+                 * closer (if closer is "}}" or before it for a custom
+                 * closer. */
+                if(current_tag.name_end > current_tag.name_beg  &&
+                            templ_data[current_tag.name_end-1] == '}') {
+                    current_tag.name_end--;
+                } else if(mustache_is_std_closer(closer, closer_len)  &&
+                            off < templ_size  &&  templ_data[off] == '}') {
+                    off++;
+                    col++;
+                } else {
+                    parser->parse_error(MUSTACHE_ERR_INCOMPATIBLETAGCLOSER,
+                            mustache_err_messages[MUSTACHE_ERR_INCOMPATIBLETAGCLOSER],
+                            (unsigned) line, (unsigned) col, parser_data);
+                    n_errors++;
+                }
+            } else if(current_tag.type == MUSTACHE_TAGTYPE_DELIM) {
+                /* Maybe we are not really the closer. Maybe the directive
+                 * does not change the closer so we are the "new closer" in
+                 * something like "{{=<something> }}=}}". */
+                if(templ_data[current_tag.name_end - 1] != '='  &&
+                   off + closer_len < templ_size  &&
+                   templ_data[off] == '='  &&
+                   memcmp(templ_data + off + 1, closer, closer_len) == 0)
+                {
+                    current_tag.name_end += closer_len + 1;
+                    off += closer_len + 1;
+                    col += closer_len + 1;
+                }
+
+                if(templ_data[current_tag.name_end - 1] != '=') {
+                    parser->parse_error(MUSTACHE_ERR_INCOMPATIBLETAGCLOSER,
+                            mustache_err_messages[MUSTACHE_ERR_INCOMPATIBLETAGCLOSER],
+                            (unsigned) line, (unsigned) col, parser_data);
+                    n_errors++;
+                } else if(current_tag.name_end > current_tag.name_beg) {
+                    current_tag.name_end--;     /* Consume the closer's '=' */
+                }
+            }
+
+            current_tag.end = off;
+
+            /* If the tag is standalone, expand it to consume also any
+             * preceding whitespace and also one new-line (before or after). */
+            if(current_tag.type != MUSTACHE_TAGTYPE_VAR &&
+               current_tag.type != MUSTACHE_TAGTYPE_VERBATIMVAR &&
+               current_tag.type != MUSTACHE_TAGTYPE_VERBATIMVAR2 &&
+               (current_tag.end >= templ_size || MUSTACHE_ISNEWLINE(templ_data[current_tag.end])))
+            {
+                size_t tmp_off = current_tag.beg;
+                while(tmp_off > 0 && MUSTACHE_ISWHITESPACE(templ_data[tmp_off-1]))
+                    tmp_off--;
+                if(tmp_off == 0 || MUSTACHE_ISNEWLINE(templ_data[tmp_off-1])) {
+                    current_tag.beg = tmp_off;
+
+                    if(current_tag.end < templ_size && templ_data[current_tag.end] == '\r')
+                        current_tag.end++;
+                    if(current_tag.end < templ_size && templ_data[current_tag.end] == '\n')
+                        current_tag.end++;
+                }
+            }
+
+            while(current_tag.name_end > current_tag.name_beg  &&
+                        MUSTACHE_ISWHITESPACE(templ_data[current_tag.name_end-1]))
+                current_tag.name_end--;
+
+            if(current_tag.type != MUSTACHE_TAGTYPE_COMMENT) {
+                if(current_tag.name_end <= current_tag.name_beg) {
+                    parser->parse_error(MUSTACHE_ERR_NOTAGNAME,
+                            mustache_err_messages[MUSTACHE_ERR_NOTAGNAME],
+                            (unsigned)current_tag.line, (unsigned)current_tag.col,
+                            parser_data);
+                    n_errors++;
+                }
+            }
+
+            if(current_tag.type == MUSTACHE_TAGTYPE_DELIM) {
+                if(mustache_parse_delimiters(templ_data + current_tag.name_beg,
+                        current_tag.name_end - current_tag.name_beg,
+                        opener, &opener_len, closer, &closer_len) != 0)
+                {
+                    parser->parse_error(MUSTACHE_ERR_INVALIDDELIMITERS,
+                            mustache_err_messages[MUSTACHE_ERR_INVALIDDELIMITERS],
+                            (unsigned)current_tag.line, (unsigned)current_tag.col,
+                            parser_data);
+                    n_errors++;
+                }
+
+                /* From now on, ignore this tag. */
+                current_tag.type = MUSTACHE_TAGTYPE_COMMENT;
+            }
+
+            if(current_tag.type != MUSTACHE_TAGTYPE_COMMENT) {
+                if(mustache_validate_tagname(templ_data + current_tag.name_beg,
+                            current_tag.name_end - current_tag.name_beg) != 0) {
+                    parser->parse_error(MUSTACHE_ERR_INVALIDTAGNAME,
+                            mustache_err_messages[MUSTACHE_ERR_INVALIDTAGNAME],
+                            (unsigned)current_tag.line, (unsigned)current_tag.col,
+                            parser_data);
+                    n_errors++;
+                }
+            }
+
+            /* Remember the tag info. */
+            if(mustache_buffer_append(&tags, &current_tag, sizeof(MUSTACHE_TAGINFO)) != 0)
+                goto err;
+
+            current_tag.type = MUSTACHE_TAGTYPE_NONE;
+        } else if(MUSTACHE_ISNEWLINE(templ_data[off])) {
+            /* Handle end of line. */
+
+            if(current_tag.type != MUSTACHE_TAGTYPE_NONE  &&  current_tag.type != MUSTACHE_TAGTYPE_COMMENT) {
+                parser->parse_error(MUSTACHE_ERR_DANGLINGTAGOPENER,
+                        mustache_err_messages[MUSTACHE_ERR_DANGLINGTAGOPENER],
+                        (unsigned)current_tag.line, (unsigned)current_tag.col,
+                        parser_data);
+                n_errors++;
+                current_tag.type = MUSTACHE_TAGTYPE_NONE;
+            }
+
+            /* New line may be formed by digraph "\r\n". */
+            if(templ_data[off] == '\r')
+                off++;
+            if(off < templ_size  &&  templ_data[off] == '\n')
+                off++;
+
+            if(current_tag.type == MUSTACHE_TAGTYPE_NONE  &&  off < templ_size) {
+                current_tag.type = MUSTACHE_TAGTYPE_INDENT;
+                current_tag.beg = off;
+                current_tag.end = off;
+                if(mustache_buffer_append(&tags, &current_tag, sizeof(MUSTACHE_TAGINFO)) != 0)
+                    goto err;
+                current_tag.type = MUSTACHE_TAGTYPE_NONE;
+            }
+
+            line++;
+            col = 1;
+        } else {
+            /* Handle any other character. */
+            off++;
+            col++;
+        }
+    }
+
+    if(mustache_validate_sections(templ_data, &tags, parser, parser_data) != 0)
+        goto err;
+
+    /* Add an extra dummy tag marking end of the template. */
+    current_tag.type = MUSTACHE_TAGTYPE_NONE;
+    current_tag.beg = templ_size;
+    current_tag.end = templ_size;
+    if(mustache_buffer_append(&tags, &current_tag, sizeof(MUSTACHE_TAGINFO)) != 0)
+        goto err;
+
+    /* Success? */
+    if(n_errors == 0) {
+        *p_tags = (MUSTACHE_TAGINFO*) tags.data;
+        *p_n_tags = tags.n / sizeof(MUSTACHE_TAGINFO);
+        return 0;
+    }
+
+    /* Error path. */
+err:
+    mustache_buffer_free(&tags);
+    *p_tags = NULL;
+    *p_n_tags = 0;
+    return -1;
+}
+
+
+/* The compiled template is a sequence of following instruction types.
+ * The instructions have two types of arguments:
+ *  -- NUM: a number encoded with mustache_buffer_[append|insert]_num().
+ *  -- STR: a string (always preceded with a NUM denoting its length).
+ */
+
+/* Instruction denoting end of template.
+ */
+#define MUSTACHE_OP_EXIT            0
+
+/* Instruction for outputting a literal text.
+ *
+ *   Arg #1: Length of the literal string (NUM).
+ *   Arg #2: The literal string (STR).
+ */
+#define MUSTACHE_OP_LITERAL         1
+
+/* Instruction to resolve a tag name.
+ *
+ *   Arg #1: (Relative) setjmp value (NUM).
+ *   Arg #2: Count of names (NUM).
+ *   Arg #3: Length of the 1st tag name (NUM).
+ *   Arg #4: The tag name (STR).
+ *   etc. (more names follow, up to the count in arg #2)
+ *
+ *   Registers: reg_node is set to the resolved node, or NULL.
+ *              reg_jmpaddr is set to address where some next instruction may
+ *              want to jump on some condition.
+ */
+#define MUSTACHE_OP_RESOLVE_setjmp  2
+
+/* Instruction to resolve a tag name.
+ *
+ *   Arg #1: Count of names (NUM).
+ *   Arg #2: Length of the tag name (NUM).
+ *   Arg #3: The tag name (STR).
+ *   etc. (more names follow, up to the count in arg #1)
+ *
+ *   Registers: reg_node is set to the resolved node, or NULL.
+ */
+#define MUSTACHE_OP_RESOLVE         3
+
+/* Instructions to output a node.
+ *
+ * Registers: If it is not NULL, reg_node determines the node to output.
+ *            Otherwise, it is noop.
+ */
+#define MUSTACHE_OP_OUTVERBATIM     4
+#define MUSTACHE_OP_OUTESCAPED      5
+
+/* Instruction to enter a node in register reg_node, i.e. to change a lookup
+ * context for resolve instructions.
+ *
+ * Registers: If it is not NULL, reg_node is pushed to the stack.
+ *            Otherwise, program counter is changed to address in reg_jmpaddr.
+ */
+#define MUSTACHE_OP_ENTER           6
+
+/* Instruction to leave a node. The top node in the lookup context stack is
+ * popped out.
+ *
+ * Arg #1: (Relative) setjmp value (NUM) for jumping back for next loop iteration.
+ */
+#define MUSTACHE_OP_LEAVE           7
+
+/* Instruction to open inverted section.
+ * Note there is no MUSTACHE_OP_LEAVEINV instruction as it is noop.
+ *
+ * Registers: If reg_node is NULL, continues normally.
+ *            Otherwise, program counter is changed to address in reg_jmpaddr.
+ */
+#define MUSTACHE_OP_ENTERINV        8
+
+/* Instruction to enter a partial.
+ *
+ * Arg #1: Length of the partial name (NUM).
+ * Arg #2: The partial name (STR).
+ * Arg #3: Length of the indentation string (NUM).
+ * Arg #4: Indentation, i.e. string composed of whitespace characters (STR).
+ */
+#define MUSTACHE_OP_PARTIAL         9
+
+/* Instruction to insert extra indentation (inherited from parent templates).
+ */
+#define MUSTACHE_OP_INDENT          10
+
+
+static int
+mustache_compile_tagname(MUSTACHE_BUFFER* insns, const char* name, size_t size)
+{
+    unsigned n_tokens = 1;
+    unsigned i;
+    size_t tok_beg, tok_end;
+
+    if(size == 1  &&  name[0] == '.') {
+        /* Implicit iterator. */
+        n_tokens = 0;
+    } else {
+        for(i = 0; i < size; i++) {
+            if(name[i] == '.')
+                n_tokens++;
+        }
+    }
+
+    if(mustache_buffer_append_num(insns, n_tokens) != 0)
+        return -1;
+
+    tok_beg = 0;
+    for(i = 0; i < n_tokens; i++) {
+        tok_end = tok_beg;
+        while(tok_end < size  &&  name[tok_end] != '.')
+            tok_end++;
+
+        if(mustache_buffer_append_num(insns, tok_end - tok_beg) != 0)
+            return -1;
+        if(mustache_buffer_append(insns, name + tok_beg, tok_end - tok_beg) != 0)
+            return -1;
+
+        tok_beg = tok_end + 1;
+    }
+
+    return 0;
+}
+
+MUSTACHE_TEMPLATE*
+mustache_compile(const char* templ_data, size_t templ_size,
+                 const MUSTACHE_PARSER* parser, void* parser_data,
+                 unsigned flags)
+{
+    (void)flags;
+
+    static const MUSTACHE_PARSER default_parser = { mustache_parse_error };
+    MUSTACHE_TAGINFO* tags = NULL;
+    unsigned n_tags;
+    size_t off;
+    size_t jmp_pos;
+    MUSTACHE_TAGINFO* tag;
+    MUSTACHE_BUFFER insns = { 0 };
+    MUSTACHE_STACK jmp_pos_stack = { 0 };
+    int done = 0;
+    int success = 0;
+    size_t indent_len;
+
+    if(parser == NULL)
+        parser = &default_parser;
+
+    /* Collect all tags from the template. */
+    if(mustache_parse(templ_data, templ_size, parser, parser_data, &tags, &n_tags) != 0)
+        goto err;
+
+    /* Build the template */
+#define APPEND(data, n)                                                                 \
+        do {                                                                            \
+            if(mustache_buffer_append(&insns, (data), (n)) != 0)                        \
+                goto err;                                                               \
+        } while(0)
+
+#define APPEND_NUM(num)                                                                 \
+        do {                                                                            \
+            if(mustache_buffer_append_num(&insns, (uint64_t)(num)) != 0)                \
+                goto err;                                                               \
+        } while(0)
+
+#define APPEND_TAGNAME(tag)                                                             \
+        do {                                                                            \
+            if(mustache_compile_tagname(&insns, templ_data + (tag)->name_beg,           \
+                                        (tag)->name_end - (tag)->name_beg) != 0)        \
+                goto err;                                                               \
+        } while(0)
+
+#define INSERT_NUM(pos, num)                                                            \
+        do {                                                                            \
+            if(mustache_buffer_insert_num(&insns, (pos), (uint64_t)(num)) != 0)         \
+                goto err;                                                               \
+        } while(0)
+
+#define PUSH_JMP_POS()                                                                  \
+        do {                                                                            \
+            if(mustache_stack_push(&jmp_pos_stack, insns.n) != 0)                       \
+                goto err;                                                               \
+        } while(0)
+
+#define POP_JMP_POS()       ((size_t) mustache_stack_pop(&jmp_pos_stack))
+
+    off = 0;
+    tag = &tags[0];
+    while(1) {
+        if(off < tag->beg) {
+            /* Handle literal text before the next tag. */
+            APPEND_NUM(MUSTACHE_OP_LITERAL);
+            APPEND_NUM(tag->beg - off);
+            APPEND(templ_data + off, tag->beg - off);
+            off = tag->beg;
+        }
+
+        switch(tag->type) {
+        case MUSTACHE_TAGTYPE_VAR:
+        case MUSTACHE_TAGTYPE_VERBATIMVAR:
+        case MUSTACHE_TAGTYPE_VERBATIMVAR2:
+            APPEND_NUM(MUSTACHE_OP_RESOLVE);
+            APPEND_TAGNAME(tag);
+            APPEND_NUM((tag->type == MUSTACHE_TAGTYPE_VAR) ?
+                        MUSTACHE_OP_OUTESCAPED : MUSTACHE_OP_OUTVERBATIM);
+            break;
+
+        case MUSTACHE_TAGTYPE_OPENSECTION:
+            APPEND_NUM(MUSTACHE_OP_RESOLVE_setjmp);
+            PUSH_JMP_POS();
+            APPEND_TAGNAME(tag);
+            APPEND_NUM(MUSTACHE_OP_ENTER);
+            PUSH_JMP_POS();
+            break;
+
+        case MUSTACHE_TAGTYPE_CLOSESECTION:
+            APPEND_NUM(MUSTACHE_OP_LEAVE);
+            APPEND_NUM(insns.n - POP_JMP_POS());
+            jmp_pos = POP_JMP_POS();
+            INSERT_NUM(jmp_pos, insns.n - jmp_pos);
+            break;
+
+        case MUSTACHE_TAGTYPE_OPENSECTIONINV:
+            APPEND_NUM(MUSTACHE_OP_RESOLVE_setjmp);
+            PUSH_JMP_POS();
+            APPEND_TAGNAME(tag);
+            APPEND_NUM(MUSTACHE_OP_ENTERINV);
+            break;
+
+        case MUSTACHE_TAGTYPE_CLOSESECTIONINV:
+            jmp_pos = POP_JMP_POS();
+            INSERT_NUM(jmp_pos, insns.n - jmp_pos);
+            break;
+
+        case MUSTACHE_TAGTYPE_PARTIAL:
+            APPEND_NUM(MUSTACHE_OP_PARTIAL);
+            APPEND_NUM(tag->name_end - tag->name_beg);
+            APPEND(templ_data + tag->name_beg, tag->name_end - tag->name_beg);
+            indent_len = 0;
+            while(MUSTACHE_ISWHITESPACE(templ_data[tag->beg + indent_len]))
+                indent_len++;
+            APPEND_NUM(indent_len);
+            APPEND(templ_data + tag->beg, indent_len);
+            break;
+
+        case MUSTACHE_TAGTYPE_INDENT:
+            APPEND_NUM(MUSTACHE_OP_INDENT);
+            break;
+
+        case MUSTACHE_TAGTYPE_NONE:
+            APPEND_NUM(MUSTACHE_OP_EXIT);
+            done = 1;
+            break;
+
+        default:
+            break;
+        }
+
+        if(done)
+            break;
+
+        off = tag->end;
+        tag++;
+    }
+
+    success = 1;
+
+err:
+    free(tags);
+    mustache_buffer_free(&jmp_pos_stack);
+    if(success) {
+        return (MUSTACHE_TEMPLATE*) insns.data;
+    } else {
+        mustache_buffer_free(&insns);
+        return NULL;
+    }
+}
+
+void
+mustache_release(MUSTACHE_TEMPLATE* t)
+{
+    if(t == NULL)
+        return;
+
+    free(t);
+}
+
+
+/**********************************
+ *** Applying Compiled Template ***
+ **********************************/
+
+int
+mustache_process(const MUSTACHE_TEMPLATE* t,
+                 const MUSTACHE_RENDERER* renderer, void* renderer_data,
+                 const MUSTACHE_DATAPROVIDER* provider, void* provider_data)
+{
+    const uint8_t* insns = (const uint8_t*) t;
+    size_t reg_pc = 0;       /* Program counter register. */
+    size_t reg_jmpaddr;      /* Jump target address register. */
+    void* reg_node = NULL;  /* Working node register. */
+    int done = 0;
+    MUSTACHE_STACK node_stack = { 0 };
+    MUSTACHE_STACK index_stack = { 0 };
+    MUSTACHE_STACK partial_stack = { 0 };
+    MUSTACHE_BUFFER indent_buffer = { 0 };
+    int ret = -1;
+
+#define PUSH_NODE()                                                         \
+        do {                                                                \
+            if(mustache_stack_push(&node_stack, (uintptr_t) reg_node) != 0) \
+                goto err;                                                   \
+        } while(0)
+
+#define POP_NODE()          ((void*) mustache_stack_pop(&node_stack))
+
+#define PEEK_NODE()         ((void*) mustache_stack_peek(&node_stack))
+
+#define PUSH_INDEX(index)                                                   \
+        do {                                                                \
+            if(mustache_stack_push(&index_stack, (uintptr_t) (index)) != 0) \
+                goto err;                                                   \
+        } while(0)
+
+#define POP_INDEX()         ((unsigned) mustache_stack_pop(&index_stack))
+
+    reg_node = provider->get_root(provider_data);
+    PUSH_NODE();
+
+    while(!done) {
+        unsigned opcode = (unsigned) mustache_decode_num(insns, reg_pc, &reg_pc);
+
+        switch(opcode) {
+        case MUSTACHE_OP_LITERAL:
+        {
+            size_t n = (size_t) mustache_decode_num(insns, reg_pc, &reg_pc);
+            if(renderer->out_verbatim((const char*)(insns + reg_pc), n, renderer_data) != 0)
+                goto err;
+            reg_pc += n;
+            break;
+        }
+
+        case MUSTACHE_OP_RESOLVE_setjmp:
+        {
+            size_t jmp_len = (size_t) mustache_decode_num(insns, reg_pc, &reg_pc);
+            reg_jmpaddr = reg_pc + jmp_len;
+            /* Pass through */
+        }
+
+        case MUSTACHE_OP_RESOLVE:
+        {
+            unsigned n_names = (unsigned) mustache_decode_num(insns, reg_pc, &reg_pc);
+            unsigned i;
+
+            if(n_names == 0) {
+                /* Implicit iterator. */
+                reg_node = PEEK_NODE();
+                break;
+            }
+
+            for(i = 0; i < n_names; i++) {
+                size_t name_len = (size_t) mustache_decode_num(insns, reg_pc, &reg_pc);
+                const char* name = (const char*)(insns + reg_pc);
+                reg_pc += name_len;
+
+                if(i == 0) {
+                    void** nodes = (void**) node_stack.data;
+                    size_t n_nodes = node_stack.n / sizeof(void*);
+
+                    while(n_nodes-- > 0) {
+                        reg_node = provider->get_child_by_name(nodes[n_nodes], 
+                                        name, name_len, provider_data);
+                        if(reg_node != NULL)
+                            break;
+                    }
+                } else if(reg_node != NULL) {
+                    reg_node = provider->get_child_by_name(reg_node,
+                                        name, name_len, provider_data);
+                }
+            }
+            break;
+        }
+
+        case MUSTACHE_OP_OUTVERBATIM:
+        case MUSTACHE_OP_OUTESCAPED:
+            if(reg_node != NULL) {
+                int (*out)(const char*, size_t, void*);
+
+                out = (opcode == MUSTACHE_OP_OUTVERBATIM) ?
+                            renderer->out_verbatim : renderer->out_escaped;
+                if(provider->dump(reg_node, out, renderer_data, provider_data) != 0)
+                    goto err;
+            }
+            break;
+
+        case MUSTACHE_OP_ENTER:
+            if(reg_node != NULL) {
+                PUSH_NODE();
+                reg_node = provider->get_child_by_index(reg_node, 0, provider_data);
+                if(reg_node != NULL) {
+                    PUSH_NODE();
+                    PUSH_INDEX(0);
+                } else {
+                    (void) POP_NODE();
+                }
+            }
+            if(reg_node == NULL)
+                reg_pc = reg_jmpaddr;
+            break;
+
+        case MUSTACHE_OP_LEAVE:
+        {
+            size_t jmp_base = reg_pc;
+            size_t jmp_len = (size_t) mustache_decode_num(insns, reg_pc, &reg_pc);
+            unsigned index = POP_INDEX();
+
+            (void) POP_NODE();
+            reg_node = provider->get_child_by_index(PEEK_NODE(), ++index, provider_data);
+            if(reg_node != NULL) {
+                PUSH_NODE();
+                PUSH_INDEX(index);
+                reg_pc = jmp_base - jmp_len;
+            } else {
+                (void) POP_NODE();
+            }
+            break;
+        }
+
+        case MUSTACHE_OP_ENTERINV:
+            if(reg_node == NULL  ||  provider->get_child_by_index(reg_node,
+                                                0, provider_data) == NULL) {
+                /* Resolve failed: Noop, continue normally. */
+            } else {
+                reg_pc = reg_jmpaddr;
+            }
+            break;
+
+        case MUSTACHE_OP_PARTIAL:
+        {
+            size_t name_len;
+            const char* name;
+            size_t indent_len;
+            const char* indent;
+            MUSTACHE_TEMPLATE* partial;
+
+            name_len = mustache_decode_num(insns, reg_pc, &reg_pc);
+            name = (const char*) (insns + reg_pc);
+            reg_pc += name_len;
+
+            indent_len = mustache_decode_num(insns, reg_pc, &reg_pc);
+            indent = (const char*) (insns + reg_pc);
+            reg_pc += indent_len;
+
+            partial = provider->get_partial(name, name_len, provider_data);
+            if(partial != NULL) {
+                if(mustache_stack_push(&partial_stack, (uintptr_t) insns) != 0)
+                    goto err;
+                if(mustache_stack_push(&partial_stack, (uintptr_t) reg_pc) != 0)
+                    goto err;
+                if(mustache_stack_push(&partial_stack, (uintptr_t) indent_len) != 0)
+                    goto err;
+                if(mustache_buffer_append(&indent_buffer, indent, indent_len) != 0)
+                    goto err;
+                reg_pc = 0;
+                insns = (uint8_t*) partial;
+            }
+            break;
+        }
+
+        case MUSTACHE_OP_INDENT:
+            if(renderer->out_verbatim((const char*)(indent_buffer.data),
+                                indent_buffer.n, renderer_data) != 0)
+                goto err;
+            break;
+
+        case MUSTACHE_OP_EXIT:
+            if(mustache_stack_is_empty(&partial_stack)) {
+                done = 1;
+            } else {
+                size_t indent_len = (size_t) mustache_stack_pop(&partial_stack);
+                reg_pc = (size_t) mustache_stack_pop(&partial_stack);
+                insns = (uint8_t*) mustache_stack_pop(&partial_stack);
+
+                indent_buffer.n -= indent_len;
+            }
+            break;
+        }
+    }
+
+    /* Success. */
+    ret = 0;
+
+err:
+    mustache_stack_free(&node_stack);
+    mustache_stack_free(&index_stack);
+    mustache_stack_free(&partial_stack);
+    mustache_buffer_free(&indent_buffer);
+    return ret;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/extern/libmustache4c/mustache.h	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,212 @@
+/*
+ * Mustache4C
+ * (http://github.com/mity/mustache4c)
+ *
+ * Copyright (c) 2017 Martin Mitas
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+ * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+#ifndef MUSTACHE4C_H
+#define MUSTACHE4C_H
+
+#include <stdlib.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+
+typedef struct MUSTACHE_TEMPLATE MUSTACHE_TEMPLATE;
+
+
+#define MUSTACHE_ERR_SUCCESS                (0)
+#define MUSTACHE_ERR_DANGLINGTAGOPENER      (1)
+#define MUSTACHE_ERR_DANGLINGTAGCLOSER      (2)
+#define MUSTACHE_ERR_INCOMPATIBLETAGCLOSER  (3)
+#define MUSTACHE_ERR_NOTAGNAME              (4)
+#define MUSTACHE_ERR_INVALIDTAGNAME         (5)
+#define MUSTACHE_ERR_DANGLINGSECTIONOPENER  (6)
+#define MUSTACHE_ERR_DANGLINGSECTIONCLOSER  (7)
+#define MUSTACHE_ERR_SECTIONNAMEMISMATCH    (8)
+#define MUSTACHE_ERR_SECTIONOPENERHERE      (9)
+#define MUSTACHE_ERR_INVALIDDELIMITERS      (10)
+
+
+typedef struct MUSTACHE_PARSER {
+    void (*parse_error)(int /*err_code*/, const char* /*msg*/,
+                        unsigned /*line*/, unsigned /*column*/, void* /*parser_data*/);
+} MUSTACHE_PARSER;
+
+
+/**
+ * An interface the application has to implement, in order to output the result
+ * of template processing.
+ */
+typedef struct MUSTACHE_RENDERER {
+    /**
+     * Called to output the given text as it is.
+     *
+     * Non-zero return value aborts mustache_process().
+     */
+    int (*out_verbatim)(const char* /*output*/, size_t /*size*/, void* /*renderer_data*/);
+
+    /**
+     * Called to output the given text. Implementation has to escape it
+     * appropriately with respect to the output format. E.g. for HTML output,
+     * "<" should be translated to "&lt;" etc.
+     *
+     * Non-zero return value aborts mustache_process().
+     *
+     * If no escaping is desired, it can be pointer to the same function
+     * as out_verbatim.
+     */
+    int (*out_escaped)(const char* /*output*/, size_t /*size*/, void* /*renderer_data*/);
+} MUSTACHE_RENDERER;
+
+
+/**
+ * An interface the application has to implement, in order to feed
+ * mustache_process() with data the template asks for.
+ *
+ * Tree hierarchy, immutable during the mustache_process() call, is assumed.
+ * Each node of the hierarchy has to be uniquely identified by some pointer.
+ *
+ * The mustache_process() never dereferences any of the pointers. It only
+ * uses them to refer to that node when calling any data provider callback.
+ */
+typedef struct MUSTACHE_DATAPROVIDER {
+    /**
+     * Called to output contents of the given node. One of the MUSTACHE_PARSER
+     * output functions is provided, depending on the type of the mustache tag
+     * (`{{...}}` versus `{{{...}}}` ). Implementation of dump() may call that
+     * function arbitrarily.
+     *
+     * In many applications, it is not desirable/expected to be able dumping
+     * specific nodes (e.g. if the node is list or array forming the data
+     * tree hierarchy). In such cases, the implementation is allowed to just
+     * return zero without calling the provided callback at all, output some
+     * dummy string (e.g. "<<object>>"), or return non-zero value as an error
+     * sign, depending what makes better sense for the application.
+     *
+     * Implementation of dump() must propagate renderer_data into the
+     * callback as its last argument.
+     *
+     * Non-zero return value aborts mustache_process(). Typically, the
+     * implementations should do so if any call of out_fn callback fails.
+     */
+    int (*dump)(void* /*node*/, int (* /*out_fn*/)(const char*, size_t, void*),
+                void* /*renderer_data*/, void* /*provider_data*/);
+
+    /**
+     * Called once at the start of mustache_process(). It sets the initial
+     * lookup context. */
+    void* (*get_root)(void* /*provider_data*/);
+
+    /**
+     * Called to get named item of the current node, or NULL if there is no item.
+     *
+     * If the node is not of appropriate type (e.g. if it is an array of
+     * values), NULL has to be returned.
+     */
+    void* (*get_child_by_name)(void* /*node*/, const char* /*name*/,
+                               size_t /*size*/, void* /*provider_data*/);
+
+    /**
+     * Called to get an indexed item of the current node, or NULL if there is
+     * no such item.
+     *
+     * The main use is for iterating over arrays.
+     *
+     * However note that accordingly to the mustache specification, single
+     * values (except FALSE, NULL, or empty lists) have to be iterable too.
+     * For such simple values, the callback should return the node itself
+     * for index 0, and NULL for any other index.
+     */
+    void* (*get_child_by_index)(void* /*node*/, unsigned /*index*/,
+                                void* /*provider_data*/);
+
+    /**
+     * Called to get a partial template when mustache_process() handles
+     * a partial tag `{{>name}}`.
+     *
+     * Implementation should perform lookup for the template (compile it, if
+     * it is not), and return the template handle.
+     *
+     * If the lookup fails, the implementation reports it by returning NULL.
+     */
+    MUSTACHE_TEMPLATE* (*get_partial)(const char* /*name*/, size_t /*size*/,
+                                      void* /*provider_data*/);
+} MUSTACHE_DATAPROVIDER;
+
+
+/**
+ * Compile template text into a form suitable for mustache_process().
+ *
+ * If application processes multiple input data with a single template, it is
+ * recommended to cache and reuse the compiled template as much as possible,
+ * as the compiling may be relatively time-consuming operation.
+ *
+ * @param templ_data Text of the template.
+ * @param templ_size Length of the template text.
+ * @param parser Pointer to structure with parser callbacks. May be @c NULL.
+ * @param parser_data Pointer just propagated into the parser callbacks.
+ * @param flags Unused, use zero.
+ * @return Pointer to the compiled template, or @c NULL on an error.
+ */
+MUSTACHE_TEMPLATE* mustache_compile(const char* templ_data, size_t templ_size,
+                                    const MUSTACHE_PARSER* parser, void* parser_data,
+                                    unsigned flags);
+
+/**
+ * Release the template compiled with @c mustache_compile().
+ *
+ * @param t The template.
+ */
+void mustache_release(MUSTACHE_TEMPLATE* t);
+
+/**
+ * Process the template.
+ *
+ * The function outputs (via MUSTACHE_RENDERER::out_verbatim()) most of the
+ * text of the template. Whenever it reaches a mustache tag, it calls
+ * appropriate callback of MUSTACHE_DATAPROVIDER to change lookup context
+ * or a callback of MUSTACHE_RENDERER to output contents of the current
+ * context.
+ *
+ * @param t The template.
+ * @param renderer Pointer to structure with output callbacks.
+ * @param render_data Pointer just propagated to the output callbacks.
+ * @param provider Pointer to structure with data-providing callbacks.
+ * @param provider_dara Pointer just propagated to the data-providing callbacks.
+ * @return Zero on success, non-zero on failure.
+ *
+ * Note this operation can fail only if any callback returns an error
+ * and aborts the operation.
+ */
+int mustache_process(const MUSTACHE_TEMPLATE* t,
+                     const MUSTACHE_RENDERER* renderer, void* renderer_data,
+                     const MUSTACHE_DATAPROVIDER* provider, void* provider_data);
+
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif  /* MUSTACHE4C_H */
--- a/lib/db.c	Mon Jul 25 21:22:13 2022 +0200
+++ b/lib/db.c	Tue Aug 02 13:24:13 2022 +0200
@@ -31,8 +31,12 @@
 
 #include "sql/init.h"
 #include "sql/job-add.h"
+#include "sql/job-list.h"
 #include "sql/job-todo.h"
 #include "sql/jobresult-add.h"
+#include "sql/jobresult-list-by-job.h"
+#include "sql/jobresult-list-by-job-group.h"
+#include "sql/jobresult-list-by-worker.h"
 #include "sql/project-find.h"
 #include "sql/project-list.h"
 #include "sql/project-save.h"
@@ -81,6 +85,29 @@
 	job->project_name = util_strdup(CHAR(sqlite3_column_text(stmt, 2)));
 }
 
+static inline char *
+dup(sqlite3_stmt *stmt, int col)
+{
+	const unsigned char *s;
+
+	if ((s = sqlite3_column_text(stmt, col)))
+		return util_strdup(CHAR(s));
+
+	return util_strdup("");
+}
+
+static void
+jobresult_unpacker(sqlite3_stmt *stmt, void *data)
+{
+	struct jobresult *r = data;
+
+	r->id = sqlite3_column_int64(stmt, 0);
+	r->job_id = sqlite3_column_int64(stmt, 1);
+	r->worker_name = util_strdup(CHAR(sqlite3_column_text(stmt, 2)));
+	r->exitcode = sqlite3_column_int(stmt, 3);
+	r->log = dup(stmt, 4);
+}
+
 static void
 vbind(sqlite3_stmt *stmt, const char *fmt, va_list ap)
 {
@@ -131,42 +158,10 @@
 	return ret;
 }
 
-#if 0
-
-static int
-update(const char *sql, const char *fmt, ...)
-{
-	assert(sql);
-	assert(fmt);
-
-	sqlite3_stmt *stmt = NULL;
-	va_list ap;
-	int ret = 1;
-
-	if (sqlite3_prepare(db, sql, -1, &stmt, NULL) != SQLITE_OK)
-		return log_warn("db: %s", sqlite3_errmsg(db)), -1;
-
-	va_start(ap, fmt);
-	vbind(stmt, fmt, ap);
-	va_end(ap);
-
-	if (sqlite3_step(stmt) != SQLITE_DONE)
-		log_warn("db: %s", sqlite3_errmsg(db));
-	else
-		ret = 0;
-
-	sqlite3_finalize(stmt);
-
-	return ret;
-}
-
-#endif
-
 static ssize_t
 list(struct list *sel, const char *sql, const char *args, ...)
 {
 	sqlite3_stmt *stmt = NULL;
-
 	va_list ap;
 	int step;
 	ssize_t ret = -1;
@@ -296,12 +291,28 @@
 		.unpack = job_unpacker,
 		.data = jobs,
 		.datasz = jobsz,
-		.elemwidth = sizeof (*jobs),
+		.elemwidth = sizeof (*jobs)
 	};
 
 	return list(&sel, CHAR(sql_job_todo), "ssz", worker, worker, jobsz);
 }
 
+ssize_t
+db_job_list(struct job *jobs, size_t jobsz, const char *project)
+{
+	assert(jobs);
+	assert(project);
+
+	struct list sel = {
+		.unpack = job_unpacker,
+		.data = jobs,
+		.datasz = jobsz,
+		.elemwidth = sizeof (*jobs)
+	};
+
+	return list(&sel, CHAR(sql_job_list), "sz", project, jobsz);
+}
+
 int
 db_jobresult_add(struct jobresult *r)
 {
@@ -311,6 +322,52 @@
 	    r->worker_name, r->exitcode, r->log)) < 0 ? -1 : 0;
 }
 
+ssize_t
+db_jobresult_list_by_job(struct jobresult *r, size_t rsz, intmax_t job_id)
+{
+	assert(r);
+
+	struct list sel = {
+		.unpack = jobresult_unpacker,
+		.data = r,
+		.datasz = rsz,
+		.elemwidth = sizeof (*r)
+	};
+
+	return list(&sel, CHAR(sql_jobresult_list_by_job), "jz", job_id, rsz);
+}
+
+ssize_t
+db_jobresult_list_by_job_group(struct jobresult *r, size_t rsz, intmax_t job_id)
+{
+	assert(r);
+
+	struct list sel = {
+		.unpack = jobresult_unpacker,
+		.data = r,
+		.datasz = rsz,
+		.elemwidth = sizeof (*r)
+	};
+
+	return list(&sel, CHAR(sql_jobresult_list_by_job_group), "jz", job_id, rsz);
+}
+
+ssize_t
+db_jobresult_list_by_worker(struct jobresult *r, size_t rsz, const char *worker)
+{
+	assert(r);
+	assert(worker);
+
+	struct list sel = {
+		.unpack = jobresult_unpacker,
+		.data = r,
+		.datasz = rsz,
+		.elemwidth = sizeof (*r)
+	};
+
+	return list(&sel, CHAR(sql_jobresult_list_by_worker), "sz", worker, rsz);
+}
+
 void
 db_finish(void)
 {
--- a/lib/db.h	Mon Jul 25 21:22:13 2022 +0200
+++ b/lib/db.h	Tue Aug 02 13:24:13 2022 +0200
@@ -37,9 +37,21 @@
 ssize_t
 db_job_todo(struct job *, size_t, const char *);
 
+ssize_t
+db_job_list(struct job *, size_t, const char *);
+
 int
 db_jobresult_add(struct jobresult *);
 
+ssize_t
+db_jobresult_list_by_job(struct jobresult *, size_t, intmax_t);
+
+ssize_t
+db_jobresult_list_by_job_group(struct jobresult *, size_t, intmax_t);
+
+ssize_t
+db_jobresult_list_by_worker(struct jobresult *, size_t, const char *);
+
 int
 db_project_save(struct project *);
 
--- a/lib/types.h	Mon Jul 25 21:22:13 2022 +0200
+++ b/lib/types.h	Tue Aug 02 13:24:13 2022 +0200
@@ -33,7 +33,7 @@
 
 struct jobresult {
 	intmax_t id;
-	int job_id;
+	intmax_t job_id;
 	char *worker_name;
 	int exitcode;
 	char *log;
--- a/lib/util.c	Mon Jul 25 21:22:13 2022 +0200
+++ b/lib/util.c	Tue Aug 02 13:24:13 2022 +0200
@@ -173,19 +173,6 @@
 	return ret;
 }
 
-const char *
-util_path(const char *filename)
-{
-	assert(filename);
-
-	/* Build path to the template file. */
-	static char path[PATH_MAX];
-
-	//snprintf(path, sizeof (path), "%s/%s", config.themedir, filename);
-
-	return path;
-}
-
 void
 util_die(const char *fmt, ...)
 {
--- a/scid/http.c	Mon Jul 25 21:22:13 2022 +0200
+++ b/scid/http.c	Tue Aug 02 13:24:13 2022 +0200
@@ -32,9 +32,11 @@
 #include "page-api-projects.h"
 #include "page-api-todo.h"
 #include "page-api-workers.h"
+#include "page-index.h"
 #include "page.h"
 
 enum page {
+	PAGE_INDEX,     /* Job results at index. */
 	PAGE_API,
 	PAGE_LAST       /* Not used. */
 };
@@ -58,14 +60,16 @@
 		if (strncmp(req->path, apis[i].prefix, strlen(apis[i].prefix)) == 0)
 			return apis[i].handler(req);
 
-	page(req, NULL, KHTTP_404, KMIME_TEXT_HTML, "pages/404.html");
+	page(req, KHTTP_404, KMIME_TEXT_HTML, "pages/404.html", NULL);
 }
 
 static const char *pages[] = {
+	[PAGE_INDEX]            = "",
 	[PAGE_API]              = "api"
 };
 
 static void (*handlers[])(struct kreq *req) = {
+	[PAGE_INDEX]            = page_index,
 	[PAGE_API]              = dispatch_api
 };
 
@@ -77,7 +81,7 @@
 	log_debug("http: accessing page '%s'", req->path);
 
 	if (req->page == PAGE_LAST)
-		page(req, NULL, KHTTP_404, KMIME_TEXT_HTML, "pages/404.html");
+		page(req, KHTTP_404, KMIME_TEXT_HTML, "pages/404.html", NULL);
 	else
 		handlers[req->page](req);
 }
--- a/scid/main.c	Mon Jul 25 21:22:13 2022 +0200
+++ b/scid/main.c	Tue Aug 02 13:24:13 2022 +0200
@@ -16,56 +16,38 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-#include <limits.h>
 #include <stdio.h>
-#include <stdnoreturn.h>
-#include <string.h>
+#include <stdlib.h>
 #include <unistd.h>
 
-#include "db.h"
-#include "log.h"
 #include "http.h"
+#include "scid.h"
+#include "util.h"
 
-static char dbpath[PATH_MAX] = VARDIR "/db/sci/sci.db";
-
-noreturn static void
+static void
 usage(void)
 {
-	fprintf(stderr, "usage: %s [-d database] [-s sock]\n", getprogname());
+	fprintf(stderr, "usage: scid [-d database] [-s sock] [-t themedir]\n");
 	exit(1);
 }
 
-static void
-init(void)
-{
-	log_open("scid");
-	log_info("opening database %s", dbpath);
-
-	if (db_open(dbpath) < 0)
-		log_die("abort: unable to open database");
-}
-
-static void
-finish(void)
-{
-	db_finish();
-	log_finish();
-}
-
 int
 main(int argc, char **argv)
 {
 	int ch;
 	void (*run)(void) = &(http_cgi_run);
 
-	while ((ch = getopt(argc, argv, "d:f")) != -1) {
+	while ((ch = getopt(argc, argv, "d:ft:")) != -1) {
 		switch (ch) {
 		case 'd':
-			strlcpy(dbpath, optarg, sizeof (dbpath));
+			util_strlcpy(scid.dbpath, optarg, sizeof (scid.dbpath));
 			break;
 		case 'f':
 			run = &(http_fcgi_run);
 			break;
+		case 't':
+			util_strlcpy(scid.themedir, optarg, sizeof (scid.themedir));
+			break;
 		default:
 			usage();
 			break;
@@ -75,10 +57,10 @@
 	argc -= optind;
 	argv += optind;
 
-	init();
+	scid_init();
 
 	for (;;)
 		run();
 
-	finish();
+	scid_finish();
 }
--- a/scid/page-api-jobresults.c	Mon Jul 25 21:22:13 2022 +0200
+++ b/scid/page-api-jobresults.c	Tue Aug 02 13:24:13 2022 +0200
@@ -55,9 +55,9 @@
 post(struct kreq *r)
 {
 	if (r->fieldsz < 1)
-		page(r, NULL, KHTTP_400, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_400, KMIME_APP_JSON, NULL, NULL);
 	else if (save(r->fields[0].val) < 0)
-		page(r, NULL, KHTTP_500, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_500, KMIME_APP_JSON, NULL, NULL);
 	else {
 		khttp_head(r, kresps[KRESP_CONTENT_TYPE], "%s", kmimetypes[KMIME_APP_JSON]);
 		khttp_head(r, kresps[KRESP_STATUS], "%s", khttps[KHTTP_200]);
@@ -76,7 +76,7 @@
 		post(r);
 		break;
 	default:
-		page(r, NULL, KHTTP_400, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_400, KMIME_APP_JSON, NULL, NULL);
 		break;
 	}
 }
--- a/scid/page-api-jobs.c	Mon Jul 25 21:22:13 2022 +0200
+++ b/scid/page-api-jobs.c	Tue Aug 02 13:24:13 2022 +0200
@@ -55,9 +55,9 @@
 post(struct kreq *r)
 {
 	if (r->fieldsz < 1)
-		page(r, NULL, KHTTP_400, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_400, KMIME_APP_JSON, NULL, NULL);
 	else if (save(r->fields[0].val) < 0)
-		page(r, NULL, KHTTP_500, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_500, KMIME_APP_JSON, NULL, NULL);
 	else {
 		khttp_head(r, kresps[KRESP_CONTENT_TYPE], "%s", kmimetypes[KMIME_APP_JSON]);
 		khttp_head(r, kresps[KRESP_STATUS], "%s", khttps[KHTTP_200]);
@@ -76,7 +76,7 @@
 		post(r);
 		break;
 	default:
-		page(r, NULL, KHTTP_400, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_400, KMIME_APP_JSON, NULL, NULL);
 		break;
 	}
 }
--- a/scid/page-api-projects.c	Mon Jul 25 21:22:13 2022 +0200
+++ b/scid/page-api-projects.c	Tue Aug 02 13:24:13 2022 +0200
@@ -88,7 +88,7 @@
 	struct project project = {0};
 
 	if (db_project_find(&project, name) < 0)
-		page(r, NULL, KHTTP_500, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_500, KMIME_APP_JSON, NULL, NULL);
 	else {
 		push(r, &project);
 		project_finish(&project);
@@ -102,7 +102,7 @@
 	ssize_t projectsz;
 
 	if ((projectsz = db_project_list(projects, UTIL_SIZE(projects))) < 0)
-		page(r, NULL, KHTTP_500, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_500, KMIME_APP_JSON, NULL, NULL);
 	else {
 		khttp_head(r, kresps[KRESP_CONTENT_TYPE], "%s", kmimetypes[KMIME_APP_JSON]);
 		khttp_head(r, kresps[KRESP_STATUS], "%s", khttps[KHTTP_200]);
@@ -143,9 +143,9 @@
 post(struct kreq *r)
 {
 	if (r->fieldsz < 1)
-		page(r, NULL, KHTTP_400, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_400, KMIME_APP_JSON, NULL, NULL);
 	else if (save(r->fields[0].val) < 0)
-		page(r, NULL, KHTTP_500, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_500, KMIME_APP_JSON, NULL, NULL);
 	else {
 		khttp_head(r, kresps[KRESP_CONTENT_TYPE], "%s", kmimetypes[KMIME_APP_JSON]);
 		khttp_head(r, kresps[KRESP_STATUS], "%s", khttps[KHTTP_200]);
@@ -167,7 +167,7 @@
 		post(r);
 		break;
 	default:
-		page(r, NULL, KHTTP_400, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_400, KMIME_APP_JSON, NULL, NULL);
 		break;
 	}
 
--- a/scid/page-api-todo.c	Mon Jul 25 21:22:13 2022 +0200
+++ b/scid/page-api-todo.c	Tue Aug 02 13:24:13 2022 +0200
@@ -87,7 +87,7 @@
 	ssize_t jobsz;
 
 	if ((jobsz = db_job_todo(jobs, UTIL_SIZE(jobs), util_basename(r->path))) < 0)
-		page(r, NULL, KHTTP_500, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_500, KMIME_APP_JSON, NULL, NULL);
 	else {
 		khttp_head(r, kresps[KRESP_CONTENT_TYPE], "%s", kmimetypes[KMIME_APP_JSON]);
 		khttp_head(r, kresps[KRESP_STATUS], "%s", khttps[KHTTP_200]);
@@ -110,7 +110,7 @@
 		get(r);
 		break;
 	default:
-		page(r, NULL, KHTTP_400, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_400, KMIME_APP_JSON, NULL, NULL);
 		break;
 	}
 }
--- a/scid/page-api-workers.c	Mon Jul 25 21:22:13 2022 +0200
+++ b/scid/page-api-workers.c	Tue Aug 02 13:24:13 2022 +0200
@@ -70,7 +70,7 @@
 	struct worker worker = {0};
 
 	if (db_worker_find(&worker, name) < 0)
-		page(r, NULL, KHTTP_500, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_500, KMIME_APP_JSON, NULL, NULL);
 	else {
 		push(r, &worker);
 		worker_finish(&worker);
@@ -84,7 +84,7 @@
 	ssize_t workersz;
 
 	if ((workersz = db_worker_list(workers, UTIL_SIZE(workers))) < 0)
-		page(r, NULL, KHTTP_500, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_500, KMIME_APP_JSON, NULL, NULL);
 	else {
 		khttp_head(r, kresps[KRESP_CONTENT_TYPE], "%s", kmimetypes[KMIME_APP_JSON]);
 		khttp_head(r, kresps[KRESP_STATUS], "%s", khttps[KHTTP_200]);
@@ -125,9 +125,9 @@
 post(struct kreq *r)
 {
 	if (r->fieldsz < 1)
-		page(r, NULL, KHTTP_400, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_400, KMIME_APP_JSON, NULL, NULL);
 	else if (save(r->fields[0].val) < 0)
-		page(r, NULL, KHTTP_500, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_500, KMIME_APP_JSON, NULL, NULL);
 	else {
 		khttp_head(r, kresps[KRESP_CONTENT_TYPE], "%s", kmimetypes[KMIME_APP_JSON]);
 		khttp_head(r, kresps[KRESP_STATUS], "%s", khttps[KHTTP_200]);
@@ -149,7 +149,7 @@
 		post(r);
 		break;
 	default:
-		page(r, NULL, KHTTP_400, KMIME_APP_JSON, NULL);
+		page(r, KHTTP_400, KMIME_APP_JSON, NULL, NULL);
 		break;
 	}
 }
--- a/scid/page-index.c	Mon Jul 25 21:22:13 2022 +0200
+++ b/scid/page-index.c	Tue Aug 02 13:24:13 2022 +0200
@@ -16,79 +16,112 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-#include <sys/types.h>
-#include <assert.h>
-#include <stdarg.h>
-#include <stdint.h>
+#include <errno.h>
+#include <string.h>
 
-#include <kcgi.h>
-
-#include "database.h"
-#include "fragment-paste.h"
-#include "page-index.h"
+#include "log.h"
+#include "config.h"
+#include "db.h"
 #include "page.h"
-#include "paste.h"
+#include "types.h"
 #include "util.h"
 
-struct template {
-	struct kreq *req;
-	const struct paste *pastes;
-	size_t pastesz;
-};
+/*
+ * Document we create for templatize.
+ *
+ * {
+ *   "projects: [
+ *     {
+ *       "name": "project name",
+ *       "description": "project short description",
+ *       "url": "project URL or homepage",
+ *       "jobs": [
+ *         {
+ *           "job": job-id,
+ *           "tag": "job tag / revision",
+ *           "success": true,                   // on success (absent otherwise)
+ *           "failed: true                      // on failure (absent otherwise)
+ *         }
+ *       ]
+ *     }
+ *   ]
+ * }
+ */
 
-static const char *keywords[] = {
-	"pastes"
-};
+static json_t *
+make_job(const struct job *job)
+{
+	struct jobresult res[SCI_WORKER_MAX];
+	ssize_t resz;
+	json_t *doc = NULL;
 
-static int
-template(size_t keyword, void *arg)
-{
-	struct template *tp = arg;
+	doc = json_pack("{sI ss}",
+		"id",   (json_int_t)job->id,
+		"tag",  job->tag
+        );
+
+        /* Find every job result associated to see if there are failures. */
+	resz = db_jobresult_list_by_job_group(res, UTIL_SIZE(res), job->id);
+
+	for (ssize_t i = 0; i < resz; ++i)
+		if (res[i].exitcode)
+			json_object_set_new(doc, "failed", json_true());
+
+	if (!json_object_get(doc, "failed"))
+		json_object_set_new(doc, "success", json_true());
 
-	switch (keyword) {
-	case 0:
-		for (size_t i = 0; i < tp->pastesz; ++i)
-			fragment_paste(tp->req, &tp->pastes[i]);
-		break;
-	default:
-		break;
+	return doc;
+}
+
+static json_t *
+make_jobs(const char *project)
+{
+	struct job jobs[10];
+	ssize_t jobsz;
+	json_t *array = NULL, *obj;
+
+	if ((jobsz = db_job_list(jobs, UTIL_SIZE(jobs), project)) >= 0) {
+		if (!(array = json_array()))
+			return NULL;
+		for (ssize_t i = 0; i < jobsz; ++i)
+			if ((obj = make_job(&jobs[i])))
+				json_array_append(array, obj);
 	}
 
-	return 1;
+	return array;
+}
+
+static json_t *
+make_project(const struct project *project)
+{
+	return json_pack("{ss ss ss so*}",
+		"name",         project->name,
+		"description",  project->desc,
+		"url",          project->url,
+		"jobs",         make_jobs(project->name)
+	);
 }
 
 static void
 get(struct kreq *r)
 {
-	struct paste pastes[10] = {0};
-	size_t pastesz = NELEM(pastes);
+	(void)r;
+	struct project projects[SCI_PROJECT_MAX] = {0};
+	ssize_t projectsz = 0;
+	json_t *array;
 
-	if (!database_recents(pastes, &pastesz))
-		page(r, NULL, KHTTP_500, "pages/500.html");
-	else
-		page_index_render(r, pastes, pastesz);
-
-	for (size_t i = 0; i < pastesz; ++i)
-		paste_finish(&pastes[i]);
-}
+        /* 'projects' array. */
+        if (!(array = json_array()))
+		log_die("page-index: %s", strerror(ENOMEM));
 
-void
-page_index_render(struct kreq *r, const struct paste *pastes, size_t pastesz)
-{
-	struct template data = {
-		.req = r,
-		.pastes = pastes,
-		.pastesz = pastesz
-	};
+        projectsz = db_project_list(projects, UTIL_SIZE(projects));
 
-	struct ktemplate kt = {
-		.key = keywords,
-		.keysz = NELEM(keywords),
-		.arg = &data,
-		.cb = template
-	};
+        for (ssize_t i = 0; i < projectsz; ++i)
+                json_array_append(array, make_project(&projects[i]));
 
-	page(r, &kt, KHTTP_200, "pages/index.html");
+	page(r, KHTTP_200, KMIME_TEXT_HTML, "pages/index.html", json_pack("{so}",
+		"projects", array
+	));
 }
 
 void
@@ -99,7 +132,7 @@
 		get(r);
 		break;
 	default:
-		page(r, NULL, KHTTP_400, "400.html");
+		page(r, KHTTP_400, KMIME_TEXT_HTML, "pages/400.html", NULL);
 		break;
 	}
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scid/page-index.h	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,27 @@
+/*
+ * page-index.h -- / route
+ *
+ * Copyright (c) 2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef SCI_PAGE_INDEX_H
+#define SCI_PAGE_INDEX_H
+
+struct kreq;
+
+void
+page_index(struct kreq *);
+
+#endif /* !SCI_PAGE_INDEX_H */
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scid/page-static.c	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,65 @@
+/*
+ * page-static.c -- page /static
+ *
+ * Copyright (c) 2020-2022 David Demelier <markand@malikania.fr>
+ * 
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <assert.h>
+#include <limits.h>
+#include <stdarg.h>
+#include <stdint.h>
+#include <stdio.h>
+
+#include "config.h"
+#include "page.h"
+#include "scid.h"
+
+static void
+get(struct kreq *req)
+{
+	struct stat st;
+	char path[PATH_MAX];
+
+	snprintf(path, sizeof (path), "%s%s", scid.themedir, req->fullpath);
+
+	if (stat(path, &st) < 0)
+		page(req, KHTTP_404, KMIME_TEXT_HTML, "404.html", NULL);
+	else {
+		khttp_head(req, kresps[KRESP_STATUS], "%s", khttps[KHTTP_200]);
+		khttp_head(req, kresps[KRESP_CONTENT_TYPE], "%s", kmimetypes[req->mime]);
+		khttp_head(req, kresps[KRESP_CONTENT_LENGTH],
+		    "%llu", (unsigned long long)(st.st_size));
+		khttp_body(req);
+		khttp_template(req, NULL, path);
+		khttp_free(req);
+	}
+}
+
+void
+page_static(struct kreq *r)
+{
+	assert(r);
+
+	switch (r->method) {
+	case KMETHOD_GET:
+		get(r);
+		break;
+	default:
+		page(r, KHTTP_400, KMIME_TEXT_HTML, "400.html", NULL);
+		break;
+	}
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scid/page-static.h	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,27 @@
+/*
+ * page-static.h -- page /static
+ *
+ * Copyright (c) 2020-2022 David Demelier <markand@malikania.fr>
+ * 
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef IMGUP_PAGE_STATIC_H
+#define IMGUP_PAGE_STATIC_H
+
+struct kreq;
+
+void
+page_static(struct kreq *);
+
+#endif /* !IMGUP_PAGE_STATIC_H */
--- a/scid/page.c	Mon Jul 25 21:22:13 2022 +0200
+++ b/scid/page.c	Tue Aug 02 13:24:13 2022 +0200
@@ -16,24 +16,304 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
+#include <sys/stat.h>
+#include <assert.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <mustache.h>
+
+#include "log.h"
 #include "page.h"
+#include "scid.h"
 #include "util.h"
 
+static void
+parse_error(int code, const char *msg, unsigned int line, unsigned int col, void *data)
+{
+	(void)code;
+
+	const char *path = data;
+
+	log_warn("page: %s:%u:%u:%s", path, line, col, msg);
+}
+
+static int
+out_verbatim(const char *output, size_t size, void *data)
+{
+	FILE *fp = data;
+
+	if (fwrite(output, 1, size, fp) != size)
+		return -1;
+
+	return 0;
+}
+
+static int
+out_escaped(const char *output, size_t size, void *data)
+{
+	FILE *fp = data;
+
+	for (size_t i = 0; i < size; ++i) {
+		switch (output[i]) {
+		case '"':
+			if (out_verbatim("&quot;", 6, fp) < 0)
+				return -1;
+			break;
+		case '&':
+			if (out_verbatim("&amp;", 5, fp) < 0)
+				return -1;
+			break;
+		case '<':
+			if (out_verbatim("&lt;", 4, fp) < 0)
+				return -1;
+			break;
+		case '>':
+			if (out_verbatim("&gt;", 4, fp) < 0)
+				return -1;
+			break;
+		default:
+			if (out_verbatim(&output[i], 1, fp) <0)
+				return -1;
+			break;
+		}
+	}
+
+	return 0;
+}
+
+static inline int
+out_int(FILE *fp, intmax_t val)
+{
+	char buf[64] = {0};
+
+	snprintf(buf, sizeof (buf), "%jd", val);
+
+	return out_verbatim(buf, strlen(buf), fp);
+}
+
+static inline int
+out_double(FILE *fp, double val)
+{
+	char buf[64] = {0};
+
+	snprintf(buf, sizeof (buf), "%f", val);
+
+	return out_verbatim(buf, strlen(buf), fp);
+}
+
+static int
+dump(void *node, int (*outputter)(const char *, size_t, void *), void *rdata, void *pdata)
+{
+	(void)pdata;
+
+	FILE *fp = rdata;
+	const json_t *value = node;
+
+	switch (json_typeof(value)) {
+	case JSON_OBJECT:
+	case JSON_ARRAY:
+		/* This indicates a document construction error. */
+		// TODO: change to error log.
+		abort();
+		break;
+	case JSON_STRING:
+		return outputter(json_string_value(value), json_string_length(value), fp);
+	case JSON_INTEGER:
+		return out_int(fp, json_integer_value(value));
+	case JSON_REAL:
+		return out_double(fp, json_real_value(value));
+	case JSON_TRUE:
+		return out_verbatim("true", 4, fp);
+	case JSON_FALSE:
+		return out_verbatim("false", 5, fp);
+	default:
+		break;
+	}
+
+	return 0;
+}
+
+static void *
+get_root(void *pdata)
+{
+	return pdata;
+}
+
+static void *
+get_child_by_name(void *node, const char *name, size_t size, void *pdata)
+{
+	(void)pdata;
+
+	json_t *value = node;
+
+#if 1
+	printf("-> Seeking name '%.*s'\n", (int)size, name);
+	printf("-> Type of node: %d (%p)\n", json_typeof(value), value);
+#endif
+
+	if (json_is_object(value))
+		return json_object_getn(node, name, size);
+
+	return NULL;
+}
+
+static void *
+get_child_by_index(void *node, unsigned int index, void *pdata)
+{
+	(void)pdata;
+
+	json_t *value = node;
+
+#if 1
+	printf("-> Seeking index '%u'\n", index);
+	printf("-> Type of node: %d (%p)\n", json_typeof(value), value);
+#endif
+
+	if (json_is_array(value))
+		return json_array_get(node, index);
+
+	return NULL;
+}
+
+static MUSTACHE_TEMPLATE *
+get_partial(const char *name, size_t size, void *pdata)
+{
+	(void)name;
+	(void)size;
+	(void)pdata;
+
+	return NULL;
+}
+
+static char *
+readall(const char *file)
+{
+	int fd;
+	char *ret = NULL;
+	struct stat st;
+
+	if ((fd = open(file, O_RDONLY)) < 0)
+		return NULL;
+	if (fstat(fd, &st) < 0)
+		goto exit;
+	if (!(ret = calloc(1, st.st_size + 1)))
+		goto exit;
+	if (read(fd, ret, st.st_size) != st.st_size) {
+		free(ret);
+		ret = NULL;
+	}
+
+exit:
+	close(fd);
+
+	return ret;
+}
+
+static int
+process(const char *path, const char *input, FILE *fp, json_t *doc)
+{
+	MUSTACHE_PARSER parser = {
+		.parse_error = parse_error
+	};
+	MUSTACHE_RENDERER rdr = {
+		.out_verbatim = out_verbatim,
+		.out_escaped = out_escaped
+	};
+	MUSTACHE_DATAPROVIDER pv = {
+		.dump = dump,
+		.get_root = get_root,
+		.get_child_by_name = get_child_by_name,
+		.get_child_by_index = get_child_by_index,
+		.get_partial = get_partial
+	};
+	MUSTACHE_TEMPLATE *tmpl;
+	int status;
+	
+	if (!(tmpl = mustache_compile(input, strlen(input), &parser, (void *)path, 0)))
+		return -1;
+
+	status = mustache_process(tmpl, &rdr, fp, &pv, doc);
+	mustache_release(tmpl);
+
+	return status ? -1 : 0;
+}
+
+char *
+templatize(const char *path, json_t *doc)
+{
+	assert(path);
+
+	char *in = NULL, *out = NULL;
+	size_t outsz = 0;
+	FILE *fp;
+
+	if (!(fp = open_memstream(&out, &outsz)))
+		goto exit;
+	if (!(in = readall(path)))
+		goto exit;
+
+	/* Process template but don't return a partially written file. */
+	if (process(path, in, fp, doc) < 0) {
+		fclose(fp);
+		free(out);
+		fp = NULL;
+		out = NULL;
+		errno = EINVAL;
+	}
+
+exit:
+	if (!out)
+		log_warn("page: %s: %s", path, strerror(errno));
+	if (fp)
+		fclose(fp);
+
+	free(in);
+	json_decref(doc);
+
+	return out;
+}
+
+static void
+render(struct kreq *req, const char *path, json_t *doc)
+{
+	char *content;
+
+	if (!(content = templatize(path, doc)))
+		log_warn("page: unable to templatize: %s", strerror(errno));
+	if (doc)
+		json_decref(doc);
+
+	khttp_printf(req, "%s", content);
+
+	free(content);
+}
+
 void
 page(struct kreq *req,
-     const struct ktemplate *tmpl,
      enum khttp status,
      enum kmime mime,
-     const char *file)
+     const char *path,
+     json_t *doc)
 {
+	assert(req);
+	assert(!path || (path && doc));
+
 	khttp_head(req, kresps[KRESP_CONTENT_TYPE], "%s", kmimetypes[mime]);
 	khttp_head(req, kresps[KRESP_STATUS], "%s", khttps[status]);
 	khttp_body(req);
 
-	if (file) {
-		khttp_template(req, NULL, util_path("fragments/header.html"));
-		khttp_template(req, tmpl, util_path(file));
-		khttp_template(req, NULL, util_path("fragments/footer.html"));
+	if (path) {
+		printf("document is: [%s]\n", json_dumps(doc, JSON_INDENT(2)));
+		/* TODO: add title in the header. */
+		render(req, scid_theme_path("fragments/header.html"), NULL);
+		render(req, scid_theme_path(path), doc);
+		render(req, scid_theme_path("fragments/footer.html"), NULL);
 	}
 
 	khttp_free(req);
--- a/scid/page.h	Mon Jul 25 21:22:13 2022 +0200
+++ b/scid/page.h	Tue Aug 02 13:24:13 2022 +0200
@@ -22,9 +22,12 @@
 #include <sys/types.h>
 #include <stdarg.h>
 #include <stdint.h>
+
 #include <kcgi.h>
 
+#include <jansson.h>
+
 void
-page(struct kreq *, const struct ktemplate *, enum khttp, enum kmime, const char *);
+page(struct kreq *, enum khttp, enum kmime, const char *, json_t *);
 
 #endif /* !SCI_PAGE_H */
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scid/scid.c	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,40 @@
+#include <assert.h>
+#include <stdio.h>
+
+#include "db.h"
+#include "log.h"
+#include "scid.h"
+
+struct scid scid = {
+	.dbpath = VARDIR "/db/sci/sci.db"
+};
+
+void
+scid_init(void)
+{
+	log_open("scid");
+	log_info("opening database %s", scid.dbpath);
+
+	if (db_open(scid.dbpath) < 0)
+		log_die("abort: unable to open database");
+}
+
+const char *
+scid_theme_path(const char *filename)
+{
+	assert(filename);
+
+	/* Build path to the template file. */
+	static _Thread_local char path[PATH_MAX];
+
+	snprintf(path, sizeof (path), "%s/%s", scid.themedir, filename);
+
+	return path;
+}
+
+void
+scid_finish(void)
+{
+	db_finish();
+	log_finish();
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scid/scid.h	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,20 @@
+#ifndef SCID_H
+#define SCID_H
+
+#include <limits.h>
+
+extern struct scid {
+	char themedir[PATH_MAX];
+	char dbpath[PATH_MAX];
+} scid;
+
+void
+scid_init(void);
+
+const char *
+scid_theme_path(const char *);
+
+void
+scid_finish(void);
+
+#endif /* !SCID_H */
--- a/sql/init.sql	Mon Jul 25 21:22:13 2022 +0200
+++ b/sql/init.sql	Tue Aug 02 13:24:13 2022 +0200
@@ -37,7 +37,7 @@
 );
 
 CREATE TABLE IF NOT EXISTS jobresult(
-	`job_id` INTEGER NOT NULL REFERENCES job (id),
+	`job_id` INTEGER NOT NULL REFERENCES job (rowid),
 	`worker_name` INTEGER NOT NULL REFERENCES worker (name),
 	`exitcode` INTEGER DEFAULT 0,
 	`console` TEXT DEFAULT NULL,
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sql/job-list.sql	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,6 @@
+  SELECT rowid
+       , *
+    FROM `job`
+   WHERE `project_name` = ?
+ORDER BY `date` ASC
+   LIMIT ?
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sql/jobresult-list-by-job-group.sql	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,7 @@
+  SELECT rowid
+       , *
+       , MAX(`date`)
+    FROM `jobresult`
+   WHERE `job_id` = ?
+GROUP BY `worker_name`
+   LIMIT ?
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sql/jobresult-list-by-job.sql	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,6 @@
+   SELECT rowid
+        , *
+     FROM `jobresult`
+    WHERE `job_id` = ?
+ ORDER BY `job_id` ASC
+    LIMIT ?
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sql/jobresult-list-by-worker.sql	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,5 @@
+   SELECT *
+     FROM `jobresult`
+    WHERE `worker_name` = ?
+ ORDER BY `job_id` ASC
+    LIMIT ?
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/themes/bulma/fragments/footer.html	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,3 @@
+		</div>
+	</section>
+</body>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/themes/bulma/fragments/header.html	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+	<head>
+		<meta charset="utf-8" />
+		<meta name="viewport" content="width=device-width, initial-scale=1" />
+		<title>sci</title>
+		<link rel="stylesheet" href="/static/bulma.min.css" />
+	</head>
+
+	<body>
+	<section class="section">
+		<div class="container">
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/themes/bulma/pages/index.html	Tue Aug 02 13:24:13 2022 +0200
@@ -0,0 +1,33 @@
+<div class="columns" style="flex-wrap: wrap">
+{{#projects}}
+<div class="card">
+	<div class="card-content">
+		<div class="media">
+			<div class="media-content">
+				<p class="title is-4"><a href="/projects/{{name}}">{{name}}</a></p>
+				<p class="subtitle is-6">{{description}}</p>
+			</div>
+		</div>
+
+		<div class="content">
+			{{#jobs}}
+			<table>
+			{{/jobs}}
+
+			{{#jobs}}
+				<tr>
+					<td><a href="jobs/{{job}}">job {{id}} ({{tag}})</td>
+				</tr>
+			{{/jobs}}
+
+			{{#jobs}}
+			</table>
+			<br/>
+			<p class="is-size-7">{{count_ok}} jobs over {{count_failed}} have failed</p>
+			{{/jobs}}
+
+			{{^jobs}}
+			<p>No jobs yet.</p>
+			{{/jobs}}
+		</div>
+{{/projects}}