Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Highlight with Prism in a worker and gzip text responses

306f36fcef89c145abcd0c5331453d0b179f37ce · Keenan Nemetz · 12d ago · parent e5542ac6

Highlight with Prism in a worker and gzip text responses

The code browser now names a blob's language and lets the browser colour it.
Chroma is gone: server/web/highlight.go is an extension table, and
static/prism-worker.js runs Prism — every language Prism ships — off the main
thread. The server sends escaped lines with their numbers and L# anchors, so a
page is complete and scrollable before any script runs and the worker only swaps
the contents of each line cell. The table covers 186 languages; a file it does
not claim stays plain rather than being coloured by a guess.

The bundle those grammars make is 545 KB, so the instance now gzips text
responses itself instead of relying on a proxy, and static assets carry a
content ETag so a browser revalidates with a 304 rather than re-downloading the
bundle on every code page. The raw-download route opts out with
Cache-Control: no-transform because it promises an exact Content-Length.

This also makes JavaScript a requirement rather than a nicety: the label and
assignee token inputs lose their noscript save buttons and their comma-separated
text fallback, and the docs say so.

Measured: release binary 26.08 MB -> 22.70 MB; /r/{repo}/code 11.1 KB -> 2.6 KB
over the wire; the Prism bundle 545 KB -> 206 KB gzipped, fetched once.

scripts/vendor-prism.py regenerates the bundle from Prism's components.json
require graph, and --check fails if the committed assets drift.

server/web/static/app.js +71−5

@@ -1,5 +1,5 @@
1 // Small progressive enhancements. The pages work without this file: it adds
2 // clipboard copy, select-to-submit, and a confirmation on destructive forms.
1 // Page behaviour the server does not render: clipboard copy, select-to-submit,
2 // and a confirmation on destructive forms. The pages need this file.
3 3
4 4 // copyText puts a string on the clipboard, falling back to a hidden textarea
5 5 // where the clipboard API is unavailable (an insecure origin, an older browser).
@@ -44,6 +44,72 @@ const copyText = (value, done) => {
44 44 });
45 45 })();
46 46
47 // Syntax highlighting. The server sends each blob as plain lines with their
48 // numbers and anchors, so the page is complete and scrollable before this runs;
49 // the tokens come from a worker (static/prism-worker.js) so a large file never
50 // blocks the main thread. A blob whose language has no grammar keeps the text it
51 // was served with.
52 (() => {
53 let worker = null;
54 let nextId = 0;
55 const pending = new Map();
56
57 // start creates the worker on first use: a page without a blob never pays for it.
58 const start = () => {
59 if (worker) return worker;
60 if (typeof Worker === 'undefined') return null;
61 try {
62 worker = new Worker('/static/prism-worker.js');
63 } catch (err) {
64 return null;
65 }
66 worker.addEventListener('message', (event) => {
67 const job = pending.get(event.data.id);
68 if (!job) return;
69 pending.delete(event.data.id);
70 const lines = event.data.lines;
71 // A grammar that answered nothing, or a line count that does not match the
72 // table, leaves the server's text alone rather than mangling it.
73 if (!lines || lines.length !== job.cells.length) return;
74 for (let i = 0; i < lines.length; i++) job.cells[i].innerHTML = lines[i];
75 job.block.dataset.highlighted = 'applied';
76 });
77 worker.addEventListener('error', () => {
78 worker.terminate();
79 worker = null;
80 // Let a later init try again with a fresh worker rather than leaving these
81 // blocks marked as queued forever.
82 for (const job of pending.values()) delete job.block.dataset.highlighted;
83 pending.clear();
84 });
85 return worker;
86 };
87
88 const queue = (block) => {
89 if (block.dataset.highlighted) return;
90 const lang = block.getAttribute('data-highlight');
91 const cells = Array.from(block.querySelectorAll('td.code'));
92 if (!lang || !cells.length) return;
93 const w = start();
94 if (!w) return;
95 block.dataset.highlighted = 'queued';
96 const id = ++nextId;
97 pending.set(id, { block, cells });
98 w.postMessage({ id, lang, code: cells.map((cell) => cell.textContent).join('\n') });
99 };
100
101 const init = (scope = document) => {
102 if (scope instanceof Element && scope.matches('[data-highlight]')) queue(scope);
103 if (scope && typeof scope.querySelectorAll === 'function') {
104 scope.querySelectorAll('[data-highlight]').forEach(queue);
105 }
106 };
107
108 init();
109 // The code browser swaps the open file into the pane; a new blob is a new job.
110 document.body.addEventListener('htmx:afterSwap', (event) => init(event.target));
111 })();
112
47 113 // One discussion composer per page. Reply buttons only select their target; the
48 114 // visible "Replying to ..." chip can be cleared without losing the draft.
49 115 (() => {
@@ -96,9 +162,9 @@ const copyText = (value, done) => {
96 162 });
97 163 })();
98 164
99 // Label token input. Without JavaScript the text field still submits labels as a
100 // comma-separated value; with it, labels become removable chips and existing
101 // labels are offered as suggestions.
165 // Label token input: labels become removable chips and existing labels are
166 // offered as suggestions. The chips are hidden inputs, so this is what submits
167 // the set.
102 168 (() => {
103 169 const setup = (root) => {
104 170 const tokens = root.querySelector('[data-label-tokens]');