"GOT", but the "O" is a cute, smiling pufferfish. Index | Thread | Search

From:
Omar Polo <op@omarpolo.com>
Subject:
gotwebd: templates take 2
To:
gameoftrees@openbsd.org
Date:
Sun, 04 Dec 2022 23:53:29 +0100

Download raw body.

Thread
Hi everyone,

Here's a second take on my quest of integrating a templating system
into gotwebd.  Some time ago I wrote for some personal projects a
small templating system that compiles templates to C code and started
to wondering how it'd fit gotwebd.  Initially I tried to just rewrite
all the page generations armed with these templates but it quickly
turned out to be a tedious, big and time consuming task.  So, here's a
second attempt, where I'm just adding the template system and
converted only a few bits, things can be improved with time diff after
diff instead that all in one go I guess.

Diff below is long; I've also set up a branch on my got mirror should
it be handier; check out the branch `tmpl' from:

https://git.omarpolo.com/?action=summary&headref=tmpl&path=got.git

(my gotwebd instance is running with this applied.)

There is a small hack in exporing gotweb_render_navs, will go away in
a follow-up diff, and there is a bit of duplication that will also go
away with the time.

So, to recap, the idea is to use a custom grammar to alleviate the
pain of dumping html via fcgi_printf (or similar) and escaping what's
needed.  As an example, instead of writing this familiar style of
(pseudo) code:

	const struct got_error *
	gotweb_render_foo(args..)
	{
		char *thing = some->thing ? some->thing : "";
		char *escaped_thing = gotweb_escape_html(thing);
		if (fcgi_printf(c, "Here's a thing: <b>%s</b>",
		    escaped_thing) == -1)
			goto err;
		/* .. */
	err:
		free(escaped_thing);
		return NULL;
	}

We can go with (almost literally literally)

	{{ define gw_foo(struct template *tp, args...) }}
	Here's a thing: <b>{{ some->thing }}</b>
	{{ end }}

that not only achieve the same thing but it also avoids allocating
temporary strings (skipping the overhead of malloc/free all over the
places) and it escapes values *by default*.

The templating system has syntax also sugar for ifs, for and
TAILQ_FOREACH, and eventually allows to easily define other kinds of
sugar around other constructs.

The idea then is to slowly replace the various gotweb_render_*
functions with these templates, having stuff easier to read, to write
and to change in the future.

This kinds of templates thusly compiles to C code and *it is not*
executed at runtime, you can see it as a gigantic syntax sugar layer
for outputting HTML.

Have to admit that there are some drawbacks that we can work out later
in tree eventually.  Two things comes to mind:

 - the template strips out most whitespaces and newlines, leading to
   fundamentally minified HTML.  it's both a pro and a cons i think

 - the error handling and logging needs attention.  Since I'm carrying
   these templates over from a separate project it uses -1/0 to signal
   error/success, and I'd like to keep the differences minimal so that
   I can port back and forward the improvements, we can't bubble up
   `got_error's on failure.  I don't think it's a huge loss, since
   currently we already are using a style that `goto err' when
   fcgi_printf fails, but needs to be kept in mind.

Last thing to note; gotwebd would now need this `template' executable
during the build phase, much like it needs yacc to generate parts of
its sources.  I'm achieving this using PROGS instead of PROG, and
since we're already using `realinstall' it should not be installed.

(the diff is also long due to the additional manpages for template --
than won't be installed, i wrote it as internal documentation, and the
regress for template.)

I'm aware that this is a huge chunk of code to digest in one go and it
won't be fun to review, I'm apologising in advance.  Future diffs will
be smaller!


diff refs/heads/main refs/heads/tmpl
commit - 6970304f7fbe7bb6534af3f344013b472a1a9698
commit + a7430904a16e4f67990a2cabe17d0cd8007f89b5
blob - 8744ffb84ade1da058f9e9837fdbbd91cd5130fc
blob + 399c42bd353b419b1fd5afd702655e3f9916bc04
--- gotwebd/Makefile
+++ gotwebd/Makefile
@@ -1,14 +1,15 @@
 .PATH:${.CURDIR}/../lib
+.PATH:${.CURDIR}/template
 
 SUBDIR = libexec
 
 .include "../got-version.mk"
 .include "Makefile.inc"
 
-PROG =		gotwebd
-SRCS =		config.c sockets.c log.c gotwebd.c parse.y proc.c \
+PROGS =		template gotwebd
+SRCS_gotwebd =	config.c sockets.c tmpl.c log.c gotwebd.c parse.y proc.c \
 		fcgi.c gotweb.c got_operations.c
-SRCS +=		blame.c commit_graph.c delta.c diff.c \
+SRCS_gotwebd +=	blame.c commit_graph.c delta.c diff.c \
 		diffreg.c error.c fileindex.c object.c object_cache.c \
 		object_idset.c object_parse.c opentemp.c path.c pack.c \
 		privsep.c reference.c repository.c sha1.c worktree.c \
@@ -21,9 +22,20 @@ MAN =		${PROG}.conf.5 ${PROG}.8
 		object_open_privsep.c read_gitconfig_privsep.c \
 		read_gotconfig_privsep.c pollfd.c reference_parse.c
 
+SRCS_template =	template.c comp.y
+
+TEMPLATES =	pages.tmpl
+
+.for t in ${TEMPLATES}
+SRCS_gotwebd +=	${t:.tmpl=.c}
+${t:.tmpl=.c}: $t template
+	./template -o $@ ${.CURDIR}/$t
+.endfor
+
 MAN =		${PROG}.conf.5 ${PROG}.8
 
 CPPFLAGS +=	-I${.CURDIR}/../include -I${.CURDIR}/../lib -I${.CURDIR}
+CPPFLAGS +=	-I${.CURDIR}/template
 LDADD +=	-lz -levent -lutil -lm
 YFLAGS =
 DPADD =		${LIBEVENT} ${LIBUTIL}
@@ -44,7 +56,7 @@ realinstall:
 	if [ ! -d ${DESTDIR}${PUB_REPOS_DIR}/. ]; then \
 		${INSTALL} -d -o root -g daemon -m 755 ${DESTDIR}${PUB_REPOS_DIR}; \
 	fi
-	${INSTALL} -c -o root -g daemon -m 0755 ${PROG} ${BINDIR}/${PROG}
+	${INSTALL} -c -o root -g daemon -m 0755 gotwebd ${BINDIR}/gotwebd
 	if [ ! -d ${DESTDIR}${HTTPD_DIR}/. ]; then \
 		${INSTALL} -d -o root -g daemon -m 755 ${DESTDIR}${HTTPD_DIR}; \
 	fi
@@ -52,6 +64,6 @@ realinstall:
 		${INSTALL} -d -o root -g daemon -m 755 ${DESTDIR}${PROG_DIR}; \
 	fi
 	${INSTALL} -c -o ${WWWUSR} -g ${WWWGRP} -m 0644 \
