stagit

static page generator for git
git clone https://github.com/michaelneuper/stagit.git
README | Files | Log | Refs | LICENSE

commit 979912b2fbe78bb3ba9a002469faff9cd9a615a4
parent a7fd5458f529d4d15ffa1d78fd2035c4f44c1003
Author: Michael Neuper <michael@michaelneuper.com>
Date:   Sun, 25 Oct 2026 22:34:37 +0200

highlight file pages with chroma

chroma picks the lexer from the filename, passed through the environment
so the shell never interprets it. Its output is used only when it has as
many lines as the file; unknown languages, a missing chroma or a mismatch
fall back to plain text. style.css gets chroma's github and github-dark
colours, each theme in its own media query. CI installs chroma v2.27.0
from its release, checked against the published checksum.

Diffstat:
M.github/workflows/mirror.yml | 8++++++++
MREADME.md | 5+++--
Mstagit.c | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mstyle.css | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 251 insertions(+), 6 deletions(-)

diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml @@ -25,6 +25,14 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends libgit2-dev libmd4c-dev libmd4c-html0-dev pandoc + # Not packaged for Ubuntu; the checksum is from the release's + # chroma-2.27.0-checksums.txt. + - name: Install chroma + run: | + curl -fsSLO https://github.com/alecthomas/chroma/releases/download/v2.27.0/chroma-2.27.0-linux-amd64.tar.gz + echo "91e1cd175006ff8a19cdd45ad12f9dd8c4eeeca4ec85703ebcc09cd31122f802 chroma-2.27.0-linux-amd64.tar.gz" | sha256sum -c - + sudo tar xzf chroma-2.27.0-linux-amd64.tar.gz -C /usr/local/bin chroma + - name: Build stagit run: make diff --git a/README.md b/README.md @@ -26,8 +26,9 @@ To see only the code changes: `git diff 1.3 -- '*.c' Makefile`. ## Building the site -Needs libgit2, md4c, pandoc and the GitHub CLI -(`brew install libgit2 md4c pandoc gh`). +Needs libgit2, md4c, pandoc, chroma and the GitHub CLI +(`brew install libgit2 md4c pandoc chroma gh`). Without chroma, file pages +aren't syntax highlighted. ./build.sh python3 -m http.server -d public diff --git a/stagit.c b/stagit.c @@ -1,5 +1,6 @@ #include <sys/stat.h> #include <sys/types.h> +#include <sys/wait.h> #include <ctype.h> #include <err.h> @@ -589,17 +590,107 @@ writefooter(FILE *fp) } size_t -writeblobhtml(FILE *fp, const git_blob *blob) +countlines(const char *s, size_t len) +{ + size_t i, n = 0; + + for (i = 0; i < len; i++) + if (s[i] == '\n') + n++; + + return n + (len > 0 && s[len - 1] != '\n'); +} + +/* source highlighted by chroma, with the spans of each line closed on that + * line, or NULL if chroma doesn't know the language or isn't installed */ +char * +highlight(const char *s, size_t len, const char *filename) +{ + static int nochroma; + char tmppath[] = "/tmp/stagit.XXXXXX", cmd[256], buf[BUFSIZ]; + char *html = NULL, *p; + size_t n, htmllen; + FILE *tmpfp, *pp, *htmlfp; + int fd, status; + + if (nochroma) + return NULL; + + if ((fd = mkstemp(tmppath)) < 0) + err(1, "mkstemp"); + if (!(tmpfp = fdopen(fd, "w"))) + err(1, "fdopen: '%s'", tmppath); + fwrite(s, 1, len, tmpfp); + checkfileerror(tmpfp, tmppath, 'w'); + fclose(tmpfp); + + /* the filename picks the lexer; passing it through the environment + * keeps the shell from interpreting it */ + if (setenv("STAGIT_FILENAME", filename, 1)) + err(1, "setenv"); + snprintf(cmd, sizeof(cmd), + "chroma --html --html-only --html-prevent-surrounding-pre --fail " + "--filename=\"$STAGIT_FILENAME\" < '%s' 2>/dev/null", tmppath); + if (!(pp = popen(cmd, "r"))) + err(1, "popen"); + if (!(htmlfp = open_memstream(&html, &htmllen))) + err(1, "open_memstream"); + while ((n = fread(buf, 1, sizeof(buf), pp)) > 0) + fwrite(buf, 1, n, htmlfp); + status = pclose(pp); + if (fclose(htmlfp)) + err(1, "fclose: '%s'", filename); + unlink(tmppath); + + if (WIFEXITED(status) && WEXITSTATUS(status) == 127) { + warnx("chroma not found, files are not highlighted"); + nochroma = 1; + } + if (status) { + free(html); + return NULL; + } + + /* chroma ends a span at each newline, but closes it after the newline */ + for (p = html; (p = strstr(p, "\n</span>")); p += strlen("</span>\n")) + memcpy(p, "</span>\n", strlen("</span>\n")); + + return html; +} + +size_t +writeblobhtml(FILE *fp, const git_blob *blob, const char *filename) { size_t n = 0, i, len, prev; /* style.css draws the number from data-n, so it isn't copied with the code */ const char *nfmt = "<a href=\"#l%zu\" class=\"line\" id=\"l%zu\" data-n=\"%zu\"></a>"; - const char *s = git_blob_rawcontent(blob); + const char *s = git_blob_rawcontent(blob), *p, *e, *q; + char *html = NULL; len = git_blob_rawsize(blob); fputs("<pre id=\"blob\">\n", fp); - if (len > 0) { + /* only use the highlighted lines if they line up with the file */ + if (len > 0 && (html = highlight(s, len, filename)) && + countlines(html, strlen(html)) != countlines(s, len)) { + free(html); + html = NULL; + } + + if (html) { + for (p = html; *p; p = *e ? e + 1 : e) { + if (!(e = strchr(p, '\n'))) + e = p + strlen(p); + n++; + fprintf(fp, nfmt, n, n, n); + for (q = p; q < e; q++) + if (*q != '\r') + putc(*q, fp); + if (*e) + putc('\n', fp); + } + free(html); + } else if (len > 0) { for (i = 0, prev = 0; i < len; i++) { if (s[i] != '\n') continue; @@ -1291,7 +1382,7 @@ writeblob(git_object *obj, const char *fpath, const char *filename, size_t files if (git_blob_is_binary((git_blob *)obj)) fputs("<p>Binary file.</p>\n", fp); else - lc = writeblobhtml(fp, (git_blob *)obj); + lc = writeblobhtml(fp, (git_blob *)obj, filename); writefooter(fp); checkfileerror(fp, fpath, 'w'); diff --git a/style.css b/style.css @@ -299,3 +299,148 @@ pre a.d { #readme .task-list-item { list-style: none; } + +/* + * Syntax highlighting on file pages. Generated with + * `chroma --html-styles --style=github` (github-dark for dark mode), + * without the background and line number rules, and with .chroma + * replaced by #blob. Each theme sits in its own media query, so a token + * one theme leaves unstyled doesn't keep the other theme's colour. + */ + +@media (prefers-color-scheme: light) { + #blob .k { color: #cf222e } + #blob .kc { color: #cf222e } + #blob .kd { color: #cf222e } + #blob .kn { color: #cf222e } + #blob .kp { color: #cf222e } + #blob .kr { color: #cf222e } + #blob .kt { color: #cf222e } + #blob .na { color: #1f2328 } + #blob .nc { color: #1f2328 } + #blob .no { color: #0550ae } + #blob .nd { color: #0550ae } + #blob .ni { color: #6639ba } + #blob .nl { color: #990000; font-weight: bold } + #blob .nn { color: #24292e } + #blob .nx { color: #1f2328 } + #blob .nt { color: #0550ae } + #blob .nb { color: #6639ba } + #blob .bp { color: #6a737d } + #blob .nv { color: #953800 } + #blob .vc { color: #953800 } + #blob .vg { color: #953800 } + #blob .vi { color: #953800 } + #blob .vm { color: #953800 } + #blob .nf { color: #6639ba } + #blob .fm { color: #6639ba } + #blob .s { color: #0a3069 } + #blob .sa { color: #0a3069 } + #blob .sb { color: #0a3069 } + #blob .sc { color: #0a3069 } + #blob .dl { color: #0a3069 } + #blob .sd { color: #0a3069 } + #blob .s2 { color: #0a3069 } + #blob .se { color: #0a3069 } + #blob .sh { color: #0a3069 } + #blob .si { color: #0a3069 } + #blob .sx { color: #0a3069 } + #blob .sr { color: #0a3069 } + #blob .s1 { color: #0a3069 } + #blob .ss { color: #032f62 } + #blob .m { color: #0550ae } + #blob .mb { color: #0550ae } + #blob .mf { color: #0550ae } + #blob .mh { color: #0550ae } + #blob .mi { color: #0550ae } + #blob .il { color: #0550ae } + #blob .mo { color: #0550ae } + #blob .o { color: #0550ae } + #blob .ow { color: #0550ae } + #blob .or { color: #0550ae } + #blob .p { color: #1f2328 } + #blob .c { color: #57606a } + #blob .ch { color: #57606a } + #blob .cm { color: #57606a } + #blob .c1 { color: #57606a } + #blob .cs { color: #57606a } + #blob .cp { color: #57606a } + #blob .cpf { color: #57606a } + #blob .gd { color: #82071e; background-color: #ffebe9 } + #blob .ge { color: #1f2328 } + #blob .gi { color: #116329; background-color: #dafbe1 } + #blob .go { color: #1f2328 } + #blob .gl { text-decoration: underline } + #blob .w { color: #ffffff } +} + +@media (prefers-color-scheme: dark) { + #blob .k { color: #ff7b72 } + #blob .kc { color: #79c0ff } + #blob .kd { color: #ff7b72 } + #blob .kn { color: #ff7b72 } + #blob .kp { color: #79c0ff } + #blob .kr { color: #ff7b72 } + #blob .kt { color: #ff7b72 } + #blob .nc { color: #f0883e; font-weight: bold } + #blob .no { color: #79c0ff; font-weight: bold } + #blob .nd { color: #d2a8ff; font-weight: bold } + #blob .ni { color: #ffa657 } + #blob .ne { color: #f0883e; font-weight: bold } + #blob .nl { color: #79c0ff; font-weight: bold } + #blob .nn { color: #ff7b72 } + #blob .py { color: #79c0ff } + #blob .nt { color: #7ee787 } + #blob .nv { color: #79c0ff } + #blob .vc { color: #79c0ff } + #blob .vg { color: #79c0ff } + #blob .vi { color: #79c0ff } + #blob .vm { color: #79c0ff } + #blob .nf { color: #d2a8ff; font-weight: bold } + #blob .fm { color: #d2a8ff; font-weight: bold } + #blob .l { color: #a5d6ff } + #blob .ld { color: #79c0ff } + #blob .s { color: #a5d6ff } + #blob .sa { color: #79c0ff } + #blob .sb { color: #a5d6ff } + #blob .sc { color: #a5d6ff } + #blob .dl { color: #79c0ff } + #blob .sd { color: #a5d6ff } + #blob .s2 { color: #a5d6ff } + #blob .se { color: #79c0ff } + #blob .sh { color: #79c0ff } + #blob .si { color: #a5d6ff } + #blob .sx { color: #a5d6ff } + #blob .sr { color: #79c0ff } + #blob .s1 { color: #a5d6ff } + #blob .ss { color: #a5d6ff } + #blob .m { color: #a5d6ff } + #blob .mb { color: #a5d6ff } + #blob .mf { color: #a5d6ff } + #blob .mh { color: #a5d6ff } + #blob .mi { color: #a5d6ff } + #blob .il { color: #a5d6ff } + #blob .mo { color: #a5d6ff } + #blob .o { color: #ff7b72; font-weight: bold } + #blob .ow { color: #ff7b72; font-weight: bold } + #blob .or { color: #ff7b72; font-weight: bold } + #blob .c { color: #8b949e; font-style: italic } + #blob .ch { color: #8b949e; font-style: italic } + #blob .cm { color: #8b949e; font-style: italic } + #blob .c1 { color: #8b949e; font-style: italic } + #blob .cs { color: #8b949e; font-weight: bold; font-style: italic } + #blob .cp { color: #8b949e; font-weight: bold; font-style: italic } + #blob .cpf { color: #8b949e; font-weight: bold; font-style: italic } + #blob .gd { color: #ffa198; background-color: #490202 } + #blob .ge { font-style: italic } + #blob .gr { color: #ffa198 } + #blob .gh { color: #79c0ff; font-weight: bold } + #blob .gi { color: #56d364; background-color: #0f5323 } + #blob .go { color: #8b949e } + #blob .gp { color: #8b949e } + #blob .gs { font-weight: bold } + #blob .gu { color: #79c0ff } + #blob .gt { color: #ff7b72 } + #blob .gl { text-decoration: underline } + #blob .w { color: #6e7681 } +}