detached 652ddb1

feat(web): add blob and raw pages with chroma highlighting

GET /<repo>/blob/<ref>/<path> shows a file: text under 1 MiB highlighted
with chroma (token classes only, no inline style; the theme is the static
56-chroma.css generated by scripts/chroma-css.go onto the --syn-* tokens),
numbered lines with #L12 anchors and #L12-L20 ranges (the mockup's hx-on
selection), Markdown rendered with its source under a fold, a notice with the
raw link for files over 1 MiB and for binary files, and png/jpeg/gif/webp as
<img src=raw>. SVG is never an image or inline: it is text or a binary state.
Bidi, format and control characters are replaced by visible <U+XXXX> markers
in the code view and in names. Files over 20000 lines show the first 20000.

GET /<repo>/raw/<ref>/<path> streams the blob: text/plain; charset=utf-8 for
text, an image type sniffed from the bytes for the four raster formats, else
application/octet-stream as an attachment; nosniff, a sandbox CSP
(default-src 'none'; sandbox), no-cache for refs and immutable for a full
commit id, ETag from the blob id.

Tests: golden pages for every kind of file, raw headers and bytes, refs with
a slash, tags and commit ids, 404s, a private repository, Trojan Source,
no inline script or style.
commit
652ddb1706570aa8b9612ff116c0a42578a1c6a2
author
brice · · 2026-10-10 13:24 UTC
committer
brice · · 2026-10-10 13:25 UTC
parent
20d1f28
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +4716 -0
A cmd/ermite/testdata/web/blob-binary.html +322 -0
@@ -0,0 +1,322 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>data.bin · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">blob</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/blob/main/data.bin" aria-current="page">data.bin</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work has-rail">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<h1 class="file-h">
62+<span>data.bin</span>
63+<span class="acts">
64+<a href="/files/raw/main/data.bin">raw</a>
65+<a href="/files/commits/main/data.bin">history</a>
66+<a href="/files/tree/main">dir</a>
67+</span>
68+</h1>
69+<p class="notice" role="note">binary file (application/octet-stream, 10 B): not shown. use raw.</p>
70+</main>
71+<aside class="rail" aria-label="Context">
72+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
73+<summary>file · last change · selection</summary>
74+<section aria-labelledby="r-file">
75+<h2 id="r-file">file</h2>
76+<dl class="kv">
77+<dt>size</dt>
78+<dd>10 B</dd>
79+<dt>type</dt>
80+<dd>application/octet-stream</dd>
81+</dl>
82+</section>
83+<section aria-labelledby="r-last">
84+<h2 id="r-last">last change <a href="/files/commits/main/data.bin">history</a>
85+</h2>
86+<p>
87+<a href="/files/commit/SHA40">SHA7</a> add data.bin</p>
88+<p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
89+</p>
90+</section>
91+</details>
92+</aside>
93+</div>
94+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
95+<div class="panel-head">
96+<span>keys</span>
97+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
98+</div>
99+<dl class="keys">
100+<div>
101+<dt>
102+<kbd>j</kbd>
103+<kbd>k</kbd>
104+</dt>
105+<dd>move cursor down / up</dd>
106+</div>
107+<div>
108+<dt>
109+<kbd>enter</kbd>
110+</dt>
111+<dd>open row under cursor</dd>
112+</div>
113+<div>
114+<dt>
115+<kbd>g</kbd>
116+<kbd>g</kbd> / <kbd>G</kbd>
117+</dt>
118+<dd>first / last row</dd>
119+</div>
120+<div>
121+<dt>
122+<kbd>/</kbd>
123+</dt>
124+<dd>focus the filter</dd>
125+</div>
126+<div>
127+<dt>
128+<kbd>g h</kbd>
129+<kbd>g t</kbd>
130+</dt>
131+<dd>go home / tree</dd>
132+</div>
133+<div>
134+<dt>
135+<kbd>g c</kbd>
136+<kbd>g r</kbd>
137+</dt>
138+<dd>go commits / refs</dd>
139+</div>
140+<div>
141+<dt>
142+<kbd>b</kbd>
143+</dt>
144+<dd>switch ref (branches, tags)</dd>
145+</div>
146+<div>
147+<dt>
148+<kbd>:</kbd>
149+<kbd>f</kbd>
150+</dt>
151+<dd>go to: section, ref, file, repo</dd>
152+</div>
153+<div>
154+<dt>
155+<kbd>y</kbd>
156+</dt>
157+<dd>permalink: ref becomes the full sha</dd>
158+</div>
159+<div>
160+<dt>
161+<kbd>s</kbd>
162+<kbd>t</kbd>
163+<kbd>c</kbd>
164+<kbd>r</kbd>
165+</dt>
166+<dd>repo sections (when shown)</dd>
167+</div>
168+<div>
169+<dt>
170+<kbd>d</kbd>
171+<kbd>u</kbd>
172+<kbd>x</kbd>
173+</dt>
174+<dd>commit: split / unified / collapse all</dd>
175+</div>
176+<div>
177+<dt>
178+<kbd>esc</kbd>
179+</dt>
180+<dd>close panel</dd>
181+</div>
182+</dl>
183+</dialog>
184+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
185+<div class="panel-head">
186+<label for="refs-filter">ref&gt;</label>
187+<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
188+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
189+</div>
190+<div class="panel-cols" data-picklist>
191+<section aria-label="Branches">
192+<h2>branches <span class="mute">2</span>
193+</h2>
194+<ul>
195+<li>
196+<a href="/files/tree/feature/x">
197+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
198+</li>
199+<li>
200+<a href="/files/tree/main" aria-current="true">
201+<span class="mark" aria-hidden="true">●</span>main</a>
202+</li>
203+</ul>
204+</section>
205+<section aria-label="Tags">
206+<h2>tags <span class="mute">1</span>
207+</h2>
208+<ul>
209+<li>
210+<a href="/files/tree/v1.0">
211+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
212+</li>
213+</ul>
214+</section>
215+</div>
216+</dialog>
217+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
218+<div class="panel-head">
219+<label for="cmd-q" class="hi">:</label>
220+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
221+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
222+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
223+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
224+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
225+ if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
226+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
227+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
228+</div>
229+<ul id="cmd-list" class="cmd-list">
230+<li data-k="go home" data-cursor>
231+<a href="/" tabindex="-1">
232+<span class="k">go</span>home</a>
233+</li>
234+<li data-k="section summary">
235+<a href="/files" tabindex="-1">
236+<span class="k">section</span>summary</a>
237+</li>
238+<li data-k="section tree">
239+<a href="/files/tree/main" tabindex="-1">
240+<span class="k">section</span>tree</a>
241+</li>
242+<li data-k="section commits">
243+<a href="/files/commits/main" tabindex="-1">
244+<span class="k">section</span>commits</a>
245+</li>
246+<li data-k="section refs">
247+<a href="/files/refs" tabindex="-1">
248+<span class="k">section</span>refs</a>
249+</li>
250+<li data-k="branch feature/x">
251+<a href="/files/tree/feature/x" tabindex="-1">
252+<span class="k">branch</span>feature/x</a>
253+</li>
254+<li data-k="branch main">
255+<a href="/files/tree/main" tabindex="-1">
256+<span class="k">branch</span>main</a>
257+</li>
258+<li data-k="tag v1.0">
259+<a href="/files/tree/v1.0" tabindex="-1">
260+<span class="k">tag</span>v1.0</a>
261+</li>
262+</ul>
263+</dialog>
264+<footer class="bar status" aria-label="Status">
265+<span class="mode">blob</span>
266+<span class="st-ctx">data.bin</span>
267+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
268+<span class="hi">:</span> go to…</button>
269+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
270+</span>
271+<span class="st-hints">click a line number · y permalink · ? help</span>
272+<span class="st-src">
273+<span class="st-ver">ermite devel · </span>
274+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
275+</span>
276+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
277+</footer>
278+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
279+ if (q('dialog[open]').count) return;
280+ let rows = q('[data-rows] > :not([hidden])').arr();
281+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
282+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
283+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
284+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
285+ })(1);
286+ k-k from:body -> ((d) => {
287+ if (q('dialog[open]').count) return;
288+ let rows = q('[data-rows] > :not([hidden])').arr();
289+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
290+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
291+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
292+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
293+ })(-1);
294+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
295+ k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
296+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
297+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
298+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
299+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
300+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
301+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
302+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
303+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
304+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
305+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
306+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
307+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
308+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
309+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
310+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
311+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
312+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
313+ k-permalink from:body -> {
314+ let b = document.body.dataset;
315+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
316+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
317+ navigator.clipboard?.writeText(location.href);
318+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
319+ }">
320+</div>
321+</body>
322+</html>
A cmd/ermite/testdata/web/blob-code.html +393 -0
@@ -0,0 +1,393 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>main.go · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">blob</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/blob/main/main.go" aria-current="page">main.go</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work has-rail">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<h1 class="file-h">
62+<span>main.go</span>
63+<span class="acts">
64+<a href="/files/raw/main/main.go">raw</a>
65+<a href="/files/commits/main/main.go">history</a>
66+<a href="/files/tree/main">dir</a>
67+</span>
68+</h1>
69+<pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();
70+ let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n;
71+ let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n);
72+ history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')" hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0;
73+ q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
74+ document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
75+ if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
76+<code>
77+<span class="line" id="L1">
78+<a class="ln" href="#L1">1</a>
79+<span class="kn">package</span>
80+<span class="w">
81+</span>
82+<span class="nx">main</span>
83+</span>
84+<span class="line" id="L2">
85+<a class="ln" href="#L2">2</a>
86+</span>
87+<span class="line" id="L3">
88+<a class="ln" href="#L3">3</a>
89+<span class="kn">import</span>
90+<span class="w">
91+</span>
92+<span class="s">&#34;fmt&#34;</span>
93+</span>
94+<span class="line" id="L4">
95+<a class="ln" href="#L4">4</a>
96+</span>
97+<span class="line" id="L5">
98+<a class="ln" href="#L5">5</a>
99+<span class="c1">// main prints.</span>
100+</span>
101+<span class="line" id="L6">
102+<a class="ln" href="#L6">6</a>
103+<span class="kd">func</span>
104+<span class="w">
105+</span>
106+<span class="nf">main</span>
107+<span class="p">()</span>
108+<span class="w">
109+</span>
110+<span class="p">{</span>
111+</span>
112+<span class="line" id="L7">
113+<a class="ln" href="#L7">7</a>
114+<span class="w">
115+</span>
116+<span class="nx">fmt</span>
117+<span class="p">.</span>
118+<span class="nf">Println</span>
119+<span class="p">(</span>
120+<span class="s">&#34;hi &lt;b&gt;&#34;</span>
121+<span class="p">,</span>
122+<span class="w">
123+</span>
124+<span class="mi">42</span>
125+<span class="p">)</span>
126+</span>
127+<span class="line" id="L8">
128+<a class="ln" href="#L8">8</a>
129+<span class="p">}</span>
130+</span>
131+</code>
132+</pre>
133+</main>
134+<aside class="rail" aria-label="Context">
135+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
136+<summary>file · last change · selection</summary>
137+<section aria-labelledby="r-file">
138+<h2 id="r-file">file</h2>
139+<dl class="kv">
140+<dt>size</dt>
141+<dd>87 B</dd>
142+<dt>lines</dt>
143+<dd>8</dd>
144+<dt>type</dt>
145+<dd>Go</dd>
146+</dl>
147+</section>
148+<section aria-labelledby="r-last">
149+<h2 id="r-last">last change <a href="/files/commits/main/main.go">history</a>
150+</h2>
151+<p>
152+<a href="/files/commit/SHA40">SHA7</a> add main.go</p>
153+<p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
154+</p>
155+</section>
156+<section aria-labelledby="r-sel">
157+<h2 id="r-sel">selection</h2>
158+<p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
159+<p class="dim">y copies a permalink pinned to the commit sha</p>
160+</section>
161+</details>
162+</aside>
163+</div>
164+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
165+<div class="panel-head">
166+<span>keys</span>
167+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
168+</div>
169+<dl class="keys">
170+<div>
171+<dt>
172+<kbd>j</kbd>
173+<kbd>k</kbd>
174+</dt>
175+<dd>move cursor down / up</dd>
176+</div>
177+<div>
178+<dt>
179+<kbd>enter</kbd>
180+</dt>
181+<dd>open row under cursor</dd>
182+</div>
183+<div>
184+<dt>
185+<kbd>g</kbd>
186+<kbd>g</kbd> / <kbd>G</kbd>
187+</dt>
188+<dd>first / last row</dd>
189+</div>
190+<div>
191+<dt>
192+<kbd>/</kbd>
193+</dt>
194+<dd>focus the filter</dd>
195+</div>
196+<div>
197+<dt>
198+<kbd>g h</kbd>
199+<kbd>g t</kbd>
200+</dt>
201+<dd>go home / tree</dd>
202+</div>
203+<div>
204+<dt>
205+<kbd>g c</kbd>
206+<kbd>g r</kbd>
207+</dt>
208+<dd>go commits / refs</dd>
209+</div>
210+<div>
211+<dt>
212+<kbd>b</kbd>
213+</dt>
214+<dd>switch ref (branches, tags)</dd>
215+</div>
216+<div>
217+<dt>
218+<kbd>:</kbd>
219+<kbd>f</kbd>
220+</dt>
221+<dd>go to: section, ref, file, repo</dd>
222+</div>
223+<div>
224+<dt>
225+<kbd>y</kbd>
226+</dt>
227+<dd>permalink: ref becomes the full sha</dd>
228+</div>
229+<div>
230+<dt>
231+<kbd>s</kbd>
232+<kbd>t</kbd>
233+<kbd>c</kbd>
234+<kbd>r</kbd>
235+</dt>
236+<dd>repo sections (when shown)</dd>
237+</div>
238+<div>
239+<dt>
240+<kbd>d</kbd>
241+<kbd>u</kbd>
242+<kbd>x</kbd>
243+</dt>
244+<dd>commit: split / unified / collapse all</dd>
245+</div>
246+<div>
247+<dt>
248+<kbd>esc</kbd>
249+</dt>
250+<dd>close panel</dd>
251+</div>
252+</dl>
253+</dialog>
254+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
255+<div class="panel-head">
256+<label for="refs-filter">ref&gt;</label>
257+<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
258+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
259+</div>
260+<div class="panel-cols" data-picklist>
261+<section aria-label="Branches">
262+<h2>branches <span class="mute">2</span>
263+</h2>
264+<ul>
265+<li>
266+<a href="/files/tree/feature/x">
267+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
268+</li>
269+<li>
270+<a href="/files/tree/main" aria-current="true">
271+<span class="mark" aria-hidden="true">●</span>main</a>
272+</li>
273+</ul>
274+</section>
275+<section aria-label="Tags">
276+<h2>tags <span class="mute">1</span>
277+</h2>
278+<ul>
279+<li>
280+<a href="/files/tree/v1.0">
281+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
282+</li>
283+</ul>
284+</section>
285+</div>
286+</dialog>
287+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
288+<div class="panel-head">
289+<label for="cmd-q" class="hi">:</label>
290+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
291+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
292+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
293+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
294+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
295+ if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
296+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
297+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
298+</div>
299+<ul id="cmd-list" class="cmd-list">
300+<li data-k="go home" data-cursor>
301+<a href="/" tabindex="-1">
302+<span class="k">go</span>home</a>
303+</li>
304+<li data-k="section summary">
305+<a href="/files" tabindex="-1">
306+<span class="k">section</span>summary</a>
307+</li>
308+<li data-k="section tree">
309+<a href="/files/tree/main" tabindex="-1">
310+<span class="k">section</span>tree</a>
311+</li>
312+<li data-k="section commits">
313+<a href="/files/commits/main" tabindex="-1">
314+<span class="k">section</span>commits</a>
315+</li>
316+<li data-k="section refs">
317+<a href="/files/refs" tabindex="-1">
318+<span class="k">section</span>refs</a>
319+</li>
320+<li data-k="branch feature/x">
321+<a href="/files/tree/feature/x" tabindex="-1">
322+<span class="k">branch</span>feature/x</a>
323+</li>
324+<li data-k="branch main">
325+<a href="/files/tree/main" tabindex="-1">
326+<span class="k">branch</span>main</a>
327+</li>
328+<li data-k="tag v1.0">
329+<a href="/files/tree/v1.0" tabindex="-1">
330+<span class="k">tag</span>v1.0</a>
331+</li>
332+</ul>
333+</dialog>
334+<footer class="bar status" aria-label="Status">
335+<span class="mode">blob</span>
336+<span class="st-ctx">main.go</span>
337+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
338+<span class="hi">:</span> go to…</button>
339+<span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
340+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
341+</span>
342+<span class="st-hints">click a line number · y permalink · ? help</span>
343+<span class="st-src">
344+<span class="st-ver">ermite devel · </span>
345+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
346+</span>
347+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
348+</footer>
349+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
350+ if (q('dialog[open]').count) return;
351+ let rows = q('[data-rows] > :not([hidden])').arr();
352+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
353+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
354+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
355+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
356+ })(1);
357+ k-k from:body -> ((d) => {
358+ if (q('dialog[open]').count) return;
359+ let rows = q('[data-rows] > :not([hidden])').arr();
360+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
361+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
362+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
363+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
364+ })(-1);
365+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
366+ k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
367+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
368+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
369+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
370+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
371+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
372+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
373+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
374+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
375+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
376+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
377+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
378+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
379+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
380+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
381+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
382+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
383+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
384+ k-permalink from:body -> {
385+ let b = document.body.dataset;
386+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
387+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
388+ navigator.clipboard?.writeText(location.href);
389+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
390+ }">
391+</div>
392+</body>
393+</html>
A cmd/ermite/testdata/web/blob-empty.html +322 -0
@@ -0,0 +1,322 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>empty.txt · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">blob</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/blob/main/empty.txt" aria-current="page">empty.txt</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work has-rail">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<h1 class="file-h">
62+<span>empty.txt</span>
63+<span class="acts">
64+<a href="/files/raw/main/empty.txt">raw</a>
65+<a href="/files/commits/main/empty.txt">history</a>
66+<a href="/files/tree/main">dir</a>
67+</span>
68+</h1>
69+<p class="notice" role="note">empty file.</p>
70+</main>
71+<aside class="rail" aria-label="Context">
72+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
73+<summary>file · last change · selection</summary>
74+<section aria-labelledby="r-file">
75+<h2 id="r-file">file</h2>
76+<dl class="kv">
77+<dt>size</dt>
78+<dd>0 B</dd>
79+<dt>type</dt>
80+<dd>text/plain</dd>
81+</dl>
82+</section>
83+<section aria-labelledby="r-last">
84+<h2 id="r-last">last change <a href="/files/commits/main/empty.txt">history</a>
85+</h2>
86+<p>
87+<a href="/files/commit/SHA40">SHA7</a> add empty.txt</p>
88+<p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
89+</p>
90+</section>
91+</details>
92+</aside>
93+</div>
94+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
95+<div class="panel-head">
96+<span>keys</span>
97+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
98+</div>
99+<dl class="keys">
100+<div>
101+<dt>
102+<kbd>j</kbd>
103+<kbd>k</kbd>
104+</dt>
105+<dd>move cursor down / up</dd>
106+</div>
107+<div>
108+<dt>
109+<kbd>enter</kbd>
110+</dt>
111+<dd>open row under cursor</dd>
112+</div>
113+<div>
114+<dt>
115+<kbd>g</kbd>
116+<kbd>g</kbd> / <kbd>G</kbd>
117+</dt>
118+<dd>first / last row</dd>
119+</div>
120+<div>
121+<dt>
122+<kbd>/</kbd>
123+</dt>
124+<dd>focus the filter</dd>
125+</div>
126+<div>
127+<dt>
128+<kbd>g h</kbd>
129+<kbd>g t</kbd>
130+</dt>
131+<dd>go home / tree</dd>
132+</div>
133+<div>
134+<dt>
135+<kbd>g c</kbd>
136+<kbd>g r</kbd>
137+</dt>
138+<dd>go commits / refs</dd>
139+</div>
140+<div>
141+<dt>
142+<kbd>b</kbd>
143+</dt>
144+<dd>switch ref (branches, tags)</dd>
145+</div>
146+<div>
147+<dt>
148+<kbd>:</kbd>
149+<kbd>f</kbd>
150+</dt>
151+<dd>go to: section, ref, file, repo</dd>
152+</div>
153+<div>
154+<dt>
155+<kbd>y</kbd>
156+</dt>
157+<dd>permalink: ref becomes the full sha</dd>
158+</div>
159+<div>
160+<dt>
161+<kbd>s</kbd>
162+<kbd>t</kbd>
163+<kbd>c</kbd>
164+<kbd>r</kbd>
165+</dt>
166+<dd>repo sections (when shown)</dd>
167+</div>
168+<div>
169+<dt>
170+<kbd>d</kbd>
171+<kbd>u</kbd>
172+<kbd>x</kbd>
173+</dt>
174+<dd>commit: split / unified / collapse all</dd>
175+</div>
176+<div>
177+<dt>
178+<kbd>esc</kbd>
179+</dt>
180+<dd>close panel</dd>
181+</div>
182+</dl>
183+</dialog>
184+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
185+<div class="panel-head">
186+<label for="refs-filter">ref&gt;</label>
187+<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
188+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
189+</div>
190+<div class="panel-cols" data-picklist>
191+<section aria-label="Branches">
192+<h2>branches <span class="mute">2</span>
193+</h2>
194+<ul>
195+<li>
196+<a href="/files/tree/feature/x">
197+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
198+</li>
199+<li>
200+<a href="/files/tree/main" aria-current="true">
201+<span class="mark" aria-hidden="true">●</span>main</a>
202+</li>
203+</ul>
204+</section>
205+<section aria-label="Tags">
206+<h2>tags <span class="mute">1</span>
207+</h2>
208+<ul>
209+<li>
210+<a href="/files/tree/v1.0">
211+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
212+</li>
213+</ul>
214+</section>
215+</div>
216+</dialog>
217+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
218+<div class="panel-head">
219+<label for="cmd-q" class="hi">:</label>
220+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
221+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
222+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
223+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
224+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
225+ if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
226+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
227+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
228+</div>
229+<ul id="cmd-list" class="cmd-list">
230+<li data-k="go home" data-cursor>
231+<a href="/" tabindex="-1">
232+<span class="k">go</span>home</a>
233+</li>
234+<li data-k="section summary">
235+<a href="/files" tabindex="-1">
236+<span class="k">section</span>summary</a>
237+</li>
238+<li data-k="section tree">
239+<a href="/files/tree/main" tabindex="-1">
240+<span class="k">section</span>tree</a>
241+</li>
242+<li data-k="section commits">
243+<a href="/files/commits/main" tabindex="-1">
244+<span class="k">section</span>commits</a>
245+</li>
246+<li data-k="section refs">
247+<a href="/files/refs" tabindex="-1">
248+<span class="k">section</span>refs</a>
249+</li>
250+<li data-k="branch feature/x">
251+<a href="/files/tree/feature/x" tabindex="-1">
252+<span class="k">branch</span>feature/x</a>
253+</li>
254+<li data-k="branch main">
255+<a href="/files/tree/main" tabindex="-1">
256+<span class="k">branch</span>main</a>
257+</li>
258+<li data-k="tag v1.0">
259+<a href="/files/tree/v1.0" tabindex="-1">
260+<span class="k">tag</span>v1.0</a>
261+</li>
262+</ul>
263+</dialog>
264+<footer class="bar status" aria-label="Status">
265+<span class="mode">blob</span>
266+<span class="st-ctx">empty.txt</span>
267+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
268+<span class="hi">:</span> go to…</button>
269+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
270+</span>
271+<span class="st-hints">click a line number · y permalink · ? help</span>
272+<span class="st-src">
273+<span class="st-ver">ermite devel · </span>
274+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
275+</span>
276+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
277+</footer>
278+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
279+ if (q('dialog[open]').count) return;
280+ let rows = q('[data-rows] > :not([hidden])').arr();
281+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
282+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
283+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
284+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
285+ })(1);
286+ k-k from:body -> ((d) => {
287+ if (q('dialog[open]').count) return;
288+ let rows = q('[data-rows] > :not([hidden])').arr();
289+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
290+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
291+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
292+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
293+ })(-1);
294+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
295+ k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
296+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
297+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
298+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
299+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
300+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
301+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
302+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
303+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
304+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
305+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
306+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
307+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
308+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
309+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
310+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
311+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
312+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
313+ k-permalink from:body -> {
314+ let b = document.body.dataset;
315+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
316+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
317+ navigator.clipboard?.writeText(location.href);
318+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
319+ }">
320+</div>
321+</body>
322+</html>
A cmd/ermite/testdata/web/blob-image.html +324 -0
@@ -0,0 +1,324 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>pic.png · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">blob</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/blob/main/pic.png" aria-current="page">pic.png</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work has-rail">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<h1 class="file-h">
62+<span>pic.png</span>
63+<span class="acts">
64+<a href="/files/raw/main/pic.png">raw</a>
65+<a href="/files/commits/main/pic.png">history</a>
66+<a href="/files/tree/main">dir</a>
67+</span>
68+</h1>
69+<p class="img-view">
70+<img src="/files/raw/main/pic.png" alt="pic.png">
71+</p>
72+</main>
73+<aside class="rail" aria-label="Context">
74+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
75+<summary>file · last change · selection</summary>
76+<section aria-labelledby="r-file">
77+<h2 id="r-file">file</h2>
78+<dl class="kv">
79+<dt>size</dt>
80+<dd>88 B</dd>
81+<dt>type</dt>
82+<dd>image/png</dd>
83+</dl>
84+</section>
85+<section aria-labelledby="r-last">
86+<h2 id="r-last">last change <a href="/files/commits/main/pic.png">history</a>
87+</h2>
88+<p>
89+<a href="/files/commit/SHA40">SHA7</a> add pic.png</p>
90+<p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
91+</p>
92+</section>
93+</details>
94+</aside>
95+</div>
96+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
97+<div class="panel-head">
98+<span>keys</span>
99+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
100+</div>
101+<dl class="keys">
102+<div>
103+<dt>
104+<kbd>j</kbd>
105+<kbd>k</kbd>
106+</dt>
107+<dd>move cursor down / up</dd>
108+</div>
109+<div>
110+<dt>
111+<kbd>enter</kbd>
112+</dt>
113+<dd>open row under cursor</dd>
114+</div>
115+<div>
116+<dt>
117+<kbd>g</kbd>
118+<kbd>g</kbd> / <kbd>G</kbd>
119+</dt>
120+<dd>first / last row</dd>
121+</div>
122+<div>
123+<dt>
124+<kbd>/</kbd>
125+</dt>
126+<dd>focus the filter</dd>
127+</div>
128+<div>
129+<dt>
130+<kbd>g h</kbd>
131+<kbd>g t</kbd>
132+</dt>
133+<dd>go home / tree</dd>
134+</div>
135+<div>
136+<dt>
137+<kbd>g c</kbd>
138+<kbd>g r</kbd>
139+</dt>
140+<dd>go commits / refs</dd>
141+</div>
142+<div>
143+<dt>
144+<kbd>b</kbd>
145+</dt>
146+<dd>switch ref (branches, tags)</dd>
147+</div>
148+<div>
149+<dt>
150+<kbd>:</kbd>
151+<kbd>f</kbd>
152+</dt>
153+<dd>go to: section, ref, file, repo</dd>
154+</div>
155+<div>
156+<dt>
157+<kbd>y</kbd>
158+</dt>
159+<dd>permalink: ref becomes the full sha</dd>
160+</div>
161+<div>
162+<dt>
163+<kbd>s</kbd>
164+<kbd>t</kbd>
165+<kbd>c</kbd>
166+<kbd>r</kbd>
167+</dt>
168+<dd>repo sections (when shown)</dd>
169+</div>
170+<div>
171+<dt>
172+<kbd>d</kbd>
173+<kbd>u</kbd>
174+<kbd>x</kbd>
175+</dt>
176+<dd>commit: split / unified / collapse all</dd>
177+</div>
178+<div>
179+<dt>
180+<kbd>esc</kbd>
181+</dt>
182+<dd>close panel</dd>
183+</div>
184+</dl>
185+</dialog>
186+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
187+<div class="panel-head">
188+<label for="refs-filter">ref&gt;</label>
189+<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
190+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
191+</div>
192+<div class="panel-cols" data-picklist>
193+<section aria-label="Branches">
194+<h2>branches <span class="mute">2</span>
195+</h2>
196+<ul>
197+<li>
198+<a href="/files/tree/feature/x">
199+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
200+</li>
201+<li>
202+<a href="/files/tree/main" aria-current="true">
203+<span class="mark" aria-hidden="true">●</span>main</a>
204+</li>
205+</ul>
206+</section>
207+<section aria-label="Tags">
208+<h2>tags <span class="mute">1</span>
209+</h2>
210+<ul>
211+<li>
212+<a href="/files/tree/v1.0">
213+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
214+</li>
215+</ul>
216+</section>
217+</div>
218+</dialog>
219+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
220+<div class="panel-head">
221+<label for="cmd-q" class="hi">:</label>
222+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
223+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
224+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
225+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
226+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
227+ if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
228+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
229+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
230+</div>
231+<ul id="cmd-list" class="cmd-list">
232+<li data-k="go home" data-cursor>
233+<a href="/" tabindex="-1">
234+<span class="k">go</span>home</a>
235+</li>
236+<li data-k="section summary">
237+<a href="/files" tabindex="-1">
238+<span class="k">section</span>summary</a>
239+</li>
240+<li data-k="section tree">
241+<a href="/files/tree/main" tabindex="-1">
242+<span class="k">section</span>tree</a>
243+</li>
244+<li data-k="section commits">
245+<a href="/files/commits/main" tabindex="-1">
246+<span class="k">section</span>commits</a>
247+</li>
248+<li data-k="section refs">
249+<a href="/files/refs" tabindex="-1">
250+<span class="k">section</span>refs</a>
251+</li>
252+<li data-k="branch feature/x">
253+<a href="/files/tree/feature/x" tabindex="-1">
254+<span class="k">branch</span>feature/x</a>
255+</li>
256+<li data-k="branch main">
257+<a href="/files/tree/main" tabindex="-1">
258+<span class="k">branch</span>main</a>
259+</li>
260+<li data-k="tag v1.0">
261+<a href="/files/tree/v1.0" tabindex="-1">
262+<span class="k">tag</span>v1.0</a>
263+</li>
264+</ul>
265+</dialog>
266+<footer class="bar status" aria-label="Status">
267+<span class="mode">blob</span>
268+<span class="st-ctx">pic.png</span>
269+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
270+<span class="hi">:</span> go to…</button>
271+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
272+</span>
273+<span class="st-hints">click a line number · y permalink · ? help</span>
274+<span class="st-src">
275+<span class="st-ver">ermite devel · </span>
276+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
277+</span>
278+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
279+</footer>
280+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
281+ if (q('dialog[open]').count) return;
282+ let rows = q('[data-rows] > :not([hidden])').arr();
283+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
284+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
285+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
286+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
287+ })(1);
288+ k-k from:body -> ((d) => {
289+ if (q('dialog[open]').count) return;
290+ let rows = q('[data-rows] > :not([hidden])').arr();
291+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
292+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
293+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
294+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
295+ })(-1);
296+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
297+ k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
298+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
299+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
300+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
301+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
302+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
303+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
304+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
305+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
306+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
307+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
308+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
309+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
310+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
311+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
312+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
313+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
314+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
315+ k-permalink from:body -> {
316+ let b = document.body.dataset;
317+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
318+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
319+ navigator.clipboard?.writeText(location.href);
320+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
321+ }">
322+</div>
323+</body>
324+</html>
A cmd/ermite/testdata/web/blob-markdown.html +390 -0
@@ -0,0 +1,390 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>docs/guide.md · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">blob</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/tree/main/docs">docs</a>
37+</li>
38+<li>
39+<a href="/files/blob/main/docs/guide.md" aria-current="page">guide.md</a>
40+</li>
41+</ol>
42+</nav>
43+<nav class="secs" aria-label="Sections">
44+<ul>
45+<li>
46+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
47+</li>
48+<li>
49+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
50+</li>
51+<li>
52+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
53+</li>
54+<li>
55+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
56+</li>
57+</ul>
58+</nav>
59+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
60+</button>
61+</header>
62+<div class="work has-rail read">
63+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
64+<h1 class="file-h">
65+<span>guide.md</span>
66+<span class="acts">
67+<a href="/files/raw/main/docs/guide.md">raw</a>
68+<a href="/files/commits/main/docs/guide.md">history</a>
69+<a href="/files/tree/main/docs">dir</a>
70+</span>
71+</h1>
72+<article class="prose" aria-label="guide.md">
73+<h1 id="md-guide">Guide</h1>
74+<h2 id="md-steps">Steps</h2>
75+<p>Run it.</p>
76+<pre>
77+<code class="language-sh">make all
78+</code>
79+</pre>
80+</article>
81+<details class="source">
82+<summary>source</summary>
83+<pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();
84+ let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n;
85+ let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n);
86+ history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')" hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0;
87+ q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
88+ document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
89+ if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
90+<code>
91+<span class="line" id="L1">
92+<a class="ln" href="#L1">1</a>
93+<span class="gh"># Guide</span>
94+</span>
95+<span class="line" id="L2">
96+<a class="ln" href="#L2">2</a>
97+</span>
98+<span class="line" id="L3">
99+<a class="ln" href="#L3">3</a>
100+<span class="gu">## Steps</span>
101+</span>
102+<span class="line" id="L4">
103+<a class="ln" href="#L4">4</a>
104+</span>
105+<span class="line" id="L5">
106+<a class="ln" href="#L5">5</a>Run it.</span>
107+<span class="line" id="L6">
108+<a class="ln" href="#L6">6</a>
109+</span>
110+<span class="line" id="L7">
111+<a class="ln" href="#L7">7</a>
112+<span class="s">```sh</span>
113+</span>
114+<span class="line" id="L8">
115+<a class="ln" href="#L8">8</a>make all</span>
116+<span class="line" id="L9">
117+<a class="ln" href="#L9">9</a>
118+<span class="s">```</span>
119+</span>
120+</code>
121+</pre>
122+</details>
123+</main>
124+<aside class="rail" aria-label="Context">
125+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
126+<summary>file · last change · selection</summary>
127+<section aria-labelledby="r-file">
128+<h2 id="r-file">file</h2>
129+<dl class="kv">
130+<dt>size</dt>
131+<dd>47 B</dd>
132+<dt>lines</dt>
133+<dd>9</dd>
134+<dt>type</dt>
135+<dd>markdown</dd>
136+</dl>
137+</section>
138+<section aria-labelledby="r-last">
139+<h2 id="r-last">last change <a href="/files/commits/main/docs/guide.md">history</a>
140+</h2>
141+<p>
142+<a href="/files/commit/SHA40">SHA7</a> add docs/guide.md</p>
143+<p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
144+</p>
145+</section>
146+<section aria-labelledby="r-outline">
147+<h2 id="r-outline">outline</h2>
148+<nav class="outline" aria-label="Outline">
149+<a class="l1" href="#md-guide">Guide</a>
150+<a class="l2" href="#md-steps">Steps</a>
151+</nav>
152+</section>
153+<section aria-labelledby="r-sel">
154+<h2 id="r-sel">selection</h2>
155+<p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
156+<p class="dim">y copies a permalink pinned to the commit sha</p>
157+</section>
158+</details>
159+</aside>
160+</div>
161+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
162+<div class="panel-head">
163+<span>keys</span>
164+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
165+</div>
166+<dl class="keys">
167+<div>
168+<dt>
169+<kbd>j</kbd>
170+<kbd>k</kbd>
171+</dt>
172+<dd>move cursor down / up</dd>
173+</div>
174+<div>
175+<dt>
176+<kbd>enter</kbd>
177+</dt>
178+<dd>open row under cursor</dd>
179+</div>
180+<div>
181+<dt>
182+<kbd>g</kbd>
183+<kbd>g</kbd> / <kbd>G</kbd>
184+</dt>
185+<dd>first / last row</dd>
186+</div>
187+<div>
188+<dt>
189+<kbd>/</kbd>
190+</dt>
191+<dd>focus the filter</dd>
192+</div>
193+<div>
194+<dt>
195+<kbd>g h</kbd>
196+<kbd>g t</kbd>
197+</dt>
198+<dd>go home / tree</dd>
199+</div>
200+<div>
201+<dt>
202+<kbd>g c</kbd>
203+<kbd>g r</kbd>
204+</dt>
205+<dd>go commits / refs</dd>
206+</div>
207+<div>
208+<dt>
209+<kbd>b</kbd>
210+</dt>
211+<dd>switch ref (branches, tags)</dd>
212+</div>
213+<div>
214+<dt>
215+<kbd>:</kbd>
216+<kbd>f</kbd>
217+</dt>
218+<dd>go to: section, ref, file, repo</dd>
219+</div>
220+<div>
221+<dt>
222+<kbd>y</kbd>
223+</dt>
224+<dd>permalink: ref becomes the full sha</dd>
225+</div>
226+<div>
227+<dt>
228+<kbd>s</kbd>
229+<kbd>t</kbd>
230+<kbd>c</kbd>
231+<kbd>r</kbd>
232+</dt>
233+<dd>repo sections (when shown)</dd>
234+</div>
235+<div>
236+<dt>
237+<kbd>d</kbd>
238+<kbd>u</kbd>
239+<kbd>x</kbd>
240+</dt>
241+<dd>commit: split / unified / collapse all</dd>
242+</div>
243+<div>
244+<dt>
245+<kbd>esc</kbd>
246+</dt>
247+<dd>close panel</dd>
248+</div>
249+</dl>
250+</dialog>
251+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
252+<div class="panel-head">
253+<label for="refs-filter">ref&gt;</label>
254+<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
255+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
256+</div>
257+<div class="panel-cols" data-picklist>
258+<section aria-label="Branches">
259+<h2>branches <span class="mute">2</span>
260+</h2>
261+<ul>
262+<li>
263+<a href="/files/tree/feature/x">
264+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
265+</li>
266+<li>
267+<a href="/files/tree/main" aria-current="true">
268+<span class="mark" aria-hidden="true">●</span>main</a>
269+</li>
270+</ul>
271+</section>
272+<section aria-label="Tags">
273+<h2>tags <span class="mute">1</span>
274+</h2>
275+<ul>
276+<li>
277+<a href="/files/tree/v1.0">
278+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
279+</li>
280+</ul>
281+</section>
282+</div>
283+</dialog>
284+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
285+<div class="panel-head">
286+<label for="cmd-q" class="hi">:</label>
287+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
288+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
289+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
290+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
291+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
292+ if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
293+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
294+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
295+</div>
296+<ul id="cmd-list" class="cmd-list">
297+<li data-k="go home" data-cursor>
298+<a href="/" tabindex="-1">
299+<span class="k">go</span>home</a>
300+</li>
301+<li data-k="section summary">
302+<a href="/files" tabindex="-1">
303+<span class="k">section</span>summary</a>
304+</li>
305+<li data-k="section tree">
306+<a href="/files/tree/main" tabindex="-1">
307+<span class="k">section</span>tree</a>
308+</li>
309+<li data-k="section commits">
310+<a href="/files/commits/main" tabindex="-1">
311+<span class="k">section</span>commits</a>
312+</li>
313+<li data-k="section refs">
314+<a href="/files/refs" tabindex="-1">
315+<span class="k">section</span>refs</a>
316+</li>
317+<li data-k="branch feature/x">
318+<a href="/files/tree/feature/x" tabindex="-1">
319+<span class="k">branch</span>feature/x</a>
320+</li>
321+<li data-k="branch main">
322+<a href="/files/tree/main" tabindex="-1">
323+<span class="k">branch</span>main</a>
324+</li>
325+<li data-k="tag v1.0">
326+<a href="/files/tree/v1.0" tabindex="-1">
327+<span class="k">tag</span>v1.0</a>
328+</li>
329+</ul>
330+</dialog>
331+<footer class="bar status" aria-label="Status">
332+<span class="mode">blob</span>
333+<span class="st-ctx">guide.md</span>
334+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
335+<span class="hi">:</span> go to…</button>
336+<span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
337+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
338+</span>
339+<span class="st-hints">click a line number · y permalink · ? help</span>
340+<span class="st-src">
341+<span class="st-ver">ermite devel · </span>
342+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
343+</span>
344+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
345+</footer>
346+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
347+ if (q('dialog[open]').count) return;
348+ let rows = q('[data-rows] > :not([hidden])').arr();
349+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
350+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
351+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
352+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
353+ })(1);
354+ k-k from:body -> ((d) => {
355+ if (q('dialog[open]').count) return;
356+ let rows = q('[data-rows] > :not([hidden])').arr();
357+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
358+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
359+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
360+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
361+ })(-1);
362+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
363+ k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
364+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
365+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
366+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
367+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
368+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
369+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
370+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
371+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
372+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
373+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
374+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
375+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
376+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
377+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
378+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
379+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
380+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
381+ k-permalink from:body -> {
382+ let b = document.body.dataset;
383+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
384+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
385+ navigator.clipboard?.writeText(location.href);
386+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
387+ }">
388+</div>
389+</body>
390+</html>
A cmd/ermite/testdata/web/blob-svg.html +348 -0
@@ -0,0 +1,348 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>logo.svg · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">blob</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/blob/main/logo.svg" aria-current="page">logo.svg</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work has-rail">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<h1 class="file-h">
62+<span>logo.svg</span>
63+<span class="acts">
64+<a href="/files/raw/main/logo.svg">raw</a>
65+<a href="/files/commits/main/logo.svg">history</a>
66+<a href="/files/tree/main">dir</a>
67+</span>
68+</h1>
69+<pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();
70+ let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n;
71+ let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n);
72+ history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')" hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0;
73+ q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
74+ document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
75+ if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
76+<code>
77+<span class="line" id="L1">
78+<a class="ln" href="#L1">1</a>
79+<span class="nt">&lt;svg</span>
80+<span class="na">xmlns=</span>
81+<span class="s">&#34;http://www.w3.org/2000/svg&#34;</span>
82+<span class="na">onload=</span>
83+<span class="s">&#34;alert(1)&#34;</span>
84+<span class="nt">&gt;&lt;script&gt;</span>alert(2)<span class="nt">&lt;/script&gt;&lt;/svg&gt;</span>
85+</span>
86+</code>
87+</pre>
88+</main>
89+<aside class="rail" aria-label="Context">
90+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
91+<summary>file · last change · selection</summary>
92+<section aria-labelledby="r-file">
93+<h2 id="r-file">file</h2>
94+<dl class="kv">
95+<dt>size</dt>
96+<dd>89 B</dd>
97+<dt>lines</dt>
98+<dd>1</dd>
99+<dt>type</dt>
100+<dd>XML</dd>
101+</dl>
102+</section>
103+<section aria-labelledby="r-last">
104+<h2 id="r-last">last change <a href="/files/commits/main/logo.svg">history</a>
105+</h2>
106+<p>
107+<a href="/files/commit/SHA40">SHA7</a> add logo.svg</p>
108+<p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
109+</p>
110+</section>
111+<section aria-labelledby="r-sel">
112+<h2 id="r-sel">selection</h2>
113+<p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
114+<p class="dim">y copies a permalink pinned to the commit sha</p>
115+</section>
116+</details>
117+</aside>
118+</div>
119+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
120+<div class="panel-head">
121+<span>keys</span>
122+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
123+</div>
124+<dl class="keys">
125+<div>
126+<dt>
127+<kbd>j</kbd>
128+<kbd>k</kbd>
129+</dt>
130+<dd>move cursor down / up</dd>
131+</div>
132+<div>
133+<dt>
134+<kbd>enter</kbd>
135+</dt>
136+<dd>open row under cursor</dd>
137+</div>
138+<div>
139+<dt>
140+<kbd>g</kbd>
141+<kbd>g</kbd> / <kbd>G</kbd>
142+</dt>
143+<dd>first / last row</dd>
144+</div>
145+<div>
146+<dt>
147+<kbd>/</kbd>
148+</dt>
149+<dd>focus the filter</dd>
150+</div>
151+<div>
152+<dt>
153+<kbd>g h</kbd>
154+<kbd>g t</kbd>
155+</dt>
156+<dd>go home / tree</dd>
157+</div>
158+<div>
159+<dt>
160+<kbd>g c</kbd>
161+<kbd>g r</kbd>
162+</dt>
163+<dd>go commits / refs</dd>
164+</div>
165+<div>
166+<dt>
167+<kbd>b</kbd>
168+</dt>
169+<dd>switch ref (branches, tags)</dd>
170+</div>
171+<div>
172+<dt>
173+<kbd>:</kbd>
174+<kbd>f</kbd>
175+</dt>
176+<dd>go to: section, ref, file, repo</dd>
177+</div>
178+<div>
179+<dt>
180+<kbd>y</kbd>
181+</dt>
182+<dd>permalink: ref becomes the full sha</dd>
183+</div>
184+<div>
185+<dt>
186+<kbd>s</kbd>
187+<kbd>t</kbd>
188+<kbd>c</kbd>
189+<kbd>r</kbd>
190+</dt>
191+<dd>repo sections (when shown)</dd>
192+</div>
193+<div>
194+<dt>
195+<kbd>d</kbd>
196+<kbd>u</kbd>
197+<kbd>x</kbd>
198+</dt>
199+<dd>commit: split / unified / collapse all</dd>
200+</div>
201+<div>
202+<dt>
203+<kbd>esc</kbd>
204+</dt>
205+<dd>close panel</dd>
206+</div>
207+</dl>
208+</dialog>
209+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
210+<div class="panel-head">
211+<label for="refs-filter">ref&gt;</label>
212+<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
213+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
214+</div>
215+<div class="panel-cols" data-picklist>
216+<section aria-label="Branches">
217+<h2>branches <span class="mute">2</span>
218+</h2>
219+<ul>
220+<li>
221+<a href="/files/tree/feature/x">
222+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
223+</li>
224+<li>
225+<a href="/files/tree/main" aria-current="true">
226+<span class="mark" aria-hidden="true">●</span>main</a>
227+</li>
228+</ul>
229+</section>
230+<section aria-label="Tags">
231+<h2>tags <span class="mute">1</span>
232+</h2>
233+<ul>
234+<li>
235+<a href="/files/tree/v1.0">
236+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
237+</li>
238+</ul>
239+</section>
240+</div>
241+</dialog>
242+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
243+<div class="panel-head">
244+<label for="cmd-q" class="hi">:</label>
245+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
246+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
247+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
248+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
249+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
250+ if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
251+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
252+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
253+</div>
254+<ul id="cmd-list" class="cmd-list">
255+<li data-k="go home" data-cursor>
256+<a href="/" tabindex="-1">
257+<span class="k">go</span>home</a>
258+</li>
259+<li data-k="section summary">
260+<a href="/files" tabindex="-1">
261+<span class="k">section</span>summary</a>
262+</li>
263+<li data-k="section tree">
264+<a href="/files/tree/main" tabindex="-1">
265+<span class="k">section</span>tree</a>
266+</li>
267+<li data-k="section commits">
268+<a href="/files/commits/main" tabindex="-1">
269+<span class="k">section</span>commits</a>
270+</li>
271+<li data-k="section refs">
272+<a href="/files/refs" tabindex="-1">
273+<span class="k">section</span>refs</a>
274+</li>
275+<li data-k="branch feature/x">
276+<a href="/files/tree/feature/x" tabindex="-1">
277+<span class="k">branch</span>feature/x</a>
278+</li>
279+<li data-k="branch main">
280+<a href="/files/tree/main" tabindex="-1">
281+<span class="k">branch</span>main</a>
282+</li>
283+<li data-k="tag v1.0">
284+<a href="/files/tree/v1.0" tabindex="-1">
285+<span class="k">tag</span>v1.0</a>
286+</li>
287+</ul>
288+</dialog>
289+<footer class="bar status" aria-label="Status">
290+<span class="mode">blob</span>
291+<span class="st-ctx">logo.svg</span>
292+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
293+<span class="hi">:</span> go to…</button>
294+<span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
295+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
296+</span>
297+<span class="st-hints">click a line number · y permalink · ? help</span>
298+<span class="st-src">
299+<span class="st-ver">ermite devel · </span>
300+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
301+</span>
302+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
303+</footer>
304+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
305+ if (q('dialog[open]').count) return;
306+ let rows = q('[data-rows] > :not([hidden])').arr();
307+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
308+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
309+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
310+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
311+ })(1);
312+ k-k from:body -> ((d) => {
313+ if (q('dialog[open]').count) return;
314+ let rows = q('[data-rows] > :not([hidden])').arr();
315+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
316+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
317+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
318+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
319+ })(-1);
320+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
321+ k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
322+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
323+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
324+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
325+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
326+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
327+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
328+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
329+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
330+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
331+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
332+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
333+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
334+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
335+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
336+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
337+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
338+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
339+ k-permalink from:body -> {
340+ let b = document.body.dataset;
341+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
342+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
343+ navigator.clipboard?.writeText(location.href);
344+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
345+ }">
346+</div>
347+</body>
348+</html>
A cmd/ermite/testdata/web/blob-text.html +343 -0
@@ -0,0 +1,343 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>notes.txt · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">blob</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/blob/main/notes.txt" aria-current="page">notes.txt</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work has-rail">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<h1 class="file-h">
62+<span>notes.txt</span>
63+<span class="acts">
64+<a href="/files/raw/main/notes.txt">raw</a>
65+<a href="/files/commits/main/notes.txt">history</a>
66+<a href="/files/tree/main">dir</a>
67+</span>
68+</h1>
69+<pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();
70+ let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n;
71+ let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n);
72+ history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')" hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0;
73+ q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
74+ document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
75+ if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
76+<code>
77+<span class="line" id="L1">
78+<a class="ln" href="#L1">1</a>plain notes</span>
79+<span class="line" id="L2">
80+<a class="ln" href="#L2">2</a>second line</span>
81+</code>
82+</pre>
83+</main>
84+<aside class="rail" aria-label="Context">
85+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
86+<summary>file · last change · selection</summary>
87+<section aria-labelledby="r-file">
88+<h2 id="r-file">file</h2>
89+<dl class="kv">
90+<dt>size</dt>
91+<dd>24 B</dd>
92+<dt>lines</dt>
93+<dd>2</dd>
94+<dt>type</dt>
95+<dd>plaintext</dd>
96+</dl>
97+</section>
98+<section aria-labelledby="r-last">
99+<h2 id="r-last">last change <a href="/files/commits/main/notes.txt">history</a>
100+</h2>
101+<p>
102+<a href="/files/commit/SHA40">SHA7</a> add notes.txt</p>
103+<p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
104+</p>
105+</section>
106+<section aria-labelledby="r-sel">
107+<h2 id="r-sel">selection</h2>
108+<p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
109+<p class="dim">y copies a permalink pinned to the commit sha</p>
110+</section>
111+</details>
112+</aside>
113+</div>
114+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
115+<div class="panel-head">
116+<span>keys</span>
117+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
118+</div>
119+<dl class="keys">
120+<div>
121+<dt>
122+<kbd>j</kbd>
123+<kbd>k</kbd>
124+</dt>
125+<dd>move cursor down / up</dd>
126+</div>
127+<div>
128+<dt>
129+<kbd>enter</kbd>
130+</dt>
131+<dd>open row under cursor</dd>
132+</div>
133+<div>
134+<dt>
135+<kbd>g</kbd>
136+<kbd>g</kbd> / <kbd>G</kbd>
137+</dt>
138+<dd>first / last row</dd>
139+</div>
140+<div>
141+<dt>
142+<kbd>/</kbd>
143+</dt>
144+<dd>focus the filter</dd>
145+</div>
146+<div>
147+<dt>
148+<kbd>g h</kbd>
149+<kbd>g t</kbd>
150+</dt>
151+<dd>go home / tree</dd>
152+</div>
153+<div>
154+<dt>
155+<kbd>g c</kbd>
156+<kbd>g r</kbd>
157+</dt>
158+<dd>go commits / refs</dd>
159+</div>
160+<div>
161+<dt>
162+<kbd>b</kbd>
163+</dt>
164+<dd>switch ref (branches, tags)</dd>
165+</div>
166+<div>
167+<dt>
168+<kbd>:</kbd>
169+<kbd>f</kbd>
170+</dt>
171+<dd>go to: section, ref, file, repo</dd>
172+</div>
173+<div>
174+<dt>
175+<kbd>y</kbd>
176+</dt>
177+<dd>permalink: ref becomes the full sha</dd>
178+</div>
179+<div>
180+<dt>
181+<kbd>s</kbd>
182+<kbd>t</kbd>
183+<kbd>c</kbd>
184+<kbd>r</kbd>
185+</dt>
186+<dd>repo sections (when shown)</dd>
187+</div>
188+<div>
189+<dt>
190+<kbd>d</kbd>
191+<kbd>u</kbd>
192+<kbd>x</kbd>
193+</dt>
194+<dd>commit: split / unified / collapse all</dd>
195+</div>
196+<div>
197+<dt>
198+<kbd>esc</kbd>
199+</dt>
200+<dd>close panel</dd>
201+</div>
202+</dl>
203+</dialog>
204+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
205+<div class="panel-head">
206+<label for="refs-filter">ref&gt;</label>
207+<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
208+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
209+</div>
210+<div class="panel-cols" data-picklist>
211+<section aria-label="Branches">
212+<h2>branches <span class="mute">2</span>
213+</h2>
214+<ul>
215+<li>
216+<a href="/files/tree/feature/x">
217+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
218+</li>
219+<li>
220+<a href="/files/tree/main" aria-current="true">
221+<span class="mark" aria-hidden="true">●</span>main</a>
222+</li>
223+</ul>
224+</section>
225+<section aria-label="Tags">
226+<h2>tags <span class="mute">1</span>
227+</h2>
228+<ul>
229+<li>
230+<a href="/files/tree/v1.0">
231+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
232+</li>
233+</ul>
234+</section>
235+</div>
236+</dialog>
237+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
238+<div class="panel-head">
239+<label for="cmd-q" class="hi">:</label>
240+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
241+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
242+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
243+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
244+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
245+ if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
246+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
247+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
248+</div>
249+<ul id="cmd-list" class="cmd-list">
250+<li data-k="go home" data-cursor>
251+<a href="/" tabindex="-1">
252+<span class="k">go</span>home</a>
253+</li>
254+<li data-k="section summary">
255+<a href="/files" tabindex="-1">
256+<span class="k">section</span>summary</a>
257+</li>
258+<li data-k="section tree">
259+<a href="/files/tree/main" tabindex="-1">
260+<span class="k">section</span>tree</a>
261+</li>
262+<li data-k="section commits">
263+<a href="/files/commits/main" tabindex="-1">
264+<span class="k">section</span>commits</a>
265+</li>
266+<li data-k="section refs">
267+<a href="/files/refs" tabindex="-1">
268+<span class="k">section</span>refs</a>
269+</li>
270+<li data-k="branch feature/x">
271+<a href="/files/tree/feature/x" tabindex="-1">
272+<span class="k">branch</span>feature/x</a>
273+</li>
274+<li data-k="branch main">
275+<a href="/files/tree/main" tabindex="-1">
276+<span class="k">branch</span>main</a>
277+</li>
278+<li data-k="tag v1.0">
279+<a href="/files/tree/v1.0" tabindex="-1">
280+<span class="k">tag</span>v1.0</a>
281+</li>
282+</ul>
283+</dialog>
284+<footer class="bar status" aria-label="Status">
285+<span class="mode">blob</span>
286+<span class="st-ctx">notes.txt</span>
287+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
288+<span class="hi">:</span> go to…</button>
289+<span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
290+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
291+</span>
292+<span class="st-hints">click a line number · y permalink · ? help</span>
293+<span class="st-src">
294+<span class="st-ver">ermite devel · </span>
295+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
296+</span>
297+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
298+</footer>
299+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
300+ if (q('dialog[open]').count) return;
301+ let rows = q('[data-rows] > :not([hidden])').arr();
302+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
303+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
304+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
305+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
306+ })(1);
307+ k-k from:body -> ((d) => {
308+ if (q('dialog[open]').count) return;
309+ let rows = q('[data-rows] > :not([hidden])').arr();
310+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
311+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
312+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
313+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
314+ })(-1);
315+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
316+ k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
317+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
318+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
319+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
320+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
321+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
322+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
323+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
324+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
325+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
326+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
327+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
328+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
329+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
330+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
331+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
332+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
333+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
334+ k-permalink from:body -> {
335+ let b = document.body.dataset;
336+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
337+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
338+ navigator.clipboard?.writeText(location.href);
339+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
340+ }">
341+</div>
342+</body>
343+</html>
A cmd/ermite/testdata/web/blob-too-large.html +322 -0
@@ -0,0 +1,322 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>big.txt · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">blob</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/blob/main/big.txt" aria-current="page">big.txt</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work has-rail">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<h1 class="file-h">
62+<span>big.txt</span>
63+<span class="acts">
64+<a href="/files/raw/main/big.txt">raw</a>
65+<a href="/files/commits/main/big.txt">history</a>
66+<a href="/files/tree/main">dir</a>
67+</span>
68+</h1>
69+<p class="notice" role="note">file too large to show (1.1 MiB, the limit is 1.0 MiB). use raw.</p>
70+</main>
71+<aside class="rail" aria-label="Context">
72+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
73+<summary>file · last change · selection</summary>
74+<section aria-labelledby="r-file">
75+<h2 id="r-file">file</h2>
76+<dl class="kv">
77+<dt>size</dt>
78+<dd>1.1 MiB</dd>
79+<dt>type</dt>
80+<dd>text/plain</dd>
81+</dl>
82+</section>
83+<section aria-labelledby="r-last">
84+<h2 id="r-last">last change <a href="/files/commits/main/big.txt">history</a>
85+</h2>
86+<p>
87+<a href="/files/commit/SHA40">SHA7</a> add big.txt</p>
88+<p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
89+</p>
90+</section>
91+</details>
92+</aside>
93+</div>
94+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
95+<div class="panel-head">
96+<span>keys</span>
97+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
98+</div>
99+<dl class="keys">
100+<div>
101+<dt>
102+<kbd>j</kbd>
103+<kbd>k</kbd>
104+</dt>
105+<dd>move cursor down / up</dd>
106+</div>
107+<div>
108+<dt>
109+<kbd>enter</kbd>
110+</dt>
111+<dd>open row under cursor</dd>
112+</div>
113+<div>
114+<dt>
115+<kbd>g</kbd>
116+<kbd>g</kbd> / <kbd>G</kbd>
117+</dt>
118+<dd>first / last row</dd>
119+</div>
120+<div>
121+<dt>
122+<kbd>/</kbd>
123+</dt>
124+<dd>focus the filter</dd>
125+</div>
126+<div>
127+<dt>
128+<kbd>g h</kbd>
129+<kbd>g t</kbd>
130+</dt>
131+<dd>go home / tree</dd>
132+</div>
133+<div>
134+<dt>
135+<kbd>g c</kbd>
136+<kbd>g r</kbd>
137+</dt>
138+<dd>go commits / refs</dd>
139+</div>
140+<div>
141+<dt>
142+<kbd>b</kbd>
143+</dt>
144+<dd>switch ref (branches, tags)</dd>
145+</div>
146+<div>
147+<dt>
148+<kbd>:</kbd>
149+<kbd>f</kbd>
150+</dt>
151+<dd>go to: section, ref, file, repo</dd>
152+</div>
153+<div>
154+<dt>
155+<kbd>y</kbd>
156+</dt>
157+<dd>permalink: ref becomes the full sha</dd>
158+</div>
159+<div>
160+<dt>
161+<kbd>s</kbd>
162+<kbd>t</kbd>
163+<kbd>c</kbd>
164+<kbd>r</kbd>
165+</dt>
166+<dd>repo sections (when shown)</dd>
167+</div>
168+<div>
169+<dt>
170+<kbd>d</kbd>
171+<kbd>u</kbd>
172+<kbd>x</kbd>
173+</dt>
174+<dd>commit: split / unified / collapse all</dd>
175+</div>
176+<div>
177+<dt>
178+<kbd>esc</kbd>
179+</dt>
180+<dd>close panel</dd>
181+</div>
182+</dl>
183+</dialog>
184+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
185+<div class="panel-head">
186+<label for="refs-filter">ref&gt;</label>
187+<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
188+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
189+</div>
190+<div class="panel-cols" data-picklist>
191+<section aria-label="Branches">
192+<h2>branches <span class="mute">2</span>
193+</h2>
194+<ul>
195+<li>
196+<a href="/files/tree/feature/x">
197+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
198+</li>
199+<li>
200+<a href="/files/tree/main" aria-current="true">
201+<span class="mark" aria-hidden="true">●</span>main</a>
202+</li>
203+</ul>
204+</section>
205+<section aria-label="Tags">
206+<h2>tags <span class="mute">1</span>
207+</h2>
208+<ul>
209+<li>
210+<a href="/files/tree/v1.0">
211+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
212+</li>
213+</ul>
214+</section>
215+</div>
216+</dialog>
217+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
218+<div class="panel-head">
219+<label for="cmd-q" class="hi">:</label>
220+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
221+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
222+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
223+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
224+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
225+ if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
226+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
227+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
228+</div>
229+<ul id="cmd-list" class="cmd-list">
230+<li data-k="go home" data-cursor>
231+<a href="/" tabindex="-1">
232+<span class="k">go</span>home</a>
233+</li>
234+<li data-k="section summary">
235+<a href="/files" tabindex="-1">
236+<span class="k">section</span>summary</a>
237+</li>
238+<li data-k="section tree">
239+<a href="/files/tree/main" tabindex="-1">
240+<span class="k">section</span>tree</a>
241+</li>
242+<li data-k="section commits">
243+<a href="/files/commits/main" tabindex="-1">
244+<span class="k">section</span>commits</a>
245+</li>
246+<li data-k="section refs">
247+<a href="/files/refs" tabindex="-1">
248+<span class="k">section</span>refs</a>
249+</li>
250+<li data-k="branch feature/x">
251+<a href="/files/tree/feature/x" tabindex="-1">
252+<span class="k">branch</span>feature/x</a>
253+</li>
254+<li data-k="branch main">
255+<a href="/files/tree/main" tabindex="-1">
256+<span class="k">branch</span>main</a>
257+</li>
258+<li data-k="tag v1.0">
259+<a href="/files/tree/v1.0" tabindex="-1">
260+<span class="k">tag</span>v1.0</a>
261+</li>
262+</ul>
263+</dialog>
264+<footer class="bar status" aria-label="Status">
265+<span class="mode">blob</span>
266+<span class="st-ctx">big.txt</span>
267+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
268+<span class="hi">:</span> go to…</button>
269+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
270+</span>
271+<span class="st-hints">click a line number · y permalink · ? help</span>
272+<span class="st-src">
273+<span class="st-ver">ermite devel · </span>
274+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
275+</span>
276+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
277+</footer>
278+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
279+ if (q('dialog[open]').count) return;
280+ let rows = q('[data-rows] > :not([hidden])').arr();
281+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
282+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
283+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
284+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
285+ })(1);
286+ k-k from:body -> ((d) => {
287+ if (q('dialog[open]').count) return;
288+ let rows = q('[data-rows] > :not([hidden])').arr();
289+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
290+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
291+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
292+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
293+ })(-1);
294+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
295+ k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
296+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
297+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
298+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
299+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
300+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
301+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
302+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
303+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
304+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
305+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
306+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
307+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
308+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
309+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
310+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
311+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
312+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
313+ k-permalink from:body -> {
314+ let b = document.body.dataset;
315+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
316+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
317+ navigator.clipboard?.writeText(location.href);
318+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
319+ }">
320+</div>
321+</body>
322+</html>
A cmd/ermite/testdata/web/blob-trojan.html +390 -0
@@ -0,0 +1,390 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>evil.go · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">blob</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/blob/main/evil.go" aria-current="page">evil.go</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work has-rail">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<h1 class="file-h">
62+<span>evil.go</span>
63+<span class="acts">
64+<a href="/files/raw/main/evil.go">raw</a>
65+<a href="/files/commits/main/evil.go">history</a>
66+<a href="/files/tree/main">dir</a>
67+</span>
68+</h1>
69+<pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();
70+ let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n;
71+ let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n);
72+ history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')" hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0;
73+ q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
74+ document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
75+ if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
76+<code>
77+<span class="line" id="L1">
78+<a class="ln" href="#L1">1</a>
79+<span class="kn">package</span>
80+<span class="w">
81+</span>
82+<span class="nx">main</span>
83+</span>
84+<span class="line" id="L2">
85+<a class="ln" href="#L2">2</a>
86+</span>
87+<span class="line" id="L3">
88+<a class="ln" href="#L3">3</a>
89+<span class="kd">func</span>
90+<span class="w">
91+</span>
92+<span class="nf">main</span>
93+<span class="p">()</span>
94+<span class="w">
95+</span>
96+<span class="p">{</span>
97+</span>
98+<span class="line" id="L4">
99+<a class="ln" href="#L4">4</a>
100+<span class="w">
101+</span>
102+<span class="nx">access</span>
103+<span class="w">
104+</span>
105+<span class="o">:=</span>
106+<span class="w">
107+</span>
108+<span class="s">&#34;user<span class="ctl" title="U+202E right-to-left override">&lt;U+202E&gt;</span>
109+<span class="ctl" title="U+2066 left-to-right isolate">&lt;U+2066&gt;</span>// admin only<span class="ctl" title="U+2069 pop directional isolate">&lt;U+2069&gt;</span>
110+<span class="ctl" title="U+2066 left-to-right isolate">&lt;U+2066&gt;</span>&#34;</span>
111+</span>
112+<span class="line" id="L5">
113+<a class="ln" href="#L5">5</a>
114+<span class="w">
115+</span>
116+<span class="nx">_</span>
117+<span class="w">
118+</span>
119+<span class="p">=</span>
120+<span class="w">
121+</span>
122+<span class="nx">access</span>
123+</span>
124+<span class="line" id="L6">
125+<a class="ln" href="#L6">6</a>
126+<span class="p">}</span>
127+</span>
128+</code>
129+</pre>
130+</main>
131+<aside class="rail" aria-label="Context">
132+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
133+<summary>file · last change · selection</summary>
134+<section aria-labelledby="r-file">
135+<h2 id="r-file">file</h2>
136+<dl class="kv">
137+<dt>size</dt>
138+<dd>87 B</dd>
139+<dt>lines</dt>
140+<dd>6</dd>
141+<dt>type</dt>
142+<dd>Go</dd>
143+</dl>
144+</section>
145+<section aria-labelledby="r-last">
146+<h2 id="r-last">last change <a href="/files/commits/main/evil.go">history</a>
147+</h2>
148+<p>
149+<a href="/files/commit/SHA40">SHA7</a> add evil.go</p>
150+<p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
151+</p>
152+</section>
153+<section aria-labelledby="r-sel">
154+<h2 id="r-sel">selection</h2>
155+<p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
156+<p class="dim">y copies a permalink pinned to the commit sha</p>
157+</section>
158+</details>
159+</aside>
160+</div>
161+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
162+<div class="panel-head">
163+<span>keys</span>
164+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
165+</div>
166+<dl class="keys">
167+<div>
168+<dt>
169+<kbd>j</kbd>
170+<kbd>k</kbd>
171+</dt>
172+<dd>move cursor down / up</dd>
173+</div>
174+<div>
175+<dt>
176+<kbd>enter</kbd>
177+</dt>
178+<dd>open row under cursor</dd>
179+</div>
180+<div>
181+<dt>
182+<kbd>g</kbd>
183+<kbd>g</kbd> / <kbd>G</kbd>
184+</dt>
185+<dd>first / last row</dd>
186+</div>
187+<div>
188+<dt>
189+<kbd>/</kbd>
190+</dt>
191+<dd>focus the filter</dd>
192+</div>
193+<div>
194+<dt>
195+<kbd>g h</kbd>
196+<kbd>g t</kbd>
197+</dt>
198+<dd>go home / tree</dd>
199+</div>
200+<div>
201+<dt>
202+<kbd>g c</kbd>
203+<kbd>g r</kbd>
204+</dt>
205+<dd>go commits / refs</dd>
206+</div>
207+<div>
208+<dt>
209+<kbd>b</kbd>
210+</dt>
211+<dd>switch ref (branches, tags)</dd>
212+</div>
213+<div>
214+<dt>
215+<kbd>:</kbd>
216+<kbd>f</kbd>
217+</dt>
218+<dd>go to: section, ref, file, repo</dd>
219+</div>
220+<div>
221+<dt>
222+<kbd>y</kbd>
223+</dt>
224+<dd>permalink: ref becomes the full sha</dd>
225+</div>
226+<div>
227+<dt>
228+<kbd>s</kbd>
229+<kbd>t</kbd>
230+<kbd>c</kbd>
231+<kbd>r</kbd>
232+</dt>
233+<dd>repo sections (when shown)</dd>
234+</div>
235+<div>
236+<dt>
237+<kbd>d</kbd>
238+<kbd>u</kbd>
239+<kbd>x</kbd>
240+</dt>
241+<dd>commit: split / unified / collapse all</dd>
242+</div>
243+<div>
244+<dt>
245+<kbd>esc</kbd>
246+</dt>
247+<dd>close panel</dd>
248+</div>
249+</dl>
250+</dialog>
251+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
252+<div class="panel-head">
253+<label for="refs-filter">ref&gt;</label>
254+<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
255+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
256+</div>
257+<div class="panel-cols" data-picklist>
258+<section aria-label="Branches">
259+<h2>branches <span class="mute">2</span>
260+</h2>
261+<ul>
262+<li>
263+<a href="/files/tree/feature/x">
264+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
265+</li>
266+<li>
267+<a href="/files/tree/main" aria-current="true">
268+<span class="mark" aria-hidden="true">●</span>main</a>
269+</li>
270+</ul>
271+</section>
272+<section aria-label="Tags">
273+<h2>tags <span class="mute">1</span>
274+</h2>
275+<ul>
276+<li>
277+<a href="/files/tree/v1.0">
278+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
279+</li>
280+</ul>
281+</section>
282+</div>
283+</dialog>
284+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
285+<div class="panel-head">
286+<label for="cmd-q" class="hi">:</label>
287+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
288+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
289+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
290+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
291+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
292+ if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
293+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
294+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
295+</div>
296+<ul id="cmd-list" class="cmd-list">
297+<li data-k="go home" data-cursor>
298+<a href="/" tabindex="-1">
299+<span class="k">go</span>home</a>
300+</li>
301+<li data-k="section summary">
302+<a href="/files" tabindex="-1">
303+<span class="k">section</span>summary</a>
304+</li>
305+<li data-k="section tree">
306+<a href="/files/tree/main" tabindex="-1">
307+<span class="k">section</span>tree</a>
308+</li>
309+<li data-k="section commits">
310+<a href="/files/commits/main" tabindex="-1">
311+<span class="k">section</span>commits</a>
312+</li>
313+<li data-k="section refs">
314+<a href="/files/refs" tabindex="-1">
315+<span class="k">section</span>refs</a>
316+</li>
317+<li data-k="branch feature/x">
318+<a href="/files/tree/feature/x" tabindex="-1">
319+<span class="k">branch</span>feature/x</a>
320+</li>
321+<li data-k="branch main">
322+<a href="/files/tree/main" tabindex="-1">
323+<span class="k">branch</span>main</a>
324+</li>
325+<li data-k="tag v1.0">
326+<a href="/files/tree/v1.0" tabindex="-1">
327+<span class="k">tag</span>v1.0</a>
328+</li>
329+</ul>
330+</dialog>
331+<footer class="bar status" aria-label="Status">
332+<span class="mode">blob</span>
333+<span class="st-ctx">evil.go</span>
334+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
335+<span class="hi">:</span> go to…</button>
336+<span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
337+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
338+</span>
339+<span class="st-hints">click a line number · y permalink · ? help</span>
340+<span class="st-src">
341+<span class="st-ver">ermite devel · </span>
342+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
343+</span>
344+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
345+</footer>
346+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
347+ if (q('dialog[open]').count) return;
348+ let rows = q('[data-rows] > :not([hidden])').arr();
349+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
350+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
351+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
352+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
353+ })(1);
354+ k-k from:body -> ((d) => {
355+ if (q('dialog[open]').count) return;
356+ let rows = q('[data-rows] > :not([hidden])').arr();
357+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
358+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
359+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
360+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
361+ })(-1);
362+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
363+ k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
364+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
365+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
366+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
367+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
368+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
369+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
370+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
371+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
372+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
373+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
374+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
375+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
376+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
377+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
378+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
379+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
380+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
381+ k-permalink from:body -> {
382+ let b = document.body.dataset;
383+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
384+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
385+ navigator.clipboard?.writeText(location.href);
386+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
387+ }">
388+</div>
389+</body>
390+</html>
A cmd/ermite/web_files_test.go +336 -0
@@ -0,0 +1,336 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bytes"
7+ "image"
8+ "image/color"
9+ "image/png"
10+ "net/http"
11+ "regexp"
12+ "strconv"
13+ "strings"
14+ "testing"
15+
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/app"
20+ "bnema.dev/ermite/internal/domain"
21+)
22+
23+// Trojan Source: a right-to-left override and an isolate around code.
24+const webTrojan = "package main\n\nfunc main() {\n\taccess := \"user\u202e \u2066// admin only\u2069 \u2066\"\n\t_ = access\n}\n"
25+
26+func tinyPNG(t *testing.T) []byte {
27+ t.Helper()
28+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
29+ img.Set(0, 0, color.RGBA{R: 255, A: 255})
30+ var b bytes.Buffer
31+ require.NoError(t, png.Encode(&b, img))
32+ return b.Bytes()
33+}
34+
35+// webFilesRepo is the repository of the tree, blob and raw tests: files of
36+// every kind the pages treat differently, a directory README, a branch and a
37+// tag with a slash.
38+func (h *harness) webFilesRepo(name string) {
39+ h.t.Helper()
40+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{
41+ Name: h.name(name), Description: "Files of every kind", Visibility: domain.VisibilityPublic,
42+ })
43+ require.NoError(h.t, err)
44+ wt := h.t.TempDir()
45+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
46+ h.writeFile(wt, "README.md", []byte("# Files\n\nSee [the guide](docs/guide.md).\n"))
47+ h.writeFile(wt, "main.go", []byte("package main\n\nimport \"fmt\"\n\n// main prints.\nfunc main() {\n\tfmt.Println(\"hi <b>\", 42)\n}\n"))
48+ h.writeFile(wt, "docs/README.md", []byte("# Docs\n\nBack to [the guide](guide.md) and [root](../main.go).\n"))
49+ h.writeFile(wt, "docs/guide.md", []byte("# Guide\n\n## Steps\n\nRun it.\n\n```sh\nmake all\n```\n"))
50+ h.writeFile(wt, "src/lib/util.go", []byte("package lib\n"))
51+ h.writeFile(wt, "notes.txt", []byte("plain notes\nsecond line\n"))
52+ h.writeFile(wt, "big.txt", bytes.Repeat([]byte("0123456789abcdef\n"), 70000)) // 1.1 MiB
53+ h.writeFile(wt, "lines.txt", bytes.Repeat([]byte("x\n"), 20005))
54+ h.writeFile(wt, "data.bin", []byte("\x00\x01\x02binary\x00"))
55+ h.writeFile(wt, "pic.png", tinyPNG(h.t))
56+ h.writeFile(wt, "logo.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script></svg>`))
57+ h.writeFile(wt, "page.html", []byte("<script>alert(3)</script>\n"))
58+ h.writeFile(wt, "evil.go", []byte(webTrojan))
59+ h.writeFile(wt, "empty.txt", nil)
60+ h.writeFile(wt, "fake.png", []byte("<html><script>alert(1)</script></html>\n"))
61+ h.writeFile(wt, "fake.jpg", []byte("GIF89a\x00\x00binary\x00"))
62+ h.mustGit(wt, "tag", "v1.0")
63+ h.mustGit(wt, "checkout", "-q", "-b", "feature/x")
64+ h.writeFile(wt, "x/y.txt", []byte("on the feature branch\n"))
65+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "feature/x", "--tags")
66+}
67+
68+// AC1: the tree and blob pages render for a fixture repository; the HTML is
69+// compared after normalising times and ids.
70+func TestWebFilesGolden(t *testing.T) {
71+ h := newHarness(t, harnessOpts{})
72+ h.webFilesRepo("files")
73+
74+ for _, tt := range []struct {
75+ name, path string
76+ status int
77+ }{
78+ {"tree-root", "/files/tree/main", 200},
79+ {"tree-subdir", "/files/tree/main/docs", 200},
80+ {"tree-nested", "/files/tree/main/src/lib", 200},
81+ {"tree-slash-ref", "/files/tree/feature/x/x", 200},
82+ {"blob-code", "/files/blob/main/main.go", 200},
83+ {"blob-markdown", "/files/blob/main/docs/guide.md", 200},
84+ {"blob-text", "/files/blob/main/notes.txt", 200},
85+ {"blob-too-large", "/files/blob/main/big.txt", 200},
86+ {"blob-binary", "/files/blob/main/data.bin", 200},
87+ {"blob-image", "/files/blob/main/pic.png", 200},
88+ {"blob-svg", "/files/blob/main/logo.svg", 200},
89+ {"blob-trojan", "/files/blob/main/evil.go", 200},
90+ {"blob-empty", "/files/blob/main/empty.txt", 200},
91+ } {
92+ t.Run(tt.name, func(t *testing.T) {
93+ status, hdr, body := h.get(tt.path)
94+ require.Equal(t, tt.status, status)
95+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
96+ assert.Contains(t, hdr.Get("Content-Type"), "text/html")
97+ assertNoInline(t, body)
98+ golden(t, tt.name, normalise(body))
99+ })
100+ }
101+}
102+
103+// Ref names with a slash are found by their longest prefix, and a commit id
104+// works where a ref does.
105+func TestWebFilesRefs(t *testing.T) {
106+ h := newHarness(t, harnessOpts{})
107+ h.webFilesRepo("files")
108+
109+ _, _, body := h.get("/files/tree/feature/x")
110+ assert.Contains(t, body, ">x/<", "the directory of the branch feature/x")
111+ status, _, body := h.get("/files/blob/feature/x/x/y.txt")
112+ require.Equal(t, 200, status)
113+ assert.Contains(t, body, "on the feature branch")
114+ status, _, body = h.get("/files/blob/v1.0/main.go")
115+ require.Equal(t, 200, status, "a tag")
116+ assert.Contains(t, body, "package")
117+
118+ sha := h.bareGit("files", "rev-parse", "main")
119+ status, _, body = h.get("/files/blob/" + sha + "/notes.txt")
120+ require.Equal(t, 200, status, "a commit id")
121+ assert.Contains(t, body, "plain notes")
122+ assert.Contains(t, body, "detached")
123+ status, _, _ = h.get("/files/tree/" + sha[:7])
124+ assert.Equal(t, 200, status, "an abbreviated commit id")
125+
126+ // Links of a directory README stay under the directory and the ref.
127+ _, _, body = h.get("/files/tree/main/docs")
128+ assert.Contains(t, body, `href="/files/blob/main/docs/guide.md"`)
129+ assert.Contains(t, body, `href="/files/blob/main/main.go"`)
130+}
131+
132+// AC3: what does not exist is the 404 page, with no hint of why.
133+func TestWebFilesNotFound(t *testing.T) {
134+ h := newHarness(t, harnessOpts{})
135+ h.webFilesRepo("files")
136+ h.seed("quiet", domain.VisibilityPublic) // empty repository
137+
138+ for _, p := range []string{
139+ "/files/tree/nope", "/files/tree/main/no/such/dir", "/files/blob/main/nothing.txt", "/files/blob/nope/main.go",
140+ "/files/blob/main", "/files/blob/main/docs", "/files/tree/main/main.go", "/files/raw/main/nothing.txt",
141+ "/files/raw/main", "/files/raw/main/docs", "/files/blob/ffffffffffffffffffffffffffffffffffffffff/main.go",
142+ "/files/blob", "/files/raw", "/files/blob/main/../main.go",
143+ "/quiet/tree/main", "/quiet/tree", "/quiet/blob/main/x", "/never/blob/main/x", "/never/raw/main/x",
144+ } {
145+ status, hdr, body := h.get(p)
146+ assert.Equal(t, 404, status, p)
147+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
148+ assert.Contains(t, hdr.Get("Content-Type"), "text/html", p)
149+ assert.NotContains(t, body, "nothing.txt", p)
150+ }
151+ status, _, body := h.get("/files/blob/main/nothing.txt")
152+ require.Equal(t, 404, status)
153+ _, _, other := h.get("/files/tree/nope")
154+ assert.Equal(t, normalise(other), normalise(body), "one 404 page for everything")
155+}
156+
157+// AC2: the tree, blob and raw pages of a private repository are the 404 of a
158+// missing one.
159+func TestWebFilesPrivate(t *testing.T) {
160+ h := newHarness(t, harnessOpts{})
161+ h.seed("secret-sauce", domain.VisibilityPrivate, "README.md", "a.txt")
162+
163+ for _, kind := range []string{"tree", "blob", "raw"} {
164+ for _, p := range []string{"/secret-sauce/" + kind + "/main", "/secret-sauce/" + kind + "/main/a.txt"} {
165+ status, hdr, body := h.get(p)
166+ assert.Equal(t, 404, status, p)
167+ assert.NotContains(t, body, "secret-sauce", p)
168+ assert.NotContains(t, body, "content of", p)
169+ assert.NotContains(t, hdr.Get("Content-Type"), "text/plain", p)
170+ _, _, missing := h.get(strings.Replace(p, "secret-sauce", "never-existed", 1))
171+ assert.Equal(t, normalise(missing), normalise(body), "%s is the page of a missing repository", p)
172+ }
173+ }
174+}
175+
176+func rawGet(t *testing.T, h *harness, path string) (int, http.Header, []byte) {
177+ t.Helper()
178+ status, hdr, body := h.get(path)
179+ return status, hdr, []byte(body)
180+}
181+
182+// The raw route serves text as text/plain, the four image types as images,
183+// everything else as an attachment; never as HTML or SVG; and always with
184+// nosniff and a CSP that makes the file inert.
185+func TestWebRaw(t *testing.T) {
186+ h := newHarness(t, harnessOpts{})
187+ h.webFilesRepo("files")
188+ sha := h.bareGit("files", "rev-parse", "main")
189+
190+ for _, tt := range []struct {
191+ name, path, typ, disposition string
192+ }{
193+ {"text", "/files/raw/main/notes.txt", "text/plain; charset=utf-8", ""},
194+ {"code", "/files/raw/main/main.go", "text/plain; charset=utf-8", ""},
195+ {"markdown", "/files/raw/main/README.md", "text/plain; charset=utf-8", ""},
196+ {"html is text", "/files/raw/main/page.html", "text/plain; charset=utf-8", ""},
197+ {"empty", "/files/raw/main/empty.txt", "text/plain; charset=utf-8", ""},
198+ {"large text", "/files/raw/main/big.txt", "text/plain; charset=utf-8", ""},
199+ {"png", "/files/raw/main/pic.png", "image/png", ""},
200+ {"binary", "/files/raw/main/data.bin", "application/octet-stream", `attachment; filename=data.bin`},
201+ {"svg is text", "/files/raw/main/logo.svg", "text/plain; charset=utf-8", ""},
202+ {"slash ref", "/files/raw/feature/x/x/y.txt", "text/plain; charset=utf-8", ""},
203+ {"tag", "/files/raw/v1.0/main.go", "text/plain; charset=utf-8", ""},
204+ {"commit", "/files/raw/" + sha + "/notes.txt", "text/plain; charset=utf-8", ""},
205+ } {
206+ t.Run(tt.name, func(t *testing.T) {
207+ status, hdr, body := rawGet(t, h, tt.path)
208+ require.Equal(t, 200, status)
209+ assert.Equal(t, tt.typ, hdr.Get("Content-Type"))
210+ assert.Equal(t, tt.disposition, hdr.Get("Content-Disposition"))
211+ assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"))
212+ assert.Equal(t, "default-src 'none'; sandbox", hdr.Get("Content-Security-Policy"))
213+ assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"))
214+ assert.Equal(t, strconv.Itoa(len(body)), hdr.Get("Content-Length"))
215+ if strings.Contains(tt.path, sha) {
216+ assert.Equal(t, "public, max-age=31536000, immutable", hdr.Get("Cache-Control"))
217+ } else {
218+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), "a branch or a tag moves")
219+ }
220+ })
221+ }
222+
223+ // Content is the file, byte for byte.
224+ _, _, body := rawGet(t, h, "/files/raw/main/data.bin")
225+ assert.Equal(t, []byte("\x00\x01\x02binary\x00"), body)
226+ _, _, body = rawGet(t, h, "/files/raw/main/pic.png")
227+ assert.Equal(t, tinyPNG(t), body)
228+ _, _, body = rawGet(t, h, "/files/raw/main/big.txt")
229+ assert.Len(t, body, 17*70000)
230+ _, _, body = rawGet(t, h, "/files/raw/main/evil.go")
231+ assert.Equal(t, webTrojan, string(body), "raw is not altered")
232+
233+ // A file named like an image is not one unless its bytes say so.
234+ _, hdr, _ := rawGet(t, h, "/files/raw/main/fake.png")
235+ assert.Equal(t, "text/plain; charset=utf-8", hdr.Get("Content-Type"), "the type comes from the bytes, not the name")
236+ _, hdr, _ = rawGet(t, h, "/files/raw/main/fake.jpg")
237+ assert.Equal(t, "image/gif", hdr.Get("Content-Type"))
238+ _, _, page := h.get("/files/blob/main/fake.png")
239+ assert.NotContains(t, page, "<script>alert", "shown as code, escaped")
240+
241+ // HEAD and conditional requests.
242+ req, err := http.NewRequest(http.MethodGet, h.url("/files/raw/main/notes.txt"), nil)
243+ require.NoError(t, err)
244+ resp, err := http.DefaultClient.Do(req)
245+ require.NoError(t, err)
246+ _ = resp.Body.Close()
247+ etag := resp.Header.Get("ETag")
248+ require.NotEmpty(t, etag)
249+ req.Header.Set("If-None-Match", etag)
250+ resp, err = http.DefaultClient.Do(req)
251+ require.NoError(t, err)
252+ _ = resp.Body.Close()
253+ assert.Equal(t, http.StatusNotModified, resp.StatusCode)
254+ assert.Equal(t, http.StatusOK, h.status(http.MethodHead, "/files/raw/main/notes.txt", nil))
255+}
256+
257+var reLineID = regexp.MustCompile(`<span class="line" id="L(\d+)">`)
258+
259+// AC1, AC6: the code view numbers its lines with anchors, the page has no
260+// inline script or style, and a file that attacks the page is shown inert.
261+func TestWebBlobCode(t *testing.T) {
262+ h := newHarness(t, harnessOpts{})
263+ h.webFilesRepo("files")
264+
265+ _, _, body := h.get("/files/blob/main/main.go")
266+ assert.Equal(t, 8, len(reLineID.FindAllString(body, -1)))
267+ assert.Contains(t, body, `<a class="ln" href="#L6">6</a>`)
268+ assert.Contains(t, body, `<span class="kn">package</span>`)
269+ assert.Contains(t, body, `hi &lt;b&gt;`, "source is escaped")
270+ assert.NotContains(t, body, "<b>")
271+ assertNoInline(t, body)
272+ assert.Regexp(t, `href="/files/raw/main/main.go"`, body)
273+
274+ // Trojan Source: the controls are shown, not applied.
275+ _, _, body = h.get("/files/blob/main/evil.go")
276+ for _, r := range []string{"\u202e", "\u2066", "\u2069"} {
277+ assert.NotContains(t, body, r, "%+q is not output", r)
278+ }
279+ assert.Contains(t, body, `<span class="ctl" title="U+202E right-to-left override">&lt;U+202E&gt;</span>`)
280+ assert.Contains(t, body, "&lt;U+2066&gt;")
281+ assert.Contains(t, body, "&lt;U+2069&gt;")
282+
283+ // The image and SVG: only the four raster types are images.
284+ _, _, body = h.get("/files/blob/main/pic.png")
285+ assert.Contains(t, body, `<img src="/files/raw/main/pic.png"`)
286+ _, _, body = h.get("/files/blob/main/logo.svg")
287+ assert.NotContains(t, body, "<img")
288+ assert.NotContains(t, body, "<svg", "the source is escaped, never markup")
289+ assert.NotContains(t, body, "<script>alert")
290+ assert.Contains(t, body, "&lt;svg")
291+ assertNoInline(t, body)
292+
293+ // Too many lines: the first ones, with a notice.
294+ _, _, body = h.get("/files/blob/main/lines.txt")
295+ assert.Equal(t, 20000, len(reLineID.FindAllString(body, -1)))
296+ assert.Contains(t, body, "20005 lines: showing the first 20000")
297+
298+ // Markdown: rendered, source under a fold, links under the directory.
299+ _, _, body = h.get("/files/blob/main/docs/guide.md")
300+ assert.Contains(t, body, `<details class="source">`)
301+ assert.Contains(t, body, "<h1 id=\"md-guide\">Guide</h1>")
302+ assert.Contains(t, body, `href="#md-steps"`, "outline")
303+}
304+
305+// AC6 on the new pages: the CSP of the site, the security headers, nothing
306+// inline and nothing loaded from another origin.
307+func TestWebFilesContentSecurity(t *testing.T) {
308+ h := newHarness(t, harnessOpts{})
309+ h.webFilesRepo("files")
310+
311+ const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"
312+ for _, p := range []string{
313+ "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md",
314+ "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt",
315+ "/files/blob/main/data.bin", "/files/tree/nope",
316+ } {
317+ _, hdr, body := h.get(p)
318+ assert.Equal(t, csp, hdr.Get("Content-Security-Policy"), p)
319+ assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"), p)
320+ assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"), p)
321+ assertNoInline(t, body)
322+ assert.NotRegexp(t, `(?i)\b(?:src|action)="(?:https?:)?//`, body, p)
323+ assert.False(t, reImport.MatchString(body), p)
324+ for _, m := range regexp.MustCompile(`<(?:script|link)\b[^>]*\b(?:src|href)="([^"]*)"`).FindAllStringSubmatch(body, -1) {
325+ assert.True(t, strings.HasPrefix(m[1], "/-/static/"), "%s loads %s", p, m[1])
326+ }
327+ }
328+
329+ // The theme of the code view is a static file with no colour of its own.
330+ _, _, page := h.get("/files/blob/main/main.go")
331+ css := regexp.MustCompile(`href="(/-/static/css/[^"]+)"`).FindStringSubmatch(page)
332+ require.Len(t, css, 2)
333+ _, _, sheet := h.get(css[1])
334+ assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)")
335+ assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet)
336+}
M internal/adapters/web/ASSETS.md +1 -0
@@ -9,6 +9,7 @@ content-hashed names. Nothing is fetched at runtime.
99 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
1111 | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | |
12+| `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
1213 | `static/css/*.css` | native CSS of design D, served as one stylesheet in name order | `mockups/d/css/` (+ `70-footer.css`, the AGPL §13 source link) | |
1314
1415 The three htmx files are the exact files the approved mockup was built with.
A internal/adapters/web/blob.go +236 -0
@@ -0,0 +1,236 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "fmt"
8+ "html/template"
9+ "net/http"
10+ "path"
11+ "strings"
12+
13+ "connectrpc.com/connect"
14+ "github.com/a-h/templ"
15+ "github.com/bnema/zerowrap"
16+
17+ pb "bnema.dev/ermite-api/gen/ermite"
18+)
19+
20+const (
21+ // maxCodeBytes is the largest file shown in the page; a larger one gets
22+ // a notice and the link to raw.
23+ maxCodeBytes = 1 << 20
24+ // maxRenderBytes is the largest Markdown file that is rendered; above it
25+ // the file is shown as source only.
26+ maxRenderBytes = 512 << 10
27+)
28+
29+// BlobPage is a file.
30+type BlobPage struct {
31+ Name string
32+ Notice string
33+ // Code is the highlighted source, shown when HasCode.
34+ Code template.HTML
35+ HasCode bool
36+ // Rendered is a Markdown file as HTML; the source is under a fold.
37+ Rendered *Readme
38+ Outline []Heading
39+ // Image says the file is shown as an image loaded from RawHref.
40+ Image bool
41+ Facts []Fact
42+ Last *CommitRow
43+ RawHref string
44+ HistHref string
45+ DirHref string
46+}
47+
48+// blobStream is an open GetBlob stream whose first message has been read.
49+type blobStream struct {
50+ stream *connect.ServerStreamForClient[pb.GetBlobResponse]
51+ info *pb.BlobInfo
52+ // buf holds the content already received and not yet handed out.
53+ buf []byte
54+}
55+
56+// openBlob starts the stream of a file and reads its metadata.
57+func (s *Server) openBlob(ctx context.Context, repo string, t target) (*blobStream, error) {
58+ if t.path == "" {
59+ return nil, errNotFound
60+ }
61+ st, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: t.rev, Path: t.path}))
62+ if err != nil {
63+ return nil, err
64+ }
65+ if !st.Receive() {
66+ err := st.Err()
67+ _ = st.Close()
68+ if err == nil {
69+ err = errNotFound
70+ }
71+ return nil, err
72+ }
73+ m := st.Msg()
74+ return &blobStream{stream: st, info: m.GetInfo(), buf: append([]byte(nil), m.GetChunk()...)}, nil
75+}
76+
77+func (b *blobStream) close() { _ = b.stream.Close() }
78+
79+// fill reads from the stream until buf holds n bytes or the content ends.
80+func (b *blobStream) fill(n int) error {
81+ for len(b.buf) < n && b.stream.Receive() {
82+ b.buf = append(b.buf, b.stream.Msg().GetChunk()...)
83+ }
84+ return b.stream.Err()
85+}
86+
87+// image types: the only images the pages show and raw serves as images.
88+var imageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true}
89+
90+// sniffImage is the image type of the first bytes of a file, "" when it is
91+// not a PNG, JPEG, GIF or WebP. SVG is never one: it can carry scripts.
92+func sniffImage(head []byte) string {
93+ if len(head) > 512 {
94+ head = head[:512]
95+ }
96+ if t := http.DetectContentType(head); imageTypes[t] {
97+ return t
98+ }
99+ return ""
100+}
101+
102+// blob shows a file: highlighted source, rendered Markdown with its source
103+// under a fold, an image, or a notice when the file is too large or binary.
104+func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, int, error) {
105+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
106+ if err != nil {
107+ return nil, 0, err
108+ }
109+ t, ok := d.locate(r.PathValue("rest"))
110+ if !ok {
111+ return nil, 0, errNotFound
112+ }
113+ b, err := s.openBlob(ctx, d.name(), t)
114+ if err != nil {
115+ return nil, 0, err
116+ }
117+ defer b.close()
118+
119+ name := path.Base(t.path)
120+ dir := path.Dir(t.path)
121+ if dir == "." {
122+ dir = ""
123+ }
124+ size := b.info.GetSize()
125+ p := BlobPage{
126+ Name: markedText(name),
127+ RawHref: repoHref(d.name(), "raw", t.name, t.path),
128+ HistHref: repoHref(d.name(), "commits", t.name, t.path),
129+ DirHref: repoHref(d.name(), "tree", t.name, dir),
130+ Facts: []Fact{{Key: "size", Val: humanSize(size)}},
131+ }
132+ typ := b.info.GetMediaType()
133+ lang := ""
134+
135+ // An image is recognised by its bytes, whatever Git's text rule says.
136+ if err := b.fill(512); err != nil {
137+ return nil, 0, err
138+ }
139+ img := sniffImage(b.buf)
140+
141+ var src []byte
142+ switch {
143+ case img != "":
144+ p.Image, typ = true, img
145+ case b.info.GetBinary():
146+ p.Notice = fmt.Sprintf("binary file (%s, %s): not shown. use raw.", typ, humanSize(size))
147+ case size > maxCodeBytes:
148+ p.Notice = fmt.Sprintf("file too large to show (%s, the limit is %s). use raw.", humanSize(size), humanSize(maxCodeBytes))
149+ case size == 0:
150+ p.Notice = "empty file."
151+ default:
152+ if err := b.fill(int(size)); err != nil {
153+ return nil, 0, err
154+ }
155+ src = b.buf
156+ }
157+
158+ if src != nil {
159+ text := string(src)
160+ lines := countLines(src)
161+ p.Facts = append(p.Facts, Fact{Key: "lines", Val: itoa(lines)})
162+ if lines > maxViewLines {
163+ text = firstLines(text, maxViewLines)
164+ p.Notice = fmt.Sprintf("%d lines: showing the first %d. use raw for the rest.", lines, maxViewLines)
165+ }
166+ cv := highlightCtx(ctx, t.path, text)
167+ p.Code, p.HasCode, lang = cv.html, true, cv.lang
168+ if isMarkdown(name) && size <= maxRenderBytes {
169+ h, outline, err := s.md.render(src, d.name(), t.name, dir)
170+ if err == nil {
171+ p.Rendered, p.Outline = &Readme{Name: p.Name, HTML: h}, outline
172+ } else {
173+ log := zerowrap.FromCtx(ctx)
174+ log.Warn().Err(err).Str("repo", d.name()).Msg("render Markdown file, showing source")
175+ }
176+ }
177+ }
178+ if lang == "" {
179+ lang = typ
180+ }
181+ p.Facts = append(p.Facts, Fact{Key: "type", Val: lang})
182+
183+ if last := s.lastChange(ctx, d.name(), t); last != nil {
184+ p.Last = last
185+ }
186+
187+ l := s.repoLayout(ctx, d, "tree")
188+ pinRef(&l, t)
189+ l.Crumbs = fileCrumbs(d.name(), "blob", t)
190+ l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance
191+ l.Status = Status{
192+ Mode: "blob", Context: p.Name,
193+ Hints: "click a line number · y permalink · ? help", Blob: p.HasCode,
194+ }
195+ l.HasRail, l.Read, l.RailSum = true, p.Rendered != nil, "file · last change · selection"
196+ return Blob(l, p), http.StatusOK, nil
197+}
198+
199+// lastChange is the latest commit that touched the path, nil when it cannot
200+// be found (the page does not depend on it).
201+func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitRow {
202+ res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
203+ Repo: repo, Revision: t.rev, Path: t.path, PageSize: 1,
204+ }))
205+ if err != nil || len(res.Msg.GetCommits()) != 1 {
206+ return nil
207+ }
208+ c := res.Msg.GetCommits()[0]
209+ row := &CommitRow{
210+ Short: short(c.GetId()), Subject: markedText(c.GetSummary()), Author: c.GetAuthor().GetName(),
211+ CHref: repoHref(repo, "commit", c.GetId()),
212+ }
213+ if ts := c.GetCommitter().GetTime(); ts.IsValid() {
214+ row.When, row.WhenAt = ago(s.cfg.Now(), ts.AsTime()), rfc3339(ts.AsTime())
215+ }
216+ return row
217+}
218+
219+// isMarkdown says whether a file name is a Markdown file.
220+func isMarkdown(name string) bool {
221+ n := strings.ToLower(name)
222+ return strings.HasSuffix(n, ".md") || strings.HasSuffix(n, ".markdown")
223+}
224+
225+// firstLines is the first n lines of s.
226+func firstLines(s string, n int) string {
227+ idx := 0
228+ for i := 0; i < n; i++ {
229+ j := strings.IndexByte(s[idx:], '\n')
230+ if j < 0 {
231+ return s
232+ }
233+ idx += j + 1
234+ }
235+ return s[:idx]
236+}
A internal/adapters/web/blob.templ +90 -0
@@ -0,0 +1,90 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import "html/template"
6+
7+// Blob is a file: its title with the actions, the notice of a state (too
8+// large, binary, empty), the code, the rendered Markdown with its source
9+// under a fold, or the image; the rail holds the facts, the last change, the
10+// outline and the line selection.
11+templ Blob(l Layout, p BlobPage) {
12+ @Shell(l, blobRail(p)) {
13+ <h1 class="file-h">
14+ <span>{ p.Name }</span>
15+ <span class="acts"><a href={ templ.SafeURL(p.RawHref) }>raw</a> <a href={ templ.SafeURL(p.HistHref) }>history</a> <a href={ templ.SafeURL(p.DirHref) }>dir</a></span>
16+ </h1>
17+ if p.Notice != "" {
18+ <p class="notice" role="note">{ p.Notice }</p>
19+ }
20+ if p.Image {
21+ <p class="img-view"><img src={ p.RawHref } alt={ p.Name }/></p>
22+ }
23+ if p.Rendered != nil {
24+ @ReadmeView(*p.Rendered)
25+ }
26+ if p.HasCode {
27+ if p.Rendered != nil {
28+ <details class="source">
29+ <summary>source</summary>
30+ @CodeView(p.Code)
31+ </details>
32+ } else {
33+ @CodeView(p.Code)
34+ }
35+ }
36+ }
37+}
38+
39+// CodeView is the highlighted lines with a link on each number. A click on a
40+// number selects the line, shift-click extends the selection to a range; the
41+// URL hash (#L10 or #L10-L24) is the state, so a page loaded with a hash is
42+// painted the same way. body[data-sel] feeds the status bar and the rail.
43+templ CodeView(h template.HTML) {
44+ <pre
45+ class="chroma code"
46+ data-anchor=""
47+ hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();
48+ let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n;
49+ let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n);
50+ history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')"
51+ hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0;
52+ q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
53+ document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
54+ if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })"
55+ hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }"
56+ data-ready=""
57+ ><code>@templ.Raw(string(h))</code></pre>
58+}
59+
60+templ blobRail(p BlobPage) {
61+ <section aria-labelledby="r-file">
62+ <h2 id="r-file">file</h2>
63+ @Facts(p.Facts)
64+ </section>
65+ if p.Last != nil {
66+ <section aria-labelledby="r-last">
67+ <h2 id="r-last">last change <a href={ templ.SafeURL(p.HistHref) }>history</a></h2>
68+ <p><a href={ templ.SafeURL(p.Last.CHref) }>{ p.Last.Short }</a> { p.Last.Subject }</p>
69+ <p class="dim">
70+ { p.Last.Author } ·
71+ if p.Last.WhenAt != "" {
72+ <time datetime={ p.Last.WhenAt } title={ p.Last.WhenAt }>{ p.Last.When }</time>
73+ }
74+ </p>
75+ </section>
76+ }
77+ if len(p.Outline) > 0 {
78+ <section aria-labelledby="r-outline">
79+ <h2 id="r-outline">outline</h2>
80+ @Outline(p.Outline)
81+ </section>
82+ }
83+ if p.HasCode {
84+ <section aria-labelledby="r-sel">
85+ <h2 id="r-sel">selection</h2>
86+ <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
87+ <p class="dim">y copies a permalink pinned to the commit sha</p>
88+ </section>
89+ }
90+}
A internal/adapters/web/blob_templ.go +426 -0 generated file: collapsed
A internal/adapters/web/highlight_test.go +210 -0
@@ -0,0 +1,210 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+func TestHighlightLines(t *testing.T) {
17+ v := highlight("a.go", "package main\n\nfunc main() {}\n")
18+ assert.Equal(t, 3, v.lines)
19+ assert.Equal(t, "Go", v.lang)
20+ h := string(v.html)
21+ assert.Contains(t, h, `<span class="line" id="L1"><a class="ln" href="#L1">1</a><span class="kn">package</span>`)
22+ assert.Contains(t, h, `<span class="line" id="L2"><a class="ln" href="#L2">2</a></span>`)
23+ assert.NotContains(t, h, "style=")
24+
25+ // No final line feed, CRLF and lone CR are lines too.
26+ assert.Equal(t, 2, highlight("x.txt", "a\nb").lines)
27+ assert.Equal(t, 3, highlight("x.txt", "a\r\nb\rc").lines)
28+ assert.Equal(t, 0, highlight("x.txt", "").lines)
29+}
30+
31+func TestHighlightFallbackIsPlainAndEscaped(t *testing.T) {
32+ v := highlight("unknown.zzz", "<script>alert(1)</script> & \"q\"\n")
33+ assert.Equal(t, "", v.lang)
34+ h := string(v.html)
35+ assert.Contains(t, h, "&lt;script&gt;alert(1)&lt;/script&gt; &amp; &#34;q&#34;")
36+ assert.NotContains(t, h, "<script>")
37+
38+ // Invalid UTF-8 is replaced, not passed on.
39+ assert.Contains(t, string(highlight("x.txt", "a\xffb\n").html), "a\uFFFDb")
40+}
41+
42+func TestHighlightMarksInvisibleCharacters(t *testing.T) {
43+ for r, cp := range map[rune]string{
44+ 0x202E: "U+202E", 0x2066: "U+2066", 0x2069: "U+2069", 0x200B: "U+200B", 0xFEFF: "U+FEFF",
45+ 0x0000: "U+0000", 0x001B: "U+001B", 0x2028: "U+2028", 0x3164: "U+3164", 0x1F: "U+001F",
46+ } {
47+ h := string(highlight("x.go", "a"+string(r)+"b\n").html)
48+ assert.NotContains(t, h, string(r), cp)
49+ assert.Contains(t, h, "&lt;"+cp+"&gt;</span>", cp)
50+ assert.Contains(t, h, `<span class="ctl" title="`+cp+" ", cp)
51+ }
52+ // A tab and ordinary non-ASCII text are left alone.
53+ h := string(highlight("x.txt", "\tcafé 日本\n").html)
54+ assert.Contains(t, h, "\tcafé 日本")
55+ assert.NotContains(t, h, "ctl")
56+ // Inside a token of a lexer too.
57+ h = string(highlight("x.go", "x := \"a\u202eb\"\n").html)
58+ assert.NotContains(t, h, "\u202e")
59+ assert.Contains(t, h, "&lt;U+202E&gt;")
60+}
61+
62+func TestMarkedText(t *testing.T) {
63+ assert.Equal(t, "plain.go", markedText("plain.go"))
64+ assert.Equal(t, "a<U+202E>b", markedText("a\u202eb"))
65+}
66+
67+// The lexers do not lose or add characters: the text of the lines is the
68+// source, whatever the language.
69+func TestHighlightKeepsTheSource(t *testing.T) {
70+ srcs := map[string]string{
71+ "a.go": "package a\n\nvar s = `multi\nline`\n// end",
72+ "b.py": "def f():\n '''doc\n string'''\n return 1\n",
73+ "c.md": "# T\n\n```go\nx := 1\n```\n",
74+ "d.json": "{\"a\": [1, 2, {\"b\": null}]}\n",
75+ "e.sh": "echo \"$(date)\" | tr a b\n",
76+ "f.yaml": "a:\n - b\n - c: d\n",
77+ "Makefile": "all:\n\t@echo hi\n",
78+ "g.rs": "fn main() { let _ = \"x\"; }\n",
79+ "h.html": "<p class=\"a\">x &amp; y</p>\n",
80+ "i.templ": "templ A() {\n\t<p>x</p>\n}\n",
81+ "Dockerfile": "FROM alpine\nRUN echo hi\n",
82+ }
83+ for name, src := range srcs {
84+ v := highlight(name, src)
85+ var text strings.Builder
86+ for _, l := range strings.Split(string(v.html), "\n") {
87+ l = strings.TrimSpace(l)
88+ if l == "" {
89+ continue
90+ }
91+ text.WriteString(stripTags(l) + "\n")
92+ }
93+ want := src
94+ assert.Equal(t, strings.Count(strings.TrimSuffix(src, "\n"), "\n")+1, v.lines, name)
95+ assert.Equal(t, normSpace(want), normSpace(unescape(text.String())), name)
96+ }
97+}
98+
99+func TestFirstLinesAndCount(t *testing.T) {
100+ assert.Equal(t, "a\nb\n", firstLines("a\nb\nc\n", 2))
101+ assert.Equal(t, "a\nb", firstLines("a\nb", 5))
102+ assert.Equal(t, 3, countLines([]byte("a\nb\nc")))
103+ assert.Equal(t, 3, countLines([]byte("a\nb\nc\n")))
104+ assert.Equal(t, 0, countLines(nil))
105+}
106+
107+func TestHighlightCtxCancelled(t *testing.T) {
108+ ctx, cancel := context.WithCancel(context.Background())
109+ cancel()
110+ v := highlightCtx(ctx, "a.go", "package a\n")
111+ assert.Equal(t, 1, v.lines, "a cancelled context still gives the plain text, or the highlighted one")
112+}
113+
114+func TestSniffImage(t *testing.T) {
115+ assert.Equal(t, "image/png", sniffImage([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")))
116+ assert.Equal(t, "image/gif", sniffImage([]byte("GIF89a....")))
117+ assert.Equal(t, "image/jpeg", sniffImage([]byte("\xff\xd8\xff\xe0\x00\x10JFIF")))
118+ assert.Equal(t, "", sniffImage([]byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)))
119+ assert.Equal(t, "", sniffImage([]byte(`<?xml version="1.0"?><svg/>`)))
120+ assert.Equal(t, "", sniffImage([]byte("plain")))
121+ assert.Equal(t, "", sniffImage(nil))
122+}
123+
124+func TestLocate(t *testing.T) {
125+ b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
126+ d := &repoData{
127+ branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")},
128+ tags: []*pb.Ref{ref(tag, "v1/rc")},
129+ }
130+ d.head = d.branches[0]
131+ for _, tt := range []struct {
132+ in, name, path string
133+ detached, ok bool
134+ }{
135+ {"", "main", "", false, true},
136+ {"main/a/b.go", "main", "a/b.go", false, true},
137+ {"feature/x/dir/f", "feature/x", "dir/f", false, true},
138+ {"v1/rc", "v1/rc", "", false, true},
139+ {"0123abc/f.txt", "0123abc", "f.txt", true, true},
140+ {"feature/y/f", "", "", false, false},
141+ {"abc/f", "", "", false, false},
142+ {"MAIN/f", "", "", false, false},
143+ } {
144+ got, ok := d.locate(tt.in)
145+ assert.Equal(t, tt.ok, ok, tt.in)
146+ if !ok {
147+ continue
148+ }
149+ assert.Equal(t, tt.name, got.name, tt.in)
150+ assert.Equal(t, tt.path, got.path, tt.in)
151+ assert.Equal(t, tt.detached, got.detached, tt.in)
152+ }
153+ empty := &repoData{}
154+ _, ok := empty.locate("")
155+ assert.False(t, ok, "an empty repository has no head")
156+ require.True(t, looksLikeCommit(strings.Repeat("a", 40)))
157+ assert.False(t, looksLikeCommit(strings.Repeat("a", 41)))
158+ assert.False(t, looksLikeCommit("abcdef"))
159+}
160+
161+func TestFileCrumbs(t *testing.T) {
162+ cs := fileCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"})
163+ require.Len(t, cs, 5)
164+ assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href)
165+ assert.Equal(t, "/demo/blob/feature/x/src/a.go", cs[4].Href)
166+ assert.True(t, cs[4].Current)
167+ cs = fileCrumbs("demo", "tree", target{name: "main", path: "src"})
168+ assert.Equal(t, "/demo/tree/main/src", cs[3].Href)
169+ assert.Equal(t, "a<U+202E>b", fileCrumbs("demo", "tree", target{name: "main", path: "a\u202eb"})[3].Label)
170+}
171+
172+func stripTags(s string) string {
173+ var b strings.Builder
174+ in := false
175+ for _, r := range s {
176+ switch {
177+ case r == '<':
178+ in = true
179+ case r == '>':
180+ in = false
181+ case !in:
182+ b.WriteRune(r)
183+ }
184+ }
185+ return b.String()
186+}
187+
188+func unescape(s string) string {
189+ return strings.NewReplacer("&lt;", "<", "&gt;", ">", "&amp;", "&", "&#34;", `"`, "&#39;", "'").Replace(s)
190+}
191+
192+// normSpace drops the leading numbers of the gutter and the white space, which
193+// the tests do not care about.
194+func normSpace(s string) string {
195+ var out []string
196+ for _, l := range strings.Split(s, "\n") {
197+ l = strings.TrimSpace(l)
198+ if l == "" {
199+ continue
200+ }
201+ i := 0
202+ for i < len(l) && l[i] >= '0' && l[i] <= '9' {
203+ i++
204+ }
205+ if l = strings.Join(strings.Fields(l[i:]), ""); l != "" {
206+ out = append(out, l)
207+ }
208+ }
209+ return strings.Join(out, "\n")
210+}
A internal/adapters/web/raw.go +118 -0
@@ -0,0 +1,118 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "mime"
8+ "net/http"
9+ "path"
10+ "strconv"
11+ "time"
12+
13+ "github.com/bnema/zerowrap"
14+)
15+
16+// rawTimeout bounds the stream of one file to the visitor.
17+const rawTimeout = 5 * time.Minute
18+
19+// rawCSP makes a raw file inert if a browser opens it as a document: it can
20+// load nothing and run nothing, whatever it contains.
21+const rawCSP = "default-src 'none'; sandbox"
22+
23+// raw streams the content of a file. Text is served as text/plain, the four
24+// image types (by their bytes, never SVG) as images, and anything else as an
25+// attachment of unknown type. A file is never served as HTML or SVG, so what
26+// a repository holds cannot run in the origin of the forge.
27+func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
28+ ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)
29+ defer cancel()
30+ b, t, repo, err := s.rawOpen(ctx, r)
31+ if err != nil {
32+ s.fail(ctx, w, r, err)
33+ return
34+ }
35+ defer b.close()
36+
37+ // The type needs the first bytes; headers must not be sent before.
38+ if err := b.fill(512); err != nil {
39+ s.fail(ctx, w, r, err)
40+ return
41+ }
42+ typ, attach := rawType(b)
43+ h := w.Header()
44+ h.Set("Content-Type", typ)
45+ h.Set("X-Content-Type-Options", "nosniff")
46+ h.Set("Content-Security-Policy", rawCSP)
47+ h.Set("Content-Length", strconv.FormatInt(b.info.GetSize(), 10))
48+ if attach {
49+ name := path.Base(t.path)
50+ cd := mime.FormatMediaType("attachment", map[string]string{"filename": name})
51+ if cd == "" {
52+ cd = "attachment"
53+ }
54+ h.Set("Content-Disposition", cd)
55+ }
56+ if etag := b.info.GetId(); etag != "" {
57+ h.Set("ETag", `"`+etag+`"`)
58+ }
59+ // A commit named in full never changes; a branch or a tag moves.
60+ if t.detached && len(t.rev) == 40 {
61+ h.Set("Cache-Control", "public, max-age=31536000, immutable")
62+ } else {
63+ h.Set("Cache-Control", "no-cache")
64+ }
65+ if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") {
66+ w.WriteHeader(http.StatusNotModified)
67+ return
68+ }
69+ w.WriteHeader(http.StatusOK)
70+ if r.Method == http.MethodHead {
71+ return
72+ }
73+ if _, err := w.Write(b.buf); err != nil {
74+ return
75+ }
76+ b.buf = nil
77+ for b.stream.Receive() {
78+ if _, err := w.Write(b.stream.Msg().GetChunk()); err != nil {
79+ return
80+ }
81+ }
82+ if err := b.stream.Err(); err != nil {
83+ // Headers are gone: all that is left is to cut the answer short, so
84+ // that the client sees it is incomplete.
85+ log := zerowrap.FromCtx(ctx)
86+ log.Error().Err(err).Str("repo", repo).Str("path", r.URL.Path).Msg("raw stream failed")
87+ panic(http.ErrAbortHandler)
88+ }
89+}
90+
91+// rawOpen finds the file a raw URL designates and opens its stream.
92+func (s *Server) rawOpen(ctx context.Context, r *http.Request) (*blobStream, target, string, error) {
93+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
94+ if err != nil {
95+ return nil, target{}, "", err
96+ }
97+ t, ok := d.locate(r.PathValue("rest"))
98+ if !ok {
99+ return nil, target{}, "", errNotFound
100+ }
101+ b, err := s.openBlob(ctx, d.name(), t)
102+ if err != nil {
103+ return nil, target{}, "", err
104+ }
105+ return b, t, d.name(), nil
106+}
107+
108+// rawType is the Content-Type of a file and whether it is an attachment.
109+// b.buf holds the first bytes (up to 512 when the file has that many).
110+func rawType(b *blobStream) (typ string, attachment bool) {
111+ if img := sniffImage(b.buf); img != "" {
112+ return img, false
113+ }
114+ if !b.info.GetBinary() {
115+ return "text/plain; charset=utf-8", false
116+ }
117+ return "application/octet-stream", true
118+}
A internal/adapters/web/static/css/56-chroma.css +42 -0
@@ -0,0 +1,42 @@
1+/* Syntax colours of the code view. Generated by scripts/chroma-css.go from
2+ chroma's token classes; the colours are the --syn-* properties of
3+ 00-tokens.css (the Despair hues, dark and light), none is defined here. */
4+@layer components {
5+ .chroma { color: var(--syn-plain); }
6+ .chroma .c { color: var(--syn-comment); font-style: italic }
7+ .chroma .c1 { color: var(--syn-comment); font-style: italic }
8+ .chroma .ch { color: var(--syn-comment); font-style: italic }
9+ .chroma .cm { color: var(--syn-comment); font-style: italic }
10+ .chroma .cs { color: var(--syn-comment); font-style: italic }
11+ .chroma .dl { color: var(--syn-string); font-style: italic }
12+ .chroma .k { color: var(--syn-keyword); font-weight: bold }
13+ .chroma .kc { color: var(--syn-keyword); font-weight: bold }
14+ .chroma .kd { color: var(--syn-keyword); font-weight: bold }
15+ .chroma .kn { color: var(--syn-keyword); font-weight: bold }
16+ .chroma .kr { color: var(--syn-keyword); font-weight: bold }
17+ .chroma .nc { color: var(--syn-type); font-weight: bold }
18+ .chroma .ne { color: var(--syn-type); font-weight: bold }
19+ .chroma .nn { color: var(--syn-type); font-weight: bold }
20+ .chroma .ow { color: var(--syn-punct); font-weight: bold }
21+ .chroma .s { color: var(--syn-string); font-style: italic }
22+ .chroma .s1 { color: var(--syn-string); font-style: italic }
23+ .chroma .s2 { color: var(--syn-string); font-style: italic }
24+ .chroma .sa { color: var(--syn-string); font-style: italic }
25+ .chroma .sb { color: var(--syn-string); font-style: italic }
26+ .chroma .sc { color: var(--syn-string); font-style: italic }
27+ .chroma .sd { color: var(--syn-string); font-style: italic }
28+ .chroma .se { color: var(--syn-string); font-weight: bold; font-style: italic }
29+ .chroma .sh { color: var(--syn-string); font-style: italic }
30+ .chroma .si { color: var(--syn-string); font-weight: bold; font-style: italic }
31+ .chroma .sr { color: var(--syn-string); font-style: italic }
32+ .chroma .ss { color: var(--syn-string); font-style: italic }
33+ .chroma .sx { color: var(--syn-string); font-style: italic }
34+ .chroma .line { display: block; }
35+
36+ /* an invisible character in code, shown instead of applied (Trojan Source) */
37+ .chroma .ctl {
38+ color: var(--red); font-weight: bold; font-style: normal; font-size: var(--fs-s);
39+ outline: 1px solid var(--red); outline-offset: -1px; padding: 0 .25ch;
40+ }
41+ .img-view { margin-top: var(--lh); & img { max-width: 100%; height: auto; } }
42+}
M internal/adapters/web/web.go +2 -0
@@ -103,6 +103,8 @@ func (s *Server) routes() *http.ServeMux {
103103 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
104104 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
105105 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
106+ mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
107+ mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
106108 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
107109 return mux
108110 }
A scripts/chroma-css.go +101 -0
@@ -0,0 +1,101 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build ignore
4+
5+// chroma-css writes internal/adapters/web/static/css/56-chroma.css: the
6+// chroma token classes mapped to the --syn-* custom properties of the design
7+// tokens (the Despair hues, dark and light). Colours are not in the file, so
8+// the theme follows the colour scheme; only bold and italic come from chroma
9+// (its "bw" style, as in the mockup).
10+//
11+// go run scripts/chroma-css.go > internal/adapters/web/static/css/56-chroma.css
12+package main
13+
14+import (
15+ "fmt"
16+ "os"
17+ "sort"
18+ "strings"
19+
20+ "github.com/alecthomas/chroma/v2"
21+ "github.com/alecthomas/chroma/v2/formatters/html"
22+ "github.com/alecthomas/chroma/v2/styles"
23+)
24+
25+// synVar maps a token type to the name of a --syn-* property, "" for none.
26+func synVar(t chroma.TokenType) string {
27+ switch {
28+ case t == chroma.KeywordType, t == chroma.NameClass, t == chroma.NameBuiltin, t == chroma.NameBuiltinPseudo,
29+ t == chroma.NameException, t == chroma.NameNamespace:
30+ return "type"
31+ case t.InCategory(chroma.Keyword):
32+ return "keyword"
33+ case t.InSubCategory(chroma.LiteralString):
34+ return "string"
35+ case t.InCategory(chroma.Comment):
36+ return "comment"
37+ case t.InSubCategory(chroma.LiteralNumber):
38+ return "number"
39+ case t.InSubCategory(chroma.NameFunction), t == chroma.NameDecorator:
40+ return "func"
41+ case t.InCategory(chroma.Punctuation), t.InCategory(chroma.Operator):
42+ return "punct"
43+ }
44+ return ""
45+}
46+
47+func main() {
48+ // Learn the classes chroma emits, so that every one is covered.
49+ var learned strings.Builder
50+ if err := html.New(html.WithClasses(true)).WriteCSS(&learned, styles.Get("bw")); err != nil {
51+ fmt.Fprintln(os.Stderr, err)
52+ os.Exit(1)
53+ }
54+ types := map[string]chroma.TokenType{}
55+ for t, c := range chroma.StandardTypes {
56+ if c != "" && t > 0 {
57+ types[c] = t
58+ }
59+ }
60+ bw := styles.Get("bw")
61+ var classes []string
62+ for _, l := range strings.Split(learned.String(), "\n") {
63+ i := strings.Index(l, ".chroma .")
64+ if i < 0 {
65+ continue
66+ }
67+ c := l[i+len(".chroma ."):]
68+ c = c[:strings.IndexAny(c, " {")]
69+ if t, ok := types[c]; ok && synVar(t) != "" {
70+ classes = append(classes, c)
71+ }
72+ }
73+ sort.Strings(classes)
74+
75+ fmt.Println("/* Syntax colours of the code view. Generated by scripts/chroma-css.go from")
76+ fmt.Println(" chroma's token classes; the colours are the --syn-* properties of")
77+ fmt.Println(" 00-tokens.css (the Despair hues, dark and light), none is defined here. */")
78+ fmt.Println("@layer components {")
79+ fmt.Println(" .chroma { color: var(--syn-plain); }")
80+ for _, c := range classes {
81+ t := types[c]
82+ decl := "color: var(--syn-" + synVar(t) + ")"
83+ e := bw.Get(t)
84+ if e.Bold == chroma.Yes {
85+ decl += "; font-weight: bold"
86+ }
87+ if e.Italic == chroma.Yes {
88+ decl += "; font-style: italic"
89+ }
90+ fmt.Printf(" .chroma .%s { %s }\n", c, decl)
91+ }
92+ fmt.Println(" .chroma .line { display: block; }")
93+ fmt.Println(`
94+ /* an invisible character in code, shown instead of applied (Trojan Source) */
95+ .chroma .ctl {
96+ color: var(--red); font-weight: bold; font-style: normal; font-size: var(--fs-s);
97+ outline: 1px solid var(--red); outline-offset: -1px; padding: 0 .25ch;
98+ }
99+ .img-view { margin-top: var(--lh); & img { max-width: 100%; height: auto; } }
100+}`)
101+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close