-	    ${.CURDIR}/files/htdocs/${PROG}/* ${DESTDIR}${PROG_DIR}
+	    ${.CURDIR}/files/htdocs/gotwebd/* ${DESTDIR}${PROG_DIR}
 
 .include <bsd.prog.mk>
blob - 04e9e72ece31adeacc0aef6f6fb7cffbaace078a
blob + b4f85fc2121de39b9a1c7ff2f9fcab75f5b7da42
--- gotwebd/fcgi.c
+++ gotwebd/fcgi.c
@@ -36,6 +36,7 @@
 
 #include "proc.h"
 #include "gotwebd.h"
+#include "tmpl.h"
 
 size_t	 fcgi_parse_record(uint8_t *, size_t, struct request *);
 void	 fcgi_parse_begin_request(uint8_t *, uint16_t, struct request *,
@@ -288,6 +289,21 @@ fcgi_vprintf(struct request *c, const char *fmt, va_li
 }
 
 int
+fcgi_puts(struct template *tp, const char *str)
+{
+	if (str == NULL)
+		return 0;
+	return fcgi_gen_binary_response(tp->tp_arg, str, strlen(str));
+}
+
+int
+fcgi_putc(struct template *tp, int ch)
+{
+	uint8_t c = ch;
+	return fcgi_gen_binary_response(tp->tp_arg, &c, 1);
+}
+
+int
 fcgi_vprintf(struct request *c, const char *fmt, va_list ap)
 {
 	char *str;
@@ -483,6 +499,7 @@ fcgi_cleanup_request(struct request *c)
 		event_del(&c->ev);
 
 	close(c->fd);
+	template_free(c->tp);
 	gotweb_free_transport(c->t);
 	free(c);
 }
blob - fc0c94f6c50f1675561ef2a30f20c748df22416c
blob + 95bbf5b0e1806e62a8c5630dd5794b3a9f5046c2
--- gotwebd/gotweb.c
+++ gotwebd/gotweb.c
@@ -51,11 +51,6 @@ enum gotweb_ref_tm {
 #include "proc.h"
 #include "gotwebd.h"
 
-enum gotweb_ref_tm {
-	TM_DIFF,
-	TM_LONG,
-};
-
 static const struct querystring_keys querystring_keys[] = {
 	{ "action",		ACTION },
 	{ "commit",		COMMIT },
@@ -86,8 +81,6 @@ static const struct got_error *gotweb_render_header(st
     char *);
 static const struct got_error *gotweb_assign_querystring(struct querystring **,
     char *, char *);
-static const struct got_error *gotweb_render_header(struct request *);
-static const struct got_error *gotweb_render_footer(struct request *);
 static const struct got_error *gotweb_render_index(struct request *);
 static const struct got_error *gotweb_init_repo_dir(struct repo_dir **,
     const char *);
@@ -97,9 +90,7 @@ static const struct got_error *gotweb_render_navs(stru
     struct server *, const char *, int);
 static const struct got_error *gotweb_get_clone_url(char **, struct server *,
     const char *, int);
-static const struct got_error *gotweb_render_navs(struct request *);
 static const struct got_error *gotweb_render_blame(struct request *);
-static const struct got_error *gotweb_render_briefs(struct request *);
 static const struct got_error *gotweb_render_commits(struct request *);
 static const struct got_error *gotweb_render_diff(struct request *);
 static const struct got_error *gotweb_render_summary(struct request *);
@@ -108,6 +99,8 @@ static void gotweb_free_querystring(struct querystring
 static const struct got_error *gotweb_render_tree(struct request *);
 static const struct got_error *gotweb_render_branches(struct request *);
 
+const struct got_error *gotweb_render_navs(struct request *);
+
 static void gotweb_free_querystring(struct querystring *);
 static void gotweb_free_repo_dir(struct repo_dir *);
 
@@ -195,11 +188,8 @@ render:
 	}
 	html = 1;
 
-	error = gotweb_render_header(c);
-	if (error) {
-		log_warnx("%s: %s", __func__, error->msg);
+	if (gw_head(c->tp) == -1)
 		goto err;
-	}
 
 	if (error2) {
 		error = error2;
@@ -215,11 +205,8 @@ render:
 		}
 		break;
 	case BRIEFS:
-		error = gotweb_render_briefs(c);
-		if (error) {
-			log_warnx("%s: %s", __func__, error->msg);
+		if (gw_briefs(c->tp) == -1)
 			goto err;
-		}
 		break;
 	case COMMITS:
 		error = gotweb_render_commits(c);
@@ -296,7 +283,7 @@ done:
 		return;
 done:
 	if (html && srv != NULL)
-		gotweb_render_footer(c);
+		gw_foot(c->tp);
 }
 
 struct server *
@@ -692,145 +679,7 @@ static const struct got_error *
 	return NULL;
 }
 
-static const struct got_error *
-gotweb_render_header(struct request *c)
-{
-	const struct got_error *err = NULL;
-	struct server *srv = c->srv;
-	struct querystring *qs = c->t->qs;
-	int r;
-
-	r = fcgi_printf(c, "<!doctype html>\n"
-	    "<html>\n"
-	    "<head>\n"
-	    "<title>%s</title>\n"
-	    "<meta charset='utf-8' />\n"
-	    "<meta name='viewport' content='initial-scale=.75' />\n"
-	    "<meta name='msapplication-TileColor' content='#da532c' />\n"
-	    "<meta name='theme-color' content='#ffffff'/>\n"
-	    "<link rel='apple-touch-icon' sizes='180x180'"
-	    " href='%sapple-touch-icon.png' />\n"
-	    "<link rel='icon' type='image/png' sizes='32x32'"
-	    " href='%sfavicon-32x32.png' />\n"
-	    "<link rel='icon' type='image/png' sizes='16x16'"
-	    " href='%sfavicon-16x16.png' />\n"
-	    "<link rel='manifest' href='%ssite.webmanifest'/>\n"
-	    "<link rel='mask-icon' href='%ssafari-pinned-tab.svg' />\n"
-	    "<link rel='stylesheet' type='text/css' href='%s%s' />\n"
-	    "</head>\n"
-	    "<body>\n"
-	    "<div id='gw_body'>\n"
-	    "<div id='header'>\n"
-	    "<div id='got_link'>"
-	    "<a href='%s' target='_blank'>"
-	    "<img src='%s%s' alt='logo' id='logo' />"
-	    "</a>\n"
-	    "</div>\n"		/* #got_link */
-	    "</div>\n"		/* #header */
-	    "<div id='site_path'>\n"
-	    "<div id='site_link'>\n"
-	    "<a href='?index_page=%d'>%s</a>",
-	    srv->site_name,
-	    c->script_name,
-	    c->script_name,
-	    c->script_name,
-	    c->script_name,
-	    c->script_name,
-	    c->script_name, srv->custom_css,
-	    srv->logo_url,
-	    c->script_name, srv->logo,
-	    qs->index_page, srv->site_link);
-	if (r == -1)
-		goto done;
-
-	if (qs->path != NULL) {
-		char *epath;
-
-		if (fcgi_printf(c, " / ") == -1)
-			goto done;
-
-		err = gotweb_escape_html(&epath, qs->path);
-		if (err)
-			return err;
-		r = gotweb_link(c, &(struct gotweb_url){
-			    .action = SUMMARY,
-			    .index_page = -1,
-			    .page = -1,
-			    .path = qs->path,
-		    }, "%s", epath);
-		free(epath);
-		if (r == -1)
-			goto done;
-	}
-	if (qs->action != INDEX) {
-		const char *action = "";
-
-		switch (qs->action) {
-		case BLAME:
-			action = "blame";
-			break;
-		case BRIEFS:
-			action = "briefs";
-			break;
-		case COMMITS:
-			action = "commits";
-			break;
-		case DIFF:
-			action = "diff";
-			break;
-		case SUMMARY:
-			action = "summary";
-			break;
-		case TAG:
-			action = "tag";
-			break;
-		case TAGS:
-			action = "tags";
-			break;
-		case TREE:
-			action = "tree";
-			break;
-		}
-
-		if (fcgi_printf(c, " / %s", action) == -1)
-			goto done;
-	}
-
-	fcgi_printf(c, "</div>\n"	/* #site_path */
-	    "</div>\n"			/* #site_link */
-	    "<div id='content'>\n");
-
-done:
-	return NULL;
-}
-
-static const struct got_error *
-gotweb_render_footer(struct request *c)
-{
-	const struct got_error *error = NULL;
-	struct server *srv = c->srv;
-	const char *siteowner = "&nbsp;";
-	char *escaped_owner = NULL;
-
-	if (srv->show_site_owner) {
-		error = gotweb_escape_html(&escaped_owner, srv->site_owner);
-		if (error)
-			return error;
-		siteowner = escaped_owner;
-	}
-
-	fcgi_printf(c, "<div id='site_owner_wrapper'>\n"
-	    "<div id='site_owner'>%s</div>\n"
-	    "</div>\n"		/* #site_owner_wrapper */
-	    "</div>\n"		/* #content */
-	    "</div>\n"		/* #gw_body */
-	    "</body>\n</html>\n", siteowner);
-
-	free(escaped_owner);
-	return NULL;
-}
-
-static const struct got_error *
+const struct got_error *
 gotweb_render_navs(struct request *c)
 {
 	const struct got_error *error = NULL;
@@ -996,7 +845,7 @@ gotweb_render_index(struct request *c)
 	struct dirent **sd_dent = NULL;
 	unsigned int d_cnt, d_i, d_disp = 0;
 	unsigned int d_skipped = 0;
-	int r, type;
+	int type;
 
 	d = opendir(srv->repos_path);
 	if (d == NULL) {
@@ -1011,26 +860,9 @@ gotweb_render_index(struct request *c)
 		goto done;
 	}
 
-	r = fcgi_printf(c, "<div id='index_header'>\n"
-	    "<div id='index_header_project'>Project</div>\n");
-	if (r == -1)
+	if (gw_repo_table_hdr(c->tp) == -1)
 		goto done;
 
-	if (srv->show_repo_description)
-		if (fcgi_printf(c, "<div id='index_header_description'>"
-		    "Description</div>\n") == -1)
-			goto done;
-	if (srv->show_repo_owner)
-		if (fcgi_printf(c, "<div id='index_header_owner'>"
-		    "Owner</div>\n") == -1)
-			goto done;
-	if (srv->show_repo_age)
-		if (fcgi_printf(c, "<div id='index_header_age'>"
-		    "Last Change</div>\n") == -1)
-			goto done;
-	if (fcgi_printf(c, "</div>\n") == -1) /* #index_header */
-		goto done;
-
 	for (d_i = 0; d_i < d_cnt; d_i++) {
 		if (srv->max_repos > 0 && t->prev_disp == srv->max_repos)
 			break;
@@ -1074,112 +906,9 @@ gotweb_render_index(struct request *c)
 		d_disp++;
 		t->prev_disp++;
 
-		if (fcgi_printf(c, "<div class='index_wrapper'>\n"
-		    "<div class='index_project'>") == -1)
+		if (gw_repo_fragment(c->tp, repo_dir) == -1)
 			goto done;
 
-		r = gotweb_link(c, &(struct gotweb_url){
-			.action = SUMMARY,
-			.index_page = -1,
-			.page = -1,
-			.path = repo_dir->name,
-		    }, "%s", repo_dir->name);
-		if (r == -1)
-			goto done;
-
-		if (fcgi_printf(c, "</div>") == -1) /* .index_project */
-			goto done;
-
-		if (srv->show_repo_description) {
-			r = fcgi_printf(c,
-			    "<div class='index_project_description'>\n"
-			    "%s</div>\n", repo_dir->description);
-			if (r == -1)
-				goto done;
-		}
-
-		if (srv->show_repo_owner) {
-			r = fcgi_printf(c, "<div class='index_project_owner'>"
-			    "%s</div>\n", repo_dir->owner);
-			if (r == -1)
-				goto done;
-		}
-
-		if (srv->show_repo_age) {
-			r = fcgi_printf(c, "<div class='index_project_age'>"
-			    "%s</div>\n", repo_dir->age);
-			if (r == -1)
-				goto done;
-		}
-
-		if (fcgi_printf(c, "<div class='navs_wrapper'>"
-		    "<div class='navs'>") == -1)
-			goto done;
-
-		r = gotweb_link(c, &(struct gotweb_url){
-			.action = SUMMARY,
-			.index_page = -1,
-			.page = -1,
-			.path = repo_dir->name
-		    }, "summary");
-		if (r == -1)
-			goto done;
-
-		if (fcgi_printf(c, " | ") == -1)
-			goto done;
-
-		r = gotweb_link(c, &(struct gotweb_url){
-			.action = BRIEFS,
-			.index_page = -1,
-			.page = -1,
-			.path = repo_dir->name
-		    }, "commit briefs");
-		if (r == -1)
-			goto done;
-
-		if (fcgi_printf(c, " | ") == -1)
-			goto done;
-
-		r = gotweb_link(c, &(struct gotweb_url){
-			.action = COMMITS,
-			.index_page = -1,
-			.page = -1,
-			.path = repo_dir->name
-		    }, "commits");
-		if (r == -1)
-			goto done;
-
-		if (fcgi_printf(c, " | ") == -1)
-			goto done;
-
-		r = gotweb_link(c, &(struct gotweb_url){
-			.action = TAGS,
-			.index_page = -1,
-			.page = -1,
-			.path = repo_dir->name
-		    }, "tags");
-		if (r == -1)
-			goto done;
-
-		if (fcgi_printf(c, " | ") == -1)
-			goto done;
-
-		r = gotweb_link(c, &(struct gotweb_url){
-			.action = TREE,
-			.index_page = -1,
-			.page = -1,
-			.path = repo_dir->name
-		    }, "tree");
-		if (r == -1)
-			goto done;
-
-		r = fcgi_printf(c, "</div>"	/* .navs */
-		    "<div class='dotted_line'></div>\n"
-		    "</div>\n"			/* .navs_wrapper */
-		    "</div>\n");		/* .index_wrapper */
-		if (r == -1)
-			goto done;
-
 		gotweb_free_repo_dir(repo_dir);
 		repo_dir = NULL;
 		t->next_disp++;
@@ -1264,143 +993,6 @@ gotweb_render_briefs(struct request *c)
 }
 
 static const struct got_error *
-gotweb_render_briefs(struct request *c)
-{
-	const struct got_error *error = NULL;
-	struct repo_commit *rc = NULL;
-	struct server *srv = c->srv;
-	struct transport *t = c->t;
-	struct querystring *qs = t->qs;
-	struct repo_dir *repo_dir = t->repo_dir;
-	char *smallerthan, *newline;
-	char *age = NULL, *author = NULL, *msg = NULL;
-	int r;
-
-	r = fcgi_printf(c, "<div id='briefs_title_wrapper'>\n"
-	    "<div id='briefs_title'>Commit Briefs</div>\n"
-	    "</div>\n"	/* #briefs_title_wrapper */
-	    "<div id='briefs_content'>\n");
-	if (r == -1)
-		goto done;
-
-	if (qs->action == SUMMARY) {
-		qs->action = BRIEFS;
-		error = got_get_repo_commits(c, D_MAXSLCOMMDISP);
-	} else
-		error = got_get_repo_commits(c, srv->max_commits_display);
-	if (error)
-		goto done;
-
-	TAILQ_FOREACH(rc, &t->repo_commits, entry) {
-		error = gotweb_get_time_str(&age, rc->committer_time, TM_DIFF);
-		if (error)
-			goto done;
-
-		smallerthan = strchr(rc->author, '<');
-		if (smallerthan)
-			*smallerthan = '\0';
-
-		newline = strchr(rc->commit_msg, '\n');
-		if (newline)
-			*newline = '\0';
-
-		error = gotweb_escape_html(&author, rc->author);
-		if (error)
-			goto done;
-		error = gotweb_escape_html(&msg, rc->commit_msg);
-		if (error)
-			goto done;
-
-		r = fcgi_printf(c, "<div class='briefs_age'>%s</div>\n"
-		    "<div class='briefs_author'>%s</div>\n"
-		    "<div class='briefs_log'>",
-		    age, author);
-		if (r == -1)
-			goto done;
-
-		r = gotweb_link(c, &(struct gotweb_url){
-			.action = DIFF,
-			.index_page = -1,
-			.page = -1,
-			.path = repo_dir->name,
-			.commit = rc->commit_id,
-			.headref = qs->headref,
-		    }, "%s", msg);
-		if (r == -1)
-			goto done;
-
-		if (rc->refs_str) {
-			char *refs;
-
-			error = gotweb_escape_html(&refs, rc->refs_str);
-			if (error)
-				goto done;
-			r = fcgi_printf(c,
-			    " <span class='refs_str'>(%s)</span>", refs);
-			free(refs);
-			if (r == -1)
-				goto done;
-		}
-		if (fcgi_printf(c, "</div>\n") == -1) /* .briefs_log */
-			goto done;
-
-		r = fcgi_printf(c, "<div class='navs_wrapper'>\n"
-		    "<div class='navs'>");
-		if (r == -1)
-			goto done;
-
-		r = gotweb_link(c, &(struct gotweb_url){
-			.action = DIFF,
-			.index_page = -1,
-			.page = -1,
-			.path = repo_dir->name,
-			.commit = rc->commit_id,
-			.headref = qs->headref,
-		    }, "diff");
-		if (r == -1)
-			goto done;
-
-		if (fcgi_printf(c, " | ") == -1)
-			goto done;
-
-		r = gotweb_link(c, &(struct gotweb_url){
-			.action = TREE,
-			.index_page = -1,
-			.page = -1,
-			.path = repo_dir->name,
-			.commit = rc->commit_id,
-			.headref = qs->headref,
-		    }, "tree");
-		if (r == -1)
-			goto done;
-
-		if (fcgi_printf(c, "</div>\n"	/* .navs */
-		    "</div>\n"	/* .navs_wrapper */
-		    "<div class='dotted_line'></div>\n") == -1)
-			goto done;
-
-		free(age);
-		age = NULL;
-		free(author);
-		author = NULL;
-		free(msg);
-		msg = NULL;
-	}
-
-	if (t->next_id || t->prev_id) {
-		error = gotweb_render_navs(c);
-		if (error)
-			goto done;
-	}
-	fcgi_printf(c, "</div>\n"); /* #briefs_content */
-done:
-	free(age);
-	free(author);
-	free(msg);
-	return error;
-}
-
-static const struct got_error *
 gotweb_render_commits(struct request *c)
 {
 	const struct got_error *error = NULL;
@@ -1811,11 +1403,8 @@ gotweb_render_summary(struct request *c)
 	if (r == -1)
 		goto done;
 
-	error = gotweb_render_briefs(c);
-	if (error) {
-		log_warnx("%s: %s", __func__, error->msg);
+	if (gw_briefs(c->tp) == -1)
 		goto done;
-	}
 
 	error = gotweb_render_tags(c);
 	if (error) {
blob - 14426b3f4ad9787e7876dc233211e8fb8875cab6
blob + 7f297c9274a3c586dc6da0d25ff005e8fcf1996a
--- gotwebd/gotwebd.h
+++ gotwebd/gotwebd.h
@@ -202,10 +202,12 @@ struct request {
 	PRIV_FDS__MAX,
 };
 
+struct template;
 struct request {
 	struct socket			*sock;
 	struct server			*srv;
 	struct transport		*t;
+	struct template			*tp;
 	struct event			 ev;
 	struct event			 tmo;
 
@@ -415,6 +417,11 @@ extern struct gotwebd	*gotwebd_env;
 	ACTIONS__MAX,
 };
 
+enum gotweb_ref_tm {
+	TM_DIFF,
+	TM_LONG,
+};
+
 extern struct gotwebd	*gotwebd_env;
 
 /* sockets.c */
@@ -440,6 +447,14 @@ void gotweb_free_transport(struct transport *);
 void gotweb_process_request(struct request *);
 void gotweb_free_transport(struct transport *);
 
+/* pages.tmpl */
+int	gw_url(struct template *, struct gotweb_url *);
+int	gw_head(struct template *);
+int	gw_foot(struct template *);
+int	gw_repo_table_hdr(struct template *);
+int	gw_repo_fragment(struct template *, struct repo_dir *);
+int	gw_briefs(struct template *);
+
 /* parse.y */
 int parse_config(const char *, struct gotwebd *);
 int cmdline_symset(char *);
@@ -450,6 +465,8 @@ int fcgi_vprintf(struct request *, const char *, va_li
 void fcgi_cleanup_request(struct request *);
 void fcgi_create_end_record(struct request *);
 void dump_fcgi_record(const char *, struct fcgi_record_header *);
+int fcgi_puts(struct template *, const char *);
+int fcgi_putc(struct template *, int);
 int fcgi_vprintf(struct request *, const char *, va_list);
 int fcgi_printf(struct request *, const char *, ...)
 	__attribute__((__format__(printf, 2, 3)))
blob - /dev/null
blob + 52a4552a401106b22ca6456a760526d8624003c4 (mode 644)
--- /dev/null
+++ gotwebd/pages.tmpl
@@ -0,0 +1,388 @@
+{!
+/*
+ * Copyright (c) 2022 Omar Polo <op@openbsd.org>
+ * Copyright (c) 2016, 2019, 2020-2022 Tracey Emery <tracey@traceyemery.net>
+ *
+ * Permission to use, copy, modify, and 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/queue.h>
+
+#include <event.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <imsg.h>
+
+#include "proc.h"
+
+#include "gotwebd.h"
+#include "tmpl.h"
+
+const struct got_error	*gotweb_render_navs(struct request *);
+
+static const char *
+action_name(int action)
+{
+	switch (action) {
+	case BLAME:
+		return "blame";
+	case BLOB:
+		return "blob";
+	case BRIEFS:
+		return "briefs";
+	case COMMITS:
+		return "commits";
+	case DIFF:
+		return "diff";
+	case ERR:
+		return "err";
+	case INDEX:
+		return "index";
+	case SUMMARY:
+		return "summary";
+	case TAG:
+		return "tag";
+	case TAGS:
+		return "tags";
+	case TREE:
+		return "tree";
+	default:
+		return NULL;
+	}
+}
+
+static int
+gw_age(struct template *tp, time_t committer_time, int ref_tm)
+{
+	struct tm tm;
+	long long diff_time;
+	const char *years = "years ago", *months = "months ago";
+	const char *weeks = "weeks ago", *days = "days ago";
+	const char *hours = "hours ago",  *minutes = "minutes ago";
+	const char *seconds = "seconds ago", *now = "right now";
+	char datebuf[32], tmp[32];
+	int n = -1;
+
+	switch (ref_tm) {
+	case TM_DIFF:
+		diff_time = time(NULL) - committer_time;
+		if (diff_time > 60 * 60 * 24 * 365 * 2) {
+			n = snprintf(datebuf, sizeof(datebuf),
+			    "%lld %s", (diff_time / 60 / 60 / 24 / 365),
+			    years);
+		} else if (diff_time > 60 * 60 * 24 * (365 / 12) * 2) {
+			n = snprintf(datebuf, sizeof(datebuf),
+			    "%lld %s", (diff_time / 60 / 60 / 24 / (365 / 12)),
+			    months);
+		} else if (diff_time > 60 * 60 * 24 * 7 * 2) {
+			n = snprintf(datebuf, sizeof(datebuf),
+			    "%lld %s", (diff_time / 60 / 60 / 24 / 7), weeks);
+		} else if (diff_time > 60 * 60 * 24 * 2) {
+			n = snprintf(datebuf, sizeof(datebuf),
+			    "%lld %s", (diff_time / 60 / 60 / 24), days);
+		} else if (diff_time > 60 * 60 * 2) {
+			n = snprintf(datebuf, sizeof(datebuf),
+			    "%lld %s", (diff_time / 60 / 60), hours);
+		} else if (diff_time > 60 * 2) {
+			n = snprintf(datebuf, sizeof(datebuf),
+			    "%lld %s", (diff_time / 60), minutes);
+		} else if (diff_time > 2) {
+			n = snprintf(datebuf, sizeof(datebuf),
+			    "%lld %s", diff_time, seconds);
+		} else
+			n = strlcpy(datebuf, now, sizeof(datebuf));
+		break;
+	case TM_LONG:
+		if (gmtime_r(&committer_time, &tm) == NULL)
+			return -1;
+
+		if (asctime_r(&tm, tmp) == NULL)
+			return -1;
+
+		n = snprintf(datebuf, sizeof(datebuf), "%s UTC", tmp);
+		break;
+	}
+
+	if (n < 0 || (size_t)n > sizeof(datebuf))
+		return -1;
+	return tp->tp_escape(tp, datebuf);
+}
+
+!}
+
+{{ define gw_url(struct template *tp, struct gotweb_url *url) }}
+?action={{ action_name(url->action) }}
+{{ if url->commit }}&commit={{ url->commit }}{{ end }}
+{{ if url->previd }}&previd={{ url->previd }}{{ end }}
+{{ if url->prevset }}&previd={{ url->prevset }}{{ end }}
+{{ if url->file }}&file={{ url->file | urlescape }}{{ end }}
+{{ if url->folder }}&folder={{ url->folder | urlescape }}{{ end }}
+{{ if url->headref }}&headref={{ url->headref | urlescape }}{{ end }}
+{{ if url->index_page != -1 }}&headref={{ printf "%d", url->index_page }}{{ end }}
+{{ if url->path }}&path={{ url->path | urlescape }}{{ end }}
+{{ if url->page != -1 }}&page={{ printf "%d", url->page }}{{ end }}
+{{ end }}
+
+{{ define gw_head(struct template *tp) }}
+{!
+	struct request		*c = tp->tp_arg;
+	struct server		*srv = c->srv;
+	struct querystring	*qs = c->t->qs;
+	struct gotweb_url	 u_path;
+	const char		*prfx = c->script_name;
+	const char		*css = srv->custom_css;
+
+	memset(&u_path, 0, sizeof(u_path));
+	u_path.index_page = -1;
+	u_path.page = -1;
+	u_path.action = SUMMARY;
+!}
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <title>{{ srv->site_name }}</title>
+    <meta name="viewport" content="initial-scale=.75" />
+    <meta name="msapplication-TileColor" content="#da532c" />
+    <meta name="theme-color" content="#ffffff"/>
+    <link rel="apple-touch-icon" sizes="180x180" href="{{ prfx }}apple-touch-icon.png" />
+    <link rel="icon" type="image/png" sizes="32x32" href="{{ prfx }}favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="{{ prfx }}favicon-16x16.png" />
+    <link rel="manifest" href="{{ prfx }}site.webmanifest"/>
+    <link rel="mask-icon" href="{{ prfx }}safari-pinned-tab.svg" />
+    <link rel="stylesheet" type="text/css" href="{{ prfx }}{{ css }}" />
+  </head>
+  <body>
+    <div id="gw_body">
+      <div id="header">
+        <div id="got_link">
+          <a href="{{ srv->logo_url }}" target="_blank">
+            <img src="{{ prfx }}{{ srv->logo }}" />
+          </a>
+        </div>
+      </div>
+      <div id="site_path">
+        <div id="site_link">
+          <a href="?index_page={{ printf "%d", qs->index_page }}">
+            {{ srv->site_link }}
+          </a>
+          {{ if qs->path }}
+            {! u_path.path = qs->path; !}
+            {{ " / " }}
+            <a href="{{ render gw_url(tp, &u_path)}}">
+              {{ qs->path }}
+            </a>
+          {{ end }}
+          {{ if qs->action != INDEX }}
+            {{ " / " }}{{ action_name(qs->action) }}
+          {{ end }}
+        </div>
+      </div>
+      <div id="content">
+{{ end }}
+
+{{ define gw_foot(struct template *tp) }}
+{!
+	struct request		*c = tp->tp_arg;
+	struct server		*srv = c->srv;
+!}
+        <div id="site_owner_wrapper">
+          <div id="site_owner">
+            {{ if srv->show_site_owner }}
+              {{ srv->site_owner }}
+            {{ end }}
+          </div>
+        </div>
+      </div>
+    </div>
+  </body>
+</html>
+{{ end }}
+
+{{ define gw_repo_table_hdr(struct template *tp) }}
+{!
+	struct request *c = tp->tp_arg;
+	struct server *srv = c->srv;
+!}
+<div id="index_header">
+  <div id="index_header_project">
+    Project
+  </div>
+  {{ if srv->show_repo_description }}
+    <div id="index_header_description">
+      Description
+    </div>
+  {{ end }}
+  {{ if srv->show_repo_owner }}
+    <div id="index_header_owner">
+      Owner
+    </div>
+  {{ end }}
+  {{ if srv->show_repo_age }}
+    <div id="index_header_age">
+      Last Change
+    </div>
+  {{ end }}
+</div>
+{{ end }}
+
+{{ define gw_repo_fragment(struct template *tp, struct repo_dir *repo_dir) }}
+{!
+	struct request *c = tp->tp_arg;
+	struct server *srv = c->srv;
+	struct gotweb_url summary = {
+		.action = SUMMARY,
+		.index_page = -1,
+		.page = -1,
+		.path = repo_dir->name,
+	}, briefs = {
+		.action = BRIEFS,
+		.index_page = -1,
+		.page = -1,
+		.path = repo_dir->name,
+	}, commits = {
+		.action = COMMITS,
+		.index_page = -1,
+		.page = -1,
+		.path = repo_dir->name,
+	}, tags = {
+		.action = TAGS,
+		.index_page = -1,
+		.page = -1,
+		.path = repo_dir->name,
+	}, tree = {
+		.action = TREE,
+		.index_page = -1,
+		.page = -1,
+		.path = repo_dir->name,
+	};
+!}
+<div class="index_wrapper">
+  <div class="index_project">
+    <a href="{{ render gw_url(tp, &summary) }}">{{ repo_dir->name }}</a>
+  </div>
+  {{ if srv->show_repo_description }}
+    <div class="index_project_description">
+      {{ repo_dir->description }}
+    </div>
+  {{ end }}
+  {{ if srv->show_repo_owner }}
+    <div class="index_project_owner">
+      {{ repo_dir->owner }}
+    </div>
+  {{ end }}
+  {{ if srv->show_repo_age }}
+    <div class="index_project_age">
+      {{ repo_dir->age }}
+    </div>
+  {{ end }}
+  <div class="navs_wrapper">
+    <div class="navs">
+      <a href="{{ render gw_url(tp, &summary) }}">summary</a>
+      {{ " | " }}
+      <a href="{{ render gw_url(tp, &briefs) }}">briefs</a>
+      {{ " | " }}
+      <a href="{{ render gw_url(tp, &commits) }}">commits</a>
+      {{ " | " }}
+      <a href="{{ render gw_url(tp, &tags) }}">tags</a>
+      {{ " | " }}
+      <a href="{{ render gw_url(tp, &tree) }}">tree</a>
+    </div>
+    <div class="dotted_line"></div>
+  </div>
+</div>
+{{ end }}
+
+{{ define gw_briefs(struct template *tp) }}
+{!
+	const struct got_error	*error;
+	struct request		*c = tp->tp_arg;
+	struct server		*srv = c->srv;
+	struct transport	*t = c->t;
+	struct querystring	*qs = c->t->qs;
+	struct repo_commit	*rc;
+	struct repo_dir		*repo_dir = t->repo_dir;
+	struct gotweb_url	 diff_url, tree_url;
+	char			*tmp;
+
+	diff_url = (struct gotweb_url){
+		.action = DIFF,
+		.index_page = -1,
+		.page = -1,
+		.path = repo_dir->name,
+		.headref = qs->headref,
+	};
+	tree_url = (struct gotweb_url){
+		.action = TREE,
+		.index_page = -1,
+		.page = -1,
+		.path = repo_dir->name,
+		.headref = qs->headref,
+	};
+
+	if (qs->action == SUMMARY) {
+		qs->action = BRIEFS;
+		error = got_get_repo_commits(c, D_MAXSLCOMMDISP);
+	} else
+		error = got_get_repo_commits(c, srv->max_commits_display);
+	if (error)
+		return -1;
+!}
+<div id="briefs_title_wrapper">
+  <div id="briefs_title">Commit Briefs</div>
+</div>
+<div id="briefs_content">
+  {{ tailq-foreach rc &t->repo_commits entry }}
+    {!
+	diff_url.commit = rc->commit_id;
+	tree_url.commit = rc->commit_id;
+
+	tmp = strchr(rc->author, '<');
+	if (tmp)
+		*tmp = '\0';
+
+	tmp = strchr(rc->commit_msg, '\n');
+	if (tmp)
+		*tmp = '\0';
+    !}
+    <div class="briefs_age">
+      {{ render gw_age(tp, rc->committer_time, TM_DIFF) }}
+    </div>
+    <div class="briefs_author">
+      {{ rc->author }}
+    </div>
+    <div class="briefs_log">
+      <a href="{{ render gw_url(tp, &diff_url) }}">
+        {{ rc->commit_msg }}
+      </a>
+      {{ if rc->refs_str }}
+        {{ " " }} <span class="refs_str">({{ rc->refs_str }})</span>
+      {{ end }}
+      </a>
+    </div>
+    <div class="navs_wrapper">
+      <div class="navs">
+        <a href="{{ render gw_url(tp, &diff_url) }}">diff</a>
+	{{ " | " }}
+	<a href="{{ render gw_url(tp, &tree_url) }}">tree</a>
+      </div>
+    </div>
+    <div class="dotted_line"></div>
+  {{ end }}
+  {{ if t->next_id || t->prev_id }}
+    {! gotweb_render_navs(c); !}
+  {{ end }}
+</div>
+{{ end }}
blob - c0691c5575dca8c931af0e8e30d9e086b5a5bccc
blob + cfba5dfb0e7368b0b37738d2eb71b53d437db169
--- gotwebd/sockets.c
+++ gotwebd/sockets.c
@@ -55,6 +55,7 @@
 
 #include "proc.h"
 #include "gotwebd.h"
+#include "tmpl.h"
 
 #define SOCKS_BACKLOG 5
 #define MAXIMUM(a, b)	(((a) > (b)) ? (a) : (b))
@@ -619,6 +620,15 @@ sockets_socket_accept(int fd, short event, void *arg)
 		return;
 	}
 
+	c->tp = template(c, fcgi_puts, fcgi_putc);
+	if (c->tp == NULL) {
+		log_warn("%s", __func__);
+		close(s);
+		cgi_inflight--;
+		free(c);
+		return;
+	}
+
 	c->fd = s;
 	c->sock = sock;
 	memcpy(c->priv_fd, sock->priv_fd, sizeof(c->priv_fd));
blob - /dev/null
blob + 525d753fa8605da41633c1d886b022f049d24ac3 (mode 644)
--- /dev/null
+++ gotwebd/template/Makefile
@@ -0,0 +1,11 @@
+# only for regress; template is also built by gotwebd' makefile
+
+PROG=	template
+SRCS=	template.c tmpl.c comp.y
+
+MAN=	template.1 template.7
+
+# XXX: would break regress/
+NOOBJ= skip
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + 34f42f83d0770ec2ab269538aac80935c414dfec (mode 644)
--- /dev/null
+++ gotwebd/template/comp.y
@@ -0,0 +1,737 @@
+/*
+ * Copyright (c) 2022 Omar Polo <op@omarpolo.com>
+ * Copyright (c) 2007-2016 Reyk Floeter <reyk@openbsd.org>
+ * Copyright (c) 2004, 2005 Esben Norby <norby@openbsd.org>
+ * Copyright (c) 2004 Ryan McBride <mcbride@openbsd.org>
+ * Copyright (c) 2002, 2003, 2004 Henning Brauer <henning@openbsd.org>
+ * Copyright (c) 2001 Markus Friedl.  All rights reserved.
+ * Copyright (c) 2001 Daniel Hartmeier.  All rights reserved.
+ * Copyright (c) 2001 Theo de Raadt.  All rights reserved.
+ *
+ * Permission to use, copy, modify, and 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/queue.h>
+
+#include <ctype.h>
+#include <err.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdarg.h>
+#include <stdint.h>
+#include <string.h>
+#include <unistd.h>
+
+#ifndef nitems
+#define nitems(_a) (sizeof((_a)) / sizeof((_a)[0]))
+#endif
+
+TAILQ_HEAD(files, file)		 files = TAILQ_HEAD_INITIALIZER(files);
+static struct file {
+	TAILQ_ENTRY(file)	 entry;
+	FILE			*stream;
+	char			*name;
+	size_t			 ungetpos;
+	size_t			 ungetsize;
+	unsigned char		*ungetbuf;
+	int			 eof_reached;
+	int			 lineno;
+	int			 errors;
+} *file, *topfile;
+int		 parse(FILE *, const char *);
+struct file	*pushfile(const char *, int);
+int		 popfile(void);
+int		 yyparse(void);
+int		 yylex(void);
+int		 yyerror(const char *, ...)
+    __attribute__((__format__ (printf, 1, 2)))
+    __attribute__((__nonnull__ (1)));
+int		 kw_cmp(const void *, const void *);
+int		 lookup(char *);
+int		 igetc(void);
+int		 lgetc(int);
+void		 lungetc(int);
+int		 findeol(void);
+
+void		 dbg(void);
+void		 printq(const char *);
+
+extern int	 nodebug;
+
+static FILE	*fp;
+
+static int	 block;
+static int	 in_define;
+static int	 errors;
+static int	 lastline = -1;
+
+typedef struct {
+	union {
+		char		*string;
+	} v;
+	int lineno;
+} YYSTYPE;
+
+%}
+
+%token	DEFINE ELSE END ERROR FINALLY FOR IF INCLUDE PRINTF
+%token	RENDER TQFOREACH UNSAFE URLESCAPE
+%token	<v.string>	STRING
+%type	<v.string>	string
+%type	<v.string>	stringy
+
+%%
+
+grammar		: /* empty */
+		| grammar include
+		| grammar verbatim
+		| grammar block
+		| grammar error		{ file->errors++; }
+		;
+
+include		: INCLUDE STRING {
+			struct file	*nfile;
+
+			if ((nfile = pushfile($2, 0)) == NULL) {
+				yyerror("failed to include file %s", $2);
+				free($2);
+				YYERROR;
+			}
+			free($2);
+
+			file = nfile;
+			lungetc('\n');
+		}
+		;
+
+verbatim	: '!' verbatim1 '!' {
+			if (in_define) {
+				/* TODO: check template status and exit in case */
+			}
+		}
+		;
+
+verbatim1	: /* empty */
+		| verbatim1 STRING {
+			if (*$2 != '\0') {
+				dbg();
+				fprintf(fp, "%s\n", $2);
+			}
+			free($2);
+		}
+		;
+
+verbatims	: /* empty */
+		| verbatims verbatim
+		;
+
+raw		: STRING {
+			dbg();
+			fprintf(fp, "if ((tp_ret = tp->tp_puts(tp, ");
+			printq($1);
+			fputs(")) == -1) goto err;\n", fp);
+
+			free($1);
+		}
+		;
+
+block		: define body end {
+			fputs("err:\n", fp);
+			fputs("return tp_ret;\n", fp);
+			fputs("}\n", fp);
+			in_define = 0;
+		}
+		| define body finally end {
+			fputs("return tp_ret;\n", fp);
+			fputs("}\n", fp);
+			in_define = 0;
+		}
+		;
+
+define		: '{' DEFINE string '}' {
+			in_define = 1;
+
+			dbg();
+			fprintf(fp, "int\n%s\n{\n", $3);
+			fputs("int tp_ret = 0;\n", fp);
+
+			free($3);
+		}
+		;
+
+body		: /* empty */
+		| body verbatim
+		| body raw
+		| body special
+		;
+
+special		: '{' RENDER string '}' {
+			dbg();
+			fprintf(fp, "if ((tp_ret = %s) == -1) goto err;\n",
+			    $3);
+			free($3);
+		}
+		| printf
+		| if body endif			{ fputs("}\n", fp); }
+		| loop
+		| '{' string '|' UNSAFE '}' {
+			dbg();
+			fprintf(fp,
+			    "if ((tp_ret = tp->tp_puts(tp, %s)) == -1)\n",
+			    $2);
+			fputs("goto err;\n", fp);
+			free($2);
+		}
+		| '{' string '|' URLESCAPE '}' {
+			dbg();
+			fprintf(fp,
+			    "if ((tp_ret = tp_urlescape(tp, %s)) == -1)\n",
+			    $2);
+			fputs("goto err;\n", fp);
+			free($2);
+		}
+		| '{' string '}' {
+			dbg();
+			fprintf(fp,
+			    "if ((tp_ret = tp->tp_escape(tp, %s)) == -1)\n",
+			    $2);
+			fputs("goto err;\n", fp);
+			free($2);
+		}
+		;
+
+printf		: '{' PRINTF {
+			dbg();
+			fprintf(fp, "if (asprintf(&tp->tp_tmp, ");
+		} printfargs '}' {
+			fputs(") == -1)\n", fp);
+			fputs("goto err;\n", fp);
+			fputs("if ((tp_ret = tp->tp_escape(tp, tp->tp_tmp)) "
+			    "== -1)\n", fp);
+			fputs("goto err;\n", fp);
+			fputs("free(tp->tp_tmp);\n", fp);
+			fputs("tp->tp_tmp = NULL;\n", fp);
+		}
+		;
+
+printfargs	: /* empty */
+		| printfargs STRING {
+			fprintf(fp, " %s", $2);
+			free($2);
+		}
+		;
+
+if		: '{' IF stringy '}' {
+			dbg();
+			fprintf(fp, "if (%s) {\n", $3);
+			free($3);
+		}
+		;
+
+endif		: end
+		| else body end
+		| elsif body endif
+		;
+
+elsif		: '{' ELSE IF stringy '}' {
+			dbg();
+			fprintf(fp, "} else if (%s) {\n", $4);
+			free($4);
+		}
+		;
+
+else		: '{' ELSE '}' {
+			dbg();
+			fputs("} else {\n", fp);
+		}
+		;
+
+loop		: '{' FOR stringy '}' {
+			fprintf(fp, "for (%s) {\n", $3);
+			free($3);
+		} body end {
+			fputs("}\n", fp);
+		}
+		| '{' TQFOREACH STRING STRING STRING '}' {
+			fprintf(fp, "TAILQ_FOREACH(%s, %s, %s) {\n",
+			    $3, $4, $5);
+			free($3);
+			free($4);
+			free($5);
+		} body end {
+			fputs("}\n", fp);
+		}
+		;
+
+end		: '{' END '}'
+		;
+
+finally		: '{' FINALLY '}' {
+			dbg();
+			fputs("err:\n", fp);
+		} verbatims
+		;
+
+string		: STRING string {
+			if (asprintf(&$$, "%s %s", $1, $2) == -1)
+				err(1, "asprintf");
+			free($1);
+			free($2);
+		}
+		| STRING
+		;
+
+stringy		: STRING
+		| STRING stringy {
+			if (asprintf(&$$, "%s %s", $1, $2) == -1)
+				err(1, "asprintf");
+			free($1);
+			free($2);
+		}
+		| '|' stringy {
+			if (asprintf(&$$, "|%s", $2) == -1)
+				err(1, "asprintf");
+			free($2);
+		}
+		;
+
+%%
+
+struct keywords {
+	const char	*k_name;
+	int		 k_val;
+};
+
+int
+yyerror(const char *fmt, ...)
+{
+	va_list	 ap;
+	char	*msg;
+
+	file->errors++;
+	va_start(ap, fmt);
+	if (vasprintf(&msg, fmt, ap) == -1)
+		err(1, "yyerror vasprintf");
+	va_end(ap);
+	fprintf(stderr, "%s:%d: %s\n", file->name, yylval.lineno, msg);
+	free(msg);
+	return (0);
+}
+
+int
+kw_cmp(const void *k, const void *e)
+{
+	return (strcmp(k, ((const struct keywords *)e)->k_name));
+}
+
+int
+lookup(char *s)
+{
+	/* this has to be sorted always */
+	static const struct keywords keywords[] = {
+		{ "define",		DEFINE },
+		{ "else",		ELSE },
+		{ "end",		END },
+		{ "finally",		FINALLY },
+		{ "for",		FOR },
+		{ "if",			IF },
+		{ "include",		INCLUDE },
+		{ "printf",		PRINTF },
+		{ "render",		RENDER },
+		{ "tailq-foreach",	TQFOREACH },
+		{ "unsafe",		UNSAFE },
+		{ "urlescape",		URLESCAPE },
+	};
+	const struct keywords	*p;
+
+	p = bsearch(s, keywords, nitems(keywords), sizeof(keywords[0]),
+	    kw_cmp);
+
+	if (p)
+		return (p->k_val);
+	else
+		return (STRING);
+}
+
+#define START_EXPAND	1
+#define DONE_EXPAND	2
+
+static int	expanding;
+
+int
+igetc(void)
+{
+	int	c;
+
+	while (1) {
+		if (file->ungetpos > 0)
+			c = file->ungetbuf[--file->ungetpos];
+		else
+			c = getc(file->stream);
+
+		if (c == START_EXPAND)
+			expanding = 1;
+		else if (c == DONE_EXPAND)
+			expanding = 0;
+		else
+			break;
+	}
+	return (c);
+}
+
+int
+lgetc(int quotec)
+{
+	int		c;
+
+	if (quotec) {
+		if ((c = igetc()) == EOF) {
+			yyerror("reached end of filewhile parsing "
+			    "quoted string");
+			if (file == topfile || popfile() == EOF)
+				return (EOF);
+			return (quotec);
+		}
+		return (c);
+	}
+
+	c = igetc();
+	if (c == '\t' || c == ' ') {
+		/* Compress blanks to a sigle space. */
+		do {
+			c = getc(file->stream);
+		} while (c == '\t'  || c == ' ');
+		ungetc(c, file->stream);
+		c = ' ';
+	}
+
+	if (c == EOF) {
+		/*
+		 * Fake EOL when hit EOF for the first time. This gets line
+		 * count rigchtif last line included file is syntactically
+		 * invalid and has no newline.
+		 */
+		if (file->eof_reached == 0) {
+			file->eof_reached = 1;
+			return ('\n');
+		}
+		while (c == EOF) {
+			if (file == topfile || popfile() == EOF)
+				return (EOF);
+			c = igetc();
+		}
+	}
+	return (c);
+}
+
+void
+lungetc(int c)
+{
+	if (c == EOF)
+		return;
+
+	if (file->ungetpos >= file->ungetsize) {
+		void *p = reallocarray(file->ungetbuf, file->ungetsize, 2);
+		if (p == NULL)
+			err(1, "reallocarray");
+		file->ungetbuf = p;
+		file->ungetsize *= 2;
+	}
+	file->ungetbuf[file->ungetpos++] = c;
+}
+
+int
+findeol(void)
+{
+	int	c;
+
+	/* skip to either EOF or the first real EOL */
+	while (1) {
+		c = lgetc(0);
+		if (c == '\n') {
+			file->lineno++;
+			break;
+		}
+		if (c == EOF)
+			break;
+	}
+	return (ERROR);
+}
+
+int
+yylex(void)
+{
+	char		 buf[8096];
+	char		*p = buf;
+	int		 c;
+	int		 token;
+	int		 starting = 0;
+	int		 ending = 0;
+	int		 quote = 0;
+
+	if (!in_define && block == 0) {
+		while ((c = lgetc(0)) != '{' && c != EOF) {
+			if (c == '\n')
+				file->lineno++;
+		}
+
+		if (c == EOF)
+			return (0);
+
+newblock:
+		c = lgetc(0);
+		if (c == '{' || c == '!') {
+			if (c == '{')
+				block = '}';
+			else
+				block = c;
+			return (c);
+		}
+		if (c == '\n')
+			file->lineno++;
+	}
+
+	while ((c = lgetc(0)) == ' ' || c == '\t' || c == '\n') {
+		if (c == '\n')
+			file->lineno++;
+	}
+
+	if (c == EOF) {
+		yyerror("unterminated block");
+		return (0);
+	}
+
+	yylval.lineno = file->lineno;
+
+	if (block != 0 && c == block) {
+		if ((c = lgetc(0)) == '}') {
+			if (block == '!') {
+				block = 0;
+				return ('!');
+			}
+			block = 0;
+			return ('}');
+		}
+		lungetc(c);
+		c = block;
+	}
+
+	if (in_define && block == 0) {
+		if (c == '{')
+			goto newblock;
+
+		do {
+			if (starting) {
+				if (c == '!' || c == '{') {
+					lungetc(c);
+					lungetc('{');
+					break;
+				}
+				starting = 0;
+				lungetc(c);
+				c = '{';
+			} else if (c == '{') {
+				starting = 1;
+				continue;
+			}
+
+			*p++ = c;
+			if ((size_t)(p - buf) >= sizeof(buf)) {
+				yyerror("string too long");
+				return (findeol());
+			}
+		} while ((c = lgetc(0)) != EOF && c != '\n');
+		*p = '\0';
+		if (c == EOF) {
+			yyerror("unterminated block");
+			return (0);
+		}
+		if (c == '\n')
+			file->lineno++;
+		if ((yylval.v.string = strdup(buf)) == NULL)
+			err(1, "strdup");
+		return (STRING);
+	}
+
+	if (block == '!') {
+		do {
+			if (ending) {
+				if (c == '}') {
+					lungetc(c);
+					lungetc(block);
+					break;
+				}
+				ending = 0;
+				lungetc(c);
+				c = block;
+			} else if (c == '!') {
+				ending = 1;
+				continue;
+			}
+
+			*p++ = c;
+			if ((size_t)(p - buf) >= sizeof(buf)) {
+				yyerror("line too long");
+				return (findeol());
+			}
+		} while ((c = lgetc(0)) != EOF && c != '\n');
+		*p = '\0';
+
+		if (c == EOF) {
+			yyerror("unterminated block");
+			return (0);
+		}
+		if (c == '\n')
+			file->lineno++;
+
+		if ((yylval.v.string = strdup(buf)) == NULL)
+			err(1, "strdup");
+		return (STRING);
+	}
+
+	if (c == '|')
+		return (c);
+
+	do {
+		if (!quote && isspace((unsigned char)c))
+			break;
+
+		if (c == '"')
+			quote = !quote;
+
+		if (!quote && c == '|') {
+			lungetc(c);
+			break;
+		}
+
+		if (ending) {
+			if (c == '}') {
+				lungetc(c);
+				lungetc('}');
+				break;
+			}
+			ending = 0;
+			lungetc(c);
+			c = block;
+		} else if (!quote && c == '}') {
+			ending = 1;
+			continue;
+		}
+
+		*p++ = c;
+		if ((size_t)(p - buf) >= sizeof(buf)) {
+			yyerror("string too long");
+			return (findeol());
+		}
+	} while ((c = lgetc(0)) != EOF);
+	*p = '\0';
+
+	if (c == EOF) {
+		yyerror(quote ? "unterminated quote" : "unterminated block");
+		return (0);
+	}
+	if (c ==  '\n')
+		file->lineno++;
+	if ((token = lookup(buf)) == STRING)
+		if ((yylval.v.string = strdup(buf)) == NULL)
+			err(1, "strdup");
+	return (token);
+}
+
+struct file *
+pushfile(const char *name, int secret)
+{
+	struct file	*nfile;
+
+	if ((nfile = calloc(1, sizeof(*nfile))) == NULL)
+		err(1, "calloc");
+	if ((nfile->name = strdup(name)) == NULL)
+		err(1, "strdup");
+	if ((nfile->stream = fopen(nfile->name, "r")) == NULL) {
+		warn("can't open %s", nfile->name);
+		free(nfile->name);
+		free(nfile);
+		return (NULL);
+	}
+	nfile->lineno = TAILQ_EMPTY(&files) ? 1 : 0;
+	nfile->ungetsize = 16;
+	nfile->ungetbuf = malloc(nfile->ungetsize);
+	if (nfile->ungetbuf == NULL)
+		err(1, "malloc");
+	TAILQ_INSERT_TAIL(&files, nfile, entry);
+	return (nfile);
+}
+
+int
+popfile(void)
+{
+	struct file	*prev;
+
+	if ((prev = TAILQ_PREV(file, files, entry)) != NULL)
+		prev->errors += file->errors;
+
+	TAILQ_REMOVE(&files, file, entry);
+	fclose(file->stream);
+	free(file->name);
+	free(file->ungetbuf);
+	free(file);
+	file = prev;
+	return (file ? 0 : EOF);
+}
+
+int
+parse(FILE *outfile, const char *filename)
+{
+	fp = outfile;
+
+	if ((file = pushfile(filename, 0)) == 0)
+		return (-1);
+	topfile = file;
+
+	yyparse();
+	errors = file->errors;
+	popfile();
+
+	return (errors ? -1 : 0);
+}
+
+void
+dbg(void)
+{
+	if (nodebug)
+		return;
+
+	if (yylval.lineno == lastline + 1) {
+		lastline = yylval.lineno;
+		return;
+	}
+	lastline = yylval.lineno;
+
+	fprintf(fp, "#line %d ", yylval.lineno);
+	printq(file->name);
+	putc('\n', fp);
+}
+
+void
+printq(const char *str)
+{
+	putc('"', fp);
+	for (; *str; ++str) {
+		if (*str == '"')
+			putc('\\', fp);
+		putc(*str, fp);
+	}
+	putc('"', fp);
+}
blob - /dev/null
blob + e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 (mode 644)
blob - /dev/null
blob + c2d0fad50754e9ec3740571ad5e77d7dc475ab26 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/01-noise-only.tmpl
@@ -0,0 +1,2 @@
+only
+noise
blob - /dev/null
blob + c35618a4287faab518c419c4bbbe06ecbca7f157 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/02-only-verbatim.tmpl
@@ -0,0 +1,17 @@
+{!
+#include <stdio.h>
+
+#include "tmpl.h"
+!}
+
+noise {! /* woops */ !}
+here
+
+{!
+int
+main(void)
+{
+	puts("hello, world!");
+	return (0);
+}
+!}
blob - /dev/null
blob + 270c611ee72c567bc1b2abec4cbc345bab9f15ba (mode 644)
--- /dev/null
+++ gotwebd/template/regress/02.expected
@@ -0,0 +1 @@
+hello, world!
blob - /dev/null
blob + d866f69531b2f1248c5bb6940f9b8c40adf87c82 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/03-block.tmpl
@@ -0,0 +1,22 @@
+{!
+#include <stdlib.h>
+
+#include "tmpl.h"
+!}
+
+{{ define base(struct template *tp, const char *title) }}
+{! char *foo = NULL; !}
+<!doctype html>
+<html>
+	<head>
+		<title>{{ title }}</title>
+	</head>
+	<body> {! /* TODO: frobnicate this line! */ !}
+		<h1>{{ title }}</h1>
+		{{ " | " }}
+		{{ "other stuff" }}
+	</body>
+</html>
+{{ finally }}
+{! free(foo); !}
+{{ end }}
blob - /dev/null
blob + 0f766820b9e5de1343ca8034bea3b2b0a4418221 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/03.expected
@@ -0,0 +1,2 @@
+<!doctype html><html><head><title> *hello* </title></head><body> <h1> *hello* </h1> | other stuff</body></html>
+<!doctype html><html><head><title>&lt;hello&gt;</title></head><body> <h1>&lt;hello&gt;</h1> | other stuff</body></html>
blob - /dev/null
blob + aa8c5edc9b8934fea4803f8b465a7f00fb389e23 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/04-flow.tmpl
@@ -0,0 +1,31 @@
+{!
+#include <stdlib.h>
+#include <string.h>
+
+#include "tmpl.h"
+!}
+
+{{ define base(struct template *tp, const char *title) }}
+{! char *foo = NULL; !}
+<!doctype html>
+<html>
+	<head>
+		<title>{{ title }}</title>
+	</head>
+	<body>
+		<h1>{{ title }}</h1>
+		{{ if strchr(title, '*') != NULL }}
+			<p>"{{ title }}" has a '*' in it</p>
+			{{ if 1 }}
+				<p>tautology!</p>
+			{{ end }}
+		{{ else if strchr(title, '=') != NULL }}
+			<p>"{{ title }}" has a '=' in it!</p>
+		{{ else }}
+			<p>"{{ title }}" doesn't have a '*' in it</p>
+		{{ end }}
+	</body>
+</html>
+{{ finally }}
+{! free(foo); !}
+{{ end }}
blob - /dev/null
blob + 32240e27d9a6dbeeb395f7e760e2a043e29b939c (mode 644)
--- /dev/null
+++ gotwebd/template/regress/04.expected
@@ -0,0 +1,2 @@
+<!doctype html><html><head><title> *hello* </title></head><body><h1> *hello* </h1><p>" *hello* " has a '*' in it</p><p>tautology!</p></body></html>
+<!doctype html><html><head><title>&lt;hello&gt;</title></head><body><h1>&lt;hello&gt;</h1><p>"&lt;hello&gt;" doesn't have a '*' in it</p></body></html>
blob - /dev/null
blob + d47673dc5bf15202d96329fe8d971d117bc930eb (mode 644)
--- /dev/null
+++ gotwebd/template/regress/05-loop.tmpl
@@ -0,0 +1,42 @@
+{!
+#include <sys/queue.h>
+#include <string.h>
+#include "lists.h"
+#include "tmpl.h"
+
+int	list(struct template *, struct tailhead *);
+
+!}
+
+{{ define base(struct template *tp, struct tailhead *head) }}
+<!doctype html>
+<html>
+	<body>
+		{{ render list(tp, head) }}
+	</body>
+</html>
+{{ end }}
+
+{{ define list(struct template *tp, struct tailhead *head) }}
+{!
+	struct entry *np;
+	int i;
+!}
+	{{ if !TAILQ_EMPTY(head) }}
+		<p>items:</p>
+		<ul>
+			{{ tailq-foreach np head entries }}
+				<li>{{ np->text }}</li>
+			{{ end }}
+		</ul>
+	{{ else }}
+		<p>no items</p>
+	{{ end }}
+
+	<p>
+		{{ for i = 0; i < 3; ++i }}
+			hello{{ " " }}
+		{{ end }}
+		world!
+	</p>
+{{ end }}
blob - /dev/null
blob + d4c20d67eeee5e6e0217d8abd4c19f2d440ba6d9 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/05.expected
@@ -0,0 +1,2 @@
+<!doctype html><html><body><p>items:</p><ul><li>1</li><li>2</li></ul><p>hello hello hello world!</p></body></html>
+<!doctype html><html><body><p>no items</p><p>hello hello hello world!</p></body></html>
blob - /dev/null
blob + f5450fbc2e7134e6fb5f5798e14983545f6347d4 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/06-escape.tmpl
@@ -0,0 +1,17 @@
+{!
+#include <stdlib.h>
+
+#include "tmpl.h"
+!}
+
+{{ define base(struct template *tp, const char *title) }}
+<!doctype html>
+<html>
+	<head>
+		<title>{{ title | urlescape }}</title>
+	</head>
+	<body>
+		<h1>{{ title | unsafe }}</h1>
+	</body>
+</html>
+{{ end }}
blob - /dev/null
blob + 6a9d734b454093206236753143743d95d5e473af (mode 644)
--- /dev/null
+++ gotwebd/template/regress/06.expected
@@ -0,0 +1,2 @@
+<!doctype html><html><head><title>%20*hello*%20</title></head><body><h1> *hello* </h1></body></html>
+<!doctype html><html><head><title><hello></title></head><body><h1><hello></h1></body></html>
blob - /dev/null
blob + 8b48d50f1257a72de04bbd33038320dd05edca49 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/07-printf.tmpl
@@ -0,0 +1,10 @@
+{!
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "tmpl.h"
+!}
+
+{{ define base(struct template *tp, const char *title) }}
+{{ printf "%.2s:\t%d\n", title, 42 }}
+{{ end }}
blob - /dev/null
blob + 681daf3915b708a380ddc75467b47beceff9b367 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/07.expected
@@ -0,0 +1,4 @@
+ *:	42
+
+&lt;h:	42
+
blob - /dev/null
blob + 5b41a3b36ca0223dab174dafa40e73c572f9a203 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/Makefile
@@ -0,0 +1,60 @@
+REGRESS_TARGETS =	00-empty \
+			01-noise-only \
+			02-only-verbatim \
+			03-block \
+			04-flow \
+			05-loop \
+			06-escape \
+			07-printf
+
+REGRESS_SETUP_ONCE =	setup-comp
+REGRESS_CLEANUP =	clean-comp
+NO_OBJ =		Yes
+
+CFLAGS +=		-I${.CURDIR}/../
+
+setup-comp:
+	cp ${.CURDIR}/../tmpl.c .
+	ln -f ${.CURDIR}/../template template || \
+		ln -f ${.CURDIR}/../obj/template template
+
+clean-comp:
+	rm template
+	rm -f t got 0*.[cdo] runbase.[do] runlist.[do] tmpl.*
+
+.SUFFIXES: .tmpl .c .o
+
+.tmpl.c:
+	./template -o $@ $?
+
+00-empty:
+	./template 00-empty.tmpl >/dev/null
+
+01-noise-only:
+	./template 01-noise-only.tmpl >/dev/null
+
+02-only-verbatim: 02-only-verbatim.o tmpl.o
+	${CC} 02-only-verbatim.o tmpl.o -o t && ./t > got
+	diff -u ${.CURDIR}/02.expected got
+
+03-block: 03-block.o runbase.o tmpl.o
+	${CC} 03-block.o runbase.o tmpl.o -o t && ./t > got
+	diff -u ${.CURDIR}/03.expected got
+
+04-flow: 04-flow.o runbase.o tmpl.o
+	${CC} 04-flow.o runbase.o tmpl.o -o t && ./t > got
+	diff -u ${.CURDIR}/04.expected got
+
+05-loop: 05-loop.o runlist.o tmpl.o
+	${CC} 05-loop.o runlist.o tmpl.o -o t && ./t > got
+	diff -u ${.CURDIR}/05.expected got
+
+06-escape: 06-escape.o runbase.o tmpl.o
+	${CC} 06-escape.o runbase.o tmpl.o -o t && ./t > got
+	diff -u ${.CURDIR}/06.expected got
+
+07-printf: 07-printf.o runbase.o tmpl.o
+	${CC} 07-printf.o runbase.o tmpl.o -o t && ./t > got
+	diff -u ${.CURDIR}/07.expected got
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 7229706ecd6bfb0bb2f38be92950b7082f7a6c41 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/lists.h
@@ -0,0 +1,7 @@
+#include <sys/queue.h>
+
+TAILQ_HEAD(tailhead, entry);
+struct entry {
+	char	*text;
+	TAILQ_ENTRY(entry) entries;
+};
blob - /dev/null
blob + 587f2ed68d2caf23a4828887385dae846d090cfe (mode 644)
--- /dev/null
+++ gotwebd/template/regress/runbase.c
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2022 Omar Polo <op@omarpolo.com>
+ *
+ * Permission to use, copy, modify, and 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 <err.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "tmpl.h"
+
+int	 base(struct template *, const char *title);
+
+int
+my_putc(struct template *tp, int c)
+{
+	FILE	*fp = tp->tp_arg;
+
+	if (putc(c, fp) < 0)
+		return (-1);
+
+	return (0);
+}
+
+int
+my_puts(struct template *tp, const char *s)
+{
+	FILE	*fp = tp->tp_arg;
+
+	if (fputs(s, fp) < 0)
+		return (-1);
+
+	return (0);
+}
+
+int
+main(int argc, char **argv)
+{
+	struct template *tp;
+
+	if ((tp = template(stdout, my_puts, my_putc)) == NULL)
+		err(1, "template");
+
+	if (base(tp, " *hello* ") == -1)
+		return (1);
+	puts("");
+
+	if (base(tp, "<hello>") == -1)
+		return (1);
+	puts("");
+
+	free(tp);
+	return (0);
+}
blob - /dev/null
blob + e5a35a7dfefa2010c52a71d596f2aa798602ab39 (mode 644)
--- /dev/null
+++ gotwebd/template/regress/runlist.c
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2022 Omar Polo <op@omarpolo.com>
+ *
+ * Permission to use, copy, modify, and 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 <err.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "tmpl.h"
+#include "lists.h"
+
+int	base(struct template *, struct tailhead *);
+
+int
+my_putc(struct template *tp, int c)
+{
+	FILE	*fp = tp->tp_arg;
+
+	if (putc(c, fp) < 0)
+		return (-1);
+
+	return (0);
+}
+
+int
+my_puts(struct template *tp, const char *s)
+{
+	FILE	*fp = tp->tp_arg;
+
+	if (fputs(s, fp) < 0)
+		return (-1);
+
+	return (0);
+}
+
+int
+main(int argc, char **argv)
+{
+	struct template	*tp;
+	struct tailhead	 head;
+	struct entry	*np;
+	int		 i;
+
+	if ((tp = template(stdout, my_puts, my_putc)) == NULL)
+		err(1, "template");
+
+	TAILQ_INIT(&head);
+	for (i = 0; i < 2; ++i) {
+		if ((np = calloc(1, sizeof(*np))) == NULL)
+			err(1, "calloc");
+		if (asprintf(&np->text, "%d", i+1) == -1)
+			err(1, "asprintf");
+		TAILQ_INSERT_TAIL(&head, np, entries);
+	}
+
+	if (base(tp, &head) == -1)
+		return (1);
+	puts("");
+
+	while ((np = TAILQ_FIRST(&head))) {
+		TAILQ_REMOVE(&head, np, entries);
+		free(np->text);
+		free(np);
+	}
+
+	if (base(tp, &head) == -1)
+		return (1);
+	puts("");
+
+	free(tp);
+
+	return (0);
+}
blob - /dev/null
blob + 4c3a752a31718ca8ad29543f9f2f61b00c13cc4e (mode 644)
--- /dev/null
+++ gotwebd/template/template.1
@@ -0,0 +1,85 @@
+.\" Copyright (c) 2022 Omar Polo <op@openbsd.org>
+.\"
+.\" Permission to use, copy, modify, and 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.
+.\"
+.Dd November 25, 2022
+.Dt TEMPLATE 1
+.Os
+.Sh NAME
+.Nm template
+.Nd templating system compiler
+.Sh SYNOPSIS
+.Nm
+.Op Fl G
+.Op Fl o Ar out
+.Op Ar
+.Sh DESCRIPTION
+.Nm
+is an utility that converts files written in the
+.Xr template 7
+format format to a set of routine writtens in the C programming
+language.
+.Nm
+converts the files given as arguments or from standard input, and
+writes to standard output.
+.Pp
+The options are as follows:
+.Bl -tag -width Ds
+.It Fl G
+Do not emit debug info in the generated source.
+It's disabled by default, unless
+.Nm
+is reading from standard input.
+.It Fl o Ar out
+Write output to file.
+.Ar out
+will be created or truncated if exists and will be removed if
+.Nm
+encounters any error.
+.El
+.Sh EXIT STATUS
+.Ex
+.Sh SEE ALSO
+.Xr template 7
+.Sh AUTHORS
+.An -nosplit
+The
+.Nm
+utility was written by
+.An Omar Polo Aq Mt op@openbsd.org .
+.Sh CAVEATS
+The compiler is very naive, so there are quite a few shortcomings:
+.Bl -bullet -compact
+.It
+No attempt is made to validate the C code provided inline, nor the
+validity of the arguments to many constructs.
+.It
+The generated code assumes that a variable called
+.Va tp
+of type
+.Vt struct template *
+is in scope inside each block.
+.It
+Each block may have additional variables used for the template
+generation implicitly defined: to avoid clashes, don't name variables
+or arguments with the
+.Sq tp_
+prefix.
+.It
+Blanks are, in most cases, trimmed.
+Normally this is not a problem, but a workaround is needed in case
+they need to be preserved, for e.g.:
+.Bd -literal -offset indent
+Name: {{ " " }} {{ render name_field(tp) }}
+.Ed
+.El
blob - /dev/null
blob + 31b32697b9affdd4231edc3b70f8abaf0bb8300c (mode 644)
--- /dev/null
+++ gotwebd/template/template.7
@@ -0,0 +1,125 @@
+.\" Copyright (c) 2022 Omar Polo <op@openbsd.org>
+.\"
+.\" Permission to use, copy, modify, and 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.
+.\"
+.Dd November 25, 2022
+.Dt TEMPLATE 7
+.Os
+.Sh NAME
+.Nm template
+.Nd templating language
+.Sh DESCRIPTION
+.Nm
+is a language used to define programs that output data in some way.
+These programs are called
+.Dq templates .
+A
+.Nm
+file is assumed to be compiled using the
+.Xr template 1
+utility into C code, to be further compiled as part of a bigger
+application.
+The language itself is format-agnostic and can thus be used to produce
+various type of outputs.
+.Pp
+There are two special sequences:
+.Bl -tag -width 9m
+.It Cm {{ Ar ... Cm }}
+used for
+.Nm
+special syntax.
+.It Cm {! Ar ... Cm !}
+used to include literal C code.
+This is the only special syntax permitted as top-level, except for block
+definition and includes.
+.El
+.Pp
+The basic unit of a
+.Nm
+file is the block.
+Each block is turned into a C function that output its content via some
+provided functions.
+Here's an example of a block:
+.Bd -literal -offset indent
+{{ define tp_base(struct template *tp, const char *title) }}
+<!doctype html>
+<html>
+	<head>
+		<title>{{ title }}</title>
+	</head>
+	<body>
+		{{ render tp->tp_body(tp) }}
+	</body>
+</html>
+{{ end }}
+.Ed
+.Ss SPECIAL SYNTAX
+This section is a reference for all the special syntaxes supported.
+.Bl -tag -indent Ds
+.It Cm {{ Ic include Ar file Cm }}
+Include additional template files.
+.It Cm {{ Ic define Ar name Ns ( Ar arguments ... ) Cm }} Ar body Cm {{ Ic end Cm }}
+Defines the block
+.Ar name
+with the given
+.Ar arguments .
+.Ar body
+will be outputted as-is via the provided functions
+.Pq i.e.\& is still escaped eventually
+and can contain all the special syntaxes documented here except
+.Ic include
+and
+.Ic define .
+.It Cm {{ Ic render Ar expression() Cm }}
+Executes
+.Ar expression()
+and terminate the template if it returns -1.
+It's used to render (call) another template.
+.It Cm {{ Ic printf Ar fmt , Ar arguments ... Cm }}
+Outputs the string that would be produced by calling
+.Xr printf 3
+with the given
+.Ar fmt
+format string and the given
+.Ar arguments .
+.It Cm {{ Ic if Ar expr Cm }} Ar ... Cm {{ Ic elseif Ar expr Cm }} Ar ... Cm {{ Ic else Cm }} Ar ... Cm {{ Ic end Cm }}
+Conditional evaluation.
+.Ic elseif
+can be provided zero or more times,
+.Ic else
+only zero or one time and always for last.
+.It Cm {{ Ic for Ar ... ; Ar ... ; Ar ... Cm  }} Ar ... Cm {{ Ic end Cm }}
+Looping construct similar to the C loop.
+.It Cm {{ Ic tailq-foreach Ar var head fieldname Cm }} Ar .. Cm {{ Ic end Cm }}
+Looping construct similar to the queue.h macro TAILQ_FOREACH.
+.It Cm {{ Ar expression Cm | Ic unsafe Cm }}
+Output
+.Ar expression
+as-is.
+.It Cm {{ Ar expression Cm | Ic urlescape Cm }}
+Output
+.Ar expression
+escaped in a way that can be made part of an URL.
+.It Cm {{ Ar expression Cm }}
+Output
+.Ar expression
+with the default escaping.
+.El
+.Sh SEE ALSO
+.Xr template 1
+.Sh AUTHORS
+.An -nosplit
+The
+.Nm
+reference was written by
+.Ar Omar Polo Aq Mt op@openbsd.org .
blob - /dev/null
blob + f52199db7b8acc13c6f1a44dc1cf4222b189b3e7 (mode 644)
--- /dev/null
+++ gotwebd/template/template.c
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2022 Omar Polo <op@omarpolo.com>
+ *
+ * Permission to use, copy, modify, and 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 <err.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+int	 parse(FILE *, const char *);
+
+int	 nodebug;
+
+static void __dead
+usage(void)
+{
+	fprintf(stderr, "usage: %s [file...]\n",
+	    getprogname());
+	exit(1);
+}
+
+int
+main(int argc, char **argv)
+{
+	FILE		*fp = stdout;
+	const char	*out = NULL;
+	int		 ch, i;
+
+	while ((ch = getopt(argc, argv, "Go:")) != -1) {
+		switch (ch) {
+		case 'G':
+			nodebug = 1;
+			break;
+		case 'o':
+			out = optarg;
+			break;
+		default:
+			usage();
+		}
+	}
+	argc -= optind;
+	argv += optind;
+
+	if (out && (fp = fopen(out, "w")) == NULL)
+		err(1, "can't open %s", out);
+
+	if (out && unveil(out, "wc") == -1)
+		err(1, "unveil %s", out);
+	if (unveil("/", "r") == -1)
+		err(1, "unveil /");
+	if (pledge(out ? "stdio rpath cpath" : "stdio rpath", NULL) == -1)
+		err(1, "pledge");
+
+	if (argc == 0) {
+		nodebug = 1;
+		if (parse(fp, "/dev/stdin") == -1)
+			goto err;
+	} else {
+		for (i = 0; i < argc; ++i)
+			if (parse(fp, argv[i]) == -1)
+				goto err;
+	}
+
+	if (ferror(fp))
+		goto err;
+
+	if (fclose(fp) == -1) {
+		fp = NULL;
+		goto err;
+	}
+
+	return (0);
+
+err:
+	if (fp)
+		fclose(fp);
+	if (out && unlink(out) == -1)
+		err(1, "unlink %s", out);
+	return (1);
+}
blob - /dev/null
blob + 36bb4882a5fec3f37fd3b75d26c1514f42bb77f5 (mode 644)
--- /dev/null
+++ gotwebd/template/tmpl.c
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2022 Omar Polo <op@omarpolo.com>
+ *
+ * Permission to use, copy, modify, and 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 <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "tmpl.h"
+
+int
+tp_urlescape(struct template *tp, const char *str)
+{
+	int	 r;
+	char	 tmp[4];
+
+	if (str == NULL)
+		return (0);
+
+	for (; *str; ++str) {
+		if (iscntrl((unsigned char)*str) ||
+		    isspace((unsigned char)*str) ||
+		    *str == '\'' || *str == '"' || *str == '\\') {
+			r = snprintf(tmp, sizeof(tmp), "%%%2X", *str);
+			if (r < 0  || (size_t)r >= sizeof(tmp))
+				return (0);
+			if (tp->tp_puts(tp, tmp) == -1)
+				return (-1);
+		} else {
+			if (tp->tp_putc(tp, *str) == -1)
+				return (-1);
+		}
+	}
+
+	return (0);
+}
+
+int
+tp_htmlescape(struct template *tp, const char *str)
+{
+	int r;
+
+	if (str == NULL)
+		return (0);
+
+	for (; *str; ++str) {
+		switch (*str) {
+		case '<':
+			r = tp->tp_puts(tp, "&lt;");
+			break;
+		case '>':
+			r = tp->tp_puts(tp, "&gt;");
+			break;
+		case '&':
+			r = tp->tp_puts(tp, "&amp;");
+			break;
+		case '"':
+			r = tp->tp_puts(tp, "&quot;");
+			break;
+		case '\'':
+			r = tp->tp_puts(tp, "&apos;");
+			break;
+		default:
+			r = tp->tp_putc(tp, *str);
+			break;
+		}
+
+		if (r == -1)
+			return (-1);
+	}
+
+	return (0);
+}
+
+struct template *
+template(void *arg, tmpl_puts putsfn, tmpl_putc putcfn)
+{
+	struct template *tp;
+
+	if ((tp = calloc(1, sizeof(*tp))) == NULL)
+		return (NULL);
+
+	tp->tp_arg = arg;
+	tp->tp_escape = tp_htmlescape;
+	tp->tp_puts = putsfn;
+	tp->tp_putc = putcfn;
+
+	return (tp);
+}
+
+void
+template_free(struct template *tp)
+{
+	free(tp->tp_tmp);
+	free(tp);
+}
blob - /dev/null
blob + 2367b76c1353b204d520091d5212fd0a438e15cf (mode 644)
--- /dev/null
+++ gotwebd/template/tmpl.h
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2022 Omar Polo <op@omarpolo.com>
+ *
+ * Permission to use, copy, modify, and 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 TMPL_H
+#define TMPL_H
+
+struct template;
+
+typedef int (*tmpl_puts)(struct template *, const char *);
+typedef int (*tmpl_putc)(struct template *, int);
+
+struct template {
+	void		*tp_arg;
+	char		*tp_tmp;
+	tmpl_puts	 tp_escape;
+	tmpl_puts	 tp_puts;
+	tmpl_putc	 tp_putc;
+};
+
+int		 tp_urlescape(struct template *, const char *);
+int		 tp_htmlescape(struct template *, const char *);
+
+struct template	*template(void *, tmpl_puts, tmpl_putc);
+void		 template_free(struct template *);
+
+#endif