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]'); | |