aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.gitignore3
-rw-r--r--Makefile11
-rw-r--r--NOTES.md306
-rw-r--r--README.md17
-rw-r--r--global.ha2
-rw-r--r--main.ha22
-rw-r--r--req.ha104
-rw-r--r--static/style.css410
-rw-r--r--templates/_footer.htmpl2
-rw-r--r--url.ha20
10 files changed, 865 insertions, 32 deletions
diff --git a/.gitignore b/.gitignore
index f61aa76..c9ce4a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
/forge
-/templates.ha
+/.templates.ha
+/.version.ha
diff --git a/Makefile b/Makefile
index c32770f..a36e9de 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,10 @@
-forge: main.ha templates.ha
- hare build -o $@ .
+forge: .version.ha .templates.ha *.ha
+ hare build $(HAREFLAGS) -o $@ .
-templates.ha: templates/*.htmpl
+.templates.ha: templates/*.htmpl
htmplgen -o $@ $^
+
+.version.ha:
+ printf 'def VERSION="%s";\n' $(shell git describe --tags --always --dirty) > $@
+
+.PHONY: version.ha
diff --git a/NOTES.md b/NOTES.md
index 70b161d..98536f6 100644
--- a/NOTES.md
+++ b/NOTES.md
@@ -7,3 +7,309 @@ You will need the following dependencies:
[various patches](https://lists.sr.ht/~sircmpwn/hare-dev/patches?search=from%3Arunxiyu+prefix%3Ahare-http)
- [hare-htmpl](https://forge.runxiyu.org/hare/:/repos/hare-htmpl/)
([backup](https://git.sr.ht/~runxiyu/hare-htmpl))
+
+
+Also, you'll need various horrible patches for `net::uri` before that gets fixed:
+
+```
+diff --git a/net/uri/+test.ha b/net/uri/+test.ha
+index 345f41ee..63272d52 100644
+--- a/net/uri/+test.ha
++++ b/net/uri/+test.ha
+@@ -10,7 +10,7 @@ use net::ip;
+ uri {
+ scheme = "file",
+ host = "",
+- path = "/my/path/to/file",
++ raw_path = "/my/path/to/file",
+ ...
+ },
+ )!;
+@@ -19,7 +19,7 @@ use net::ip;
+ uri {
+ scheme = "http",
+ host = "harelang.org",
+- path = "/",
++ raw_path = "/",
+ ...
+ },
+ )!;
+@@ -38,7 +38,7 @@ use net::ip;
+ scheme = "ldap",
+ host = [13, 37, 73, 31]: ip::addr4,
+ port = 1234,
+- path = "/",
++ raw_path = "/",
+ ...
+ },
+ )!;
+@@ -47,7 +47,7 @@ use net::ip;
+ uri {
+ scheme = "http",
+ host = ip::parse("::1")!,
+- path = "/test",
++ raw_path = "/test",
+ ...
+ },
+ )!;
+@@ -58,7 +58,7 @@ use net::ip;
+ uri {
+ scheme = "urn",
+ host = "",
+- path = "example:animal:ferret:nose",
++ raw_path = "example:animal:ferret:nose",
+ ...
+ },
+ )!;
+@@ -67,7 +67,7 @@ use net::ip;
+ uri {
+ scheme = "mailto",
+ host = "",
+- path = "~sircmpwn/hare-dev@lists.sr.ht",
++ raw_path = "~sircmpwn/hare-dev@lists.sr.ht",
+ ...
+ },
+ )!;
+@@ -76,7 +76,7 @@ use net::ip;
+ uri {
+ scheme = "http",
+ host = "",
+- path = "/foo/bar",
++ raw_path = "/foo/bar",
+ ...
+ },
+ )!;
+@@ -85,7 +85,7 @@ use net::ip;
+ uri {
+ scheme = "http",
+ host = "",
+- path = "/",
++ raw_path = "/",
+ ...
+ },
+ )!;
+@@ -94,7 +94,7 @@ use net::ip;
+ uri {
+ scheme = "https",
+ host = "sr.ht",
+- path = "/projects",
++ raw_path = "/projects",
+ query = "search=%23risc-v&sort=longest-active",
+ fragment = "foo",
+ ...
+@@ -105,7 +105,7 @@ use net::ip;
+ uri {
+ scheme = "https",
+ host = "en.wiktionary.org",
+- path = "/wiki/おはよう",
++ raw_path = "/wiki/%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86",
+ fragment = "Japanese",
+ ...
+ }
+@@ -135,11 +135,11 @@ use net::ip;
+
+ @test fn percent_encoding() void = {
+ test_uri(
+- "https://git%2esr.ht/~sircmpw%6e/hare#Build%20status",
++ "https://git.sr.ht/~sircmpwn/hare#Build%20status",
+ uri {
+ scheme = "https",
+ host = "git.sr.ht",
+- path = "/~sircmpwn/hare",
++ raw_path = "/~sircmpwn/hare",
+ fragment = "Build status",
+ ...
+ },
+@@ -152,7 +152,7 @@ use net::ip;
+ uri {
+ scheme = "ldap",
+ host = ip::parse("2001:db8::7")!,
+- path = "/c=GB",
++ raw_path = "/c=GB",
+ query = "objectClass?one",
+ ...
+ },
+@@ -161,11 +161,11 @@ use net::ip;
+
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=841105
+ test_uri(
+- "https://web-safety.net/..;@www.google.com:%3443",
++ "https://web-safety.net/..;@www.google.com:443",
+ uri {
+ scheme = "https",
+ host = "web-safety.net",
+- path = "/..;@www.google.com:443",
++ raw_path = "/..;@www.google.com:443",
+ ...
+ },
+ "https://web-safety.net/..;@www.google.com:443",
+@@ -180,6 +180,7 @@ fn test_uri(in: str, expected_uri: uri, expected_str: str) (void | invalid) = {
+ const u = parse(in)?;
+ defer finish(&u);
+
++
+ assert_str(u.scheme, expected_uri.scheme);
+ match (u.host) {
+ case let s: str =>
+@@ -189,7 +190,7 @@ fn test_uri(in: str, expected_uri: uri, expected_str: str) (void | invalid) = {
+ };
+ assert(u.port == expected_uri.port);
+ assert_str(u.userinfo, expected_uri.userinfo);
+- assert_str(u.path, expected_uri.path);
++ assert_str(u.raw_path, expected_uri.raw_path);
+ assert_str(u.query, expected_uri.query);
+ assert_str(u.fragment, expected_uri.fragment);
+
+diff --git a/net/uri/fmt.ha b/net/uri/fmt.ha
+index 48a43f24..07cb3f7b 100644
+--- a/net/uri/fmt.ha
++++ b/net/uri/fmt.ha
+@@ -20,9 +20,9 @@ use strings;
+ // query = *( pchar / "/" / "?" )
+ // fragment = *( pchar / "/" / "?" )
+
+-def unres_host: str = "-._~!$&'()*+,;=";
+-def unres_query_frag: str = "-._~!$&'()*+,;=:@/?";
+-def unres_path: str = "-._~!$&'()*+,;=:@/";
++export def unres_host: str = "-._~!$&'()*+,;=";
++export def unres_query_frag: str = "-._~!$&'()*+,;=:@/?";
++export def unres_path: str = "-._~!$&'()*+,;=:@/";
+
+ // Writes a formatted [[uri]] to an [[io::handle]]. Returns the number of bytes
+ // written.
+@@ -63,10 +63,10 @@ export fn fmt(out: io::handle, u: *const uri) (size | io::error) = {
+ if (u.port != 0) {
+ n += fmt::fprintf(out, ":{}", u.port)?;
+ };
+- if (has_host && len(u.path) > 0 && !strings::hasprefix(u.path, '/')) {
++ if (has_host && len(u.raw_path) > 0 && !strings::hasprefix(u.raw_path, '/')) {
+ n += fmt::fprint(out, "/")?;
+ };
+- n += percent_encode(out, u.path, unres_path)?;
++ n += memio::concat(out, u.raw_path)?;
+ if (len(u.query) > 0) {
+ // Always percent-encoded, see parse and encodequery/decodequery
+ n += fmt::fprintf(out, "?{}", u.query)?;
+@@ -92,7 +92,7 @@ fn fmtaddr(out: io::handle, addr: ip::addr) (size | io::error) = {
+ return n;
+ };
+
+-fn percent_encode(out: io::handle, src: str, allowed: str) (size | io::error) = {
++export fn percent_encode(out: io::handle, src: str, allowed: str) (size | io::error) = {
+ let iter = strings::iter(src);
+ let n = 0z;
+ for (let r => strings::next(&iter)) {
+diff --git a/net/uri/parse.ha b/net/uri/parse.ha
+index f2522c01..e108bd75 100644
+--- a/net/uri/parse.ha
++++ b/net/uri/parse.ha
+@@ -22,10 +22,10 @@ export fn parse(in: str) (uri | invalid) = {
+ defer if (!success) free(scheme);
+
+ // Determine hier-part variant
+- let path = "";
++ let raw_path = "";
+ let authority: ((str | ip::addr6), u16, str) = ("", 0u16, "");
+ defer if (!success) {
+- free(path);
++ free(raw_path);
+ free_host(authority.0);
+ free(authority.2);
+ };
+@@ -50,7 +50,7 @@ export fn parse(in: str) (uri | invalid) = {
+ case '/' =>
+ // path-absolute
+ strings::prev(&in);
+- path = parse_path(&in,
++ raw_path = parse_path(&in,
+ path_mode::ABSOLUTE)?;
+ case =>
+ return invalid;
+@@ -61,17 +61,17 @@ export fn parse(in: str) (uri | invalid) = {
+ // path-absolute
+ strings::prev(&in); // return current token
+ strings::prev(&in); // return leading slash
+- path = parse_path(&in, path_mode::ABSOLUTE)?;
++ raw_path = parse_path(&in, path_mode::ABSOLUTE)?;
+ };
+ case =>
+ // path-absolute (just '/')
+ strings::prev(&in); // return leading slash
+- path = parse_path(&in, path_mode::ABSOLUTE)?;
++ raw_path = parse_path(&in, path_mode::ABSOLUTE)?;
+ };
+ case =>
+ // path-rootless
+ strings::prev(&in);
+- path = parse_path(&in, path_mode::ROOTLESS)?;
++ raw_path = parse_path(&in, path_mode::ROOTLESS)?;
+ };
+ case => void; // path-empty
+ };
+@@ -118,7 +118,7 @@ export fn parse(in: str) (uri | invalid) = {
+ port = authority.1,
+ userinfo = authority.2,
+
+- path = path,
++ raw_path = raw_path,
+ query = query,
+ fragment = fragment,
+ };
+@@ -274,7 +274,7 @@ fn parse_path(in: *strings::iterator, mode: path_mode) (str | invalid) = {
+ };
+ };
+
+- return percent_decode(strings::slice(&copy, in));
++ return strings::dup(strings::slice(&copy, in))!;
+ };
+
+ fn parse_query(in: *strings::iterator) (str | invalid) = {
+@@ -323,13 +323,14 @@ fn parse_port(in: *strings::iterator) (u16 | invalid) = {
+ };
+ };
+
+-fn percent_decode(s: str) (str | invalid) = {
++// must be freed by caller
++export fn percent_decode(s: str) (str | invalid) = {
+ let buf = memio::dynamic();
+ percent_decode_static(&buf, s)?;
+ return memio::string(&buf)!;
+ };
+
+-fn percent_decode_static(out: io::handle, s: str) (void | invalid) = {
++export fn percent_decode_static(out: io::handle, s: str) (void | invalid) = {
+ let iter = strings::iter(s);
+ let tmp = memio::dynamic();
+ defer io::close(&tmp)!;
+diff --git a/net/uri/uri.ha b/net/uri/uri.ha
+index 623ffafb..3b7b7c4c 100644
+--- a/net/uri/uri.ha
++++ b/net/uri/uri.ha
+@@ -12,7 +12,7 @@ export type uri = struct {
+ port: u16,
+ userinfo: str,
+
+- path: str,
++ raw_path: str,
+ query: str,
+ fragment: str,
+ };
+@@ -31,7 +31,7 @@ export fn dup(u: *uri) uri = {
+ port = u.port,
+ userinfo = strings::dup(u.userinfo)!,
+
+- path = strings::dup(u.path)!,
++ raw_path = strings::dup(u.raw_path)!,
+ query = strings::dup(u.query)!,
+ fragment = strings::dup(u.fragment)!,
+ };
+@@ -46,7 +46,7 @@ export fn finish(u: *uri) void = {
+ case => void;
+ };
+ free(u.userinfo);
+- free(u.path);
++ free(u.raw_path);
+ free(u.query);
+ free(u.fragment);
+ };
+```
diff --git a/README.md b/README.md
index 7b0adf8..42b2883 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,2 @@
-# Lindenii Forge
-
-**Work in progress.**
-
-This is the new implementation in the [Hare](https://harelang.org) programming
-language. We will set this as the primary branch once it reaches feature parity
-with the Go implementation.
-
-## Architecture
-
-* Most components are one single daemon written in Hare.
-* Because libssh is difficult to use and there aren't many other SSH server
- libraries for C or Hare, we will temporarily use
- [the gliberlabs SSH library for Go](https://github.com/gliderlabs/ssh)
- in a separate process, and communicate via UNIX domain sockets.
+**Please check out the `master` branch. Everything that used to be in the
+`hare` branch is now in `master`. The `hare` branch is deprectated.**
diff --git a/global.ha b/global.ha
index 11d7886..ac5ac14 100644
--- a/global.ha
+++ b/global.ha
@@ -5,7 +5,7 @@ let global: struct {
ssh_fp: str,
} = struct {
title: str = "Test Forge",
- version: str = "v0.0.0",
+ version: str = VERSION,
ssh_pubkey: str = "pubkey",
ssh_fp: str = "fp",
};
diff --git a/main.ha b/main.ha
index b52f111..fc41240 100644
--- a/main.ha
+++ b/main.ha
@@ -2,26 +2,28 @@
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
// Adapted from template by Willow Barraco <contact@willowbarraco.fr>
+use fs;
use getopt;
-use htmpl;
use log;
use net;
use net::dial;
use net::http;
use net::ip;
use net::tcp;
+use net::uri;
use os;
use memio;
use io;
use fmt;
use bufio;
-use strings;
const usage: [_]getopt::help = [
"Lindenii Forge Server",
('c', "config", "path to configuration file")
];
+let static_fs: nullable *fs::fs = null;
+
export fn main() void = {
const cmd = getopt::parse(os::args, usage...);
defer getopt::finish(&cmd);
@@ -32,10 +34,12 @@ export fn main() void = {
for (let opt .. cmd.opts) {
switch (opt.0) {
case 'c' => yield; // TODO: actually handle the config
- case => abort(); // unreachable
+ case => abort("unreachable");
};
};
+ static_fs = os::diropen("static")!;
+
const server = match (http::listen(ip_addr, port, net::tcp::reuseport, net::tcp::reuseaddr)) {
case let this: *http::server =>
yield this;
@@ -47,19 +51,15 @@ export fn main() void = {
const serv_req = match (http::serve(server)) {
case let this: *http::server_request =>
yield this;
- case net::error => abort("failure while serving");
+ case =>
+ log::println("failure while serving");
+ continue;
};
defer http::serve_finish(serv_req);
match (handlereq(serv_req.socket, &serv_req.request)) {
case void => yield;
- case io::error => log::println("error while handling request");
+ case => log::println("error while handling request");
};
};
};
-
-export fn handlereq(conn: io::handle, request: *http::request) (void | io::error | nomem) = {
- htmpl::write(conn, "HTTP/1.1 200 OK\r\n")?;
- htmpl::write(conn, "Content-Type: text/html\r\n\r\n")?;
- tp_index(conn)?;
-};
diff --git a/req.ha b/req.ha
new file mode 100644
index 0000000..59f6438
--- /dev/null
+++ b/req.ha
@@ -0,0 +1,104 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
+
+use fmt;
+use fs;
+use htmpl;
+use io;
+use mime;
+use net::http;
+use net::uri;
+use strconv;
+use strings;
+
+fn handlereq(conn: io::handle, request: *http::request) (void | io::error | nomem | fs::error) = {
+ let segments = match(segments_from_path(request.target.raw_path)) {
+ case let s: []str =>
+ yield s;
+ case uri::invalid =>
+ start_response(conn, 400, "text/plain")?;
+ fmt::fprintln(conn, "Invalid URI")?;
+ return void;
+ case nomem =>
+ return nomem;
+ case =>
+ abort("unreachable");
+ };
+ defer strings::freeall(segments);
+
+ let trailing_slash: bool = false;
+
+ if (segments[len(segments) - 1] == "") {
+ trailing_slash = true;
+ free(segments[len(segments) - 1]);
+ segments = segments[.. len(segments) - 1];
+ };
+
+ if (len(segments) == 0) {
+ start_response(conn, 200, "text/html")?;
+ return tp_index(conn);
+ };
+
+ if (segments[0] == ":") {
+ if (len(segments) == 1) {
+ start_response(conn, 404, "text/plain")?;
+ fmt::fprintln(conn, "Error: Blank system endpoint")?;
+ return;
+ };
+
+ switch (segments[1]) {
+ case "static" =>
+ if (len(segments) == 2) {
+ start_response(conn, 404, "text/plain")?;
+ fmt::fprintln(conn, "Error: Blank static endpoint")?;
+ return;
+ };
+
+ let fs_segments = segments[2 ..];
+ for (let fs_segment .. fs_segments) {
+ if (strings::contains(fs_segment, "/")) {
+ start_response(conn, 400, "text/plain")?;
+ fmt::fprintln(conn, "Error: Slash found in filesystem path")?;
+ return;
+ };
+ };
+ let fs_segment_path = strings::join("/", fs_segments...)?;
+ defer free(fs_segment_path);
+
+ let file = match (fs::open(static_fs as *fs::fs, fs_segment_path)) {
+ case let f: io::handle => yield f;
+ case fs::error =>
+ start_response(conn, 500, "text/plain")?;
+ fmt::fprintln(conn, "Filesystem error")?;
+ return;
+ };
+ defer io::close(file)!;
+
+ let ext = strings::rcut(fs_segments[len(fs_segments) - 1], ".").1;
+
+ let mimetype = match (mime::lookup_ext(ext)) {
+ case let m: *mime::mimetype => yield m.mime;
+ case null => yield "application/octet-stream";
+ };
+
+ start_response(conn, 200, mimetype)?;
+ io::copy(conn, file)?;
+
+ case =>
+ start_response(conn, 404, "text/plain")?;
+ fmt::fprintln(conn, "Error: Unknown system endpoint")?;
+ };
+ };
+};
+
+fn start_response(conn: io::handle, status: uint, content_type: str) (void | io::error | nomem) = { // TODO: add len and other headers
+ fmt::fprint(conn, "HTTP/1.1 ")?;
+ fmt::fprint(conn, strconv::utos(status))?;
+ fmt::fprint(conn, " ")?;
+ fmt::fprint(conn, http::status_reason(status))?;
+ fmt::fprint(conn, "\r\n")?;
+ fmt::fprint(conn, "Content-Type: ")?;
+ fmt::fprint(conn, content_type)?;
+ fmt::fprint(conn, "\r\n")?;
+ fmt::fprint(conn, "\r\n")?;
+};
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..e5398ce
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,410 @@
+/*
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * SPDX-FileContributor: Runxi Yu <https://runxiyu.org>
+ * SPDX-FileContributor: luk3yx <https://luk3yx.github.io>
+ */
+
+/* Base styles and variables */
+html {
+ font-family: sans-serif;
+ background-color: var(--background-color);
+ color: var(--text-color);
+ --radius-1: 0.32rem;
+ --background-color: hsl(0, 0%, 100%);
+ --text-color: hsl(0, 0%, 0%);
+ --link-color: hsl(320, 50%, 36%);
+ --light-text-color: hsl(0, 0%, 45%);
+ --darker-border-color: hsl(0, 0%, 72%);
+ --lighter-border-color: hsl(0, 0%, 85%);
+ --text-decoration-color: hsl(0, 0%, 72%);
+ --darker-box-background-color: hsl(0, 0%, 92%);
+ --lighter-box-background-color: hsl(0, 0%, 95%);
+ --primary-color: hsl(320, 50%, 36%);
+ --primary-color-contrast: hsl(320, 0%, 100%);
+ --danger-color: #ff0000;
+ --danger-color-contrast: #ffffff;
+}
+
+/* Dark mode overrides */
+@media (prefers-color-scheme: dark) {
+ html {
+ --background-color: hsl(0, 0%, 0%);
+ --text-color: hsl(0, 0%, 100%);
+ --link-color: hsl(320, 50%, 76%);
+ --light-text-color: hsl(0, 0%, 78%);
+ --darker-border-color: hsl(0, 0%, 35%);
+ --lighter-border-color: hsl(0, 0%, 25%);
+ --text-decoration-color: hsl(0, 0%, 30%);
+ --darker-box-background-color: hsl(0, 0%, 20%);
+ --lighter-box-background-color: hsl(0, 0%, 15%);
+ }
+}
+
+/* Global layout */
+body {
+ margin: 0;
+}
+html, code, pre {
+ font-size: 0.96rem; /* TODO: Not always correct */
+}
+
+/* Toggle table controls */
+.toggle-table-off, .toggle-table-on {
+ opacity: 0;
+ position: absolute;
+}
+.toggle-table-off:focus-visible + table > thead > tr > th > label,
+.toggle-table-on:focus-visible + table > thead > tr > th > label {
+ outline: 1.5px var(--primary-color) solid;
+}
+.toggle-table-off + table > thead > tr > th, .toggle-table-on + table > thead > tr > th {
+ padding: 0;
+}
+.toggle-table-off + table > thead > tr > th > label, .toggle-table-on + table > thead > tr > th > label {
+ width: 100%;
+ display: inline-block;
+ padding: 3px 0;
+ cursor: pointer;
+}
+.toggle-table-off:checked + table > tbody {
+ display: none;
+}
+.toggle-table-on + table > tbody {
+ display: none;
+}
+.toggle-table-on:checked + table > tbody {
+ display: table-row-group;
+}
+
+table.rounded, table.rounded-footed {
+ overflow: hidden;
+ border-spacing: 0;
+ border-collapse: separate;
+ border-radius: var(--radius-1);
+ border: var(--lighter-border-color) solid 1px;
+}
+
+table.rounded th, table.rounded td,
+table.rounded-footed th, table.rounded-footed td {
+ border: none;
+}
+
+table.rounded th:not(:last-child),
+table.rounded td:not(:last-child),
+table.rounded-footed th:not(:last-child),
+table.rounded-footed td:not(:last-child) {
+ border-right: var(--lighter-border-color) solid 1px;
+}
+
+table.rounded>thead>tr>th,
+table.rounded>thead>tr>td,
+table.rounded>tbody>tr:not(:last-child)>th,
+table.rounded>tbody>tr:not(:last-child)>td {
+ border-bottom: var(--lighter-border-color) solid 1px;
+}
+
+table.rounded-footed>thead>tr>th,
+table.rounded-footed>thead>tr>td,
+table.rounded-footed>tbody>tr>th,
+table.rounded-footed>tbody>tr>td,
+table.rounded-footed>tfoot>tr:not(:last-child)>th,
+table.rounded-footed>tfoot>tr:not(:last-child)>td {
+ border-bottom: var(--lighter-border-color) solid 1px;
+}
+
+
+/* Footer styles */
+footer {
+ margin-top: 1rem;
+ margin-left: auto;
+ margin-right: auto;
+ display: block;
+ padding: 0 5px;
+ width: fit-content;
+ text-align: center;
+ color: var(--light-text-color);
+}
+footer a:link, footer a:visited {
+ color: inherit;
+}
+
+/* Padding containers */
+.padding-wrapper {
+ margin: 1rem auto;
+ max-width: 60rem;
+ padding: 0 5px;
+}
+.padding {
+ padding: 0 5px;
+}
+
+/* Link styles */
+a:link, a:visited {
+ text-decoration-color: var(--text-decoration-color);
+ color: var(--link-color);
+}
+
+/* Readme inline code styling */
+#readme code:not(pre > code) {
+ background-color: var(--lighter-box-background-color);
+ border-radius: 2px;
+ padding: 2px;
+}
+
+/* Readme word breaks to avoid overfull hboxes */
+#readme {
+ word-break: break-word;
+}
+
+/* Table styles */
+table {
+ border: var(--lighter-border-color) solid 1px;
+ border-spacing: 0px;
+ border-collapse: collapse;
+}
+table.wide {
+ width: 100%;
+}
+td, th {
+ padding: 3px 5px;
+ border: var(--lighter-border-color) solid 1px;
+}
+.pad {
+ padding: 3px 5px;
+}
+th, thead, tfoot {
+ background-color: var(--lighter-box-background-color);
+}
+th[scope=row] {
+ text-align: left;
+}
+th {
+ font-weight: normal;
+}
+tr.title-row > th, th.title-row, .title-row {
+ background-color: var(--lighter-box-background-color);
+ font-weight: bold;
+}
+td > pre {
+ margin: 0;
+}
+#readme > *:last-child {
+ margin-bottom: 0;
+}
+#readme > *:first-child {
+ margin-top: 0;
+}
+
+/* Table misc and scrolling */
+.commit-id {
+ font-family: monospace;
+ word-break: break-word;
+}
+.scroll {
+ overflow-x: auto;
+}
+
+/* Diff/chunk styles */
+.chunk-unchanged {
+ color: grey;
+}
+.chunk-addition {
+ color: green;
+}
+@media (prefers-color-scheme: dark) {
+ .chunk-addition {
+ color: lime;
+ }
+}
+.chunk-deletion {
+ color: red;
+}
+.chunk-unknown {
+ color: yellow;
+}
+pre.chunk {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+.centering {
+ text-align: center;
+}
+
+/* Toggle content sections */
+.toggle-off-wrapper, .toggle-on-wrapper {
+ border: var(--lighter-border-color) solid 1px;
+}
+.toggle-off-toggle, .toggle-on-toggle {
+ opacity: 0;
+ position: absolute;
+}
+.toggle-off-header, .toggle-on-header {
+ font-weight: bold;
+ cursor: pointer;
+ display: block;
+ width: 100%;
+ background-color: var(--lighter-box-background-color);
+}
+.toggle-off-header > div, .toggle-on-header > div {
+ padding: 3px 5px;
+ display: block;
+}
+.toggle-on-content {
+ display: none;
+}
+.toggle-on-toggle:focus-visible + .toggle-on-header, .toggle-off-toggle:focus-visible + .toggle-off-header {
+ outline: 1.5px var(--primary-color) solid;
+}
+.toggle-on-toggle:checked + .toggle-on-header + .toggle-on-content {
+ display: block;
+}
+.toggle-off-content {
+ display: block;
+}
+.toggle-off-toggle:checked + .toggle-off-header + .toggle-off-content {
+ display: none;
+}
+
+*:focus-visible {
+ outline: 1.5px var(--primary-color) solid;
+}
+
+/* File display styles */
+.file-patch + .file-patch {
+ margin-top: 0.5rem;
+}
+.file-content {
+ padding: 3px 5px;
+}
+.file-header {
+ font-family: monospace;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+.file-header::after {
+ content: "\25b6";
+ font-family: sans-serif;
+ margin-left: auto;
+ line-height: 100%;
+ margin-right: 0.25em;
+}
+.file-toggle:checked + .file-header::after {
+ content: "\25bc";
+}
+
+/* Form elements */
+textarea {
+ box-sizing: border-box;
+ background-color: var(--lighter-box-background-color);
+ resize: vertical;
+}
+textarea,
+input[type=text],
+input[type=password] {
+ font-family: sans-serif;
+ font-size: smaller;
+ background-color: var(--lighter-box-background-color);
+ color: var(--text-color);
+ border: none;
+ padding: 0.3rem;
+ width: 100%;
+ box-sizing: border-box;
+}
+td.tdinput, th.tdinput {
+ padding: 0;
+ position: relative;
+}
+td.tdinput textarea,
+td.tdinput input[type=text],
+td.tdinput input[type=password],
+th.tdinput textarea,
+th.tdinput input[type=text],
+th.tdinput input[type=password] {
+ background-color: transparent;
+}
+td.tdinput select {
+ position: absolute;
+ background-color: var(--background-color);
+ border: none;
+ /*
+ width: 100%;
+ height: 100%;
+ */
+ box-sizing: border-box;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+select:active {
+ outline: 1.5px var(--primary-color) solid;
+}
+
+
+/* Button styles */
+.btn-primary, a.btn-primary {
+ background: var(--primary-color);
+ color: var(--primary-color-contrast);
+ border: var(--lighter-border-color) 1px solid;
+ font-weight: bold;
+}
+.btn-danger, a.btn-danger {
+ background: var(--danger-color);
+ color: var(--danger-color-contrast);
+ border: var(--lighter-border-color) 1px solid;
+ font-weight: bold;
+}
+.btn-white, a.btn-white {
+ background: var(--primary-color-contrast);
+ color: var(--primary-color);
+ border: var(--lighter-border-color) 1px solid;
+}
+.btn-normal, a.btn-normal,
+input[type=file]::file-selector-button {
+ background: var(--lighter-box-background-color);
+ border: var(--lighter-border-color) 1px solid !important;
+ color: var(--text-color);
+}
+.btn, .btn-white, .btn-danger, .btn-normal, .btn-primary,
+input[type=submit],
+input[type=file]::file-selector-button {
+ display: inline-block;
+ width: auto;
+ min-width: fit-content;
+ border-radius: var(--radius-1);
+ padding: .1rem .75rem;
+ font-size: 0.9rem;
+ transition: background .1s linear;
+ cursor: pointer;
+}
+a.btn, a.btn-white, a.btn-danger, a.btn-normal, a.btn-primary {
+ text-decoration: none;
+}
+
+/* Header layout */
+header#main-header {
+ background-color: var(--lighter-box-background-color);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px;
+}
+header#main-header > div#main-header-forge-title {
+ flex-grow: 1;
+}
+header#main-header > div#main-header-user {
+ display: flex;
+ align-items: center;
+}
+
+/* Uncategorized */
+table + table {
+ margin-top: 1rem;
+}
+
+td > ul {
+ padding-left: 1.5rem;
+ margin-top: 0;
+ margin-bottom: 0;
+}
diff --git a/templates/_footer.htmpl b/templates/_footer.htmpl
index 71d5318..a0d1987 100644
--- a/templates/_footer.htmpl
+++ b/templates/_footer.htmpl
@@ -5,5 +5,5 @@
{{ " " }}
(<a href="/:/source/">source</a>,
{{ " " }}
-<a href="https://forge.lindenii.runxiyu.org/lindenii/forge/:/repos/server/">upstream</a>)
+<a href="https://forge.lindenii.runxiyu.org/lindenii/forge/:/repos/server/?branch=hare">upstream</a>)
{{ end }}
diff --git a/url.ha b/url.ha
new file mode 100644
index 0000000..1c511ba
--- /dev/null
+++ b/url.ha
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
+
+use strings;
+use net::uri;
+
+// The result, if not erroring out, must be freed with strings::freeall.
+fn segments_from_path(s: str) ([]str | nomem | uri::invalid) = {
+ let sp: []str = strings::split(s, "/")?;
+ for (let i = 1z; i < len(sp); i += 1) {
+ match (uri::percent_decode(sp[i])) {
+ case let s: str =>
+ sp[i - 1] = s;
+ case uri::invalid =>
+ strings::freeall(sp[.. i - 1]);
+ return uri::invalid;
+ };
+ };
+ return sp[.. len(sp) - 1];
+};