detached b90cbe4

feat(cmd): serve the web on the in-process API as an anonymous visitor

Golden pages, private-repo invisibility, README neutralisation and
CSP/asset checks run against a real forge.
commit
b90cbe4a91e7e7f55eaf06889b178dcedab6929b
author
brice · · 2026-10-10 12:33 UTC
committer
brice · · 2026-10-10 12:34 UTC
parent
5cd052a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +2071 -6
M README.md +1 -1
@@ -2,7 +2,7 @@
22
33 A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, and shows them in a read-only web interface.
44
5-Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push) and answers `GET /-/health`. There is no web interface or token command yet.
5+Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push), answers `GET /-/health`, and serves the read-only web: the home list, the summary of a repository (README, clone URL, facts, recent commits) and its refs. Tree, file, commit and live pages are still to come.
66
77 ## Run
88
@@ -2,7 +2,7 @@
2 2
3 A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, and shows them in a read-only web interface.3 A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, and shows them in a read-only web interface.
4 4
5-Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push) and answers `GET /-/health`. There is no web interface or token command yet.5+Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push), answers `GET /-/health`, and serves the read-only web: the home list, the summary of a repository (README, clone URL, facts, recent commits) and its refs. Tree, file, commit and live pages are still to come.
6 6
7 ## Run7 ## Run
8 8
M cmd/ermite/e2e_test.go +9 -4
@@ -91,7 +91,8 @@ func TestAC1AnonymousCloneAndPrivateRepo(t *testing.T) {
9191 require.NoError(t, err, out)
9292 }
9393
94-// Only the smart HTTP routes exist; the rest of the repository tree is 404.
94+// Only the smart HTTP routes exist under a repository's .git URL; the rest of
95+// the repository tree is the 404 page of the web.
9596 func TestRoutes(t *testing.T) {
9697 h := newHarness(t, harnessOpts{})
9798 h.publicRepo("pub", "a")
@@ -102,8 +103,7 @@ func TestRoutes(t *testing.T) {
102103 "/pub.git/info/refs", // dumb protocol: no service
103104 "/pub.git/info/refs?service=git-bogus",
104105 "/pub.git/config",
105- "/pub/tree/main",
106- "/",
106+ "/pub/tree/main", // a page of a later phase
107107 "/-/nothing",
108108 "/Bad.git/info/refs?service=git-upload-pack",
109109 "/..%2f..%2fetc/info/refs?service=git-upload-pack",
@@ -111,7 +111,12 @@ func TestRoutes(t *testing.T) {
111111 assert.Equal(t, 404, h.status("GET", path, nil), path)
112112 }
113113 assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil))
114- assert.Equal(t, 405, h.status("GET", "/pub.git/git-upload-pack", nil))
114+ // The web owns GET on every other path: a GET of a POST-only Git route
115+ // is its 404 page, not a 405.
116+ assert.Equal(t, 404, h.status("GET", "/pub.git/git-upload-pack", nil))
117+ assert.Equal(t, 200, h.status("GET", "/", nil), "the home page")
118+ assert.Equal(t, 200, h.status("GET", "/pub", nil), "the summary page")
119+ assert.Equal(t, 405, h.status("POST", "/", nil))
115120 assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact")
116121 assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil))
117122 // Anonymous receive-pack is a challenge even for a public repository.
@@ -91,7 +91,8 @@ func TestAC1AnonymousCloneAndPrivateRepo(t *testing.T) {
91 require.NoError(t, err, out)91 require.NoError(t, err, out)
92 }92 }
93 93
94-// Only the smart HTTP routes exist; the rest of the repository tree is 404.94+// Only the smart HTTP routes exist under a repository's .git URL; the rest of
95+// the repository tree is the 404 page of the web.
95 func TestRoutes(t *testing.T) {96 func TestRoutes(t *testing.T) {
96 h := newHarness(t, harnessOpts{})97 h := newHarness(t, harnessOpts{})
97 h.publicRepo("pub", "a")98 h.publicRepo("pub", "a")
@@ -102,8 +103,7 @@ func TestRoutes(t *testing.T) {
102 "/pub.git/info/refs", // dumb protocol: no service103 "/pub.git/info/refs", // dumb protocol: no service
103 "/pub.git/info/refs?service=git-bogus",104 "/pub.git/info/refs?service=git-bogus",
104 "/pub.git/config",105 "/pub.git/config",
105- "/pub/tree/main",106+ "/pub/tree/main", // a page of a later phase
106- "/",
107 "/-/nothing",107 "/-/nothing",
108 "/Bad.git/info/refs?service=git-upload-pack",108 "/Bad.git/info/refs?service=git-upload-pack",
109 "/..%2f..%2fetc/info/refs?service=git-upload-pack",109 "/..%2f..%2fetc/info/refs?service=git-upload-pack",
@@ -111,7 +111,12 @@ func TestRoutes(t *testing.T) {
111 assert.Equal(t, 404, h.status("GET", path, nil), path)111 assert.Equal(t, 404, h.status("GET", path, nil), path)
112 }112 }
113 assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil))113 assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil))
114- assert.Equal(t, 405, h.status("GET", "/pub.git/git-upload-pack", nil))114+ // The web owns GET on every other path: a GET of a POST-only Git route
115+ // is its 404 page, not a 405.
116+ assert.Equal(t, 404, h.status("GET", "/pub.git/git-upload-pack", nil))
117+ assert.Equal(t, 200, h.status("GET", "/", nil), "the home page")
118+ assert.Equal(t, 200, h.status("GET", "/pub", nil), "the summary page")
119+ assert.Equal(t, 405, h.status("POST", "/", nil))
115 assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact")120 assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact")
116 assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil))121 assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil))
117 // Anonymous receive-pack is a challenge even for a public repository.122 // Anonymous receive-pack is a challenge even for a public repository.
M cmd/ermite/forge.go +7 -1
@@ -19,6 +19,7 @@ import (
1919 "bnema.dev/ermite/internal/adapters/httpserver"
2020 "bnema.dev/ermite/internal/adapters/kvstore"
2121 "bnema.dev/ermite/internal/adapters/rpc"
22+ "bnema.dev/ermite/internal/adapters/web"
2223 "bnema.dev/ermite/internal/app"
2324 "bnema.dev/ermite/internal/clientlimit"
2425 "bnema.dev/ermite/internal/config"
@@ -175,10 +176,15 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
175176 Limiter: limiter,
176177 MaxBodyBytes: maxBody,
177178 })
178- routes := make([]httpserver.Route, 0, len(githttp.Patterns))
179+ routes := make([]httpserver.Route, 0, len(githttp.Patterns)+len(web.Patterns))
179180 for _, p := range githttp.Patterns {
180181 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
181182 }
183+ site, err := newWeb(cfg, f.API)
184+ if err != nil {
185+ return nil, fmt.Errorf("web: %w", err)
186+ }
187+ routes = append(routes, webRoutes(site)...)
182188 f.server = httpserver.New(httpserver.Config{
183189 Addr: cfg.Addr,
184190 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
@@ -19,6 +19,7 @@ import (
19 "bnema.dev/ermite/internal/adapters/httpserver"19 "bnema.dev/ermite/internal/adapters/httpserver"
20 "bnema.dev/ermite/internal/adapters/kvstore"20 "bnema.dev/ermite/internal/adapters/kvstore"
21 "bnema.dev/ermite/internal/adapters/rpc"21 "bnema.dev/ermite/internal/adapters/rpc"
22+ "bnema.dev/ermite/internal/adapters/web"
22 "bnema.dev/ermite/internal/app"23 "bnema.dev/ermite/internal/app"
23 "bnema.dev/ermite/internal/clientlimit"24 "bnema.dev/ermite/internal/clientlimit"
24 "bnema.dev/ermite/internal/config"25 "bnema.dev/ermite/internal/config"
@@ -175,10 +176,15 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
175 Limiter: limiter,176 Limiter: limiter,
176 MaxBodyBytes: maxBody,177 MaxBodyBytes: maxBody,
177 })178 })
178- routes := make([]httpserver.Route, 0, len(githttp.Patterns))179+ routes := make([]httpserver.Route, 0, len(githttp.Patterns)+len(web.Patterns))
179 for _, p := range githttp.Patterns {180 for _, p := range githttp.Patterns {
180 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})181 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
181 }182 }
183+ site, err := newWeb(cfg, f.API)
184+ if err != nil {
185+ return nil, fmt.Errorf("web: %w", err)
186+ }
187+ routes = append(routes, webRoutes(site)...)
182 f.server = httpserver.New(httpserver.Config{188 f.server = httpserver.New(httpserver.Config{
183 Addr: cfg.Addr,189 Addr: cfg.Addr,
184 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},190 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
A cmd/ermite/testdata/web/empty.html +227 -0
@@ -0,0 +1,227 @@
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>quiet · 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="" data-sha="" 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="/quiet" aria-current="page">quiet</a>
28+</li>
29+</ol>
30+</nav>
31+</header>
32+<div class="work">
33+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
34+<section class="state" aria-labelledby="state-title">
35+<h1 id="state-title">
36+<span class="code">empty</span> quiet is empty</h1>
37+<p class="dim">no commits yet. push a branch to create one.</p>
38+<div class="clone" id="clone-quiet">
39+<div class="clone-line">
40+<span class="mute" aria-hidden="true">$ </span>
41+<code>git remote add origin http://example.test/quiet.git</code>
42+<button type="button" class="copy" data-label="[copy]" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button>
43+</div>
44+<div class="clone-line">
45+<span class="mute" aria-hidden="true">$ </span>
46+<code>git push -u origin main</code>
47+<button type="button" class="copy" data-label="[copy]" aria-label="copy: git push -u origin main" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button>
48+</div>
49+</div>
50+<p class="state-back">
51+<a href="/">home</a>
52+</p>
53+</section>
54+</main>
55+</div>
56+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
57+<div class="panel-head">
58+<span>keys</span>
59+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
60+</div>
61+<dl class="keys">
62+<div>
63+<dt>
64+<kbd>j</kbd>
65+<kbd>k</kbd>
66+</dt>
67+<dd>move cursor down / up</dd>
68+</div>
69+<div>
70+<dt>
71+<kbd>enter</kbd>
72+</dt>
73+<dd>open row under cursor</dd>
74+</div>
75+<div>
76+<dt>
77+<kbd>g</kbd>
78+<kbd>g</kbd> / <kbd>G</kbd>
79+</dt>
80+<dd>first / last row</dd>
81+</div>
82+<div>
83+<dt>
84+<kbd>/</kbd>
85+</dt>
86+<dd>focus the filter</dd>
87+</div>
88+<div>
89+<dt>
90+<kbd>g h</kbd>
91+<kbd>g t</kbd>
92+</dt>
93+<dd>go home / tree</dd>
94+</div>
95+<div>
96+<dt>
97+<kbd>g c</kbd>
98+<kbd>g r</kbd>
99+</dt>
100+<dd>go commits / refs</dd>
101+</div>
102+<div>
103+<dt>
104+<kbd>b</kbd>
105+</dt>
106+<dd>switch ref (branches, tags)</dd>
107+</div>
108+<div>
109+<dt>
110+<kbd>:</kbd>
111+<kbd>f</kbd>
112+</dt>
113+<dd>go to: section, ref, file, repo</dd>
114+</div>
115+<div>
116+<dt>
117+<kbd>y</kbd>
118+</dt>
119+<dd>permalink: ref becomes the full sha</dd>
120+</div>
121+<div>
122+<dt>
123+<kbd>s</kbd>
124+<kbd>t</kbd>
125+<kbd>c</kbd>
126+<kbd>r</kbd>
127+</dt>
128+<dd>repo sections (when shown)</dd>
129+</div>
130+<div>
131+<dt>
132+<kbd>d</kbd>
133+<kbd>u</kbd>
134+<kbd>x</kbd>
135+</dt>
136+<dd>commit: split / unified / collapse all</dd>
137+</div>
138+<div>
139+<dt>
140+<kbd>esc</kbd>
141+</dt>
142+<dd>close panel</dd>
143+</div>
144+</dl>
145+</dialog>
146+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
147+<div class="panel-head">
148+<label for="cmd-q" class="hi">:</label>
149+<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();
150+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
151+ 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();
152+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
153+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
154+ 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' })) }
155+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
156+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
157+</div>
158+<ul id="cmd-list" class="cmd-list">
159+<li data-k="go home" data-cursor>
160+<a href="/" tabindex="-1">
161+<span class="k">go</span>home</a>
162+</li>
163+<li data-k="repo demo">
164+<a href="/demo" tabindex="-1">
165+<span class="k">repo</span>demo</a>
166+</li>
167+</ul>
168+</dialog>
169+<footer class="bar status" aria-label="Status">
170+<span class="mode">empty</span>
171+<span class="st-ctx">empty repository · 0 commits</span>
172+<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'))">
173+<span class="hi">:</span> go to…</button>
174+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
175+</span>
176+<span class="st-hints">g h home · ? help</span>
177+<span class="st-src">
178+<span class="st-ver">ermite devel · </span>
179+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
180+</span>
181+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
182+</footer>
183+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
184+ if (q('dialog[open]').count) return;
185+ let rows = q('[data-rows] > :not([hidden])').arr();
186+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
187+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
188+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
189+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
190+ })(1);
191+ k-k from:body -> ((d) => {
192+ if (q('dialog[open]').count) return;
193+ let rows = q('[data-rows] > :not([hidden])').arr();
194+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
195+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
196+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
197+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
198+ })(-1);
199+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
200+ 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));
201+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
202+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
203+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
204+ 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());
205+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
206+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
207+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
208+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
209+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
210+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
211+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
212+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
213+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
214+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
215+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
216+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
217+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
218+ k-permalink from:body -> {
219+ let b = document.body.dataset;
220+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
221+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
222+ navigator.clipboard?.writeText(location.href);
223+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
224+ }">
225+</div>
226+</body>
227+</html>
@@ -0,0 +1,227 @@
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>quiet · 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="" data-sha="" 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="/quiet" aria-current="page">quiet</a>
28+</li>
29+</ol>
30+</nav>
31+</header>
32+<div class="work">
33+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
34+<section class="state" aria-labelledby="state-title">
35+<h1 id="state-title">
36+<span class="code">empty</span> quiet is empty</h1>
37+<p class="dim">no commits yet. push a branch to create one.</p>
38+<div class="clone" id="clone-quiet">
39+<div class="clone-line">
40+<span class="mute" aria-hidden="true">$ </span>
41+<code>git remote add origin http://example.test/quiet.git</code>
42+<button type="button" class="copy" data-label="[copy]" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button>
43+</div>
44+<div class="clone-line">
45+<span class="mute" aria-hidden="true">$ </span>
46+<code>git push -u origin main</code>
47+<button type="button" class="copy" data-label="[copy]" aria-label="copy: git push -u origin main" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button>
48+</div>
49+</div>
50+<p class="state-back">
51+<a href="/">home</a>
52+</p>
53+</section>
54+</main>
55+</div>
56+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
57+<div class="panel-head">
58+<span>keys</span>
59+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
60+</div>
61+<dl class="keys">
62+<div>
63+<dt>
64+<kbd>j</kbd>
65+<kbd>k</kbd>
66+</dt>
67+<dd>move cursor down / up</dd>
68+</div>
69+<div>
70+<dt>
71+<kbd>enter</kbd>
72+</dt>
73+<dd>open row under cursor</dd>
74+</div>
75+<div>
76+<dt>
77+<kbd>g</kbd>
78+<kbd>g</kbd> / <kbd>G</kbd>
79+</dt>
80+<dd>first / last row</dd>
81+</div>
82+<div>
83+<dt>
84+<kbd>/</kbd>
85+</dt>
86+<dd>focus the filter</dd>
87+</div>
88+<div>
89+<dt>
90+<kbd>g h</kbd>
91+<kbd>g t</kbd>
92+</dt>
93+<dd>go home / tree</dd>
94+</div>
95+<div>
96+<dt>
97+<kbd>g c</kbd>
98+<kbd>g r</kbd>
99+</dt>
100+<dd>go commits / refs</dd>
101+</div>
102+<div>
103+<dt>
104+<kbd>b</kbd>
105+</dt>
106+<dd>switch ref (branches, tags)</dd>
107+</div>
108+<div>
109+<dt>
110+<kbd>:</kbd>
111+<kbd>f</kbd>
112+</dt>
113+<dd>go to: section, ref, file, repo</dd>
114+</div>
115+<div>
116+<dt>
117+<kbd>y</kbd>
118+</dt>
119+<dd>permalink: ref becomes the full sha</dd>
120+</div>
121+<div>
122+<dt>
123+<kbd>s</kbd>
124+<kbd>t</kbd>
125+<kbd>c</kbd>
126+<kbd>r</kbd>
127+</dt>
128+<dd>repo sections (when shown)</dd>
129+</div>
130+<div>
131+<dt>
132+<kbd>d</kbd>
133+<kbd>u</kbd>
134+<kbd>x</kbd>
135+</dt>
136+<dd>commit: split / unified / collapse all</dd>
137+</div>
138+<div>
139+<dt>
140+<kbd>esc</kbd>
141+</dt>
142+<dd>close panel</dd>
143+</div>
144+</dl>
145+</dialog>
146+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
147+<div class="panel-head">
148+<label for="cmd-q" class="hi">:</label>
149+<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();
150+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
151+ 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();
152+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
153+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
154+ 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' })) }
155+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
156+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
157+</div>
158+<ul id="cmd-list" class="cmd-list">
159+<li data-k="go home" data-cursor>
160+<a href="/" tabindex="-1">
161+<span class="k">go</span>home</a>
162+</li>
163+<li data-k="repo demo">
164+<a href="/demo" tabindex="-1">
165+<span class="k">repo</span>demo</a>
166+</li>
167+</ul>
168+</dialog>
169+<footer class="bar status" aria-label="Status">
170+<span class="mode">empty</span>
171+<span class="st-ctx">empty repository · 0 commits</span>
172+<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'))">
173+<span class="hi">:</span> go to…</button>
174+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
175+</span>
176+<span class="st-hints">g h home · ? help</span>
177+<span class="st-src">
178+<span class="st-ver">ermite devel · </span>
179+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
180+</span>
181+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
182+</footer>
183+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
184+ if (q('dialog[open]').count) return;
185+ let rows = q('[data-rows] > :not([hidden])').arr();
186+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
187+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
188+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
189+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
190+ })(1);
191+ k-k from:body -> ((d) => {
192+ if (q('dialog[open]').count) return;
193+ let rows = q('[data-rows] > :not([hidden])').arr();
194+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
195+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
196+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
197+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
198+ })(-1);
199+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
200+ 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));
201+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
202+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
203+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
204+ 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());
205+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
206+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
207+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
208+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
209+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
210+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
211+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
212+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
213+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
214+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
215+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
216+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
217+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
218+ k-permalink from:body -> {
219+ let b = document.body.dataset;
220+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
221+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
222+ navigator.clipboard?.writeText(location.href);
223+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
224+ }">
225+</div>
226+</body>
227+</html>
A cmd/ermite/testdata/web/home.html +268 -0
@@ -0,0 +1,268 @@
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>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="" data-sha="" 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+</ol>
27+</nav>
28+<nav class="secs" aria-label="Sections">
29+<ul>
30+<li>
31+<a href="/" data-key="h" aria-keyshortcuts="g h" aria-current="page">home</a>
32+</li>
33+</ul>
34+</nav>
35+</header>
36+<div class="work">
37+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
38+<div class="filter">
39+<label for="home-filter">filter&gt;</label>
40+<input id="home-filter" type="search" data-filter placeholder="name or description ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')">
41+</div>
42+<table class="list home" aria-label="Repositories">
43+<thead>
44+<tr>
45+<th class="cur">
46+</th>
47+<th class="c-name" aria-sort="none">
48+<a href="/?sort=name&amp;dir=asc">name </a>
49+</th>
50+<th class="c-desc" aria-sort="none">
51+<a href="/?sort=desc&amp;dir=asc">description </a>
52+</th>
53+<th class="c-when" aria-sort="descending">
54+<a href="/?sort=push&amp;dir=asc">last push <span aria-hidden="true">▼</span>
55+</a>
56+</th>
57+<th class="c-ref" aria-sort="none">
58+<a href="/?sort=branch&amp;dir=asc">branch </a>
59+</th>
60+</tr>
61+</thead>
62+<tbody id="repo-rows" data-rows>
63+<tr id="repo-demo" data-cursor>
64+<td class="cur" aria-hidden="true">
65+</td>
66+<td class="c-name">
67+<a href="/demo">demo</a>
68+</td>
69+<td class="c-desc dim">A demo repository</td>
70+<td class="c-when nowrap">
71+<time datetime="STAMP">AGO</time>
72+</td>
73+<td class="c-ref">main</td>
74+</tr>
75+<tr id="repo-quiet">
76+<td class="cur" aria-hidden="true">
77+</td>
78+<td class="c-name">
79+<a href="/quiet">quiet</a>
80+</td>
81+<td class="c-desc dim">
82+</td>
83+<td class="c-when nowrap">
84+<time>never</time>
85+</td>
86+<td class="c-ref">main</td>
87+</tr>
88+</tbody>
89+</table>
90+</main>
91+</div>
92+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
93+<div class="panel-head">
94+<span>keys</span>
95+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
96+</div>
97+<dl class="keys">
98+<div>
99+<dt>
100+<kbd>j</kbd>
101+<kbd>k</kbd>
102+</dt>
103+<dd>move cursor down / up</dd>
104+</div>
105+<div>
106+<dt>
107+<kbd>enter</kbd>
108+</dt>
109+<dd>open row under cursor</dd>
110+</div>
111+<div>
112+<dt>
113+<kbd>g</kbd>
114+<kbd>g</kbd> / <kbd>G</kbd>
115+</dt>
116+<dd>first / last row</dd>
117+</div>
118+<div>
119+<dt>
120+<kbd>/</kbd>
121+</dt>
122+<dd>focus the filter</dd>
123+</div>
124+<div>
125+<dt>
126+<kbd>g h</kbd>
127+<kbd>g t</kbd>
128+</dt>
129+<dd>go home / tree</dd>
130+</div>
131+<div>
132+<dt>
133+<kbd>g c</kbd>
134+<kbd>g r</kbd>
135+</dt>
136+<dd>go commits / refs</dd>
137+</div>
138+<div>
139+<dt>
140+<kbd>b</kbd>
141+</dt>
142+<dd>switch ref (branches, tags)</dd>
143+</div>
144+<div>
145+<dt>
146+<kbd>:</kbd>
147+<kbd>f</kbd>
148+</dt>
149+<dd>go to: section, ref, file, repo</dd>
150+</div>
151+<div>
152+<dt>
153+<kbd>y</kbd>
154+</dt>
155+<dd>permalink: ref becomes the full sha</dd>
156+</div>
157+<div>
158+<dt>
159+<kbd>s</kbd>
160+<kbd>t</kbd>
161+<kbd>c</kbd>
162+<kbd>r</kbd>
163+</dt>
164+<dd>repo sections (when shown)</dd>
165+</div>
166+<div>
167+<dt>
168+<kbd>d</kbd>
169+<kbd>u</kbd>
170+<kbd>x</kbd>
171+</dt>
172+<dd>commit: split / unified / collapse all</dd>
173+</div>
174+<div>
175+<dt>
176+<kbd>esc</kbd>
177+</dt>
178+<dd>close panel</dd>
179+</div>
180+</dl>
181+</dialog>
182+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
183+<div class="panel-head">
184+<label for="cmd-q" class="hi">:</label>
185+<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();
186+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
187+ 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();
188+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
189+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
190+ 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' })) }
191+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
192+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
193+</div>
194+<ul id="cmd-list" class="cmd-list">
195+<li data-k="go home" data-cursor>
196+<a href="/" tabindex="-1">
197+<span class="k">go</span>home</a>
198+</li>
199+<li data-k="repo demo">
200+<a href="/demo" tabindex="-1">
201+<span class="k">repo</span>demo</a>
202+</li>
203+<li data-k="repo quiet">
204+<a href="/quiet" tabindex="-1">
205+<span class="k">repo</span>quiet</a>
206+</li>
207+</ul>
208+</dialog>
209+<footer class="bar status" aria-label="Status">
210+<span class="mode">home</span>
211+<span class="st-ctx">2 repos · sort push ▼</span>
212+<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'))">
213+<span class="hi">:</span> go to…</button>
214+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
215+</span>
216+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
217+<span class="st-src">
218+<span class="st-ver">ermite devel · </span>
219+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
220+</span>
221+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
222+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
223+</footer>
224+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
225+ if (q('dialog[open]').count) return;
226+ let rows = q('[data-rows] > :not([hidden])').arr();
227+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
228+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
229+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
230+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
231+ })(1);
232+ k-k from:body -> ((d) => {
233+ if (q('dialog[open]').count) return;
234+ let rows = q('[data-rows] > :not([hidden])').arr();
235+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
236+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
237+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
238+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
239+ })(-1);
240+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
241+ 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));
242+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
243+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
244+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
245+ 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());
246+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
247+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
248+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
249+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
250+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
251+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
252+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
253+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
254+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
255+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
256+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
257+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
258+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
259+ k-permalink from:body -> {
260+ let b = document.body.dataset;
261+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
262+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
263+ navigator.clipboard?.writeText(location.href);
264+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
265+ }">
266+</div>
267+</body>
268+</html>
@@ -0,0 +1,268 @@
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>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="" data-sha="" 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+</ol>
27+</nav>
28+<nav class="secs" aria-label="Sections">
29+<ul>
30+<li>
31+<a href="/" data-key="h" aria-keyshortcuts="g h" aria-current="page">home</a>
32+</li>
33+</ul>
34+</nav>
35+</header>
36+<div class="work">
37+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
38+<div class="filter">
39+<label for="home-filter">filter&gt;</label>
40+<input id="home-filter" type="search" data-filter placeholder="name or description ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')">
41+</div>
42+<table class="list home" aria-label="Repositories">
43+<thead>
44+<tr>
45+<th class="cur">
46+</th>
47+<th class="c-name" aria-sort="none">
48+<a href="/?sort=name&amp;dir=asc">name </a>
49+</th>
50+<th class="c-desc" aria-sort="none">
51+<a href="/?sort=desc&amp;dir=asc">description </a>
52+</th>
53+<th class="c-when" aria-sort="descending">
54+<a href="/?sort=push&amp;dir=asc">last push <span aria-hidden="true">▼</span>
55+</a>
56+</th>
57+<th class="c-ref" aria-sort="none">
58+<a href="/?sort=branch&amp;dir=asc">branch </a>
59+</th>
60+</tr>
61+</thead>
62+<tbody id="repo-rows" data-rows>
63+<tr id="repo-demo" data-cursor>
64+<td class="cur" aria-hidden="true">
65+</td>
66+<td class="c-name">
67+<a href="/demo">demo</a>
68+</td>
69+<td class="c-desc dim">A demo repository</td>
70+<td class="c-when nowrap">
71+<time datetime="STAMP">AGO</time>
72+</td>
73+<td class="c-ref">main</td>
74+</tr>
75+<tr id="repo-quiet">
76+<td class="cur" aria-hidden="true">
77+</td>
78+<td class="c-name">
79+<a href="/quiet">quiet</a>
80+</td>
81+<td class="c-desc dim">
82+</td>
83+<td class="c-when nowrap">
84+<time>never</time>
85+</td>
86+<td class="c-ref">main</td>
87+</tr>
88+</tbody>
89+</table>
90+</main>
91+</div>
92+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
93+<div class="panel-head">
94+<span>keys</span>
95+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
96+</div>
97+<dl class="keys">
98+<div>
99+<dt>
100+<kbd>j</kbd>
101+<kbd>k</kbd>
102+</dt>
103+<dd>move cursor down / up</dd>
104+</div>
105+<div>
106+<dt>
107+<kbd>enter</kbd>
108+</dt>
109+<dd>open row under cursor</dd>
110+</div>
111+<div>
112+<dt>
113+<kbd>g</kbd>
114+<kbd>g</kbd> / <kbd>G</kbd>
115+</dt>
116+<dd>first / last row</dd>
117+</div>
118+<div>
119+<dt>
120+<kbd>/</kbd>
121+</dt>
122+<dd>focus the filter</dd>
123+</div>
124+<div>
125+<dt>
126+<kbd>g h</kbd>
127+<kbd>g t</kbd>
128+</dt>
129+<dd>go home / tree</dd>
130+</div>
131+<div>
132+<dt>
133+<kbd>g c</kbd>
134+<kbd>g r</kbd>
135+</dt>
136+<dd>go commits / refs</dd>
137+</div>
138+<div>
139+<dt>
140+<kbd>b</kbd>
141+</dt>
142+<dd>switch ref (branches, tags)</dd>
143+</div>
144+<div>
145+<dt>
146+<kbd>:</kbd>
147+<kbd>f</kbd>
148+</dt>
149+<dd>go to: section, ref, file, repo</dd>
150+</div>
151+<div>
152+<dt>
153+<kbd>y</kbd>
154+</dt>
155+<dd>permalink: ref becomes the full sha</dd>
156+</div>
157+<div>
158+<dt>
159+<kbd>s</kbd>
160+<kbd>t</kbd>
161+<kbd>c</kbd>
162+<kbd>r</kbd>
163+</dt>
164+<dd>repo sections (when shown)</dd>
165+</div>
166+<div>
167+<dt>
168+<kbd>d</kbd>
169+<kbd>u</kbd>
170+<kbd>x</kbd>
171+</dt>
172+<dd>commit: split / unified / collapse all</dd>
173+</div>
174+<div>
175+<dt>
176+<kbd>esc</kbd>
177+</dt>
178+<dd>close panel</dd>
179+</div>
180+</dl>
181+</dialog>
182+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
183+<div class="panel-head">
184+<label for="cmd-q" class="hi">:</label>
185+<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();
186+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
187+ 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();
188+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
189+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
190+ 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' })) }
191+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
192+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
193+</div>
194+<ul id="cmd-list" class="cmd-list">
195+<li data-k="go home" data-cursor>
196+<a href="/" tabindex="-1">
197+<span class="k">go</span>home</a>
198+</li>
199+<li data-k="repo demo">
200+<a href="/demo" tabindex="-1">
201+<span class="k">repo</span>demo</a>
202+</li>
203+<li data-k="repo quiet">
204+<a href="/quiet" tabindex="-1">
205+<span class="k">repo</span>quiet</a>
206+</li>
207+</ul>
208+</dialog>
209+<footer class="bar status" aria-label="Status">
210+<span class="mode">home</span>
211+<span class="st-ctx">2 repos · sort push ▼</span>
212+<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'))">
213+<span class="hi">:</span> go to…</button>
214+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
215+</span>
216+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
217+<span class="st-src">
218+<span class="st-ver">ermite devel · </span>
219+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
220+</span>
221+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
222+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
223+</footer>
224+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
225+ if (q('dialog[open]').count) return;
226+ let rows = q('[data-rows] > :not([hidden])').arr();
227+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
228+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
229+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
230+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
231+ })(1);
232+ k-k from:body -> ((d) => {
233+ if (q('dialog[open]').count) return;
234+ let rows = q('[data-rows] > :not([hidden])').arr();
235+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
236+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
237+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
238+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
239+ })(-1);
240+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
241+ 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));
242+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
243+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
244+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
245+ 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());
246+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
247+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
248+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
249+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
250+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
251+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
252+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
253+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
254+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
255+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
256+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
257+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
258+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
259+ k-permalink from:body -> {
260+ let b = document.body.dataset;
261+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
262+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
263+ navigator.clipboard?.writeText(location.href);
264+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
265+ }">
266+</div>
267+</body>
268+</html>
A cmd/ermite/testdata/web/not-found-page.html +222 -0
@@ -0,0 +1,222 @@
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>404 · 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="" data-sha="" 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+<span aria-current="page">-</span>
28+</li>
29+<li>
30+<span aria-current="page">404</span>
31+</li>
32+</ol>
33+</nav>
34+<nav class="secs" aria-label="Sections">
35+<ul>
36+<li>
37+<a href="/" data-key="h" aria-keyshortcuts="g h">home</a>
38+</li>
39+</ul>
40+</nav>
41+</header>
42+<div class="work">
43+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
44+<section class="state" aria-labelledby="state-title">
45+<h1 id="state-title">
46+<span class="code">404</span> not found</h1>
47+<p class="dim">no such page, ref or path.</p>
48+<p class="dim">check the URL, or start from the repo list.</p>
49+<p class="state-back">
50+<a href="/">home</a>
51+</p>
52+</section>
53+</main>
54+</div>
55+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
56+<div class="panel-head">
57+<span>keys</span>
58+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
59+</div>
60+<dl class="keys">
61+<div>
62+<dt>
63+<kbd>j</kbd>
64+<kbd>k</kbd>
65+</dt>
66+<dd>move cursor down / up</dd>
67+</div>
68+<div>
69+<dt>
70+<kbd>enter</kbd>
71+</dt>
72+<dd>open row under cursor</dd>
73+</div>
74+<div>
75+<dt>
76+<kbd>g</kbd>
77+<kbd>g</kbd> / <kbd>G</kbd>
78+</dt>
79+<dd>first / last row</dd>
80+</div>
81+<div>
82+<dt>
83+<kbd>/</kbd>
84+</dt>
85+<dd>focus the filter</dd>
86+</div>
87+<div>
88+<dt>
89+<kbd>g h</kbd>
90+<kbd>g t</kbd>
91+</dt>
92+<dd>go home / tree</dd>
93+</div>
94+<div>
95+<dt>
96+<kbd>g c</kbd>
97+<kbd>g r</kbd>
98+</dt>
99+<dd>go commits / refs</dd>
100+</div>
101+<div>
102+<dt>
103+<kbd>b</kbd>
104+</dt>
105+<dd>switch ref (branches, tags)</dd>
106+</div>
107+<div>
108+<dt>
109+<kbd>:</kbd>
110+<kbd>f</kbd>
111+</dt>
112+<dd>go to: section, ref, file, repo</dd>
113+</div>
114+<div>
115+<dt>
116+<kbd>y</kbd>
117+</dt>
118+<dd>permalink: ref becomes the full sha</dd>
119+</div>
120+<div>
121+<dt>
122+<kbd>s</kbd>
123+<kbd>t</kbd>
124+<kbd>c</kbd>
125+<kbd>r</kbd>
126+</dt>
127+<dd>repo sections (when shown)</dd>
128+</div>
129+<div>
130+<dt>
131+<kbd>d</kbd>
132+<kbd>u</kbd>
133+<kbd>x</kbd>
134+</dt>
135+<dd>commit: split / unified / collapse all</dd>
136+</div>
137+<div>
138+<dt>
139+<kbd>esc</kbd>
140+</dt>
141+<dd>close panel</dd>
142+</div>
143+</dl>
144+</dialog>
145+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
146+<div class="panel-head">
147+<label for="cmd-q" class="hi">:</label>
148+<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();
149+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
150+ 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();
151+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
152+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
153+ 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' })) }
154+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
155+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
156+</div>
157+<ul id="cmd-list" class="cmd-list">
158+<li data-k="go home" data-cursor>
159+<a href="/" tabindex="-1">
160+<span class="k">go</span>home</a>
161+</li>
162+</ul>
163+</dialog>
164+<footer class="bar status" aria-label="Status">
165+<span class="mode">state</span>
166+<span class="st-ctx">404 · not found</span>
167+<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'))">
168+<span class="hi">:</span> go to…</button>
169+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
170+</span>
171+<span class="st-hints">g h home · ? help</span>
172+<span class="st-src">
173+<span class="st-ver">ermite devel · </span>
174+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
175+</span>
176+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
177+</footer>
178+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
179+ if (q('dialog[open]').count) return;
180+ let rows = q('[data-rows] > :not([hidden])').arr();
181+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
182+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
183+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
184+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
185+ })(1);
186+ k-k from:body -> ((d) => {
187+ if (q('dialog[open]').count) return;
188+ let rows = q('[data-rows] > :not([hidden])').arr();
189+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
190+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
191+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
192+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
193+ })(-1);
194+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
195+ 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));
196+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
197+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
198+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
199+ 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());
200+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
201+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
202+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
203+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
204+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
205+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
206+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
207+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
208+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
209+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
210+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
211+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
212+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
213+ k-permalink from:body -> {
214+ let b = document.body.dataset;
215+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
216+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
217+ navigator.clipboard?.writeText(location.href);
218+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
219+ }">
220+</div>
221+</body>
222+</html>
@@ -0,0 +1,222 @@
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>404 · 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="" data-sha="" 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+<span aria-current="page">-</span>
28+</li>
29+<li>
30+<span aria-current="page">404</span>
31+</li>
32+</ol>
33+</nav>
34+<nav class="secs" aria-label="Sections">
35+<ul>
36+<li>
37+<a href="/" data-key="h" aria-keyshortcuts="g h">home</a>
38+</li>
39+</ul>
40+</nav>
41+</header>
42+<div class="work">
43+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
44+<section class="state" aria-labelledby="state-title">
45+<h1 id="state-title">
46+<span class="code">404</span> not found</h1>
47+<p class="dim">no such page, ref or path.</p>
48+<p class="dim">check the URL, or start from the repo list.</p>
49+<p class="state-back">
50+<a href="/">home</a>
51+</p>
52+</section>
53+</main>
54+</div>
55+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
56+<div class="panel-head">
57+<span>keys</span>
58+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
59+</div>
60+<dl class="keys">
61+<div>
62+<dt>
63+<kbd>j</kbd>
64+<kbd>k</kbd>
65+</dt>
66+<dd>move cursor down / up</dd>
67+</div>
68+<div>
69+<dt>
70+<kbd>enter</kbd>
71+</dt>
72+<dd>open row under cursor</dd>
73+</div>
74+<div>
75+<dt>
76+<kbd>g</kbd>
77+<kbd>g</kbd> / <kbd>G</kbd>
78+</dt>
79+<dd>first / last row</dd>
80+</div>
81+<div>
82+<dt>
83+<kbd>/</kbd>
84+</dt>
85+<dd>focus the filter</dd>
86+</div>
87+<div>
88+<dt>
89+<kbd>g h</kbd>
90+<kbd>g t</kbd>
91+</dt>
92+<dd>go home / tree</dd>
93+</div>
94+<div>
95+<dt>
96+<kbd>g c</kbd>
97+<kbd>g r</kbd>
98+</dt>
99+<dd>go commits / refs</dd>
100+</div>
101+<div>
102+<dt>
103+<kbd>b</kbd>
104+</dt>
105+<dd>switch ref (branches, tags)</dd>
106+</div>
107+<div>
108+<dt>
109+<kbd>:</kbd>
110+<kbd>f</kbd>
111+</dt>
112+<dd>go to: section, ref, file, repo</dd>
113+</div>
114+<div>
115+<dt>
116+<kbd>y</kbd>
117+</dt>
118+<dd>permalink: ref becomes the full sha</dd>
119+</div>
120+<div>
121+<dt>
122+<kbd>s</kbd>
123+<kbd>t</kbd>
124+<kbd>c</kbd>
125+<kbd>r</kbd>
126+</dt>
127+<dd>repo sections (when shown)</dd>
128+</div>
129+<div>
130+<dt>
131+<kbd>d</kbd>
132+<kbd>u</kbd>
133+<kbd>x</kbd>
134+</dt>
135+<dd>commit: split / unified / collapse all</dd>
136+</div>
137+<div>
138+<dt>
139+<kbd>esc</kbd>
140+</dt>
141+<dd>close panel</dd>
142+</div>
143+</dl>
144+</dialog>
145+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
146+<div class="panel-head">
147+<label for="cmd-q" class="hi">:</label>
148+<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();
149+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
150+ 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();
151+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
152+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
153+ 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' })) }
154+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
155+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
156+</div>
157+<ul id="cmd-list" class="cmd-list">
158+<li data-k="go home" data-cursor>
159+<a href="/" tabindex="-1">
160+<span class="k">go</span>home</a>
161+</li>
162+</ul>
163+</dialog>
164+<footer class="bar status" aria-label="Status">
165+<span class="mode">state</span>
166+<span class="st-ctx">404 · not found</span>
167+<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'))">
168+<span class="hi">:</span> go to…</button>
169+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
170+</span>
171+<span class="st-hints">g h home · ? help</span>
172+<span class="st-src">
173+<span class="st-ver">ermite devel · </span>
174+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
175+</span>
176+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
177+</footer>
178+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
179+ if (q('dialog[open]').count) return;
180+ let rows = q('[data-rows] > :not([hidden])').arr();
181+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
182+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
183+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
184+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
185+ })(1);
186+ k-k from:body -> ((d) => {
187+ if (q('dialog[open]').count) return;
188+ let rows = q('[data-rows] > :not([hidden])').arr();
189+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
190+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
191+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
192+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
193+ })(-1);
194+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
195+ 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));
196+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
197+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
198+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
199+ 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());
200+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
201+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
202+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
203+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
204+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
205+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
206+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
207+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
208+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
209+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
210+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
211+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
212+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
213+ k-permalink from:body -> {
214+ let b = document.body.dataset;
215+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
216+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
217+ navigator.clipboard?.writeText(location.href);
218+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
219+ }">
220+</div>
221+</body>
222+</html>
A cmd/ermite/testdata/web/not-found.html +222 -0
@@ -0,0 +1,222 @@
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>404 · 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="" data-sha="" 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+<span aria-current="page">-</span>
28+</li>
29+<li>
30+<span aria-current="page">404</span>
31+</li>
32+</ol>
33+</nav>
34+<nav class="secs" aria-label="Sections">
35+<ul>
36+<li>
37+<a href="/" data-key="h" aria-keyshortcuts="g h">home</a>
38+</li>
39+</ul>
40+</nav>
41+</header>
42+<div class="work">
43+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
44+<section class="state" aria-labelledby="state-title">
45+<h1 id="state-title">
46+<span class="code">404</span> not found</h1>
47+<p class="dim">no such page, ref or path.</p>
48+<p class="dim">check the URL, or start from the repo list.</p>
49+<p class="state-back">
50+<a href="/">home</a>
51+</p>
52+</section>
53+</main>
54+</div>
55+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
56+<div class="panel-head">
57+<span>keys</span>
58+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
59+</div>
60+<dl class="keys">
61+<div>
62+<dt>
63+<kbd>j</kbd>
64+<kbd>k</kbd>
65+</dt>
66+<dd>move cursor down / up</dd>
67+</div>
68+<div>
69+<dt>
70+<kbd>enter</kbd>
71+</dt>
72+<dd>open row under cursor</dd>
73+</div>
74+<div>
75+<dt>
76+<kbd>g</kbd>
77+<kbd>g</kbd> / <kbd>G</kbd>
78+</dt>
79+<dd>first / last row</dd>
80+</div>
81+<div>
82+<dt>
83+<kbd>/</kbd>
84+</dt>
85+<dd>focus the filter</dd>
86+</div>
87+<div>
88+<dt>
89+<kbd>g h</kbd>
90+<kbd>g t</kbd>
91+</dt>
92+<dd>go home / tree</dd>
93+</div>
94+<div>
95+<dt>
96+<kbd>g c</kbd>
97+<kbd>g r</kbd>
98+</dt>
99+<dd>go commits / refs</dd>
100+</div>
101+<div>
102+<dt>
103+<kbd>b</kbd>
104+</dt>
105+<dd>switch ref (branches, tags)</dd>
106+</div>
107+<div>
108+<dt>
109+<kbd>:</kbd>
110+<kbd>f</kbd>
111+</dt>
112+<dd>go to: section, ref, file, repo</dd>
113+</div>
114+<div>
115+<dt>
116+<kbd>y</kbd>
117+</dt>
118+<dd>permalink: ref becomes the full sha</dd>
119+</div>
120+<div>
121+<dt>
122+<kbd>s</kbd>
123+<kbd>t</kbd>
124+<kbd>c</kbd>
125+<kbd>r</kbd>
126+</dt>
127+<dd>repo sections (when shown)</dd>
128+</div>
129+<div>
130+<dt>
131+<kbd>d</kbd>
132+<kbd>u</kbd>
133+<kbd>x</kbd>
134+</dt>
135+<dd>commit: split / unified / collapse all</dd>
136+</div>
137+<div>
138+<dt>
139+<kbd>esc</kbd>
140+</dt>
141+<dd>close panel</dd>
142+</div>
143+</dl>
144+</dialog>
145+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
146+<div class="panel-head">
147+<label for="cmd-q" class="hi">:</label>
148+<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();
149+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
150+ 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();
151+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
152+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
153+ 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' })) }
154+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
155+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
156+</div>
157+<ul id="cmd-list" class="cmd-list">
158+<li data-k="go home" data-cursor>
159+<a href="/" tabindex="-1">
160+<span class="k">go</span>home</a>
161+</li>
162+</ul>
163+</dialog>
164+<footer class="bar status" aria-label="Status">
165+<span class="mode">state</span>
166+<span class="st-ctx">404 · not found</span>
167+<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'))">
168+<span class="hi">:</span> go to…</button>
169+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
170+</span>
171+<span class="st-hints">g h home · ? help</span>
172+<span class="st-src">
173+<span class="st-ver">ermite devel · </span>
174+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
175+</span>
176+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
177+</footer>
178+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
179+ if (q('dialog[open]').count) return;
180+ let rows = q('[data-rows] > :not([hidden])').arr();
181+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
182+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
183+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
184+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
185+ })(1);
186+ k-k from:body -> ((d) => {
187+ if (q('dialog[open]').count) return;
188+ let rows = q('[data-rows] > :not([hidden])').arr();
189+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
190+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
191+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
192+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
193+ })(-1);
194+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
195+ 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));
196+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
197+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
198+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
199+ 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());
200+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
201+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
202+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
203+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
204+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
205+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
206+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
207+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
208+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
209+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
210+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
211+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
212+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
213+ k-permalink from:body -> {
214+ let b = document.body.dataset;
215+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
216+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
217+ navigator.clipboard?.writeText(location.href);
218+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
219+ }">
220+</div>
221+</body>
222+</html>
@@ -0,0 +1,222 @@
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>404 · 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="" data-sha="" 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+<span aria-current="page">-</span>
28+</li>
29+<li>
30+<span aria-current="page">404</span>
31+</li>
32+</ol>
33+</nav>
34+<nav class="secs" aria-label="Sections">
35+<ul>
36+<li>
37+<a href="/" data-key="h" aria-keyshortcuts="g h">home</a>
38+</li>
39+</ul>
40+</nav>
41+</header>
42+<div class="work">
43+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
44+<section class="state" aria-labelledby="state-title">
45+<h1 id="state-title">
46+<span class="code">404</span> not found</h1>
47+<p class="dim">no such page, ref or path.</p>
48+<p class="dim">check the URL, or start from the repo list.</p>
49+<p class="state-back">
50+<a href="/">home</a>
51+</p>
52+</section>
53+</main>
54+</div>
55+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
56+<div class="panel-head">
57+<span>keys</span>
58+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
59+</div>
60+<dl class="keys">
61+<div>
62+<dt>
63+<kbd>j</kbd>
64+<kbd>k</kbd>
65+</dt>
66+<dd>move cursor down / up</dd>
67+</div>
68+<div>
69+<dt>
70+<kbd>enter</kbd>
71+</dt>
72+<dd>open row under cursor</dd>
73+</div>
74+<div>
75+<dt>
76+<kbd>g</kbd>
77+<kbd>g</kbd> / <kbd>G</kbd>
78+</dt>
79+<dd>first / last row</dd>
80+</div>
81+<div>
82+<dt>
83+<kbd>/</kbd>
84+</dt>
85+<dd>focus the filter</dd>
86+</div>
87+<div>
88+<dt>
89+<kbd>g h</kbd>
90+<kbd>g t</kbd>
91+</dt>
92+<dd>go home / tree</dd>
93+</div>
94+<div>
95+<dt>
96+<kbd>g c</kbd>
97+<kbd>g r</kbd>
98+</dt>
99+<dd>go commits / refs</dd>
100+</div>
101+<div>
102+<dt>
103+<kbd>b</kbd>
104+</dt>
105+<dd>switch ref (branches, tags)</dd>
106+</div>
107+<div>
108+<dt>
109+<kbd>:</kbd>
110+<kbd>f</kbd>
111+</dt>
112+<dd>go to: section, ref, file, repo</dd>
113+</div>
114+<div>
115+<dt>
116+<kbd>y</kbd>
117+</dt>
118+<dd>permalink: ref becomes the full sha</dd>
119+</div>
120+<div>
121+<dt>
122+<kbd>s</kbd>
123+<kbd>t</kbd>
124+<kbd>c</kbd>
125+<kbd>r</kbd>
126+</dt>
127+<dd>repo sections (when shown)</dd>
128+</div>
129+<div>
130+<dt>
131+<kbd>d</kbd>
132+<kbd>u</kbd>
133+<kbd>x</kbd>
134+</dt>
135+<dd>commit: split / unified / collapse all</dd>
136+</div>
137+<div>
138+<dt>
139+<kbd>esc</kbd>
140+</dt>
141+<dd>close panel</dd>
142+</div>
143+</dl>
144+</dialog>
145+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
146+<div class="panel-head">
147+<label for="cmd-q" class="hi">:</label>
148+<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();
149+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
150+ 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();
151+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
152+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
153+ 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' })) }
154+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
155+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
156+</div>
157+<ul id="cmd-list" class="cmd-list">
158+<li data-k="go home" data-cursor>
159+<a href="/" tabindex="-1">
160+<span class="k">go</span>home</a>
161+</li>
162+</ul>
163+</dialog>
164+<footer class="bar status" aria-label="Status">
165+<span class="mode">state</span>
166+<span class="st-ctx">404 · not found</span>
167+<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'))">
168+<span class="hi">:</span> go to…</button>
169+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
170+</span>
171+<span class="st-hints">g h home · ? help</span>
172+<span class="st-src">
173+<span class="st-ver">ermite devel · </span>
174+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
175+</span>
176+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
177+</footer>
178+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
179+ if (q('dialog[open]').count) return;
180+ let rows = q('[data-rows] > :not([hidden])').arr();
181+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
182+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
183+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
184+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
185+ })(1);
186+ k-k from:body -> ((d) => {
187+ if (q('dialog[open]').count) return;
188+ let rows = q('[data-rows] > :not([hidden])').arr();
189+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
190+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
191+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
192+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
193+ })(-1);
194+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
195+ 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));
196+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
197+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
198+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
199+ 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());
200+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
201+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
202+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
203+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
204+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
205+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
206+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
207+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
208+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
209+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
210+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
211+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
212+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
213+ k-permalink from:body -> {
214+ let b = document.body.dataset;
215+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
216+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
217+ navigator.clipboard?.writeText(location.href);
218+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
219+ }">
220+</div>
221+</body>
222+</html>
A cmd/ermite/testdata/web/refs.html +404 -0
@@ -0,0 +1,404 @@
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>refs · demo · 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="/demo">demo</a>
28+</li>
29+<li>
30+<span aria-current="page">refs</span>
31+</li>
32+</ol>
33+</nav>
34+<nav class="secs" aria-label="Sections">
35+<ul>
36+<li>
37+<a href="/demo" data-key="s" aria-keyshortcuts="g s">summary</a>
38+</li>
39+<li>
40+<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
41+</li>
42+<li>
43+<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
44+</li>
45+<li>
46+<a href="/demo/refs" data-key="r" aria-keyshortcuts="g r" aria-current="page">refs</a>
47+</li>
48+</ul>
49+</nav>
50+<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>
51+</button>
52+</header>
53+<div class="work has-rail">
54+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
55+<div class="filter">
56+<label for="ref-filter">filter&gt;</label>
57+<input id="ref-filter" type="search" data-filter placeholder="branch or tag ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
58+ let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')">
59+</div>
60+<table class="list refs" aria-label="Branches and tags">
61+<thead>
62+<tr>
63+<th class="cur">
64+</th>
65+<th class="c-kind">kind</th>
66+<th class="c-name">name</th>
67+<th class="c-sha">commit</th>
68+<th class="c-sub">subject</th>
69+<th class="c-when">date</th>
70+</tr>
71+</thead>
72+<tbody id="ref-rows" data-rows>
73+<tr data-cursor>
74+<td class="cur" aria-hidden="true">
75+</td>
76+<td class="c-kind mute">branch</td>
77+<td class="c-name">
78+<a href="/demo/tree/main">main</a>
79+<span class="tagged">*</span>
80+</td>
81+<td class="c-sha">
82+<a href="/demo/commit/SHA40">SHA7</a>
83+</td>
84+<td class="c-sub">add main.go</td>
85+<td class="c-when dim">
86+<time datetime="STAMP" title="STAMP">AGO</time>
87+</td>
88+</tr>
89+<tr>
90+<td class="cur" aria-hidden="true">
91+</td>
92+<td class="c-kind mute">branch</td>
93+<td class="c-name">
94+<a href="/demo/tree/feature/x">feature/x</a>
95+</td>
96+<td class="c-sha">
97+<a href="/demo/commit/SHA40">SHA7</a>
98+</td>
99+<td class="c-sub">work on x</td>
100+<td class="c-when dim">
101+<time datetime="STAMP" title="STAMP">AGO</time>
102+</td>
103+</tr>
104+<tr>
105+<td class="cur" aria-hidden="true">
106+</td>
107+<td class="c-kind mute">tag</td>
108+<td class="c-name">
109+<a href="/demo/tree/v0.2.0">v0.2.0</a>
110+</td>
111+<td class="c-sha">
112+<a href="/demo/commit/SHA40">SHA7</a>
113+</td>
114+<td class="c-sub">add main.go</td>
115+<td class="c-when dim">
116+<time datetime="STAMP" title="STAMP">AGO</time>
117+</td>
118+</tr>
119+<tr>
120+<td class="cur" aria-hidden="true">
121+</td>
122+<td class="c-kind mute">tag</td>
123+<td class="c-name">
124+<a href="/demo/tree/v0.1.0">v0.1.0</a>
125+</td>
126+<td class="c-sha">
127+<a href="/demo/commit/SHA40">SHA7</a>
128+</td>
129+<td class="c-sub">first commit</td>
130+<td class="c-when dim">
131+<time datetime="STAMP" title="STAMP">AGO</time>
132+</td>
133+</tr>
134+</tbody>
135+</table>
136+</main>
137+<aside class="rail" aria-label="Context">
138+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
139+<summary>refs · legend</summary>
140+<section aria-labelledby="r-refs">
141+<h2 id="r-refs">refs</h2>
142+<dl class="kv">
143+<dt>default</dt>
144+<dd>main</dd>
145+<dt>branches</dt>
146+<dd>2</dd>
147+<dt>tags</dt>
148+<dd>2</dd>
149+<dt>latest tag</dt>
150+<dd>v0.2.0</dd>
151+</dl>
152+</section>
153+<section aria-labelledby="r-legend">
154+<h2 id="r-legend">legend</h2>
155+<dl class="kv">
156+<dt>*</dt>
157+<dd>default branch</dd>
158+</dl>
159+</section>
160+</details>
161+</aside>
162+</div>
163+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
164+<div class="panel-head">
165+<span>keys</span>
166+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
167+</div>
168+<dl class="keys">
169+<div>
170+<dt>
171+<kbd>j</kbd>
172+<kbd>k</kbd>
173+</dt>
174+<dd>move cursor down / up</dd>
175+</div>
176+<div>
177+<dt>
178+<kbd>enter</kbd>
179+</dt>
180+<dd>open row under cursor</dd>
181+</div>
182+<div>
183+<dt>
184+<kbd>g</kbd>
185+<kbd>g</kbd> / <kbd>G</kbd>
186+</dt>
187+<dd>first / last row</dd>
188+</div>
189+<div>
190+<dt>
191+<kbd>/</kbd>
192+</dt>
193+<dd>focus the filter</dd>
194+</div>
195+<div>
196+<dt>
197+<kbd>g h</kbd>
198+<kbd>g t</kbd>
199+</dt>
200+<dd>go home / tree</dd>
201+</div>
202+<div>
203+<dt>
204+<kbd>g c</kbd>
205+<kbd>g r</kbd>
206+</dt>
207+<dd>go commits / refs</dd>
208+</div>
209+<div>
210+<dt>
211+<kbd>b</kbd>
212+</dt>
213+<dd>switch ref (branches, tags)</dd>
214+</div>
215+<div>
216+<dt>
217+<kbd>:</kbd>
218+<kbd>f</kbd>
219+</dt>
220+<dd>go to: section, ref, file, repo</dd>
221+</div>
222+<div>
223+<dt>
224+<kbd>y</kbd>
225+</dt>
226+<dd>permalink: ref becomes the full sha</dd>
227+</div>
228+<div>
229+<dt>
230+<kbd>s</kbd>
231+<kbd>t</kbd>
232+<kbd>c</kbd>
233+<kbd>r</kbd>
234+</dt>
235+<dd>repo sections (when shown)</dd>
236+</div>
237+<div>
238+<dt>
239+<kbd>d</kbd>
240+<kbd>u</kbd>
241+<kbd>x</kbd>
242+</dt>
243+<dd>commit: split / unified / collapse all</dd>
244+</div>
245+<div>
246+<dt>
247+<kbd>esc</kbd>
248+</dt>
249+<dd>close panel</dd>
250+</div>
251+</dl>
252+</dialog>
253+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
254+<div class="panel-head">
255+<label for="refs-filter">ref&gt;</label>
256+<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))">
257+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
258+</div>
259+<div class="panel-cols" data-picklist>
260+<section aria-label="Branches">
261+<h2>branches <span class="mute">2</span>
262+</h2>
263+<ul>
264+<li>
265+<a href="/demo/tree/feature/x">
266+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
267+</li>
268+<li>
269+<a href="/demo/tree/main" aria-current="true">
270+<span class="mark" aria-hidden="true">●</span>main</a>
271+</li>
272+</ul>
273+</section>
274+<section aria-label="Tags">
275+<h2>tags <span class="mute">2</span>
276+</h2>
277+<ul>
278+<li>
279+<a href="/demo/tree/v0.1.0">
280+<span class="mark" aria-hidden="true">&nbsp;</span>v0.1.0</a>
281+</li>
282+<li>
283+<a href="/demo/tree/v0.2.0">
284+<span class="mark" aria-hidden="true">&nbsp;</span>v0.2.0</a>
285+</li>
286+</ul>
287+</section>
288+</div>
289+</dialog>
290+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
291+<div class="panel-head">
292+<label for="cmd-q" class="hi">:</label>
293+<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();
294+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
295+ 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();
296+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
297+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
298+ 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' })) }
299+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
300+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
301+</div>
302+<ul id="cmd-list" class="cmd-list">
303+<li data-k="go home" data-cursor>
304+<a href="/" tabindex="-1">
305+<span class="k">go</span>home</a>
306+</li>
307+<li data-k="section summary">
308+<a href="/demo" tabindex="-1">
309+<span class="k">section</span>summary</a>
310+</li>
311+<li data-k="section tree">
312+<a href="/demo/tree/main" tabindex="-1">
313+<span class="k">section</span>tree</a>
314+</li>
315+<li data-k="section commits">
316+<a href="/demo/commits/main" tabindex="-1">
317+<span class="k">section</span>commits</a>
318+</li>
319+<li data-k="section refs">
320+<a href="/demo/refs" tabindex="-1">
321+<span class="k">section</span>refs</a>
322+</li>
323+<li data-k="branch feature/x">
324+<a href="/demo/tree/feature/x" tabindex="-1">
325+<span class="k">branch</span>feature/x</a>
326+</li>
327+<li data-k="branch main">
328+<a href="/demo/tree/main" tabindex="-1">
329+<span class="k">branch</span>main</a>
330+</li>
331+<li data-k="tag v0.1.0">
332+<a href="/demo/tree/v0.1.0" tabindex="-1">
333+<span class="k">tag</span>v0.1.0</a>
334+</li>
335+<li data-k="tag v0.2.0">
336+<a href="/demo/tree/v0.2.0" tabindex="-1">
337+<span class="k">tag</span>v0.2.0</a>
338+</li>
339+<li data-k="repo quiet">
340+<a href="/quiet" tabindex="-1">
341+<span class="k">repo</span>quiet</a>
342+</li>
343+</ul>
344+</dialog>
345+<footer class="bar status" aria-label="Status">
346+<span class="mode">refs</span>
347+<span class="st-ctx">2 branches · 2 tags</span>
348+<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'))">
349+<span class="hi">:</span> go to…</button>
350+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
351+</span>
352+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
353+<span class="st-src">
354+<span class="st-ver">ermite devel · </span>
355+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
356+</span>
357+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
358+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span>
359+</footer>
360+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
361+ if (q('dialog[open]').count) return;
362+ let rows = q('[data-rows] > :not([hidden])').arr();
363+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
364+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
365+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
366+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
367+ })(1);
368+ k-k from:body -> ((d) => {
369+ if (q('dialog[open]').count) return;
370+ let rows = q('[data-rows] > :not([hidden])').arr();
371+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
372+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
373+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
374+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
375+ })(-1);
376+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
377+ 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));
378+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
379+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
380+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
381+ 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());
382+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
383+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
384+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
385+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
386+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
387+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
388+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
389+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
390+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
391+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
392+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
393+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
394+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
395+ k-permalink from:body -> {
396+ let b = document.body.dataset;
397+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
398+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
399+ navigator.clipboard?.writeText(location.href);
400+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
401+ }">
402+</div>
403+</body>
404+</html>
@@ -0,0 +1,404 @@
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>refs · demo · 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="/demo">demo</a>
28+</li>
29+<li>
30+<span aria-current="page">refs</span>
31+</li>
32+</ol>
33+</nav>
34+<nav class="secs" aria-label="Sections">
35+<ul>
36+<li>
37+<a href="/demo" data-key="s" aria-keyshortcuts="g s">summary</a>
38+</li>
39+<li>
40+<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
41+</li>
42+<li>
43+<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
44+</li>
45+<li>
46+<a href="/demo/refs" data-key="r" aria-keyshortcuts="g r" aria-current="page">refs</a>
47+</li>
48+</ul>
49+</nav>
50+<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>
51+</button>
52+</header>
53+<div class="work has-rail">
54+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
55+<div class="filter">
56+<label for="ref-filter">filter&gt;</label>
57+<input id="ref-filter" type="search" data-filter placeholder="branch or tag ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
58+ let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')">
59+</div>
60+<table class="list refs" aria-label="Branches and tags">
61+<thead>
62+<tr>
63+<th class="cur">
64+</th>
65+<th class="c-kind">kind</th>
66+<th class="c-name">name</th>
67+<th class="c-sha">commit</th>
68+<th class="c-sub">subject</th>
69+<th class="c-when">date</th>
70+</tr>
71+</thead>
72+<tbody id="ref-rows" data-rows>
73+<tr data-cursor>
74+<td class="cur" aria-hidden="true">
75+</td>
76+<td class="c-kind mute">branch</td>
77+<td class="c-name">
78+<a href="/demo/tree/main">main</a>
79+<span class="tagged">*</span>
80+</td>
81+<td class="c-sha">
82+<a href="/demo/commit/SHA40">SHA7</a>
83+</td>
84+<td class="c-sub">add main.go</td>
85+<td class="c-when dim">
86+<time datetime="STAMP" title="STAMP">AGO</time>
87+</td>
88+</tr>
89+<tr>
90+<td class="cur" aria-hidden="true">
91+</td>
92+<td class="c-kind mute">branch</td>
93+<td class="c-name">
94+<a href="/demo/tree/feature/x">feature/x</a>
95+</td>
96+<td class="c-sha">
97+<a href="/demo/commit/SHA40">SHA7</a>
98+</td>
99+<td class="c-sub">work on x</td>
100+<td class="c-when dim">
101+<time datetime="STAMP" title="STAMP">AGO</time>
102+</td>
103+</tr>
104+<tr>
105+<td class="cur" aria-hidden="true">
106+</td>
107+<td class="c-kind mute">tag</td>
108+<td class="c-name">
109+<a href="/demo/tree/v0.2.0">v0.2.0</a>
110+</td>
111+<td class="c-sha">
112+<a href="/demo/commit/SHA40">SHA7</a>
113+</td>
114+<td class="c-sub">add main.go</td>
115+<td class="c-when dim">
116+<time datetime="STAMP" title="STAMP">AGO</time>
117+</td>
118+</tr>
119+<tr>
120+<td class="cur" aria-hidden="true">
121+</td>
122+<td class="c-kind mute">tag</td>
123+<td class="c-name">
124+<a href="/demo/tree/v0.1.0">v0.1.0</a>
125+</td>
126+<td class="c-sha">
127+<a href="/demo/commit/SHA40">SHA7</a>
128+</td>
129+<td class="c-sub">first commit</td>
130+<td class="c-when dim">
131+<time datetime="STAMP" title="STAMP">AGO</time>
132+</td>
133+</tr>
134+</tbody>
135+</table>
136+</main>
137+<aside class="rail" aria-label="Context">
138+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
139+<summary>refs · legend</summary>
140+<section aria-labelledby="r-refs">
141+<h2 id="r-refs">refs</h2>
142+<dl class="kv">
143+<dt>default</dt>
144+<dd>main</dd>
145+<dt>branches</dt>
146+<dd>2</dd>
147+<dt>tags</dt>
148+<dd>2</dd>
149+<dt>latest tag</dt>
150+<dd>v0.2.0</dd>
151+</dl>
152+</section>
153+<section aria-labelledby="r-legend">
154+<h2 id="r-legend">legend</h2>
155+<dl class="kv">
156+<dt>*</dt>
157+<dd>default branch</dd>
158+</dl>
159+</section>
160+</details>
161+</aside>
162+</div>
163+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
164+<div class="panel-head">
165+<span>keys</span>
166+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
167+</div>
168+<dl class="keys">
169+<div>
170+<dt>
171+<kbd>j</kbd>
172+<kbd>k</kbd>
173+</dt>
174+<dd>move cursor down / up</dd>
175+</div>
176+<div>
177+<dt>
178+<kbd>enter</kbd>
179+</dt>
180+<dd>open row under cursor</dd>
181+</div>
182+<div>
183+<dt>
184+<kbd>g</kbd>
185+<kbd>g</kbd> / <kbd>G</kbd>
186+</dt>
187+<dd>first / last row</dd>
188+</div>
189+<div>
190+<dt>
191+<kbd>/</kbd>
192+</dt>
193+<dd>focus the filter</dd>
194+</div>
195+<div>
196+<dt>
197+<kbd>g h</kbd>
198+<kbd>g t</kbd>
199+</dt>
200+<dd>go home / tree</dd>
201+</div>
202+<div>
203+<dt>
204+<kbd>g c</kbd>
205+<kbd>g r</kbd>
206+</dt>
207+<dd>go commits / refs</dd>
208+</div>
209+<div>
210+<dt>
211+<kbd>b</kbd>
212+</dt>
213+<dd>switch ref (branches, tags)</dd>
214+</div>
215+<div>
216+<dt>
217+<kbd>:</kbd>
218+<kbd>f</kbd>
219+</dt>
220+<dd>go to: section, ref, file, repo</dd>
221+</div>
222+<div>
223+<dt>
224+<kbd>y</kbd>
225+</dt>
226+<dd>permalink: ref becomes the full sha</dd>
227+</div>
228+<div>
229+<dt>
230+<kbd>s</kbd>
231+<kbd>t</kbd>
232+<kbd>c</kbd>
233+<kbd>r</kbd>
234+</dt>
235+<dd>repo sections (when shown)</dd>
236+</div>
237+<div>
238+<dt>
239+<kbd>d</kbd>
240+<kbd>u</kbd>
241+<kbd>x</kbd>
242+</dt>
243+<dd>commit: split / unified / collapse all</dd>
244+</div>
245+<div>
246+<dt>
247+<kbd>esc</kbd>
248+</dt>
249+<dd>close panel</dd>
250+</div>
251+</dl>
252+</dialog>
253+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
254+<div class="panel-head">
255+<label for="refs-filter">ref&gt;</label>
256+<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))">
257+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
258+</div>
259+<div class="panel-cols" data-picklist>
260+<section aria-label="Branches">
261+<h2>branches <span class="mute">2</span>
262+</h2>
263+<ul>
264+<li>
265+<a href="/demo/tree/feature/x">
266+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
267+</li>
268+<li>
269+<a href="/demo/tree/main" aria-current="true">
270+<span class="mark" aria-hidden="true">●</span>main</a>
271+</li>
272+</ul>
273+</section>
274+<section aria-label="Tags">
275+<h2>tags <span class="mute">2</span>
276+</h2>
277+<ul>
278+<li>
279+<a href="/demo/tree/v0.1.0">
280+<span class="mark" aria-hidden="true">&nbsp;</span>v0.1.0</a>
281+</li>
282+<li>
283+<a href="/demo/tree/v0.2.0">
284+<span class="mark" aria-hidden="true">&nbsp;</span>v0.2.0</a>
285+</li>
286+</ul>
287+</section>
288+</div>
289+</dialog>
290+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
291+<div class="panel-head">
292+<label for="cmd-q" class="hi">:</label>
293+<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();
294+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
295+ 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();
296+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
297+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
298+ 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' })) }
299+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
300+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
301+</div>
302+<ul id="cmd-list" class="cmd-list">
303+<li data-k="go home" data-cursor>
304+<a href="/" tabindex="-1">
305+<span class="k">go</span>home</a>
306+</li>
307+<li data-k="section summary">
308+<a href="/demo" tabindex="-1">
309+<span class="k">section</span>summary</a>
310+</li>
311+<li data-k="section tree">
312+<a href="/demo/tree/main" tabindex="-1">
313+<span class="k">section</span>tree</a>
314+</li>
315+<li data-k="section commits">
316+<a href="/demo/commits/main" tabindex="-1">
317+<span class="k">section</span>commits</a>
318+</li>
319+<li data-k="section refs">
320+<a href="/demo/refs" tabindex="-1">
321+<span class="k">section</span>refs</a>
322+</li>
323+<li data-k="branch feature/x">
324+<a href="/demo/tree/feature/x" tabindex="-1">
325+<span class="k">branch</span>feature/x</a>
326+</li>
327+<li data-k="branch main">
328+<a href="/demo/tree/main" tabindex="-1">
329+<span class="k">branch</span>main</a>
330+</li>
331+<li data-k="tag v0.1.0">
332+<a href="/demo/tree/v0.1.0" tabindex="-1">
333+<span class="k">tag</span>v0.1.0</a>
334+</li>
335+<li data-k="tag v0.2.0">
336+<a href="/demo/tree/v0.2.0" tabindex="-1">
337+<span class="k">tag</span>v0.2.0</a>
338+</li>
339+<li data-k="repo quiet">
340+<a href="/quiet" tabindex="-1">
341+<span class="k">repo</span>quiet</a>
342+</li>
343+</ul>
344+</dialog>
345+<footer class="bar status" aria-label="Status">
346+<span class="mode">refs</span>
347+<span class="st-ctx">2 branches · 2 tags</span>
348+<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'))">
349+<span class="hi">:</span> go to…</button>
350+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
351+</span>
352+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
353+<span class="st-src">
354+<span class="st-ver">ermite devel · </span>
355+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
356+</span>
357+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
358+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span>
359+</footer>
360+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
361+ if (q('dialog[open]').count) return;
362+ let rows = q('[data-rows] > :not([hidden])').arr();
363+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
364+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
365+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
366+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
367+ })(1);
368+ k-k from:body -> ((d) => {
369+ if (q('dialog[open]').count) return;
370+ let rows = q('[data-rows] > :not([hidden])').arr();
371+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
372+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
373+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
374+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
375+ })(-1);
376+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
377+ 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));
378+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
379+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
380+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
381+ 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());
382+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
383+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
384+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
385+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
386+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
387+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
388+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
389+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
390+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
391+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
392+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
393+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
394+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
395+ k-permalink from:body -> {
396+ let b = document.body.dataset;
397+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
398+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
399+ navigator.clipboard?.writeText(location.href);
400+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
401+ }">
402+</div>
403+</body>
404+</html>
A cmd/ermite/testdata/web/summary.html +398 -0
@@ -0,0 +1,398 @@
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>demo · 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="/demo" aria-current="page">demo</a>
28+</li>
29+</ol>
30+</nav>
31+<nav class="secs" aria-label="Sections">
32+<ul>
33+<li>
34+<a href="/demo" data-key="s" aria-keyshortcuts="g s" aria-current="page">summary</a>
35+</li>
36+<li>
37+<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
38+</li>
39+<li>
40+<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
41+</li>
42+<li>
43+<a href="/demo/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
44+</li>
45+</ul>
46+</nav>
47+<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>
48+</button>
49+</header>
50+<div class="work has-rail read">
51+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
52+<p class="lede">A demo repository</p>
53+<h2 class="rule-h">
54+<span>README.md</span>
55+</h2>
56+<article class="prose" aria-label="README.md">
57+<h1 id="md-demo">Demo</h1>
58+<p>A small <em>demo</em> with a <a href="/demo/blob/main/docs/guide.md">guide</a> and <a href="https://example.org/x" rel="nofollow noopener noreferrer">the site</a>.</p>
59+<h2 id="md-install">Install</h2>
60+<pre>
61+<code class="language-sh">go install example.org/demo@latest
62+</code>
63+</pre>
64+<h2 id="md-usage">Usage</h2>
65+<table>
66+<thead>
67+<tr>
68+<th>a</th>
69+<th align="right">b</th>
70+</tr>
71+</thead>
72+<tbody>
73+<tr>
74+<td>1</td>
75+<td align="right">2</td>
76+</tr>
77+</tbody>
78+</table>
79+</article>
80+</main>
81+<aside class="rail" aria-label="Context">
82+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
83+<summary>clone · facts · recent commits · outline</summary>
84+<section aria-labelledby="r-clone">
85+<h2 id="r-clone">clone</h2>
86+<div class="clone" id="clone-demo">
87+<div class="clone-line">
88+<span class="mute" aria-hidden="true">$ </span>
89+<code>git clone http://example.test/demo.git</code>
90+<button type="button" class="copy" data-label="[copy]" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button>
91+</div>
92+</div>
93+</section>
94+<section aria-labelledby="r-facts">
95+<h2 id="r-facts">facts</h2>
96+<dl class="kv">
97+<dt>last push</dt>
98+<dd id="last-push-demo">
99+<time datetime="STAMP">AGO · STAMP</time>
100+</dd>
101+<dt>branch</dt>
102+<dd>main · 2 branches</dd>
103+<dt>tag</dt>
104+<dd>v0.2.0 · AGO · 2 tags</dd>
105+<dt>created</dt>
106+<dd>
107+<time datetime="STAMP">STAMP</time>
108+</dd>
109+</dl>
110+</section>
111+<section aria-labelledby="r-recent">
112+<h2 id="r-recent">recent commits <a href="/demo/commits/main">all</a>
113+</h2>
114+<table class="list commits" aria-label="Commits">
115+<tbody data-rows>
116+<tr data-cursor>
117+<td class="cur" aria-hidden="true">
118+</td>
119+<td class="c-sha mute">SHA7</td>
120+<td class="c-subject">
121+<a href="/demo/commit/SHA40">add main.go</a>
122+<span class="tagged">[main]</span>
123+<span class="tagged">[tag: v0.2.0]</span>
124+</td>
125+<td class="c-who dim">T</td>
126+<td class="c-when nowrap dim">
127+<time datetime="STAMP" title="STAMP">AGO</time>
128+</td>
129+</tr>
130+<tr>
131+<td class="cur" aria-hidden="true">
132+</td>
133+<td class="c-sha mute">SHA7</td>
134+<td class="c-subject">
135+<a href="/demo/commit/SHA40">first commit</a>
136+<span class="tagged">[tag: v0.1.0]</span>
137+</td>
138+<td class="c-who dim">T</td>
139+<td class="c-when nowrap dim">
140+<time datetime="STAMP" title="STAMP">AGO</time>
141+</td>
142+</tr>
143+</tbody>
144+</table>
145+</section>
146+<section aria-labelledby="r-outline">
147+<h2 id="r-outline">README.md</h2>
148+<nav class="outline" aria-label="Outline">
149+<a class="l1" href="#md-demo">Demo</a>
150+<a class="l2" href="#md-install">Install</a>
151+<a class="l2" href="#md-usage">Usage</a>
152+</nav>
153+</section>
154+</details>
155+</aside>
156+</div>
157+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
158+<div class="panel-head">
159+<span>keys</span>
160+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
161+</div>
162+<dl class="keys">
163+<div>
164+<dt>
165+<kbd>j</kbd>
166+<kbd>k</kbd>
167+</dt>
168+<dd>move cursor down / up</dd>
169+</div>
170+<div>
171+<dt>
172+<kbd>enter</kbd>
173+</dt>
174+<dd>open row under cursor</dd>
175+</div>
176+<div>
177+<dt>
178+<kbd>g</kbd>
179+<kbd>g</kbd> / <kbd>G</kbd>
180+</dt>
181+<dd>first / last row</dd>
182+</div>
183+<div>
184+<dt>
185+<kbd>/</kbd>
186+</dt>
187+<dd>focus the filter</dd>
188+</div>
189+<div>
190+<dt>
191+<kbd>g h</kbd>
192+<kbd>g t</kbd>
193+</dt>
194+<dd>go home / tree</dd>
195+</div>
196+<div>
197+<dt>
198+<kbd>g c</kbd>
199+<kbd>g r</kbd>
200+</dt>
201+<dd>go commits / refs</dd>
202+</div>
203+<div>
204+<dt>
205+<kbd>b</kbd>
206+</dt>
207+<dd>switch ref (branches, tags)</dd>
208+</div>
209+<div>
210+<dt>
211+<kbd>:</kbd>
212+<kbd>f</kbd>
213+</dt>
214+<dd>go to: section, ref, file, repo</dd>
215+</div>
216+<div>
217+<dt>
218+<kbd>y</kbd>
219+</dt>
220+<dd>permalink: ref becomes the full sha</dd>
221+</div>
222+<div>
223+<dt>
224+<kbd>s</kbd>
225+<kbd>t</kbd>
226+<kbd>c</kbd>
227+<kbd>r</kbd>
228+</dt>
229+<dd>repo sections (when shown)</dd>
230+</div>
231+<div>
232+<dt>
233+<kbd>d</kbd>
234+<kbd>u</kbd>
235+<kbd>x</kbd>
236+</dt>
237+<dd>commit: split / unified / collapse all</dd>
238+</div>
239+<div>
240+<dt>
241+<kbd>esc</kbd>
242+</dt>
243+<dd>close panel</dd>
244+</div>
245+</dl>
246+</dialog>
247+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
248+<div class="panel-head">
249+<label for="refs-filter">ref&gt;</label>
250+<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))">
251+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
252+</div>
253+<div class="panel-cols" data-picklist>
254+<section aria-label="Branches">
255+<h2>branches <span class="mute">2</span>
256+</h2>
257+<ul>
258+<li>
259+<a href="/demo/tree/feature/x">
260+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
261+</li>
262+<li>
263+<a href="/demo/tree/main" aria-current="true">
264+<span class="mark" aria-hidden="true">●</span>main</a>
265+</li>
266+</ul>
267+</section>
268+<section aria-label="Tags">
269+<h2>tags <span class="mute">2</span>
270+</h2>
271+<ul>
272+<li>
273+<a href="/demo/tree/v0.1.0">
274+<span class="mark" aria-hidden="true">&nbsp;</span>v0.1.0</a>
275+</li>
276+<li>
277+<a href="/demo/tree/v0.2.0">
278+<span class="mark" aria-hidden="true">&nbsp;</span>v0.2.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="/demo" tabindex="-1">
303+<span class="k">section</span>summary</a>
304+</li>
305+<li data-k="section tree">
306+<a href="/demo/tree/main" tabindex="-1">
307+<span class="k">section</span>tree</a>
308+</li>
309+<li data-k="section commits">
310+<a href="/demo/commits/main" tabindex="-1">
311+<span class="k">section</span>commits</a>
312+</li>
313+<li data-k="section refs">
314+<a href="/demo/refs" tabindex="-1">
315+<span class="k">section</span>refs</a>
316+</li>
317+<li data-k="branch feature/x">
318+<a href="/demo/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="/demo/tree/main" tabindex="-1">
323+<span class="k">branch</span>main</a>
324+</li>
325+<li data-k="tag v0.1.0">
326+<a href="/demo/tree/v0.1.0" tabindex="-1">
327+<span class="k">tag</span>v0.1.0</a>
328+</li>
329+<li data-k="tag v0.2.0">
330+<a href="/demo/tree/v0.2.0" tabindex="-1">
331+<span class="k">tag</span>v0.2.0</a>
332+</li>
333+<li data-k="repo quiet">
334+<a href="/quiet" tabindex="-1">
335+<span class="k">repo</span>quiet</a>
336+</li>
337+</ul>
338+</dialog>
339+<footer class="bar status" aria-label="Status">
340+<span class="mode">summary</span>
341+<span class="st-ctx">2 recent commits</span>
342+<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'))">
343+<span class="hi">:</span> go to…</button>
344+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
345+</span>
346+<span class="st-hints">j/k move · enter open · b ref · y permalink · ? help</span>
347+<span class="st-src">
348+<span class="st-ver">ermite devel · </span>
349+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
350+</span>
351+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
352+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
353+</footer>
354+<div id="keys" hidden hx-on="k-j 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-k from:body -> ((d) => {
363+ if (q('dialog[open]').count) return;
364+ let rows = q('[data-rows] > :not([hidden])').arr();
365+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
366+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
367+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
368+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
369+ })(-1);
370+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
371+ 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));
372+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
373+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
374+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
375+ 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());
376+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
377+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
378+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
379+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
380+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
381+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
382+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
383+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
384+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
385+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
386+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
387+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
388+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
389+ k-permalink from:body -> {
390+ let b = document.body.dataset;
391+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
392+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
393+ navigator.clipboard?.writeText(location.href);
394+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
395+ }">
396+</div>
397+</body>
398+</html>
@@ -0,0 +1,398 @@
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>demo · 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="/demo" aria-current="page">demo</a>
28+</li>
29+</ol>
30+</nav>
31+<nav class="secs" aria-label="Sections">
32+<ul>
33+<li>
34+<a href="/demo" data-key="s" aria-keyshortcuts="g s" aria-current="page">summary</a>
35+</li>
36+<li>
37+<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
38+</li>
39+<li>
40+<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
41+</li>
42+<li>
43+<a href="/demo/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
44+</li>
45+</ul>
46+</nav>
47+<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>
48+</button>
49+</header>
50+<div class="work has-rail read">
51+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
52+<p class="lede">A demo repository</p>
53+<h2 class="rule-h">
54+<span>README.md</span>
55+</h2>
56+<article class="prose" aria-label="README.md">
57+<h1 id="md-demo">Demo</h1>
58+<p>A small <em>demo</em> with a <a href="/demo/blob/main/docs/guide.md">guide</a> and <a href="https://example.org/x" rel="nofollow noopener noreferrer">the site</a>.</p>
59+<h2 id="md-install">Install</h2>
60+<pre>
61+<code class="language-sh">go install example.org/demo@latest
62+</code>
63+</pre>
64+<h2 id="md-usage">Usage</h2>
65+<table>
66+<thead>
67+<tr>
68+<th>a</th>
69+<th align="right">b</th>
70+</tr>
71+</thead>
72+<tbody>
73+<tr>
74+<td>1</td>
75+<td align="right">2</td>
76+</tr>
77+</tbody>
78+</table>
79+</article>
80+</main>
81+<aside class="rail" aria-label="Context">
82+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
83+<summary>clone · facts · recent commits · outline</summary>
84+<section aria-labelledby="r-clone">
85+<h2 id="r-clone">clone</h2>
86+<div class="clone" id="clone-demo">
87+<div class="clone-line">
88+<span class="mute" aria-hidden="true">$ </span>
89+<code>git clone http://example.test/demo.git</code>
90+<button type="button" class="copy" data-label="[copy]" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button>
91+</div>
92+</div>
93+</section>
94+<section aria-labelledby="r-facts">
95+<h2 id="r-facts">facts</h2>
96+<dl class="kv">
97+<dt>last push</dt>
98+<dd id="last-push-demo">
99+<time datetime="STAMP">AGO · STAMP</time>
100+</dd>
101+<dt>branch</dt>
102+<dd>main · 2 branches</dd>
103+<dt>tag</dt>
104+<dd>v0.2.0 · AGO · 2 tags</dd>
105+<dt>created</dt>
106+<dd>
107+<time datetime="STAMP">STAMP</time>
108+</dd>
109+</dl>
110+</section>
111+<section aria-labelledby="r-recent">
112+<h2 id="r-recent">recent commits <a href="/demo/commits/main">all</a>
113+</h2>
114+<table class="list commits" aria-label="Commits">
115+<tbody data-rows>
116+<tr data-cursor>
117+<td class="cur" aria-hidden="true">
118+</td>
119+<td class="c-sha mute">SHA7</td>
120+<td class="c-subject">
121+<a href="/demo/commit/SHA40">add main.go</a>
122+<span class="tagged">[main]</span>
123+<span class="tagged">[tag: v0.2.0]</span>
124+</td>
125+<td class="c-who dim">T</td>
126+<td class="c-when nowrap dim">
127+<time datetime="STAMP" title="STAMP">AGO</time>
128+</td>
129+</tr>
130+<tr>
131+<td class="cur" aria-hidden="true">
132+</td>
133+<td class="c-sha mute">SHA7</td>
134+<td class="c-subject">
135+<a href="/demo/commit/SHA40">first commit</a>
136+<span class="tagged">[tag: v0.1.0]</span>
137+</td>
138+<td class="c-who dim">T</td>
139+<td class="c-when nowrap dim">
140+<time datetime="STAMP" title="STAMP">AGO</time>
141+</td>
142+</tr>
143+</tbody>
144+</table>
145+</section>
146+<section aria-labelledby="r-outline">
147+<h2 id="r-outline">README.md</h2>
148+<nav class="outline" aria-label="Outline">
149+<a class="l1" href="#md-demo">Demo</a>
150+<a class="l2" href="#md-install">Install</a>
151+<a class="l2" href="#md-usage">Usage</a>
152+</nav>
153+</section>
154+</details>
155+</aside>
156+</div>
157+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
158+<div class="panel-head">
159+<span>keys</span>
160+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
161+</div>
162+<dl class="keys">
163+<div>
164+<dt>
165+<kbd>j</kbd>
166+<kbd>k</kbd>
167+</dt>
168+<dd>move cursor down / up</dd>
169+</div>
170+<div>
171+<dt>
172+<kbd>enter</kbd>
173+</dt>
174+<dd>open row under cursor</dd>
175+</div>
176+<div>
177+<dt>
178+<kbd>g</kbd>
179+<kbd>g</kbd> / <kbd>G</kbd>
180+</dt>
181+<dd>first / last row</dd>
182+</div>
183+<div>
184+<dt>
185+<kbd>/</kbd>
186+</dt>
187+<dd>focus the filter</dd>
188+</div>
189+<div>
190+<dt>
191+<kbd>g h</kbd>
192+<kbd>g t</kbd>
193+</dt>
194+<dd>go home / tree</dd>
195+</div>
196+<div>
197+<dt>
198+<kbd>g c</kbd>
199+<kbd>g r</kbd>
200+</dt>
201+<dd>go commits / refs</dd>
202+</div>
203+<div>
204+<dt>
205+<kbd>b</kbd>
206+</dt>
207+<dd>switch ref (branches, tags)</dd>
208+</div>
209+<div>
210+<dt>
211+<kbd>:</kbd>
212+<kbd>f</kbd>
213+</dt>
214+<dd>go to: section, ref, file, repo</dd>
215+</div>
216+<div>
217+<dt>
218+<kbd>y</kbd>
219+</dt>
220+<dd>permalink: ref becomes the full sha</dd>
221+</div>
222+<div>
223+<dt>
224+<kbd>s</kbd>
225+<kbd>t</kbd>
226+<kbd>c</kbd>
227+<kbd>r</kbd>
228+</dt>
229+<dd>repo sections (when shown)</dd>
230+</div>
231+<div>
232+<dt>
233+<kbd>d</kbd>
234+<kbd>u</kbd>
235+<kbd>x</kbd>
236+</dt>
237+<dd>commit: split / unified / collapse all</dd>
238+</div>
239+<div>
240+<dt>
241+<kbd>esc</kbd>
242+</dt>
243+<dd>close panel</dd>
244+</div>
245+</dl>
246+</dialog>
247+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
248+<div class="panel-head">
249+<label for="refs-filter">ref&gt;</label>
250+<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))">
251+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
252+</div>
253+<div class="panel-cols" data-picklist>
254+<section aria-label="Branches">
255+<h2>branches <span class="mute">2</span>
256+</h2>
257+<ul>
258+<li>
259+<a href="/demo/tree/feature/x">
260+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
261+</li>
262+<li>
263+<a href="/demo/tree/main" aria-current="true">
264+<span class="mark" aria-hidden="true">●</span>main</a>
265+</li>
266+</ul>
267+</section>
268+<section aria-label="Tags">
269+<h2>tags <span class="mute">2</span>
270+</h2>
271+<ul>
272+<li>
273+<a href="/demo/tree/v0.1.0">
274+<span class="mark" aria-hidden="true">&nbsp;</span>v0.1.0</a>
275+</li>
276+<li>
277+<a href="/demo/tree/v0.2.0">
278+<span class="mark" aria-hidden="true">&nbsp;</span>v0.2.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="/demo" tabindex="-1">
303+<span class="k">section</span>summary</a>
304+</li>
305+<li data-k="section tree">
306+<a href="/demo/tree/main" tabindex="-1">
307+<span class="k">section</span>tree</a>
308+</li>
309+<li data-k="section commits">
310+<a href="/demo/commits/main" tabindex="-1">
311+<span class="k">section</span>commits</a>
312+</li>
313+<li data-k="section refs">
314+<a href="/demo/refs" tabindex="-1">
315+<span class="k">section</span>refs</a>
316+</li>
317+<li data-k="branch feature/x">
318+<a href="/demo/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="/demo/tree/main" tabindex="-1">
323+<span class="k">branch</span>main</a>
324+</li>
325+<li data-k="tag v0.1.0">
326+<a href="/demo/tree/v0.1.0" tabindex="-1">
327+<span class="k">tag</span>v0.1.0</a>
328+</li>
329+<li data-k="tag v0.2.0">
330+<a href="/demo/tree/v0.2.0" tabindex="-1">
331+<span class="k">tag</span>v0.2.0</a>
332+</li>
333+<li data-k="repo quiet">
334+<a href="/quiet" tabindex="-1">
335+<span class="k">repo</span>quiet</a>
336+</li>
337+</ul>
338+</dialog>
339+<footer class="bar status" aria-label="Status">
340+<span class="mode">summary</span>
341+<span class="st-ctx">2 recent commits</span>
342+<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'))">
343+<span class="hi">:</span> go to…</button>
344+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
345+</span>
346+<span class="st-hints">j/k move · enter open · b ref · y permalink · ? help</span>
347+<span class="st-src">
348+<span class="st-ver">ermite devel · </span>
349+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
350+</span>
351+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
352+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
353+</footer>
354+<div id="keys" hidden hx-on="k-j 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-k from:body -> ((d) => {
363+ if (q('dialog[open]').count) return;
364+ let rows = q('[data-rows] > :not([hidden])').arr();
365+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
366+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
367+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
368+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
369+ })(-1);
370+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
371+ 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));
372+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
373+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
374+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
375+ 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());
376+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
377+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
378+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
379+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
380+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
381+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
382+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
383+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
384+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
385+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
386+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
387+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
388+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
389+ k-permalink from:body -> {
390+ let b = document.body.dataset;
391+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
392+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
393+ navigator.clipboard?.writeText(location.href);
394+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
395+ }">
396+</div>
397+</body>
398+</html>
A cmd/ermite/web.go +49 -0
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "net/http"
7+
8+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
9+ "bnema.dev/ermite/internal/adapters/httpserver"
10+ "bnema.dev/ermite/internal/adapters/rpc"
11+ "bnema.dev/ermite/internal/adapters/web"
12+ "bnema.dev/ermite/internal/config"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+// newWeb builds the web pages on the API of this process: the generated
17+// Connect clients are bound to the handlers of api, with no socket (ADR 0020).
18+func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) {
19+ hc := api.InProcessClient()
20+ srv, err := web.New(web.Config{
21+ Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
22+ Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL),
23+ PublicURL: cfg.PublicURL,
24+ SourceURL: cfg.SourceURL,
25+ Version: serverVersion(),
26+ })
27+ if err != nil {
28+ return nil, err
29+ }
30+ return asPrincipal(domain.Anonymous(), srv), nil
31+}
32+
33+// asPrincipal runs next with p as the caller of every request: the API calls
34+// the web makes carry it (rpc.API.InProcessClient). Browsers are anonymous
35+// until the web login of P5b brings a session.
36+func asPrincipal(p domain.Principal, next http.Handler) http.Handler {
37+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+ next.ServeHTTP(w, r.WithContext(domain.ContextWithPrincipal(r.Context(), p)))
39+ })
40+}
41+
42+// webRoutes mounts the web on the patterns it serves.
43+func webRoutes(h http.Handler) []httpserver.Route {
44+ routes := make([]httpserver.Route, 0, len(web.Patterns))
45+ for _, p := range web.Patterns {
46+ routes = append(routes, httpserver.Route{Pattern: p, Handler: h})
47+ }
48+ return routes
49+}
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "net/http"
7+
8+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
9+ "bnema.dev/ermite/internal/adapters/httpserver"
10+ "bnema.dev/ermite/internal/adapters/rpc"
11+ "bnema.dev/ermite/internal/adapters/web"
12+ "bnema.dev/ermite/internal/config"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+// newWeb builds the web pages on the API of this process: the generated
17+// Connect clients are bound to the handlers of api, with no socket (ADR 0020).
18+func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) {
19+ hc := api.InProcessClient()
20+ srv, err := web.New(web.Config{
21+ Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
22+ Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL),
23+ PublicURL: cfg.PublicURL,
24+ SourceURL: cfg.SourceURL,
25+ Version: serverVersion(),
26+ })
27+ if err != nil {
28+ return nil, err
29+ }
30+ return asPrincipal(domain.Anonymous(), srv), nil
31+}
32+
33+// asPrincipal runs next with p as the caller of every request: the API calls
34+// the web makes carry it (rpc.API.InProcessClient). Browsers are anonymous
35+// until the web login of P5b brings a session.
36+func asPrincipal(p domain.Principal, next http.Handler) http.Handler {
37+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+ next.ServeHTTP(w, r.WithContext(domain.ContextWithPrincipal(r.Context(), p)))
39+ })
40+}
41+
42+// webRoutes mounts the web on the patterns it serves.
43+func webRoutes(h http.Handler) []httpserver.Route {
44+ routes := make([]httpserver.Route, 0, len(web.Patterns))
45+ for _, p := range web.Patterns {
46+ routes = append(routes, httpserver.Route{Pattern: p, Handler: h})
47+ }
48+ return routes
49+}
A cmd/ermite/web_test.go +247 -0
@@ -0,0 +1,247 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "flag"
7+ "io"
8+ "net/http"
9+ "os"
10+ "path/filepath"
11+ "regexp"
12+ "strings"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/app"
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+var updateGolden = flag.Bool("update", false, "rewrite the golden files of the web tests")
23+
24+// webFixtureReadme exercises the Markdown policy and the outline.
25+const webFixtureReadme = "# Demo\n\nA small *demo* with a [guide](docs/guide.md) and [the site](https://example.org/x).\n\n" +
26+ "## Install\n\n```sh\ngo install example.org/demo@latest\n```\n\n## Usage\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n"
27+
28+// webRepo is the repository of the golden pages: public, with a README, two
29+// branches (one with a slash), two tags and a description.
30+func (h *harness) webRepo(name string) {
31+ h.t.Helper()
32+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{
33+ Name: h.name(name), Description: "A demo repository", Visibility: domain.VisibilityPublic,
34+ })
35+ require.NoError(h.t, err)
36+ wt := h.t.TempDir()
37+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
38+ commit := func(date, msg string, files map[string]string) {
39+ for p, c := range files {
40+ require.NoError(h.t, os.MkdirAll(filepath.Dir(filepath.Join(wt, p)), 0o755))
41+ require.NoError(h.t, os.WriteFile(filepath.Join(wt, p), []byte(c), 0o644))
42+ h.mustGit(wt, "add", p)
43+ }
44+ env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date}
45+ out, err := h.gitEnv(env, wt, "commit", "-q", "-m", msg)
46+ require.NoError(h.t, err, out)
47+ }
48+ commit("2026-01-01T10:00:00Z", "first commit", map[string]string{"README.md": webFixtureReadme, "docs/guide.md": "# Guide\n"})
49+ h.mustGit(wt, "tag", "v0.1.0")
50+ commit("2026-01-02T10:00:00Z", "add main.go", map[string]string{"main.go": "package main\n"})
51+ h.mustGit(wt, "tag", "-a", "-m", "release", "v0.2.0")
52+ h.mustGit(wt, "checkout", "-q", "-b", "feature/x")
53+ commit("2026-01-03T10:00:00Z", "work on x", map[string]string{"x.txt": "x\n"})
54+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "feature/x", "--tags")
55+}
56+
57+// get does a GET and returns the status, the headers and the body.
58+func (h *harness) get(path string) (int, http.Header, string) {
59+ h.t.Helper()
60+ resp, err := http.Get(h.url(path))
61+ require.NoError(h.t, err)
62+ defer func() { _ = resp.Body.Close() }()
63+ b, err := io.ReadAll(resp.Body)
64+ require.NoError(h.t, err)
65+ return resp.StatusCode, resp.Header, string(b)
66+}
67+
68+var (
69+ reSHA40 = regexp.MustCompile(`\b[0-9a-f]{40}\b`)
70+ reSHA7 = regexp.MustCompile(`\b[0-9a-f]{7}\b`)
71+ reAsset = regexp.MustCompile(`(/-/static/[a-z]+/[a-z.-]+?)\.[0-9a-f]{12}(\.\w+)`)
72+ reAgo = regexp.MustCompile(`(\bnow\b|\d+(m|h|d|mo|y) ago)`)
73+ reStamp = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}Z| UTC)?`)
74+ reReqID = regexp.MustCompile(`request id: [0-9a-f]{8}`)
75+ reSpaces = regexp.MustCompile(`[ \t]+\n|\n[ \t]*\n+`)
76+ reMarkers = regexp.MustCompile(`>\s*<`)
77+)
78+
79+// normalise makes a page comparable between runs: it hides what depends on
80+// the clock and on the commit ids, which hold the commit times.
81+func normalise(s string) string {
82+ s = reAsset.ReplaceAllString(s, "$1.HASH$2")
83+ s = reSHA40.ReplaceAllString(s, "SHA40")
84+ s = reSHA7.ReplaceAllString(s, "SHA7")
85+ s = reAgo.ReplaceAllString(s, "AGO")
86+ s = reStamp.ReplaceAllString(s, "STAMP")
87+ s = reReqID.ReplaceAllString(s, "request id: ID")
88+ s = reSpaces.ReplaceAllString(s, "\n")
89+ s = reMarkers.ReplaceAllString(s, ">\n<")
90+ return strings.TrimSpace(s) + "\n"
91+}
92+
93+func golden(t *testing.T, name, got string) {
94+ t.Helper()
95+ path := filepath.Join("testdata", "web", name+".html")
96+ if *updateGolden {
97+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
98+ require.NoError(t, os.WriteFile(path, []byte(got), 0o644))
99+ return
100+ }
101+ want, err := os.ReadFile(path)
102+ require.NoError(t, err, "run: go test ./cmd/ermite -run TestWebGolden -update")
103+ assert.Equal(t, string(want), got, name)
104+}
105+
106+// AC1: the pages render for a fixture repository with the structure of the
107+// mockup; the HTML is compared after normalising times and ids.
108+func TestWebGolden(t *testing.T) {
109+ h := newHarness(t, harnessOpts{})
110+ h.webRepo("demo")
111+ h.seed("quiet", domain.VisibilityPublic) // empty repository
112+
113+ for _, tt := range []struct {
114+ name, path string
115+ status int
116+ }{
117+ {"home", "/", 200},
118+ {"summary", "/demo", 200},
119+ {"refs", "/demo/refs", 200},
120+ {"empty", "/quiet", 200},
121+ {"not-found", "/nope", 404},
122+ {"not-found-page", "/demo/tree/main", 404},
123+ } {
124+ t.Run(tt.name, func(t *testing.T) {
125+ status, hdr, body := h.get(tt.path)
126+ require.Equal(t, tt.status, status)
127+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
128+ assert.Contains(t, hdr.Get("Content-Type"), "text/html")
129+ golden(t, tt.name, normalise(body))
130+ })
131+ }
132+}
133+
134+// AC2: a private repository is invisible on the web: not in the home list,
135+// not in the ":" targets, and its URLs give the same 404 page as a missing
136+// repository.
137+func TestWebPrivateRepoIsInvisible(t *testing.T) {
138+ h := newHarness(t, harnessOpts{})
139+ h.seed("open", domain.VisibilityPublic, "README.md")
140+ h.seed("secret-sauce", domain.VisibilityPrivate, "README.md")
141+
142+ status, _, home := h.get("/")
143+ require.Equal(t, 200, status)
144+ assert.Contains(t, home, "open")
145+ assert.NotContains(t, home, "secret-sauce")
146+
147+ _, _, missingBody := h.get("/never-existed")
148+ for _, p := range []string{"/secret-sauce", "/secret-sauce/", "/secret-sauce/refs", "/secret-sauce/tree/main", "/secret-sauce.git"} {
149+ status, hdr, body := h.get(p)
150+ assert.Equal(t, 404, status, p)
151+ assert.NotContains(t, body, "secret-sauce", p)
152+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
153+ _, _, missing := h.get(strings.Replace(p, "secret-sauce", "never-existed", 1))
154+ assert.Equal(t, normalise(missing), normalise(missingBody), "%s is the page of a missing repository", p)
155+ }
156+ // The open repository's pages do not list it either.
157+ _, _, summary := h.get("/open")
158+ assert.NotContains(t, summary, "secret-sauce")
159+}
160+
161+// AC4, end to end: a README that attacks the page is neutralised.
162+func TestWebReadmeIsNeutralised(t *testing.T) {
163+ h := newHarness(t, harnessOpts{})
164+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("evil"), Visibility: domain.VisibilityPublic})
165+ require.NoError(t, err)
166+ wt := h.t.TempDir()
167+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
168+ h.writeFile(wt, "README.md", []byte("# <script>alert(1)</script>Evil\n\n"+
169+ "<img src=x onerror=alert(1)>\n\n[click](javascript:alert(1))\n\n![p](https://evil.example/p.png)\n\n"+
170+ "<svg onload=alert(1)></svg>\n\n[ok](docs/a.md)\n"))
171+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/evil.git"), "main")
172+
173+ status, _, body := h.get("/evil")
174+ require.Equal(t, 200, status)
175+ for _, bad := range []string{"alert(1)</script", "onerror", "javascript:", "evil.example", "<svg", "<img"} {
176+ assert.NotContains(t, body, bad)
177+ }
178+ assert.Contains(t, body, `href="/evil/blob/main/docs/a.md"`)
179+ assertNoInline(t, body)
180+}
181+
182+var (
183+ reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`)
184+ reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`)
185+ reStyleTag = regexp.MustCompile(`(?i)<style\b`)
186+ reImport = regexp.MustCompile(`(?i)@import|url\(`)
187+)
188+
189+// assertNoInline checks the page has no inline script, style element or
190+// style attribute.
191+func assertNoInline(t *testing.T, body string) {
192+ t.Helper()
193+ for _, m := range reInlineScript.FindAllString(body, -1) {
194+ assert.Contains(t, m, "src=", "inline script: %s", m)
195+ }
196+ assert.False(t, reStyleAttr.MatchString(body), "style attribute")
197+ assert.False(t, reStyleTag.MatchString(body), "style element")
198+}
199+
200+// AC6: the CSP forbids inline scripts and styles, nothing is loaded from
201+// another origin, no page has an inline script or a style attribute, and
202+// every page carries the security headers.
203+func TestWebContentSecurity(t *testing.T) {
204+ h := newHarness(t, harnessOpts{})
205+ h.webRepo("demo")
206+ h.seed("quiet", domain.VisibilityPublic)
207+
208+ const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"
209+ for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} {
210+ status, hdr, body := h.get(p)
211+ assert.Contains(t, []int{200, 404}, status, p)
212+ assert.Equal(t, csp, hdr.Get("Content-Security-Policy"), p)
213+ assert.NotContains(t, hdr.Get("Content-Security-Policy"), "unsafe-inline")
214+ assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"), p)
215+ assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"), p)
216+ assert.Equal(t, "same-origin", hdr.Get("Referrer-Policy"), p)
217+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
218+ assertNoInline(t, body)
219+
220+ // Everything the page loads is a path on this origin.
221+ for _, m := range regexp.MustCompile(`<(?:script|link)\b[^>]*\b(?:src|href)="([^"]*)"`).FindAllStringSubmatch(body, -1) {
222+ assert.True(t, strings.HasPrefix(m[1], "/-/static/"), "%s loads %s", p, m[1])
223+ }
224+ assert.NotRegexp(t, `(?i)\b(?:src|action)="(?:https?:)?//`, body, p)
225+ assert.False(t, reImport.MatchString(body), p)
226+
227+ // Every static asset the page uses is served, immutable, from here.
228+ for _, m := range regexp.MustCompile(`"(/-/static/[^"]+)"`).FindAllStringSubmatch(body, -1) {
229+ st, ah, ab := h.get(m[1])
230+ assert.Equal(t, 200, st, m[1])
231+ assert.Equal(t, "public, max-age=31536000, immutable", ah.Get("Cache-Control"), m[1])
232+ assert.Equal(t, "nosniff", ah.Get("X-Content-Type-Options"), m[1])
233+ assert.NotEmpty(t, ab)
234+ assert.False(t, reImport.MatchString(ab) && strings.HasSuffix(m[1], ".css"), "%s imports or loads a URL", m[1])
235+ }
236+ }
237+
238+ // The footer links to the source (AGPL §13).
239+ _, _, body := h.get("/")
240+ assert.Contains(t, body, `<a href="http://example.test/ermite"`)
241+
242+ // Unknown, stale or traversing asset names are 404s.
243+ for _, p := range []string{"/-/static/css/ermite.000000000000.css", "/-/static/css/ermite.css", "/-/static/", "/-/static/../x", "/-/static/js/"} {
244+ st, _, _ := h.get(p)
245+ assert.Equal(t, 404, st, p)
246+ }
247+}
@@ -0,0 +1,247 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "flag"
7+ "io"
8+ "net/http"
9+ "os"
10+ "path/filepath"
11+ "regexp"
12+ "strings"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/app"
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+var updateGolden = flag.Bool("update", false, "rewrite the golden files of the web tests")
23+
24+// webFixtureReadme exercises the Markdown policy and the outline.
25+const webFixtureReadme = "# Demo\n\nA small *demo* with a [guide](docs/guide.md) and [the site](https://example.org/x).\n\n" +
26+ "## Install\n\n```sh\ngo install example.org/demo@latest\n```\n\n## Usage\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n"
27+
28+// webRepo is the repository of the golden pages: public, with a README, two
29+// branches (one with a slash), two tags and a description.
30+func (h *harness) webRepo(name string) {
31+ h.t.Helper()
32+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{
33+ Name: h.name(name), Description: "A demo repository", Visibility: domain.VisibilityPublic,
34+ })
35+ require.NoError(h.t, err)
36+ wt := h.t.TempDir()
37+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
38+ commit := func(date, msg string, files map[string]string) {
39+ for p, c := range files {
40+ require.NoError(h.t, os.MkdirAll(filepath.Dir(filepath.Join(wt, p)), 0o755))
41+ require.NoError(h.t, os.WriteFile(filepath.Join(wt, p), []byte(c), 0o644))
42+ h.mustGit(wt, "add", p)
43+ }
44+ env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date}
45+ out, err := h.gitEnv(env, wt, "commit", "-q", "-m", msg)
46+ require.NoError(h.t, err, out)
47+ }
48+ commit("2026-01-01T10:00:00Z", "first commit", map[string]string{"README.md": webFixtureReadme, "docs/guide.md": "# Guide\n"})
49+ h.mustGit(wt, "tag", "v0.1.0")
50+ commit("2026-01-02T10:00:00Z", "add main.go", map[string]string{"main.go": "package main\n"})
51+ h.mustGit(wt, "tag", "-a", "-m", "release", "v0.2.0")
52+ h.mustGit(wt, "checkout", "-q", "-b", "feature/x")
53+ commit("2026-01-03T10:00:00Z", "work on x", map[string]string{"x.txt": "x\n"})
54+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "feature/x", "--tags")
55+}
56+
57+// get does a GET and returns the status, the headers and the body.
58+func (h *harness) get(path string) (int, http.Header, string) {
59+ h.t.Helper()
60+ resp, err := http.Get(h.url(path))
61+ require.NoError(h.t, err)
62+ defer func() { _ = resp.Body.Close() }()
63+ b, err := io.ReadAll(resp.Body)
64+ require.NoError(h.t, err)
65+ return resp.StatusCode, resp.Header, string(b)
66+}
67+
68+var (
69+ reSHA40 = regexp.MustCompile(`\b[0-9a-f]{40}\b`)
70+ reSHA7 = regexp.MustCompile(`\b[0-9a-f]{7}\b`)
71+ reAsset = regexp.MustCompile(`(/-/static/[a-z]+/[a-z.-]+?)\.[0-9a-f]{12}(\.\w+)`)
72+ reAgo = regexp.MustCompile(`(\bnow\b|\d+(m|h|d|mo|y) ago)`)
73+ reStamp = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}Z| UTC)?`)
74+ reReqID = regexp.MustCompile(`request id: [0-9a-f]{8}`)
75+ reSpaces = regexp.MustCompile(`[ \t]+\n|\n[ \t]*\n+`)
76+ reMarkers = regexp.MustCompile(`>\s*<`)
77+)
78+
79+// normalise makes a page comparable between runs: it hides what depends on
80+// the clock and on the commit ids, which hold the commit times.
81+func normalise(s string) string {
82+ s = reAsset.ReplaceAllString(s, "$1.HASH$2")
83+ s = reSHA40.ReplaceAllString(s, "SHA40")
84+ s = reSHA7.ReplaceAllString(s, "SHA7")
85+ s = reAgo.ReplaceAllString(s, "AGO")
86+ s = reStamp.ReplaceAllString(s, "STAMP")
87+ s = reReqID.ReplaceAllString(s, "request id: ID")
88+ s = reSpaces.ReplaceAllString(s, "\n")
89+ s = reMarkers.ReplaceAllString(s, ">\n<")
90+ return strings.TrimSpace(s) + "\n"
91+}
92+
93+func golden(t *testing.T, name, got string) {
94+ t.Helper()
95+ path := filepath.Join("testdata", "web", name+".html")
96+ if *updateGolden {
97+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
98+ require.NoError(t, os.WriteFile(path, []byte(got), 0o644))
99+ return
100+ }
101+ want, err := os.ReadFile(path)
102+ require.NoError(t, err, "run: go test ./cmd/ermite -run TestWebGolden -update")
103+ assert.Equal(t, string(want), got, name)
104+}
105+
106+// AC1: the pages render for a fixture repository with the structure of the
107+// mockup; the HTML is compared after normalising times and ids.
108+func TestWebGolden(t *testing.T) {
109+ h := newHarness(t, harnessOpts{})
110+ h.webRepo("demo")
111+ h.seed("quiet", domain.VisibilityPublic) // empty repository
112+
113+ for _, tt := range []struct {
114+ name, path string
115+ status int
116+ }{
117+ {"home", "/", 200},
118+ {"summary", "/demo", 200},
119+ {"refs", "/demo/refs", 200},
120+ {"empty", "/quiet", 200},
121+ {"not-found", "/nope", 404},
122+ {"not-found-page", "/demo/tree/main", 404},
123+ } {
124+ t.Run(tt.name, func(t *testing.T) {
125+ status, hdr, body := h.get(tt.path)
126+ require.Equal(t, tt.status, status)
127+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
128+ assert.Contains(t, hdr.Get("Content-Type"), "text/html")
129+ golden(t, tt.name, normalise(body))
130+ })
131+ }
132+}
133+
134+// AC2: a private repository is invisible on the web: not in the home list,
135+// not in the ":" targets, and its URLs give the same 404 page as a missing
136+// repository.
137+func TestWebPrivateRepoIsInvisible(t *testing.T) {
138+ h := newHarness(t, harnessOpts{})
139+ h.seed("open", domain.VisibilityPublic, "README.md")
140+ h.seed("secret-sauce", domain.VisibilityPrivate, "README.md")
141+
142+ status, _, home := h.get("/")
143+ require.Equal(t, 200, status)
144+ assert.Contains(t, home, "open")
145+ assert.NotContains(t, home, "secret-sauce")
146+
147+ _, _, missingBody := h.get("/never-existed")
148+ for _, p := range []string{"/secret-sauce", "/secret-sauce/", "/secret-sauce/refs", "/secret-sauce/tree/main", "/secret-sauce.git"} {
149+ status, hdr, body := h.get(p)
150+ assert.Equal(t, 404, status, p)
151+ assert.NotContains(t, body, "secret-sauce", p)
152+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
153+ _, _, missing := h.get(strings.Replace(p, "secret-sauce", "never-existed", 1))
154+ assert.Equal(t, normalise(missing), normalise(missingBody), "%s is the page of a missing repository", p)
155+ }
156+ // The open repository's pages do not list it either.
157+ _, _, summary := h.get("/open")
158+ assert.NotContains(t, summary, "secret-sauce")
159+}
160+
161+// AC4, end to end: a README that attacks the page is neutralised.
162+func TestWebReadmeIsNeutralised(t *testing.T) {
163+ h := newHarness(t, harnessOpts{})
164+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("evil"), Visibility: domain.VisibilityPublic})
165+ require.NoError(t, err)
166+ wt := h.t.TempDir()
167+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
168+ h.writeFile(wt, "README.md", []byte("# <script>alert(1)</script>Evil\n\n"+
169+ "<img src=x onerror=alert(1)>\n\n[click](javascript:alert(1))\n\n![p](https://evil.example/p.png)\n\n"+
170+ "<svg onload=alert(1)></svg>\n\n[ok](docs/a.md)\n"))
171+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/evil.git"), "main")
172+
173+ status, _, body := h.get("/evil")
174+ require.Equal(t, 200, status)
175+ for _, bad := range []string{"alert(1)</script", "onerror", "javascript:", "evil.example", "<svg", "<img"} {
176+ assert.NotContains(t, body, bad)
177+ }
178+ assert.Contains(t, body, `href="/evil/blob/main/docs/a.md"`)
179+ assertNoInline(t, body)
180+}
181+
182+var (
183+ reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`)
184+ reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`)
185+ reStyleTag = regexp.MustCompile(`(?i)<style\b`)
186+ reImport = regexp.MustCompile(`(?i)@import|url\(`)
187+)
188+
189+// assertNoInline checks the page has no inline script, style element or
190+// style attribute.
191+func assertNoInline(t *testing.T, body string) {
192+ t.Helper()
193+ for _, m := range reInlineScript.FindAllString(body, -1) {
194+ assert.Contains(t, m, "src=", "inline script: %s", m)
195+ }
196+ assert.False(t, reStyleAttr.MatchString(body), "style attribute")
197+ assert.False(t, reStyleTag.MatchString(body), "style element")
198+}
199+
200+// AC6: the CSP forbids inline scripts and styles, nothing is loaded from
201+// another origin, no page has an inline script or a style attribute, and
202+// every page carries the security headers.
203+func TestWebContentSecurity(t *testing.T) {
204+ h := newHarness(t, harnessOpts{})
205+ h.webRepo("demo")
206+ h.seed("quiet", domain.VisibilityPublic)
207+
208+ const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"
209+ for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} {
210+ status, hdr, body := h.get(p)
211+ assert.Contains(t, []int{200, 404}, status, p)
212+ assert.Equal(t, csp, hdr.Get("Content-Security-Policy"), p)
213+ assert.NotContains(t, hdr.Get("Content-Security-Policy"), "unsafe-inline")
214+ assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"), p)
215+ assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"), p)
216+ assert.Equal(t, "same-origin", hdr.Get("Referrer-Policy"), p)
217+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
218+ assertNoInline(t, body)
219+
220+ // Everything the page loads is a path on this origin.
221+ for _, m := range regexp.MustCompile(`<(?:script|link)\b[^>]*\b(?:src|href)="([^"]*)"`).FindAllStringSubmatch(body, -1) {
222+ assert.True(t, strings.HasPrefix(m[1], "/-/static/"), "%s loads %s", p, m[1])
223+ }
224+ assert.NotRegexp(t, `(?i)\b(?:src|action)="(?:https?:)?//`, body, p)
225+ assert.False(t, reImport.MatchString(body), p)
226+
227+ // Every static asset the page uses is served, immutable, from here.
228+ for _, m := range regexp.MustCompile(`"(/-/static/[^"]+)"`).FindAllStringSubmatch(body, -1) {
229+ st, ah, ab := h.get(m[1])
230+ assert.Equal(t, 200, st, m[1])
231+ assert.Equal(t, "public, max-age=31536000, immutable", ah.Get("Cache-Control"), m[1])
232+ assert.Equal(t, "nosniff", ah.Get("X-Content-Type-Options"), m[1])
233+ assert.NotEmpty(t, ab)
234+ assert.False(t, reImport.MatchString(ab) && strings.HasSuffix(m[1], ".css"), "%s imports or loads a URL", m[1])
235+ }
236+ }
237+
238+ // The footer links to the source (AGPL §13).
239+ _, _, body := h.get("/")
240+ assert.Contains(t, body, `<a href="http://example.test/ermite"`)
241+
242+ // Unknown, stale or traversing asset names are 404s.
243+ for _, p := range []string{"/-/static/css/ermite.000000000000.css", "/-/static/css/ermite.css", "/-/static/", "/-/static/../x", "/-/static/js/"} {
244+ st, _, _ := h.get(p)
245+ assert.Equal(t, 404, st, p)
246+ }
247+}
A scripts/templ-drift.sh +17 -0
@@ -0,0 +1,17 @@
1+#!/usr/bin/env bash
2+# Fails when the committed *_templ.go files differ from what
3+# `go tool templ generate` produces from the .templ sources.
4+set -euo pipefail
5+
6+cd "$(dirname "$0")/.."
7+
8+go tool templ generate >/dev/null
9+if ! git diff --exit-code --stat -- '*_templ.go'; then
10+ echo "templ output is stale: run 'go tool templ generate' and commit the result" >&2
11+ exit 1
12+fi
13+if [ -n "$(git ls-files --others --exclude-standard -- '*_templ.go')" ]; then
14+ echo "templ generated files that are not committed:" >&2
15+ git ls-files --others --exclude-standard -- '*_templ.go' >&2
16+ exit 1
17+fi
@@ -0,0 +1,17 @@
1+#!/usr/bin/env bash
2+# Fails when the committed *_templ.go files differ from what
3+# `go tool templ generate` produces from the .templ sources.
4+set -euo pipefail
5+
6+cd "$(dirname "$0")/.."
7+
8+go tool templ generate >/dev/null
9+if ! git diff --exit-code --stat -- '*_templ.go'; then
10+ echo "templ output is stale: run 'go tool templ generate' and commit the result" >&2
11+ exit 1
12+fi
13+if [ -n "$(git ls-files --others --exclude-standard -- '*_templ.go')" ]; then
14+ echo "templ generated files that are not committed:" >&2
15+ git ls-files --others --exclude-standard -- '*_templ.go' >&2
16+ exit 1
17+fi

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 44

↑↓ moveenter openesc close