summary refs log tree commit diff
path: root/html.c
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--html.c559
1 files changed, 266 insertions, 293 deletions
diff --git a/html.c b/html.c
index 2d3ce2f..a28d84b 100644
--- a/html.c
+++ b/html.c
@@ -47,32 +47,19 @@ enum kcgi_err htmlHead(struct khtmlreq *html, const char *title) {
 		|| khtml_closeelem(html, 1);
 }
 
-enum kcgi_err htmlScopeFields(struct khtmlreq *html, struct Scope scope) {
-	if (scope.network) {
-		enum kcgi_err error = khtml_attr(
-			html, KELEM_INPUT,
-			KATTR_TYPE, "hidden",
-			KATTR_NAME, Keys[Network].name,
-			KATTR_VALUE, scope.network,
-			KATTR__MAX
-		);
-		if (error) return error;
-	}
-	if (scope.context) {
-		enum kcgi_err error = khtml_attr(
-			html, KELEM_INPUT,
-			KATTR_TYPE, "hidden",
-			KATTR_NAME, Keys[Context].name,
-			KATTR_VALUE, scope.context,
-			KATTR__MAX
-		);
-		if (error) return error;
-	}
-	return KCGI_OK;
+enum kcgi_err
+htmlHidden(struct khtmlreq *html, struct kreq *req, enum Key key) {
+	if (!req->fieldmap[key]) return KCGI_OK;
+	return khtml_attr(
+		html, KELEM_INPUT,
+		KATTR_TYPE, "hidden",
+		KATTR_NAME, Keys[key].name,
+		KATTR_VALUE, req->fieldmap[key]->val,
+		KATTR__MAX
+	);
 }
 
-enum kcgi_err
-htmlNav(struct khtmlreq *html, struct Scope scope) {
+enum kcgi_err htmlNav(struct khtmlreq *html, struct kreq *req) {
 	enum kcgi_err error = 0
 		|| khtml_elem(html, KELEM_NAV)
 		|| khtml_elem(html, KELEM_OL)
@@ -82,44 +69,53 @@ htmlNav(struct khtmlreq *html, struct Scope scope) {
 		|| khtml_closeelem(html, 2);
 	if (error) return error;
 
-	if (scope.network) {
+	const char *network = NULL;
+	const char *context = NULL;
+	if (req->fieldmap[Network]) {
+		network = req->fieldmap[Network]->parsed.s;
+		if (req->fieldmap[Context]) {
+			context = req->fieldmap[Context]->parsed.s;
+		}
+	}
+
+	if (network) {
 		char *href = khttp_urlpart(
 			NULL, NULL, Pages[Contexts],
-			Keys[Network].name, scope.network,
+			Keys[Network].name, network,
 			NULL
 		);
 		if (!href) err(EX_OSERR, "khttp_urlpart");
 		error = 0
 			|| khtml_elem(html, KELEM_LI)
 			|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
-			|| khtml_puts(html, scope.network)
+			|| khtml_puts(html, network)
 			|| khtml_closeelem(html, 2);
+		free(href);
 		if (error) return error;
 	}
 
-	if (scope.network && scope.context) {
+	if (context) {
+		const char *context = req->fieldmap[Context]->parsed.s;
 		char *href = khttp_urlpart(
 			NULL, NULL, Pages[Events],
-			Keys[Network].name, scope.network,
-			Keys[Context].name, scope.context,
+			Keys[Network].name, network,
+			Keys[Context].name, context,
 			NULL
 		);
 		if (!href) err(EX_OSERR, "khttp_urlpart");
 		error = 0
 			|| khtml_elem(html, KELEM_LI)
 			|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
-			|| khtml_puts(html, scope.context)
+			|| khtml_puts(html, context)
 			|| khtml_closeelem(html, 2);
+		free(href);
 		if (error) return error;
 	}
 
-	char label[256];
-	snprintf(
-		label, sizeof(label), "Search%s%s",
-		(scope.network ? " " : ""),
-		(scope.context ? scope.context : scope.network ? scope.network : "")
-	);
-
+	const char *query = NULL;
+	if (req->fieldmap[Query]) {
+		query = req->fieldmap[Query]->parsed.s;
+	}
 	return 0
 		|| khtml_closeelem(html, 1)
 		|| khtml_attr(
@@ -132,44 +128,124 @@ htmlNav(struct khtmlreq *html, struct Scope scope) {
 			html, KELEM_INPUT,
 			KATTR_TYPE, "search",
 			KATTR_NAME, Keys[Query].name,
-			KATTR_VALUE, (scope.query ? scope.query : ""),
+			KATTR_VALUE, (query ? query : ""),
 			KATTR__MAX
 		)
-		|| htmlScopeFields(html, scope)
-		|| khtml_attr(
-			html, KELEM_INPUT,
-			KATTR_TYPE, "submit",
-			KATTR_VALUE, label,
-			KATTR__MAX
+		|| htmlHidden(html, req, Network)
+		|| htmlHidden(html, req, Context)
+		|| khtml_elem(html, KELEM_BUTTON)
+		|| khtml_printf(
+			html, "Search %s",
+			(context ? context : network ? network : "")
 		)
-		|| khtml_closeelem(html, 2);
+		|| khtml_closeelem(html, 3);
 }
 
-static const char *SourceURL = "https://git.causal.agency/scooper";
-static const char *SyntaxURL = {
-	"https://www.sqlite.org/fts5.html#full_text_query_syntax"
-};
-static const char *Columns = {
-	"network, channel, query, nick, user, target, message"
-};
+enum kcgi_err linkify(struct khtmlreq *html, const char *str, size_t len) {
+	static const char *Pattern = "https?://([^[:space:]>\"()]|[(][^)]*[)])+";
+	static regex_t regex;
+	if (!regex.re_nsub) {
+		int error = regcomp(&regex, Pattern, REG_EXTENDED);
+		assert(!error);
+	}
 
-enum kcgi_err htmlFooter(struct khtmlreq *html) {
-	return 0
-		|| khtml_closeto(html, 0)
-		|| khtml_elem(html, KELEM_FOOTER)
-		|| khtml_elem(html, KELEM_SPAN)
-		|| khtml_attr(html, KELEM_A, KATTR_HREF, SourceURL, KATTR__MAX)
-		|| khtml_puts(html, "scooper is AGPLv3+")
-		|| khtml_closeelem(html, 2)
-		|| khtml_putc(html, ' ')
-		|| khtml_elem(html, KELEM_SPAN)
-		|| khtml_attr(html, KELEM_A, KATTR_HREF, SyntaxURL, KATTR__MAX)
-		|| khtml_puts(html, "Search syntax")
-		|| khtml_closeelem(html, 1)
-		|| khtml_putc(html, ' ')
-		|| khtml_attr(html, KELEM_SPAN, KATTR_TITLE, Columns, KATTR__MAX)
-		|| khtml_puts(html, "Columns")
-		|| khtml_closeto(html, 0);
+	regmatch_t match = {0};
+	while (!regexec(&regex, str, 1, &match, 0)) {
+		if ((size_t)match.rm_eo > len) break;
+
+		char *href = strndup(&str[match.rm_so], match.rm_eo - match.rm_so);
+		if (!href) err(EX_OSERR, "strndup");
+
+		enum kcgi_err error = 0
+			|| khtml_write(str, match.rm_so, html)
+			|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
+			|| khtml_write(&str[match.rm_so], match.rm_eo - match.rm_so, html)
+			|| khtml_closeelem(html, 1);
+		free(href);
+		if (error) return error;
+
+		str += match.rm_eo;
+		len -= match.rm_eo;
+	}
+	if (len) return khtml_write(str, len, html);
+	return KCGI_OK;
+}
+
+static const struct Style {
+	int fg, bg;
+	bool b, r, i, u;
+} Default = { .fg = 99, .bg = 99 };
+
+enum kcgi_err htmlStyle(struct khtmlreq *html, struct Style style) {
+	enum kcgi_err error = KCGI_OK;
+	char class[sizeof("fg99")];
+	if (style.fg != Default.fg) {
+		snprintf(class, sizeof(class), "fg%02d", style.fg);
+		error = error || khtml_attr(
+			html, KELEM_SPAN,
+			KATTR_CLASS, class,
+			KATTR__MAX
+		);
+	}
+	if (style.bg != Default.bg) {
+		snprintf(class, sizeof(class), "bg%02d", style.bg);
+		error = error || khtml_attr(
+			html, KELEM_SPAN,
+			KATTR_CLASS, class,
+			KATTR__MAX
+		);
+	}
+	if (style.b) error = error || khtml_elem(html, KELEM_B);
+	if (style.r) error = error || khtml_elem(html, KELEM_MARK);
+	if (style.i) error = error || khtml_elem(html, KELEM_I);
+	if (style.u) error = error || khtml_elem(html, KELEM_U);
+	return error;
+}
+
+enum kcgi_err htmlIRC(struct khtmlreq *html, const char *str) {
+	enum kcgi_err error = khtml_attr(
+		html, KELEM_SPAN,
+		KATTR_CLASS, "irc",
+		KATTR__MAX
+	);
+	if (error) return error;
+
+	size_t top = khtml_elemat(html);
+	struct Style style = Default;
+	for (;;) {
+		size_t len = strcspn(str, "\2\3\17\26\35\37");
+		if (len) {
+			error = 0
+				|| khtml_closeto(html, top)
+				|| htmlStyle(html, style)
+				|| linkify(html, str, len);
+			if (error) return error;
+		}
+
+		str += len;
+		if (!*str) break;
+		switch (*str++) {
+			break; case '\2':  style.b ^= true;
+			break; case '\17': style = Default;
+			break; case '\26': style.r ^= true;
+			break; case '\35': style.i ^= true;
+			break; case '\37': style.u ^= true;
+			break; case '\3': {
+				if (!isdigit(*str)) {
+					style.fg = Default.fg;
+					style.bg = Default.bg;
+					break;
+				}
+				style.fg = *str++ - '0';
+				if (isdigit(*str)) style.fg = style.fg * 10 + *str++ - '0';
+				if (str[0] != ',' || !isdigit(str[1])) break;
+				str++;
+				style.bg = *str++ - '0';
+				if (isdigit(*str)) style.bg = style.bg * 10 + *str++ - '0';
+			}
+		}
+	}
+	return khtml_closeto(html, top) || khtml_closeelem(html, 1);
 }
 
 static const char *timestamp(time_t time) {
@@ -178,74 +254,73 @@ static const char *timestamp(time_t time) {
 	return stamp;
 }
 
-static enum kcgi_err eventTime(struct khtmlreq *html, struct Event event) {
-	char *base = NULL;
-	if (event.network && event.context) {
-		base = khttp_urlpart(
+static enum kcgi_err
+eventTime(struct khtmlreq *html, const struct Event *event) {
+	char *page = NULL;
+	char *href = NULL;
+	if (event->network && event->context) {
+		page = khttp_urlpart(
 			NULL, NULL, Pages[Events],
-			Keys[Network].name, event.network,
-			Keys[Context].name, event.context,
-			Keys[After].name, timestamp(event.time - eventsOverlap),
+			Keys[Network].name, event->network,
+			Keys[Context].name, event->context,
+			Keys[After].name, timestamp(event->time - eventsOverlap),
 			NULL
 		);
-		if (!base) err(EX_OSERR, "khttp_urlpart");
+		if (!page) err(EX_OSERR, "khttp_urlpart");
 	}
-	char *href = NULL;
-	asprintf(&href, "%s#%" PRId64, (base ? base : ""), event.event);
+	asprintf(&href, "%s#%" PRId64, (page ? page : ""), event->event);
 	if (!href) err(EX_OSERR, "asprintf");
-	if (base) free(base);
+	if (page) free(page);
 
+	const char *stamp = timestamp(event->time);
 	enum kcgi_err error = 0
 		|| khtml_attr(html, KELEM_TD, KATTR_CLASS, "time", KATTR__MAX)
 		|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
 		|| khtml_attr(
 			html, KELEM_TIME,
-			KATTR_DATETIME, timestamp(event.time),
+			KATTR_DATETIME, stamp,
 			KATTR__MAX
 		)
-		|| khtml_puts(html, timestamp(event.time))
+		|| khtml_puts(html, stamp)
 		|| khtml_closeelem(html, 3);
-
 	free(href);
 	return error;
 }
 
 static enum kcgi_err
-eventNetwork(struct khtmlreq *html, struct Scope scope, struct Event event) {
-	if (scope.network || !scope.query || !event.network) return KCGI_OK;
+eventNetwork(struct khtmlreq *html, struct kreq *req, struct Event *event) {
+	if (!req->fieldmap[Query] || req->fieldmap[Network]) return KCGI_OK;
 	char *href = khttp_urlpart(
 		NULL, NULL, Pages[Search],
-		Keys[Network].name, event.network,
-		Keys[Query].name, scope.query,
+		Keys[Query].name, req->fieldmap[Query]->parsed.s,
+		Keys[Network].name, event->network,
 		NULL
 	);
 	if (!href) err(EX_OSERR, "khttp_urlpart");
 	enum kcgi_err error = 0
 		|| khtml_attr(html, KELEM_TD, KATTR_CLASS, "network", KATTR__MAX)
 		|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
-		|| khtml_puts(html, event.network)
+		|| khtml_puts(html, event->network)
 		|| khtml_closeelem(html, 2);
 	free(href);
 	return error;
 }
 
 static enum kcgi_err
-eventContext(struct khtmlreq *html, struct Scope scope, struct Event event) {
-	if (scope.context || !scope.query || !event.network || !event.context) {
-		return KCGI_OK;
-	}
+eventContext(struct khtmlreq *html, struct kreq *req, struct Event *event) {
+	if (!req->fieldmap[Query] || req->fieldmap[Context]) return KCGI_OK;
 	char *href = khttp_urlpart(
 		NULL, NULL, Pages[Search],
-		Keys[Network].name, event.network,
-		Keys[Context].name, event.context,
-		Keys[Query].name, scope.query,
+		Keys[Query].name, req->fieldmap[Query]->parsed.s,
+		Keys[Network].name, event->network,
+		Keys[Context].name, event->context,
 		NULL
 	);
 	if (!href) err(EX_OSERR, "khttp_urlpart");
 	enum kcgi_err error = 0
 		|| khtml_attr(html, KELEM_TD, KATTR_CLASS, "context", KATTR__MAX)
 		|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
-		|| khtml_puts(html, event.context)
+		|| khtml_puts(html, event->context)
 		|| khtml_closeelem(html, 2);
 	free(href);
 	return error;
@@ -262,115 +337,93 @@ static int hash(const char *str) {
 	return 2 + hash % 74;
 }
 
-static const char *colorClass(int color) {
-	static char class[sizeof("fg99")];
-	snprintf(class, sizeof(class), "fg%02d", color);
-	return class;
-}
-
-static enum kcgi_err eventNick(struct khtmlreq *html, struct Event event) {
+static enum kcgi_err
+eventNick(struct khtmlreq *html, const struct Event *event) {
 	char *mask = NULL;
-	asprintf(&mask, "%s!%s@%s", event.nick, event.user, event.host);
+	char class[sizeof("fg99")];
+	snprintf(class, sizeof(class), "fg%02d", hash(event->user));
+	asprintf(&mask, "%s!%s@%s", event->nick, event->user, event->host);
 	if (!mask) err(EX_OSERR, "asprintf");
-
+	const char *format = "%s";
+	switch (event->type) {
+		break; case Privmsg: format = "<%s>";
+		break; case Notice:  format = "-%s-";
+		break; case Action:  format = "* %s";
+		break; default:;
+	}
 	enum kcgi_err error = 0
 		|| khtml_attr(html, KELEM_TD, KATTR_CLASS, "nick", KATTR__MAX)
 		|| khtml_attr(
 			html, KELEM_SPAN,
-			KATTR_CLASS, colorClass(hash(event.user)),
+			KATTR_CLASS, class,
 			KATTR_TITLE, mask,
 			KATTR__MAX
-		);
+		)
+		|| khtml_printf(html, format, event->nick)
+		|| khtml_closeelem(html, 2);
 	free(mask);
-	if (error) return error;
-
-	switch (event.type) {
-		break; case Privmsg: error = khtml_printf(html, "<%s>", event.nick);
-		break; case Action:  error = khtml_printf(html, "* %s", event.nick);
-		break; case Notice:  error = khtml_printf(html, "-%s-", event.nick);
-		break; default: error = khtml_puts(html, event.nick);
-	}
-	return error || khtml_closeelem(html, 2);
-}
-
-static enum kcgi_err typeJoin(struct khtmlreq *html, struct Event event) {
-	(void)event;
-	return khtml_puts(html, "joined");
-}
-
-static enum kcgi_err typePart(struct khtmlreq *html, struct Event event) {
-	if (!event.message) return khtml_puts(html, "left");
-	return 0
-		|| khtml_puts(html, "left (")
-		|| htmlIRC(html, event.message)
-		|| khtml_puts(html, ")");
-}
-
-static enum kcgi_err typeQuit(struct khtmlreq *html, struct Event event) {
-	if (!event.message) return khtml_puts(html, "quit");
-	return 0
-		|| khtml_puts(html, "quit (")
-		|| htmlIRC(html, event.message)
-		|| khtml_puts(html, ")");
+	return error;
 }
 
-static enum kcgi_err typeKick(struct khtmlreq *html, struct Event event) {
-	if (!event.target) return KCGI_OK;
-	if (!event.message) return khtml_printf(html, "kicked %s", event.target);
+static enum kcgi_err
+maybeMessage(struct khtmlreq *html, const struct Event *event) {
+	if (!event->message) return KCGI_OK;
 	return 0
-		|| khtml_printf(html, "kicked %s (", event.target)
-		|| htmlIRC(html, event.message)
+		|| khtml_puts(html, " (")
+		|| htmlIRC(html, event->message)
 		|| khtml_puts(html, ")");
 }
 
-static enum kcgi_err typeNick(struct khtmlreq *html, struct Event event) {
-	if (!event.target) return KCGI_OK;
-	return 0
-		|| khtml_puts(html, "changed nick to ")
-		|| khtml_attr(
-			html, KELEM_SPAN,
-			KATTR_CLASS, colorClass(hash(event.user)),
-			KATTR__MAX
-		)
-		|| khtml_puts(html, event.target)
-		|| khtml_closeelem(html, 1);
-}
-
-static enum kcgi_err typeTopic(struct khtmlreq *html, struct Event event) {
-	if (!event.message) return KCGI_OK;
-	return 0
-		|| khtml_puts(html, "set the topic: ")
-		|| htmlIRC(html, event.message);
-}
-
-static enum kcgi_err typeBan(struct khtmlreq *html, struct Event event) {
-	if (!event.target) return KCGI_OK;
-	return khtml_printf(html, "banned %s", event.target);
-}
-
-static enum kcgi_err typeUnban(struct khtmlreq *html, struct Event event) {
-	if (!event.target) return KCGI_OK;
-	return khtml_printf(html, "unbanned %s", event.target);
-}
-
-static enum kcgi_err eventMessage(struct khtmlreq *html, struct Event event) {
+static enum kcgi_err
+eventMessage(struct khtmlreq *html, const struct Event *event) {
 	enum kcgi_err error = khtml_attr(
 		html, KELEM_TD,
 		KATTR_CLASS, "message",
 		KATTR__MAX
 	);
 	if (error) return error;
-	switch (event.type) {
-		break; case Join: error = typeJoin(html, event);
-		break; case Part: error = typePart(html, event);
-		break; case Quit: error = typeQuit(html, event);
-		break; case Kick: error = typeKick(html, event);
-		break; case Nick: error = typeNick(html, event);
-		break; case Topic: error = typeTopic(html, event);
-		break; case Ban: error = typeBan(html, event);
-		break; case Unban: error = typeUnban(html, event);
+	switch (event->type) {
+		break; case Join: {
+			error = khtml_puts(html, "joined");
+		}
+		break; case Part: {
+			error = khtml_puts(html, "left") || maybeMessage(html, event);
+		}
+		break; case Quit: {
+			error = khtml_puts(html, "quit") || maybeMessage(html, event);
+		}
+		break; case Kick: {
+			if (!event->target) break;
+			error = 0
+				|| khtml_printf(html, "kicked %s", event->target)
+				|| maybeMessage(html, event);
+		}
+		break; case Nick: {
+			if (!event->target) break;
+			char class[sizeof("fg99")];
+			snprintf(class, sizeof(class), "fg%02d", hash(event->user));
+			error = 0
+				|| khtml_puts(html, "changed nick to ")
+				|| khtml_attr(html, KELEM_SPAN, KATTR_CLASS, class, KATTR__MAX)
+				|| khtml_puts(html, event->target)
+				|| khtml_closeelem(html, 1);
+		}
+		break; case Topic: {
+			if (!event->message) break;
+			error = 0
+				|| khtml_puts(html, "set the topic: ")
+				|| htmlIRC(html, event->message);
+		}
+		break; case Ban: {
+			if (!event->target) break;
+			error = khtml_printf(html, "banned %s", event->target);
+		}
+		break; case Unban: {
+			if (!event->target) break;
+			error = khtml_printf(html, "unbanned %s", event->target);
+		}
 		break; default: {
-			if (event.message) error = htmlIRC(html, event.message);
+			if (event->message) error = htmlIRC(html, event->message);
 		}
 	}
 	return error || khtml_closeelem(html, 1);
@@ -383,126 +436,46 @@ static const char *Types[TypesLen] = {
 };
 
 enum kcgi_err
-htmlEvent(struct khtmlreq *html, struct Scope scope, struct Event event) {
-	const char *type = (event.type < TypesLen ? Types[event.type] : "unknown");
+htmlEvent(struct khtmlreq *html, struct kreq *req, struct Event *event) {
+	const char *type = (event->type < TypesLen ? Types[event->type] : "unknown");
 	return 0
 		|| khtml_attrx(
 			html, KELEM_TR,
-			KATTR_ID, KATTRX_INT, event.event,
+			KATTR_ID, KATTRX_INT, event->event,
 			KATTR_CLASS, KATTRX_STRING, type,
 			KATTR__MAX
 		)
 		|| eventTime(html, event)
-		|| eventNetwork(html, scope, event)
-		|| eventContext(html, scope, event)
+		|| eventNetwork(html, req, event)
+		|| eventContext(html, req, event)
 		|| eventNick(html, event)
 		|| eventMessage(html, event)
 		|| khtml_closeelem(html, 1);
 }
 
-enum kcgi_err linkify(struct khtmlreq *html, const char *str, size_t len) {
-	static const char *Pattern = "https?://([^[:space:]>\"()]|[(][^)]*[)])+";
-	static regex_t regex;
-	if (!regex.re_nsub) {
-		int error = regcomp(&regex, Pattern, REG_EXTENDED);
-		assert(!error);
-	}
-
-	regmatch_t match = {0};
-	while (!regexec(&regex, str, 1, &match, 0)) {
-		if ((size_t)match.rm_eo > len) break;
-
-		char *href = strndup(&str[match.rm_so], match.rm_eo - match.rm_so);
-		if (!href) err(EX_OSERR, "strndup");
-
-		enum kcgi_err error = 0
-			|| khtml_write(str, match.rm_so, html)
-			|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
-			|| khtml_write(&str[match.rm_so], match.rm_eo - match.rm_so, html)
-			|| khtml_closeelem(html, 1);
-		free(href);
-		if (error) return error;
-
-		str += match.rm_eo;
-		len -= match.rm_eo;
-	}
-	if (len) return khtml_write(str, len, html);
-	return KCGI_OK;
-}
-
-static const struct Style {
-	int fg, bg;
-	bool b, r, i, u;
-} Default = { .fg = 99, .bg = 99 };
-
-enum kcgi_err htmlStyle(struct khtmlreq *html, struct Style style) {
-	enum kcgi_err error = KCGI_OK;
-	char class[sizeof("fg99")];
-	if (style.fg != Default.fg) {
-		snprintf(class, sizeof(class), "fg%02d", style.fg);
-		error = error || khtml_attr(
-			html, KELEM_SPAN,
-			KATTR_CLASS, class,
-			KATTR__MAX
-		);
-	}
-	if (style.bg != Default.bg) {
-		snprintf(class, sizeof(class), "bg%02d", style.bg);
-		error = error || khtml_attr(
-			html, KELEM_SPAN,
-			KATTR_CLASS, class,
-			KATTR__MAX
-		);
-	}
-	if (style.b) error = error || khtml_elem(html, KELEM_B);
-	if (style.r) error = error || khtml_elem(html, KELEM_MARK);
-	if (style.i) error = error || khtml_elem(html, KELEM_I);
-	if (style.u) error = error || khtml_elem(html, KELEM_U);
-	return error;
-}
-
-enum kcgi_err htmlIRC(struct khtmlreq *html, const char *str) {
-	enum kcgi_err error = khtml_attr(
-		html, KELEM_SPAN,
-		KATTR_CLASS, "irc",
-		KATTR__MAX
-	);
-	if (error) return error;
-
-	size_t top = khtml_elemat(html);
-	struct Style style = Default;
-	for (;;) {
-		size_t len = strcspn(str, "\2\3\17\26\35\37");
-		if (len) {
-			error = 0
-				|| khtml_closeto(html, top)
-				|| htmlStyle(html, style)
-				|| linkify(html, str, len);
-			if (error) return error;
-		}
+static const char *SourceURL = "https://git.causal.agency/scooper";
+static const char *SyntaxURL = {
+	"https://www.sqlite.org/fts5.html#full_text_query_syntax"
+};
+static const char *Columns = {
+	"network, channel, query, nick, user, target, message"
+};
 
-		str += len;
-		if (!*str) break;
-		switch (*str++) {
-			break; case '\2':  style.b ^= true;
-			break; case '\17': style = Default;
-			break; case '\26': style.r ^= true;
-			break; case '\35': style.i ^= true;
-			break; case '\37': style.u ^= true;
-			break; case '\3': {
-				if (!isdigit(*str)) {
-					style.fg = Default.fg;
-					style.bg = Default.bg;
-					break;
-				}
-				style.fg = *str++ - '0';
-				if (isdigit(*str)) style.fg = style.fg * 10 + *str++ - '0';
-				if (str[0] != ',' || !isdigit(str[1])) break;
-				str++;
-				style.bg = *str++ - '0';
-				if (isdigit(*str)) style.bg = style.bg * 10 + *str++ - '0';
-			}
-		}
-	}
-	return khtml_closeto(html, top) || khtml_closeelem(html, 1);
+enum kcgi_err htmlFooter(struct khtmlreq *html) {
+	return 0
+		|| khtml_closeto(html, 0)
+		|| khtml_elem(html, KELEM_FOOTER)
+		|| khtml_elem(html, KELEM_SPAN)
+		|| khtml_attr(html, KELEM_A, KATTR_HREF, SourceURL, KATTR__MAX)
+		|| khtml_puts(html, "scooper is AGPLv3+")
+		|| khtml_closeelem(html, 2)
+		|| khtml_putc(html, ' ')
+		|| khtml_elem(html, KELEM_SPAN)
+		|| khtml_attr(html, KELEM_A, KATTR_HREF, SyntaxURL, KATTR__MAX)
+		|| khtml_puts(html, "Search syntax")
+		|| khtml_closeelem(html, 1)
+		|| khtml_putc(html, ' ')
+		|| khtml_attr(html, KELEM_SPAN, KATTR_TITLE, Columns, KATTR__MAX)
+		|| khtml_puts(html, "Columns")
+		|| khtml_closeto(html, 0);
 }