detached 5cd052a

feat(web): add the web adapter with the shell, home, summary and refs pages

Components per mockup D (topbar, statusbar, footer with the AGPL source
link, facts, clone, home row, outline, readme, keys, overlays), embedded
content-hashed assets, goldmark Markdown with a link and image policy,
and 404/500/empty state pages. The adapter only depends on the ermite-api
Connect clients.
commit
5cd052a36155b0722b27aed38dc31ef774f76bb0
author
brice · · 2026-10-10 12:33 UTC
committer
brice · · 2026-10-10 12:34 UTC
parent
c04f618
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
38 files · +5819 -0
A internal/adapters/web/ASSETS.md +19 -0
@@ -0,0 +1,19 @@
1+# Vendored web assets
2+
3+`static/` is embedded in the binary and served under `/-/static/` with
4+content-hashed names. Nothing is fetched at runtime.
5+
6+| File | What | Source | SHA-256 |
7+| --- | --- | --- | --- |
8+| `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` |
9+| `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
10+| `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11+| `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | |
12+| `static/css/*.css` | native CSS of design D, served as one stylesheet in name order | `mockups/d/css/` (+ `70-footer.css`, the AGPL §13 source link) | |
13+
14+The three htmx files are the exact files the approved mockup was built with.
15+To upgrade, replace the file, update the checksum here, and run the web tests
16+(`go test ./cmd/ermite -run TestWeb`).
17+
18+`*_templ.go` files are generated by `go tool templ generate` (templ is pinned
19+in `go.mod`) and committed; `scripts/templ-drift.sh` fails when they are stale.
@@ -0,0 +1,19 @@
1+# Vendored web assets
2+
3+`static/` is embedded in the binary and served under `/-/static/` with
4+content-hashed names. Nothing is fetched at runtime.
5+
6+| File | What | Source | SHA-256 |
7+| --- | --- | --- | --- |
8+| `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` |
9+| `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
10+| `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11+| `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | |
12+| `static/css/*.css` | native CSS of design D, served as one stylesheet in name order | `mockups/d/css/` (+ `70-footer.css`, the AGPL §13 source link) | |
13+
14+The three htmx files are the exact files the approved mockup was built with.
15+To upgrade, replace the file, update the checksum here, and run the web tests
16+(`go test ./cmd/ermite -run TestWeb`).
17+
18+`*_templ.go` files are generated by `go tool templ generate` (templ is pinned
19+in `go.mod`) and committed; `scripts/templ-drift.sh` fails when they are stale.
A internal/adapters/web/assets.go +105 -0
@@ -0,0 +1,105 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "crypto/sha256"
7+ "embed"
8+ "encoding/hex"
9+ "io/fs"
10+ "net/http"
11+ "path"
12+ "sort"
13+ "strings"
14+)
15+
16+//go:embed static
17+var staticFS embed.FS
18+
19+// StaticPrefix is where the embedded assets are served.
20+const StaticPrefix = "/-/static/"
21+
22+// Assets are the URLs of the assets a page loads. Their names carry a hash of
23+// the content, so they can be cached for ever.
24+type Assets struct {
25+ CSS, HTMX, Live, SSE, App string
26+}
27+
28+// asset is one embedded file under its content-hashed name.
29+type asset struct {
30+ body []byte
31+ typ string
32+}
33+
34+// assetSet is the embedded files by hashed URL path (under StaticPrefix).
35+type assetSet struct {
36+ byName map[string]asset
37+ urls Assets
38+}
39+
40+// scripts are the JavaScript files of the page, in embedded static/js: the
41+// file to serve, as it is named in Assets.
42+var scripts = []struct {
43+ file string
44+ set func(*Assets, string)
45+}{
46+ {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},
47+ {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
48+ {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
49+ {"ermite.js", func(a *Assets, u string) { a.App = u }},
50+}
51+
52+// loadAssets hashes the embedded files. The stylesheet is the concatenation
53+// of static/css/*.css in name order, as in the mockup.
54+func loadAssets() (*assetSet, error) {
55+ s := &assetSet{byName: map[string]asset{}}
56+ put := func(dir, file string, body []byte, typ string) string {
57+ sum := sha256.Sum256(body)
58+ ext := path.Ext(file)
59+ name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext
60+ s.byName[name] = asset{body: body, typ: typ}
61+ return StaticPrefix + name
62+ }
63+
64+ files, err := fs.Glob(staticFS, "static/css/*.css")
65+ if err != nil {
66+ return nil, err
67+ }
68+ sort.Strings(files)
69+ var css strings.Builder
70+ for _, f := range files {
71+ b, err := staticFS.ReadFile(f)
72+ if err != nil {
73+ return nil, err
74+ }
75+ css.WriteString("/* " + path.Base(f) + " */\n")
76+ css.Write(b)
77+ }
78+ s.urls.CSS = put("css", "ermite.css", []byte(css.String()), "text/css; charset=utf-8")
79+
80+ for _, sc := range scripts {
81+ b, err := staticFS.ReadFile("static/js/" + sc.file)
82+ if err != nil {
83+ return nil, err
84+ }
85+ sc.set(&s.urls, put("js", sc.file, b, "text/javascript; charset=utf-8"))
86+ }
87+ return s, nil
88+}
89+
90+// serve answers GET /-/static/<hashed name>. A name that does not match a
91+// current asset is a 404, so a stale hash never serves the wrong content.
92+func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) {
93+ a, ok := s.byName[strings.TrimPrefix(r.URL.Path, StaticPrefix)]
94+ if !ok {
95+ http.NotFound(w, r)
96+ return
97+ }
98+ h := w.Header()
99+ h.Set("Content-Type", a.typ)
100+ h.Set("Cache-Control", "public, max-age=31536000, immutable")
101+ h.Set("Content-Length", itoa(len(a.body)))
102+ if r.Method != http.MethodHead {
103+ _, _ = w.Write(a.body)
104+ }
105+}
@@ -0,0 +1,105 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "crypto/sha256"
7+ "embed"
8+ "encoding/hex"
9+ "io/fs"
10+ "net/http"
11+ "path"
12+ "sort"
13+ "strings"
14+)
15+
16+//go:embed static
17+var staticFS embed.FS
18+
19+// StaticPrefix is where the embedded assets are served.
20+const StaticPrefix = "/-/static/"
21+
22+// Assets are the URLs of the assets a page loads. Their names carry a hash of
23+// the content, so they can be cached for ever.
24+type Assets struct {
25+ CSS, HTMX, Live, SSE, App string
26+}
27+
28+// asset is one embedded file under its content-hashed name.
29+type asset struct {
30+ body []byte
31+ typ string
32+}
33+
34+// assetSet is the embedded files by hashed URL path (under StaticPrefix).
35+type assetSet struct {
36+ byName map[string]asset
37+ urls Assets
38+}
39+
40+// scripts are the JavaScript files of the page, in embedded static/js: the
41+// file to serve, as it is named in Assets.
42+var scripts = []struct {
43+ file string
44+ set func(*Assets, string)
45+}{
46+ {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},
47+ {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
48+ {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
49+ {"ermite.js", func(a *Assets, u string) { a.App = u }},
50+}
51+
52+// loadAssets hashes the embedded files. The stylesheet is the concatenation
53+// of static/css/*.css in name order, as in the mockup.
54+func loadAssets() (*assetSet, error) {
55+ s := &assetSet{byName: map[string]asset{}}
56+ put := func(dir, file string, body []byte, typ string) string {
57+ sum := sha256.Sum256(body)
58+ ext := path.Ext(file)
59+ name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext
60+ s.byName[name] = asset{body: body, typ: typ}
61+ return StaticPrefix + name
62+ }
63+
64+ files, err := fs.Glob(staticFS, "static/css/*.css")
65+ if err != nil {
66+ return nil, err
67+ }
68+ sort.Strings(files)
69+ var css strings.Builder
70+ for _, f := range files {
71+ b, err := staticFS.ReadFile(f)
72+ if err != nil {
73+ return nil, err
74+ }
75+ css.WriteString("/* " + path.Base(f) + " */\n")
76+ css.Write(b)
77+ }
78+ s.urls.CSS = put("css", "ermite.css", []byte(css.String()), "text/css; charset=utf-8")
79+
80+ for _, sc := range scripts {
81+ b, err := staticFS.ReadFile("static/js/" + sc.file)
82+ if err != nil {
83+ return nil, err
84+ }
85+ sc.set(&s.urls, put("js", sc.file, b, "text/javascript; charset=utf-8"))
86+ }
87+ return s, nil
88+}
89+
90+// serve answers GET /-/static/<hashed name>. A name that does not match a
91+// current asset is a 404, so a stale hash never serves the wrong content.
92+func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) {
93+ a, ok := s.byName[strings.TrimPrefix(r.URL.Path, StaticPrefix)]
94+ if !ok {
95+ http.NotFound(w, r)
96+ return
97+ }
98+ h := w.Header()
99+ h.Set("Content-Type", a.typ)
100+ h.Set("Cache-Control", "public, max-age=31536000, immutable")
101+ h.Set("Content-Length", itoa(len(a.body)))
102+ if r.Method != http.MethodHead {
103+ _, _ = w.Write(a.body)
104+ }
105+}
A internal/adapters/web/components.templ +114 -0
@@ -0,0 +1,114 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Facts is a key/value block on an 11ch key column.
6+templ Facts(facts []Fact) {
7+ <dl class="kv">
8+ for _, f := range facts {
9+ <dt>{ f.Key }</dt>
10+ <dd
11+ if f.ID != "" {
12+ id={ f.ID }
13+ }
14+ >
15+ if f.Time != "" {
16+ <time datetime={ f.Time }>{ f.Val }</time>
17+ } else {
18+ { f.Val }
19+ }
20+ </dd>
21+ }
22+ </dl>
23+}
24+
25+// CloneBlock is a block of copyable shell lines; each has its own [copy]
26+// button, whose label says "copied" for 1.5 s.
27+templ CloneBlock(c Clone) {
28+ <div class="clone" id={ c.ID }>
29+ for _, line := range c.Lines {
30+ <div class="clone-line">
31+ <span class="mute" aria-hidden="true">$ </span><code>{ line }</code>
32+ <button
33+ type="button"
34+ class="copy"
35+ data-label="[copy]"
36+ aria-label={ "copy: " + line }
37+ hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label"
38+ >[copy]</button>
39+ </div>
40+ }
41+ </div>
42+}
43+
44+// HomeRow is one row of the home list. Its id is the target of SSE swaps.
45+templ HomeRowView(r HomeRow) {
46+ <tr id={ r.ID } data-cursor?={ r.Cursor }>
47+ <td class="cur" aria-hidden="true"></td>
48+ <td class="c-name"><a href={ templ.SafeURL(r.Href) }>{ r.Name }</a></td>
49+ <td class="c-desc dim">{ r.Desc }</td>
50+ <td class="c-when nowrap">
51+ <time
52+ if r.PushedAt != "" {
53+ datetime={ r.PushedAt }
54+ }
55+ >{ r.Pushed }</time>
56+ </td>
57+ <td class="c-ref">{ r.Branch }</td>
58+ </tr>
59+}
60+
61+// Outline is the README outline; levels indent by 2ch, the h1 is bold.
62+templ Outline(headings []Heading) {
63+ <nav class="outline" aria-label="Outline">
64+ for _, h := range headings {
65+ <a class={ "l" + itoa(h.Level) } href={ templ.SafeURL("#" + h.ID) }>{ h.Text }</a>
66+ }
67+ </nav>
68+}
69+
70+// ReadmeView is a rendered README. Its HTML comes from the Markdown
71+// renderer, which drops raw HTML and filters URLs.
72+templ ReadmeView(r Readme) {
73+ <article class="prose" aria-label={ r.Name }>
74+ if r.Note != "" {
75+ <p class="dim">{ r.Note }</p>
76+ } else {
77+ @templ.Raw(r.HTML)
78+ }
79+ </article>
80+}
81+
82+// CommitRows is a cursor table of commits: sha, subject with inline refs,
83+// author, relative date (absolute in the title).
84+templ CommitRows(rows []CommitRow) {
85+ <table class="list commits" aria-label="Commits">
86+ <tbody data-rows>
87+ for _, c := range rows {
88+ <tr
89+ if c.ID != "" {
90+ id={ c.ID }
91+ }
92+ data-cursor?={ c.Cursor }
93+ >
94+ <td class="cur" aria-hidden="true"></td>
95+ <td class="c-sha mute">{ c.Short }</td>
96+ <td class="c-subject">
97+ <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a>
98+ if c.Merge {
99+ <span class="tagged">(merge)</span>
100+ }
101+ for _, b := range c.Branches {
102+ <span class="tagged">[{ b }]</span>
103+ }
104+ for _, t := range c.Tags {
105+ <span class="tagged">[tag: { t }]</span>
106+ }
107+ </td>
108+ <td class="c-who dim">{ c.Author }</td>
109+ <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td>
110+ </tr>
111+ }
112+ </tbody>
113+ </table>
114+}
@@ -0,0 +1,114 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Facts is a key/value block on an 11ch key column.
6+templ Facts(facts []Fact) {
7+ <dl class="kv">
8+ for _, f := range facts {
9+ <dt>{ f.Key }</dt>
10+ <dd
11+ if f.ID != "" {
12+ id={ f.ID }
13+ }
14+ >
15+ if f.Time != "" {
16+ <time datetime={ f.Time }>{ f.Val }</time>
17+ } else {
18+ { f.Val }
19+ }
20+ </dd>
21+ }
22+ </dl>
23+}
24+
25+// CloneBlock is a block of copyable shell lines; each has its own [copy]
26+// button, whose label says "copied" for 1.5 s.
27+templ CloneBlock(c Clone) {
28+ <div class="clone" id={ c.ID }>
29+ for _, line := range c.Lines {
30+ <div class="clone-line">
31+ <span class="mute" aria-hidden="true">$ </span><code>{ line }</code>
32+ <button
33+ type="button"
34+ class="copy"
35+ data-label="[copy]"
36+ aria-label={ "copy: " + line }
37+ hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label"
38+ >[copy]</button>
39+ </div>
40+ }
41+ </div>
42+}
43+
44+// HomeRow is one row of the home list. Its id is the target of SSE swaps.
45+templ HomeRowView(r HomeRow) {
46+ <tr id={ r.ID } data-cursor?={ r.Cursor }>
47+ <td class="cur" aria-hidden="true"></td>
48+ <td class="c-name"><a href={ templ.SafeURL(r.Href) }>{ r.Name }</a></td>
49+ <td class="c-desc dim">{ r.Desc }</td>
50+ <td class="c-when nowrap">
51+ <time
52+ if r.PushedAt != "" {
53+ datetime={ r.PushedAt }
54+ }
55+ >{ r.Pushed }</time>
56+ </td>
57+ <td class="c-ref">{ r.Branch }</td>
58+ </tr>
59+}
60+
61+// Outline is the README outline; levels indent by 2ch, the h1 is bold.
62+templ Outline(headings []Heading) {
63+ <nav class="outline" aria-label="Outline">
64+ for _, h := range headings {
65+ <a class={ "l" + itoa(h.Level) } href={ templ.SafeURL("#" + h.ID) }>{ h.Text }</a>
66+ }
67+ </nav>
68+}
69+
70+// ReadmeView is a rendered README. Its HTML comes from the Markdown
71+// renderer, which drops raw HTML and filters URLs.
72+templ ReadmeView(r Readme) {
73+ <article class="prose" aria-label={ r.Name }>
74+ if r.Note != "" {
75+ <p class="dim">{ r.Note }</p>
76+ } else {
77+ @templ.Raw(r.HTML)
78+ }
79+ </article>
80+}
81+
82+// CommitRows is a cursor table of commits: sha, subject with inline refs,
83+// author, relative date (absolute in the title).
84+templ CommitRows(rows []CommitRow) {
85+ <table class="list commits" aria-label="Commits">
86+ <tbody data-rows>
87+ for _, c := range rows {
88+ <tr
89+ if c.ID != "" {
90+ id={ c.ID }
91+ }
92+ data-cursor?={ c.Cursor }
93+ >
94+ <td class="cur" aria-hidden="true"></td>
95+ <td class="c-sha mute">{ c.Short }</td>
96+ <td class="c-subject">
97+ <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a>
98+ if c.Merge {
99+ <span class="tagged">(merge)</span>
100+ }
101+ for _, b := range c.Branches {
102+ <span class="tagged">[{ b }]</span>
103+ }
104+ for _, t := range c.Tags {
105+ <span class="tagged">[tag: { t }]</span>
106+ }
107+ </td>
108+ <td class="c-who dim">{ c.Author }</td>
109+ <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td>
110+ </tr>
111+ }
112+ </tbody>
113+ </table>
114+}
A internal/adapters/web/components_templ.go +721 -0 generated file: collapsed
A internal/adapters/web/errors_test.go +84 -0
@@ -0,0 +1,84 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "net/http"
10+ "net/http/httptest"
11+ "regexp"
12+ "testing"
13+
14+ "connectrpc.com/connect"
15+ "github.com/bnema/zerowrap"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+
19+ pb "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
21+)
22+
23+// failing is a repository client that fails every call with err.
24+type failing struct {
25+ ermiteconnect.RepoServiceClient
26+ err error
27+}
28+
29+func (f failing) ListRepos(context.Context, *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) {
30+ return nil, f.err
31+}
32+
33+func (f failing) GetRepo(context.Context, *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) {
34+ return nil, f.err
35+}
36+
37+func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) {
38+ t.Helper()
39+ srv, nerr := New(Config{
40+ Repos: failing{err: err}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
41+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
42+ })
43+ require.NoError(t, nerr)
44+ var logs bytes.Buffer
45+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
46+ rec := httptest.NewRecorder()
47+ srv.ServeHTTP(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil))
48+ return rec, logs.String()
49+}
50+
51+// Errors of the API: not found (and an invalid name) are the 404 page, any
52+// other is the 500 page with an id that the log carries.
53+func TestErrorPages(t *testing.T) {
54+ rec, logs := serve(t, connect.NewError(connect.CodeNotFound, errors.New("repository not found")), "/gone")
55+ assert.Equal(t, http.StatusNotFound, rec.Code)
56+ assert.Contains(t, rec.Body.String(), "not found")
57+ assert.Empty(t, logs, "a 404 is not an error")
58+
59+ rec, _ = serve(t, connect.NewError(connect.CodeInvalidArgument, errors.New("bad name")), "/Bad")
60+ assert.Equal(t, http.StatusNotFound, rec.Code)
61+ assert.NotContains(t, rec.Body.String(), "bad name")
62+
63+ secret := "dial tcp 10.0.0.5: kv exploded"
64+ rec, logs = serve(t, connect.NewError(connect.CodeInternal, errors.New(secret)), "/demo")
65+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
66+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
67+ assert.Contains(t, rec.Body.String(), "internal error")
68+ assert.NotContains(t, rec.Body.String(), secret, "the cause stays in the log")
69+ m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String())
70+ require.Len(t, m, 2, "the page shows a request id")
71+ assert.Contains(t, logs, m[1], "the log carries the same id")
72+ assert.Contains(t, logs, "kv exploded")
73+
74+ rec, _ = serve(t, connect.NewError(connect.CodeInternal, errors.New("x")), "/")
75+ assert.Equal(t, http.StatusInternalServerError, rec.Code, "the home page too")
76+}
77+
78+func TestNewValidates(t *testing.T) {
79+ _, err := New(Config{PublicURL: "https://forge.example"})
80+ assert.Error(t, err, "clients are required")
81+ git := ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid")
82+ _, err = New(Config{Repos: failing{}, Git: git, PublicURL: "not a url"})
83+ assert.Error(t, err)
84+}
@@ -0,0 +1,84 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "net/http"
10+ "net/http/httptest"
11+ "regexp"
12+ "testing"
13+
14+ "connectrpc.com/connect"
15+ "github.com/bnema/zerowrap"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+
19+ pb "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
21+)
22+
23+// failing is a repository client that fails every call with err.
24+type failing struct {
25+ ermiteconnect.RepoServiceClient
26+ err error
27+}
28+
29+func (f failing) ListRepos(context.Context, *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) {
30+ return nil, f.err
31+}
32+
33+func (f failing) GetRepo(context.Context, *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) {
34+ return nil, f.err
35+}
36+
37+func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) {
38+ t.Helper()
39+ srv, nerr := New(Config{
40+ Repos: failing{err: err}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
41+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
42+ })
43+ require.NoError(t, nerr)
44+ var logs bytes.Buffer
45+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
46+ rec := httptest.NewRecorder()
47+ srv.ServeHTTP(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil))
48+ return rec, logs.String()
49+}
50+
51+// Errors of the API: not found (and an invalid name) are the 404 page, any
52+// other is the 500 page with an id that the log carries.
53+func TestErrorPages(t *testing.T) {
54+ rec, logs := serve(t, connect.NewError(connect.CodeNotFound, errors.New("repository not found")), "/gone")
55+ assert.Equal(t, http.StatusNotFound, rec.Code)
56+ assert.Contains(t, rec.Body.String(), "not found")
57+ assert.Empty(t, logs, "a 404 is not an error")
58+
59+ rec, _ = serve(t, connect.NewError(connect.CodeInvalidArgument, errors.New("bad name")), "/Bad")
60+ assert.Equal(t, http.StatusNotFound, rec.Code)
61+ assert.NotContains(t, rec.Body.String(), "bad name")
62+
63+ secret := "dial tcp 10.0.0.5: kv exploded"
64+ rec, logs = serve(t, connect.NewError(connect.CodeInternal, errors.New(secret)), "/demo")
65+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
66+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
67+ assert.Contains(t, rec.Body.String(), "internal error")
68+ assert.NotContains(t, rec.Body.String(), secret, "the cause stays in the log")
69+ m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String())
70+ require.Len(t, m, 2, "the page shows a request id")
71+ assert.Contains(t, logs, m[1], "the log carries the same id")
72+ assert.Contains(t, logs, "kv exploded")
73+
74+ rec, _ = serve(t, connect.NewError(connect.CodeInternal, errors.New("x")), "/")
75+ assert.Equal(t, http.StatusInternalServerError, rec.Code, "the home page too")
76+}
77+
78+func TestNewValidates(t *testing.T) {
79+ _, err := New(Config{PublicURL: "https://forge.example"})
80+ assert.Error(t, err, "clients are required")
81+ git := ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid")
82+ _, err = New(Config{Repos: failing{}, Git: git, PublicURL: "not a url"})
83+ assert.Error(t, err)
84+}
A internal/adapters/web/format.go +69 -0
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "fmt"
7+ "strconv"
8+ "time"
9+)
10+
11+func itoa(n int) string { return strconv.Itoa(n) }
12+
13+// cursorText is the initial text of the cursor position in the status bar.
14+func cursorText(n int) string {
15+ if n == 0 {
16+ return "0/0"
17+ }
18+ return "1/" + itoa(n)
19+}
20+
21+// ago renders a short relative date: now, 5m ago, 3h ago, 12d ago, 5mo ago,
22+// 2y ago.
23+func ago(now, t time.Time) string {
24+ if t.IsZero() {
25+ return "never"
26+ }
27+ d := now.Sub(t)
28+ switch {
29+ case d < time.Minute:
30+ return "now"
31+ case d < time.Hour:
32+ return fmt.Sprintf("%dm ago", int(d.Minutes()))
33+ case d < 48*time.Hour:
34+ return fmt.Sprintf("%dh ago", int(d.Hours()))
35+ case d < 60*24*time.Hour:
36+ return fmt.Sprintf("%dd ago", int(d.Hours()/24))
37+ case d < 700*24*time.Hour:
38+ return fmt.Sprintf("%dmo ago", int(d.Hours()/24/30))
39+ }
40+ return fmt.Sprintf("%dy ago", int(d.Hours()/24/365))
41+}
42+
43+// absolute is the absolute form shown on hover.
44+func absolute(t time.Time) string {
45+ return t.UTC().Format("2006-01-02 15:04 UTC")
46+}
47+
48+// rfc3339 is the machine form of a time, "" for the zero time.
49+func rfc3339(t time.Time) string {
50+ if t.IsZero() {
51+ return ""
52+ }
53+ return t.UTC().Format(time.RFC3339)
54+}
55+
56+func plural(n int, one, many string) string {
57+ if n == 1 {
58+ return "1 " + one
59+ }
60+ return itoa(n) + " " + many
61+}
62+
63+// short is the abbreviated form of a commit id.
64+func short(id string) string {
65+ if len(id) > 7 {
66+ return id[:7]
67+ }
68+ return id
69+}
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "fmt"
7+ "strconv"
8+ "time"
9+)
10+
11+func itoa(n int) string { return strconv.Itoa(n) }
12+
13+// cursorText is the initial text of the cursor position in the status bar.
14+func cursorText(n int) string {
15+ if n == 0 {
16+ return "0/0"
17+ }
18+ return "1/" + itoa(n)
19+}
20+
21+// ago renders a short relative date: now, 5m ago, 3h ago, 12d ago, 5mo ago,
22+// 2y ago.
23+func ago(now, t time.Time) string {
24+ if t.IsZero() {
25+ return "never"
26+ }
27+ d := now.Sub(t)
28+ switch {
29+ case d < time.Minute:
30+ return "now"
31+ case d < time.Hour:
32+ return fmt.Sprintf("%dm ago", int(d.Minutes()))
33+ case d < 48*time.Hour:
34+ return fmt.Sprintf("%dh ago", int(d.Hours()))
35+ case d < 60*24*time.Hour:
36+ return fmt.Sprintf("%dd ago", int(d.Hours()/24))
37+ case d < 700*24*time.Hour:
38+ return fmt.Sprintf("%dmo ago", int(d.Hours()/24/30))
39+ }
40+ return fmt.Sprintf("%dy ago", int(d.Hours()/24/365))
41+}
42+
43+// absolute is the absolute form shown on hover.
44+func absolute(t time.Time) string {
45+ return t.UTC().Format("2006-01-02 15:04 UTC")
46+}
47+
48+// rfc3339 is the machine form of a time, "" for the zero time.
49+func rfc3339(t time.Time) string {
50+ if t.IsZero() {
51+ return ""
52+ }
53+ return t.UTC().Format(time.RFC3339)
54+}
55+
56+func plural(n int, one, many string) string {
57+ if n == 1 {
58+ return "1 " + one
59+ }
60+ return itoa(n) + " " + many
61+}
62+
63+// short is the abbreviated form of a commit id.
64+func short(id string) string {
65+ if len(id) > 7 {
66+ return id[:7]
67+ }
68+ return id
69+}
A internal/adapters/web/home.go +149 -0
@@ -0,0 +1,149 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "sort"
9+ "strings"
10+ "time"
11+
12+ "connectrpc.com/connect"
13+ "github.com/a-h/templ"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+)
17+
18+// maxRepos bounds the repositories one page lists.
19+const maxRepos = 1000
20+
21+// pageSize is the page size asked from the list RPCs: the largest the API
22+// accepts.
23+const pageSize = 100
24+
25+// listRepos reads the repositories the visitor may see, ordered by name.
26+func (s *Server) listRepos(ctx context.Context) ([]*pb.Repo, error) {
27+ var repos []*pb.Repo
28+ token := ""
29+ for len(repos) < maxRepos {
30+ res, err := s.cfg.Repos.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{PageSize: pageSize, PageToken: token}))
31+ if err != nil {
32+ return nil, err
33+ }
34+ repos = append(repos, res.Msg.GetRepos()...)
35+ if token = res.Msg.GetNextPageToken(); token == "" {
36+ break
37+ }
38+ }
39+ return repos, nil
40+}
41+
42+// homeCols are the sortable columns of the home list, in order.
43+var homeCols = []struct{ key, label, class string }{
44+ {"name", "name", "c-name"},
45+ {"desc", "description", "c-desc"},
46+ {"push", "last push", "c-when"},
47+ {"branch", "branch", "c-ref"},
48+}
49+
50+// sortRepos orders repos by the column key; ties keep the order by name.
51+func sortRepos(repos []*pb.Repo, key string, desc bool) {
52+ less := func(a, b *pb.Repo) bool {
53+ switch key {
54+ case "desc":
55+ return strings.ToLower(a.GetDescription()) < strings.ToLower(b.GetDescription())
56+ case "branch":
57+ return a.GetDefaultBranch() < b.GetDefaultBranch()
58+ case "push":
59+ return a.GetPushedAt().AsTime().Before(b.GetPushedAt().AsTime())
60+ }
61+ return a.GetName() < b.GetName()
62+ }
63+ sort.SliceStable(repos, func(i, j int) bool {
64+ if desc {
65+ return less(repos[j], repos[i])
66+ }
67+ return less(repos[i], repos[j])
68+ })
69+}
70+
71+// home lists the repositories: /?sort=<col>&dir=asc|desc, newest push first
72+// by default.
73+func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) {
74+ repos, err := s.listRepos(ctx)
75+ if err != nil {
76+ return nil, 0, err
77+ }
78+ key, desc := "push", true
79+ for _, c := range homeCols {
80+ if r.URL.Query().Get("sort") == c.key {
81+ key, desc = c.key, c.key == "push"
82+ }
83+ }
84+ if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {
85+ desc = d == "desc"
86+ }
87+ sortRepos(repos, key, desc)
88+
89+ now := s.cfg.Now()
90+ p := HomePage{}
91+ for _, c := range homeCols {
92+ sc := SortCol{Label: c.label, Class: c.class, Dir: "none"}
93+ next := "asc"
94+ if c.key == key {
95+ sc.Dir, sc.Mark = "ascending", "▲"
96+ if desc {
97+ sc.Dir, sc.Mark = "descending", "▼"
98+ } else {
99+ next = "desc"
100+ }
101+ }
102+ sc.Href = "/?sort=" + c.key + "&dir=" + next
103+ p.Cols = append(p.Cols, sc)
104+ }
105+ for i, repo := range repos {
106+ p.Rows = append(p.Rows, homeRow(repo, now, i == 0))
107+ }
108+
109+ l := s.base()
110+ l.Title = s.instance
111+ l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}}
112+ dir := "▲"
113+ if desc {
114+ dir = "▼"
115+ }
116+ l.Status = Status{
117+ Mode: "home",
118+ Context: plural(len(repos), "repo", "repos") + " · sort " + key + " " + dir,
119+ Hints: "j/k move · enter open · / filter · ? help",
120+ Cursor: true, Count: len(repos),
121+ }
122+ l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)
123+ return Home(l, p), http.StatusOK, nil
124+}
125+
126+// homeRow is the row of a repository in the home list.
127+func homeRow(r *pb.Repo, now time.Time, cursor bool) HomeRow {
128+ row := HomeRow{
129+ ID: "repo-" + r.GetName(), Name: r.GetName(), Href: repoHref(r.GetName()),
130+ Desc: r.GetDescription(), Pushed: "never", Branch: r.GetDefaultBranch(), Cursor: cursor,
131+ }
132+ if r.GetPushedAt().IsValid() && r.GetPushedAt().GetSeconds() != 0 {
133+ t := r.GetPushedAt().AsTime()
134+ row.Pushed, row.PushedAt = ago(now, t), rfc3339(t)
135+ }
136+ return row
137+}
138+
139+// repoCmds are the repositories as targets of the ":" command line, but the
140+// one being viewed.
141+func repoCmds(repos []*pb.Repo, except string) []CmdItem {
142+ var out []CmdItem
143+ for _, r := range repos {
144+ if r.GetName() != except {
145+ out = append(out, CmdItem{Kind: "repo", Label: r.GetName(), Href: repoHref(r.GetName())})
146+ }
147+ }
148+ return out
149+}
@@ -0,0 +1,149 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "sort"
9+ "strings"
10+ "time"
11+
12+ "connectrpc.com/connect"
13+ "github.com/a-h/templ"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+)
17+
18+// maxRepos bounds the repositories one page lists.
19+const maxRepos = 1000
20+
21+// pageSize is the page size asked from the list RPCs: the largest the API
22+// accepts.
23+const pageSize = 100
24+
25+// listRepos reads the repositories the visitor may see, ordered by name.
26+func (s *Server) listRepos(ctx context.Context) ([]*pb.Repo, error) {
27+ var repos []*pb.Repo
28+ token := ""
29+ for len(repos) < maxRepos {
30+ res, err := s.cfg.Repos.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{PageSize: pageSize, PageToken: token}))
31+ if err != nil {
32+ return nil, err
33+ }
34+ repos = append(repos, res.Msg.GetRepos()...)
35+ if token = res.Msg.GetNextPageToken(); token == "" {
36+ break
37+ }
38+ }
39+ return repos, nil
40+}
41+
42+// homeCols are the sortable columns of the home list, in order.
43+var homeCols = []struct{ key, label, class string }{
44+ {"name", "name", "c-name"},
45+ {"desc", "description", "c-desc"},
46+ {"push", "last push", "c-when"},
47+ {"branch", "branch", "c-ref"},
48+}
49+
50+// sortRepos orders repos by the column key; ties keep the order by name.
51+func sortRepos(repos []*pb.Repo, key string, desc bool) {
52+ less := func(a, b *pb.Repo) bool {
53+ switch key {
54+ case "desc":
55+ return strings.ToLower(a.GetDescription()) < strings.ToLower(b.GetDescription())
56+ case "branch":
57+ return a.GetDefaultBranch() < b.GetDefaultBranch()
58+ case "push":
59+ return a.GetPushedAt().AsTime().Before(b.GetPushedAt().AsTime())
60+ }
61+ return a.GetName() < b.GetName()
62+ }
63+ sort.SliceStable(repos, func(i, j int) bool {
64+ if desc {
65+ return less(repos[j], repos[i])
66+ }
67+ return less(repos[i], repos[j])
68+ })
69+}
70+
71+// home lists the repositories: /?sort=<col>&dir=asc|desc, newest push first
72+// by default.
73+func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) {
74+ repos, err := s.listRepos(ctx)
75+ if err != nil {
76+ return nil, 0, err
77+ }
78+ key, desc := "push", true
79+ for _, c := range homeCols {
80+ if r.URL.Query().Get("sort") == c.key {
81+ key, desc = c.key, c.key == "push"
82+ }
83+ }
84+ if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {
85+ desc = d == "desc"
86+ }
87+ sortRepos(repos, key, desc)
88+
89+ now := s.cfg.Now()
90+ p := HomePage{}
91+ for _, c := range homeCols {
92+ sc := SortCol{Label: c.label, Class: c.class, Dir: "none"}
93+ next := "asc"
94+ if c.key == key {
95+ sc.Dir, sc.Mark = "ascending", "▲"
96+ if desc {
97+ sc.Dir, sc.Mark = "descending", "▼"
98+ } else {
99+ next = "desc"
100+ }
101+ }
102+ sc.Href = "/?sort=" + c.key + "&dir=" + next
103+ p.Cols = append(p.Cols, sc)
104+ }
105+ for i, repo := range repos {
106+ p.Rows = append(p.Rows, homeRow(repo, now, i == 0))
107+ }
108+
109+ l := s.base()
110+ l.Title = s.instance
111+ l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}}
112+ dir := "▲"
113+ if desc {
114+ dir = "▼"
115+ }
116+ l.Status = Status{
117+ Mode: "home",
118+ Context: plural(len(repos), "repo", "repos") + " · sort " + key + " " + dir,
119+ Hints: "j/k move · enter open · / filter · ? help",
120+ Cursor: true, Count: len(repos),
121+ }
122+ l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)
123+ return Home(l, p), http.StatusOK, nil
124+}
125+
126+// homeRow is the row of a repository in the home list.
127+func homeRow(r *pb.Repo, now time.Time, cursor bool) HomeRow {
128+ row := HomeRow{
129+ ID: "repo-" + r.GetName(), Name: r.GetName(), Href: repoHref(r.GetName()),
130+ Desc: r.GetDescription(), Pushed: "never", Branch: r.GetDefaultBranch(), Cursor: cursor,
131+ }
132+ if r.GetPushedAt().IsValid() && r.GetPushedAt().GetSeconds() != 0 {
133+ t := r.GetPushedAt().AsTime()
134+ row.Pushed, row.PushedAt = ago(now, t), rfc3339(t)
135+ }
136+ return row
137+}
138+
139+// repoCmds are the repositories as targets of the ":" command line, but the
140+// one being viewed.
141+func repoCmds(repos []*pb.Repo, except string) []CmdItem {
142+ var out []CmdItem
143+ for _, r := range repos {
144+ if r.GetName() != except {
145+ out = append(out, CmdItem{Kind: "repo", Label: r.GetName(), Href: repoHref(r.GetName())})
146+ }
147+ }
148+ return out
149+}
A internal/adapters/web/keys.templ +55 -0
@@ -0,0 +1,55 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Keys is the keyboard map. ermite.js turns keys into k-<name> events on
6+// <body>; this element (hx-on + hx-live helpers) is the only place that
7+// reacts to them.
8+templ Keys() {
9+ <div
10+ id="keys"
11+ hidden
12+ hx-on="k-j from:body -> ((d) => {
13+ if (q('dialog[open]').count) return;
14+ let rows = q('[data-rows] > :not([hidden])').arr();
15+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
16+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
17+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
18+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
19+ })(1);
20+ k-k from:body -> ((d) => {
21+ if (q('dialog[open]').count) return;
22+ let rows = q('[data-rows] > :not([hidden])').arr();
23+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
24+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
25+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
26+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
27+ })(-1);
28+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
29+ 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));
30+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
31+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
32+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
33+ 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());
34+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
35+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
36+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
37+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
38+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
39+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
40+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
41+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
42+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
43+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
44+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
45+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
46+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
47+ k-permalink from:body -> {
48+ let b = document.body.dataset;
49+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
50+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
51+ navigator.clipboard?.writeText(location.href);
52+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
53+ }"
54+ ></div>
55+}
@@ -0,0 +1,55 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Keys is the keyboard map. ermite.js turns keys into k-<name> events on
6+// <body>; this element (hx-on + hx-live helpers) is the only place that
7+// reacts to them.
8+templ Keys() {
9+ <div
10+ id="keys"
11+ hidden
12+ hx-on="k-j from:body -> ((d) => {
13+ if (q('dialog[open]').count) return;
14+ let rows = q('[data-rows] > :not([hidden])').arr();
15+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
16+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
17+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
18+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
19+ })(1);
20+ k-k from:body -> ((d) => {
21+ if (q('dialog[open]').count) return;
22+ let rows = q('[data-rows] > :not([hidden])').arr();
23+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
24+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
25+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
26+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
27+ })(-1);
28+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
29+ 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));
30+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
31+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
32+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
33+ 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());
34+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
35+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
36+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
37+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
38+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
39+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
40+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
41+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
42+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
43+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
44+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
45+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
46+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
47+ k-permalink from:body -> {
48+ let b = document.body.dataset;
49+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
50+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
51+ navigator.clipboard?.writeText(location.href);
52+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
53+ }"
54+ ></div>
55+}
A internal/adapters/web/keys_templ.go +45 -0 generated file: collapsed
A internal/adapters/web/markdown.go +281 -0
@@ -0,0 +1,281 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "html/template"
8+ "net/url"
9+ "path"
10+ "strings"
11+
12+ "github.com/yuin/goldmark"
13+ "github.com/yuin/goldmark/ast"
14+ "github.com/yuin/goldmark/extension"
15+ "github.com/yuin/goldmark/parser"
16+ "github.com/yuin/goldmark/renderer"
17+ "github.com/yuin/goldmark/text"
18+ "github.com/yuin/goldmark/util"
19+)
20+
21+// headingIDPrefix keeps the ids of README headings apart from the ids of the
22+// page (main, keys, cmd-list…): a heading must not be able to take one.
23+const headingIDPrefix = "md-"
24+
25+// markdown renders the Markdown of a repository. Its output is safe to put in
26+// a page: raw HTML is dropped, and links and images follow the policy below.
27+//
28+// Link policy:
29+// - http and https links are kept, with rel="nofollow noopener noreferrer";
30+// - relative links and links to the root of the repository are rewritten to
31+// the blob route of the ref being viewed, never above the repository root;
32+// - #fragments point at the (prefixed) heading ids;
33+// - everything else (javascript:, data:, vbscript:, file:, mailto:,
34+// protocol-relative //host, empty targets…) loses its link: the text stays;
35+// - images are never loaded (the CSP forbids other origins, and the design
36+// shows an image as its alt text): each becomes <span class="alt">[alt]</span>.
37+type markdown struct {
38+ md goldmark.Markdown
39+}
40+
41+func newMarkdown() *markdown {
42+ return &markdown{md: goldmark.New(
43+ goldmark.WithExtensions(extension.GFM),
44+ goldmark.WithParserOptions(parser.WithAutoHeadingID()),
45+ goldmark.WithRendererOptions(
46+ renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100)),
47+ // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.
48+ // Table alignment as an align attribute: the CSP forbids style="".
49+ extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),
50+ ),
51+ )}
52+}
53+
54+// render turns source into HTML and the outline of its headings. repo and ref
55+// are where relative links point; dir is the directory of the Markdown file
56+// in the repository, "" for the root.
57+func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, []Heading, error) {
58+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{inner: parser.NewContext().IDs()}))
59+ doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
60+ pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source}
61+ pol.apply(doc)
62+
63+ var buf bytes.Buffer
64+ if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
65+ return "", nil, err
66+ }
67+ // #nosec G203 -- goldmark without WithUnsafe escapes text and drops raw
68+ // HTML; linkPolicy has removed every URL it does not allow.
69+ return template.HTML(buf.String()), outline(doc, source), nil
70+}
71+
72+// prefixedIDs prefixes the heading ids that goldmark generates.
73+type prefixedIDs struct{ inner parser.IDs }
74+
75+func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
76+ return append([]byte(headingIDPrefix), p.inner.Generate(value, kind)...)
77+}
78+
79+func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) }
80+
81+// outline lists the headings of a document in order.
82+func outline(doc ast.Node, source []byte) []Heading {
83+ var out []Heading
84+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
85+ h, ok := n.(*ast.Heading)
86+ if !ok || !entering {
87+ return ast.WalkContinue, nil
88+ }
89+ id, _ := h.AttributeString("id")
90+ idb, _ := id.([]byte)
91+ out = append(out, Heading{Level: h.Level, ID: string(idb), Text: plainText(h, source)})
92+ return ast.WalkSkipChildren, nil
93+ })
94+ return out
95+}
96+
97+// plainText is the text of a node without markup.
98+func plainText(n ast.Node, source []byte) string {
99+ var b strings.Builder
100+ _ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
101+ if !entering {
102+ return ast.WalkContinue, nil
103+ }
104+ switch t := c.(type) {
105+ case *ast.Text:
106+ b.Write(t.Value(source))
107+ if t.SoftLineBreak() || t.HardLineBreak() {
108+ b.WriteByte(' ')
109+ }
110+ case *ast.String:
111+ b.Write(t.Value)
112+ }
113+ return ast.WalkContinue, nil
114+ })
115+ return strings.TrimSpace(b.String())
116+}
117+
118+// linkPolicy applies the link and image policy to a parsed document.
119+type linkPolicy struct {
120+ repo, ref, dir string
121+ source []byte
122+}
123+
124+func (p linkPolicy) apply(doc ast.Node) {
125+ // Collect first, change after: the walk must not see the tree move.
126+ var links, images, autos []ast.Node
127+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
128+ if !entering {
129+ return ast.WalkContinue, nil
130+ }
131+ switch n.(type) {
132+ case *ast.Link:
133+ links = append(links, n)
134+ case *ast.Image:
135+ images = append(images, n)
136+ case *ast.AutoLink:
137+ autos = append(autos, n)
138+ }
139+ return ast.WalkContinue, nil
140+ })
141+ for _, n := range images {
142+ p.image(n.(*ast.Image))
143+ }
144+ for _, n := range links {
145+ l := n.(*ast.Link)
146+ dest, kind := p.target(string(l.Destination))
147+ if kind == dropped {
148+ unwrap(l)
149+ continue
150+ }
151+ l.Destination = []byte(dest)
152+ l.Title = nil
153+ if kind == external {
154+ l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
155+ }
156+ }
157+ for _, n := range autos {
158+ a := n.(*ast.AutoLink)
159+ if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
160+ a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
161+ continue
162+ }
163+ // An email address or another scheme: the label as plain text.
164+ a.Parent().ReplaceChild(a.Parent(), a, ast.NewString(append([]byte(nil), a.Label(p.source)...)))
165+ }
166+}
167+
168+// unwrap replaces a node by its children.
169+func unwrap(n ast.Node) {
170+ parent := n.Parent()
171+ for c := n.FirstChild(); c != nil; c = n.FirstChild() {
172+ parent.InsertBefore(parent, n, c)
173+ }
174+ parent.RemoveChild(parent, n)
175+}
176+
177+// image replaces an image by its alt text.
178+func (p linkPolicy) image(img *ast.Image) {
179+ alt := plainText(img, p.source)
180+ if alt == "" {
181+ alt = "image"
182+ }
183+ img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt})
184+}
185+
186+type linkKind int
187+
188+const (
189+ dropped linkKind = iota
190+ external
191+ internal
192+)
193+
194+// target applies the link policy to a destination.
195+func (p linkPolicy) target(dest string) (string, linkKind) {
196+ dest = strings.TrimSpace(dest)
197+ if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
198+ return "", dropped
199+ }
200+ if strings.HasPrefix(dest, "#") {
201+ frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
202+ if frag == "" || strings.ContainsAny(frag, " \"'<>") {
203+ return "", dropped
204+ }
205+ return "#" + headingIDPrefix + frag, internal
206+ }
207+ if strings.HasPrefix(dest, "//") {
208+ return "", dropped
209+ }
210+ u, err := url.Parse(dest)
211+ if err != nil {
212+ return "", dropped
213+ }
214+ switch strings.ToLower(u.Scheme) {
215+ case "http", "https":
216+ if u.Host == "" {
217+ return "", dropped
218+ }
219+ return u.String(), external
220+ case "":
221+ default:
222+ return "", dropped
223+ }
224+ if u.Host != "" || u.Opaque != "" || u.Path == "" {
225+ return "", dropped
226+ }
227+ // A path in the repository: from the directory of the file, or from the
228+ // root when it starts with a slash. path.Clean on a rooted path never
229+ // leaves the root, so ../../ cannot climb out of the repository.
230+ var rel string
231+ if strings.HasPrefix(u.Path, "/") {
232+ rel = path.Clean(u.Path)
233+ } else {
234+ rel = path.Clean("/" + path.Join(p.dir, u.Path))
235+ }
236+ rel = strings.TrimPrefix(rel, "/")
237+ href := repoHref(p.repo)
238+ if rel != "" {
239+ href = repoHref(p.repo, "blob", p.ref, rel)
240+ }
241+ if u.Fragment != "" {
242+ href += "#" + url.PathEscape(u.Fragment)
243+ }
244+ return href, internal
245+}
246+
247+func isWebURL(s string) bool {
248+ u, err := url.Parse(s)
249+ if err != nil || u.Host == "" {
250+ return false
251+ }
252+ sc := strings.ToLower(u.Scheme)
253+ return sc == "http" || sc == "https"
254+}
255+
256+// kindAlt is the kind of altSpan.
257+var kindAlt = ast.NewNodeKind("AltSpan")
258+
259+// altSpan stands for an image: its alt text, in a span the stylesheet styles.
260+type altSpan struct {
261+ ast.BaseInline
262+ alt string
263+}
264+
265+func (n *altSpan) Kind() ast.NodeKind { return kindAlt }
266+
267+func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
268+
269+// altRenderer renders altSpan nodes.
270+type altRenderer struct{}
271+
272+func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
273+ r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
274+ if entering {
275+ _, _ = w.WriteString(`<span class="alt">[`)
276+ _, _ = w.Write(util.EscapeHTML([]byte(n.(*altSpan).alt)))
277+ _, _ = w.WriteString(`]</span>`)
278+ }
279+ return ast.WalkContinue, nil
280+ })
281+}
@@ -0,0 +1,281 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "html/template"
8+ "net/url"
9+ "path"
10+ "strings"
11+
12+ "github.com/yuin/goldmark"
13+ "github.com/yuin/goldmark/ast"
14+ "github.com/yuin/goldmark/extension"
15+ "github.com/yuin/goldmark/parser"
16+ "github.com/yuin/goldmark/renderer"
17+ "github.com/yuin/goldmark/text"
18+ "github.com/yuin/goldmark/util"
19+)
20+
21+// headingIDPrefix keeps the ids of README headings apart from the ids of the
22+// page (main, keys, cmd-list…): a heading must not be able to take one.
23+const headingIDPrefix = "md-"
24+
25+// markdown renders the Markdown of a repository. Its output is safe to put in
26+// a page: raw HTML is dropped, and links and images follow the policy below.
27+//
28+// Link policy:
29+// - http and https links are kept, with rel="nofollow noopener noreferrer";
30+// - relative links and links to the root of the repository are rewritten to
31+// the blob route of the ref being viewed, never above the repository root;
32+// - #fragments point at the (prefixed) heading ids;
33+// - everything else (javascript:, data:, vbscript:, file:, mailto:,
34+// protocol-relative //host, empty targets…) loses its link: the text stays;
35+// - images are never loaded (the CSP forbids other origins, and the design
36+// shows an image as its alt text): each becomes <span class="alt">[alt]</span>.
37+type markdown struct {
38+ md goldmark.Markdown
39+}
40+
41+func newMarkdown() *markdown {
42+ return &markdown{md: goldmark.New(
43+ goldmark.WithExtensions(extension.GFM),
44+ goldmark.WithParserOptions(parser.WithAutoHeadingID()),
45+ goldmark.WithRendererOptions(
46+ renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100)),
47+ // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.
48+ // Table alignment as an align attribute: the CSP forbids style="".
49+ extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),
50+ ),
51+ )}
52+}
53+
54+// render turns source into HTML and the outline of its headings. repo and ref
55+// are where relative links point; dir is the directory of the Markdown file
56+// in the repository, "" for the root.
57+func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, []Heading, error) {
58+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{inner: parser.NewContext().IDs()}))
59+ doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
60+ pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source}
61+ pol.apply(doc)
62+
63+ var buf bytes.Buffer
64+ if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
65+ return "", nil, err
66+ }
67+ // #nosec G203 -- goldmark without WithUnsafe escapes text and drops raw
68+ // HTML; linkPolicy has removed every URL it does not allow.
69+ return template.HTML(buf.String()), outline(doc, source), nil
70+}
71+
72+// prefixedIDs prefixes the heading ids that goldmark generates.
73+type prefixedIDs struct{ inner parser.IDs }
74+
75+func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
76+ return append([]byte(headingIDPrefix), p.inner.Generate(value, kind)...)
77+}
78+
79+func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) }
80+
81+// outline lists the headings of a document in order.
82+func outline(doc ast.Node, source []byte) []Heading {
83+ var out []Heading
84+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
85+ h, ok := n.(*ast.Heading)
86+ if !ok || !entering {
87+ return ast.WalkContinue, nil
88+ }
89+ id, _ := h.AttributeString("id")
90+ idb, _ := id.([]byte)
91+ out = append(out, Heading{Level: h.Level, ID: string(idb), Text: plainText(h, source)})
92+ return ast.WalkSkipChildren, nil
93+ })
94+ return out
95+}
96+
97+// plainText is the text of a node without markup.
98+func plainText(n ast.Node, source []byte) string {
99+ var b strings.Builder
100+ _ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
101+ if !entering {
102+ return ast.WalkContinue, nil
103+ }
104+ switch t := c.(type) {
105+ case *ast.Text:
106+ b.Write(t.Value(source))
107+ if t.SoftLineBreak() || t.HardLineBreak() {
108+ b.WriteByte(' ')
109+ }
110+ case *ast.String:
111+ b.Write(t.Value)
112+ }
113+ return ast.WalkContinue, nil
114+ })
115+ return strings.TrimSpace(b.String())
116+}
117+
118+// linkPolicy applies the link and image policy to a parsed document.
119+type linkPolicy struct {
120+ repo, ref, dir string
121+ source []byte
122+}
123+
124+func (p linkPolicy) apply(doc ast.Node) {
125+ // Collect first, change after: the walk must not see the tree move.
126+ var links, images, autos []ast.Node
127+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
128+ if !entering {
129+ return ast.WalkContinue, nil
130+ }
131+ switch n.(type) {
132+ case *ast.Link:
133+ links = append(links, n)
134+ case *ast.Image:
135+ images = append(images, n)
136+ case *ast.AutoLink:
137+ autos = append(autos, n)
138+ }
139+ return ast.WalkContinue, nil
140+ })
141+ for _, n := range images {
142+ p.image(n.(*ast.Image))
143+ }
144+ for _, n := range links {
145+ l := n.(*ast.Link)
146+ dest, kind := p.target(string(l.Destination))
147+ if kind == dropped {
148+ unwrap(l)
149+ continue
150+ }
151+ l.Destination = []byte(dest)
152+ l.Title = nil
153+ if kind == external {
154+ l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
155+ }
156+ }
157+ for _, n := range autos {
158+ a := n.(*ast.AutoLink)
159+ if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
160+ a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
161+ continue
162+ }
163+ // An email address or another scheme: the label as plain text.
164+ a.Parent().ReplaceChild(a.Parent(), a, ast.NewString(append([]byte(nil), a.Label(p.source)...)))
165+ }
166+}
167+
168+// unwrap replaces a node by its children.
169+func unwrap(n ast.Node) {
170+ parent := n.Parent()
171+ for c := n.FirstChild(); c != nil; c = n.FirstChild() {
172+ parent.InsertBefore(parent, n, c)
173+ }
174+ parent.RemoveChild(parent, n)
175+}
176+
177+// image replaces an image by its alt text.
178+func (p linkPolicy) image(img *ast.Image) {
179+ alt := plainText(img, p.source)
180+ if alt == "" {
181+ alt = "image"
182+ }
183+ img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt})
184+}
185+
186+type linkKind int
187+
188+const (
189+ dropped linkKind = iota
190+ external
191+ internal
192+)
193+
194+// target applies the link policy to a destination.
195+func (p linkPolicy) target(dest string) (string, linkKind) {
196+ dest = strings.TrimSpace(dest)
197+ if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
198+ return "", dropped
199+ }
200+ if strings.HasPrefix(dest, "#") {
201+ frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
202+ if frag == "" || strings.ContainsAny(frag, " \"'<>") {
203+ return "", dropped
204+ }
205+ return "#" + headingIDPrefix + frag, internal
206+ }
207+ if strings.HasPrefix(dest, "//") {
208+ return "", dropped
209+ }
210+ u, err := url.Parse(dest)
211+ if err != nil {
212+ return "", dropped
213+ }
214+ switch strings.ToLower(u.Scheme) {
215+ case "http", "https":
216+ if u.Host == "" {
217+ return "", dropped
218+ }
219+ return u.String(), external
220+ case "":
221+ default:
222+ return "", dropped
223+ }
224+ if u.Host != "" || u.Opaque != "" || u.Path == "" {
225+ return "", dropped
226+ }
227+ // A path in the repository: from the directory of the file, or from the
228+ // root when it starts with a slash. path.Clean on a rooted path never
229+ // leaves the root, so ../../ cannot climb out of the repository.
230+ var rel string
231+ if strings.HasPrefix(u.Path, "/") {
232+ rel = path.Clean(u.Path)
233+ } else {
234+ rel = path.Clean("/" + path.Join(p.dir, u.Path))
235+ }
236+ rel = strings.TrimPrefix(rel, "/")
237+ href := repoHref(p.repo)
238+ if rel != "" {
239+ href = repoHref(p.repo, "blob", p.ref, rel)
240+ }
241+ if u.Fragment != "" {
242+ href += "#" + url.PathEscape(u.Fragment)
243+ }
244+ return href, internal
245+}
246+
247+func isWebURL(s string) bool {
248+ u, err := url.Parse(s)
249+ if err != nil || u.Host == "" {
250+ return false
251+ }
252+ sc := strings.ToLower(u.Scheme)
253+ return sc == "http" || sc == "https"
254+}
255+
256+// kindAlt is the kind of altSpan.
257+var kindAlt = ast.NewNodeKind("AltSpan")
258+
259+// altSpan stands for an image: its alt text, in a span the stylesheet styles.
260+type altSpan struct {
261+ ast.BaseInline
262+ alt string
263+}
264+
265+func (n *altSpan) Kind() ast.NodeKind { return kindAlt }
266+
267+func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
268+
269+// altRenderer renders altSpan nodes.
270+type altRenderer struct{}
271+
272+func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
273+ r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
274+ if entering {
275+ _, _ = w.WriteString(`<span class="alt">[`)
276+ _, _ = w.Write(util.EscapeHTML([]byte(n.(*altSpan).alt)))
277+ _, _ = w.WriteString(`]</span>`)
278+ }
279+ return ast.WalkContinue, nil
280+ })
281+}
A internal/adapters/web/markdown_test.go +132 -0
@@ -0,0 +1,132 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func renderMD(t *testing.T, src string) (string, []Heading) {
14+ t.Helper()
15+ h, outline, err := newMarkdown().render([]byte(src), "demo", "main", "docs")
16+ require.NoError(t, err)
17+ return string(h), outline
18+}
19+
20+// AC4: raw HTML, javascript: links and SVG are neutralised.
21+func TestMarkdownNeutralisation(t *testing.T) {
22+ tests := []struct {
23+ name, src string
24+ absent []string
25+ present []string
26+ }{
27+ {
28+ "raw html block", "<script>alert(1)</script>\n\n<div onclick=\"x()\">hi</div>\n",
29+ []string{"<script", "<div", "onclick", "alert(1)</"}, nil,
30+ },
31+ {
32+ "raw inline html", "a <img src=x onerror=alert(1)> b <b>c</b> <iframe src=//evil></iframe>\n",
33+ []string{"<img", "onerror", "<iframe", "<b>"}, []string{"<p>a "},
34+ },
35+ {
36+ "javascript link", "[x](javascript:alert(1)) [y](JaVaScRiPt:alert(1)) [z](java\tscript:alert(1))\n",
37+ []string{"javascript", "JaVaScRiPt", "href=\"java"}, []string{"<p>x y "},
38+ },
39+ {
40+ "data and other schemes", "[a](data:text/html,<script>1</script>) [b](vbscript:x) [c](file:///etc/passwd) [d](mailto:a@b.c) [e](//evil.example/x) [f](ftp://h/x)\n",
41+ []string{"href=\"data", "vbscript", "file:", "mailto", "evil.example", "ftp:"}, []string{"<p>a b c d e f</p>"},
42+ },
43+ {
44+ "autolinks", "<javascript:alert(1)> <https://ok.example/a> <me@example.org>\n",
45+ []string{"href=\"javascript", "mailto:"}, []string{`href="https://ok.example/a"`, "me@example.org"},
46+ },
47+ {
48+ "svg and images", "![logo](x.svg) ![remote](https://evil.example/p.png) ![](data:image/svg+xml;base64,PHN2Zz4=)\n\n<svg onload=alert(1)><circle/></svg>\n",
49+ []string{"<img", "<svg", "onload", "evil.example", "base64", "src="}, []string{`<span class="alt">[logo]</span>`, `<span class="alt">[remote]</span>`, `<span class="alt">[image]</span>`},
50+ },
51+ {
52+ "escaped text", "# <b>x</b> & \"q\"\n\nplain <u>tags</u> & \"quotes\"\n",
53+ []string{"<b>", "<u>"}, []string{"&amp;", "&quot;"},
54+ },
55+ {
56+ "title injection", "[x](https://a.example \"t\\\" onmouseover=\\\"alert(1)\")\n",
57+ []string{"onmouseover=\"", " title="}, []string{`href="https://a.example"`},
58+ },
59+ }
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ out, _ := renderMD(t, tt.src)
63+ for _, s := range tt.absent {
64+ assert.NotContains(t, out, s)
65+ }
66+ for _, s := range tt.present {
67+ assert.Contains(t, out, s)
68+ }
69+ // Whatever the input: no inline script, no event handler, no
70+ // style, no element that loads something.
71+ for _, banned := range []string{"<script", "<style", "<svg", "<img", "<iframe", "<object", "<embed", "<form", " on", "style="} {
72+ if banned == " on" {
73+ assert.NotRegexp(t, ` on[a-z]+=`, out)
74+ continue
75+ }
76+ assert.NotContains(t, strings.ToLower(out), banned)
77+ }
78+ })
79+ }
80+}
81+
82+func TestMarkdownLinks(t *testing.T) {
83+ tests := []struct{ name, src, href string }{
84+ {"relative from the file's directory", "[a](other.md)", `href="/demo/blob/main/docs/other.md"`},
85+ {"parent", "[a](../LICENSE)", `href="/demo/blob/main/LICENSE"`},
86+ {"cannot climb out of the repository", "[a](../../../../etc/passwd)", `href="/demo/blob/main/etc/passwd"`},
87+ {"root relative", "[a](/src/main.go)", `href="/demo/blob/main/src/main.go"`},
88+ {"fragment on a file", "[a](x.md#part)", `href="/demo/blob/main/docs/x.md#part"`},
89+ {"dot is the repository", "[a](../)", `href="/demo"`},
90+ {"escaped segments", "[a](a%20b/c%3Fd.md)", `href="/demo/blob/main/docs/a%20b/c%3Fd.md"`},
91+ {"https is external", "[a](https://example.org/p?q=1)", `href="https://example.org/p?q=1" rel="nofollow noopener noreferrer"`},
92+ {"http too", "[a](http://example.org/)", `rel="nofollow noopener noreferrer"`},
93+ {"heading anchor", "[a](#Install Now)", ``},
94+ {"heading anchor ok", "[a](#install)", `href="#md-install"`},
95+ }
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ out, _ := renderMD(t, tt.src)
99+ if tt.href == "" {
100+ assert.NotContains(t, out, "<a ")
101+ return
102+ }
103+ assert.Contains(t, out, tt.href)
104+ })
105+ }
106+}
107+
108+func TestMarkdownOutline(t *testing.T) {
109+ out, outline := renderMD(t, "# Title\n\n## Install `now`\n\n### Install `now`\n\ntext\n\n#### raw *em*\n")
110+ assert.Equal(t, []Heading{
111+ {Level: 1, ID: "md-title", Text: "Title"},
112+ {Level: 2, ID: "md-install-now", Text: "Install now"},
113+ {Level: 3, ID: "md-install-now-1", Text: "Install now"},
114+ {Level: 4, ID: "md-raw-em", Text: "raw em"},
115+ }, outline, "ids are prefixed and unique, text has no markup")
116+ for _, h := range outline {
117+ assert.Contains(t, out, `id="`+h.ID+`"`)
118+ }
119+ // A heading cannot take the id of a page element.
120+ out, outline = renderMD(t, "# main\n\n# keys\n")
121+ assert.Equal(t, "md-main", outline[0].ID)
122+ assert.NotContains(t, out, `id="main"`)
123+ assert.NotContains(t, out, `id="keys"`)
124+}
125+
126+func TestMarkdownGFM(t *testing.T) {
127+ out, _ := renderMD(t, "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n~~gone~~\n\n- [x] task\n")
128+ assert.Contains(t, out, `<th align="right">b</th>`, "alignment is an attribute, never a style")
129+ assert.NotContains(t, out, "style=")
130+ assert.Contains(t, out, "<del>gone</del>")
131+ assert.Contains(t, out, `type="checkbox"`)
132+}
@@ -0,0 +1,132 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func renderMD(t *testing.T, src string) (string, []Heading) {
14+ t.Helper()
15+ h, outline, err := newMarkdown().render([]byte(src), "demo", "main", "docs")
16+ require.NoError(t, err)
17+ return string(h), outline
18+}
19+
20+// AC4: raw HTML, javascript: links and SVG are neutralised.
21+func TestMarkdownNeutralisation(t *testing.T) {
22+ tests := []struct {
23+ name, src string
24+ absent []string
25+ present []string
26+ }{
27+ {
28+ "raw html block", "<script>alert(1)</script>\n\n<div onclick=\"x()\">hi</div>\n",
29+ []string{"<script", "<div", "onclick", "alert(1)</"}, nil,
30+ },
31+ {
32+ "raw inline html", "a <img src=x onerror=alert(1)> b <b>c</b> <iframe src=//evil></iframe>\n",
33+ []string{"<img", "onerror", "<iframe", "<b>"}, []string{"<p>a "},
34+ },
35+ {
36+ "javascript link", "[x](javascript:alert(1)) [y](JaVaScRiPt:alert(1)) [z](java\tscript:alert(1))\n",
37+ []string{"javascript", "JaVaScRiPt", "href=\"java"}, []string{"<p>x y "},
38+ },
39+ {
40+ "data and other schemes", "[a](data:text/html,<script>1</script>) [b](vbscript:x) [c](file:///etc/passwd) [d](mailto:a@b.c) [e](//evil.example/x) [f](ftp://h/x)\n",
41+ []string{"href=\"data", "vbscript", "file:", "mailto", "evil.example", "ftp:"}, []string{"<p>a b c d e f</p>"},
42+ },
43+ {
44+ "autolinks", "<javascript:alert(1)> <https://ok.example/a> <me@example.org>\n",
45+ []string{"href=\"javascript", "mailto:"}, []string{`href="https://ok.example/a"`, "me@example.org"},
46+ },
47+ {
48+ "svg and images", "![logo](x.svg) ![remote](https://evil.example/p.png) ![](data:image/svg+xml;base64,PHN2Zz4=)\n\n<svg onload=alert(1)><circle/></svg>\n",
49+ []string{"<img", "<svg", "onload", "evil.example", "base64", "src="}, []string{`<span class="alt">[logo]</span>`, `<span class="alt">[remote]</span>`, `<span class="alt">[image]</span>`},
50+ },
51+ {
52+ "escaped text", "# <b>x</b> & \"q\"\n\nplain <u>tags</u> & \"quotes\"\n",
53+ []string{"<b>", "<u>"}, []string{"&amp;", "&quot;"},
54+ },
55+ {
56+ "title injection", "[x](https://a.example \"t\\\" onmouseover=\\\"alert(1)\")\n",
57+ []string{"onmouseover=\"", " title="}, []string{`href="https://a.example"`},
58+ },
59+ }
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ out, _ := renderMD(t, tt.src)
63+ for _, s := range tt.absent {
64+ assert.NotContains(t, out, s)
65+ }
66+ for _, s := range tt.present {
67+ assert.Contains(t, out, s)
68+ }
69+ // Whatever the input: no inline script, no event handler, no
70+ // style, no element that loads something.
71+ for _, banned := range []string{"<script", "<style", "<svg", "<img", "<iframe", "<object", "<embed", "<form", " on", "style="} {
72+ if banned == " on" {
73+ assert.NotRegexp(t, ` on[a-z]+=`, out)
74+ continue
75+ }
76+ assert.NotContains(t, strings.ToLower(out), banned)
77+ }
78+ })
79+ }
80+}
81+
82+func TestMarkdownLinks(t *testing.T) {
83+ tests := []struct{ name, src, href string }{
84+ {"relative from the file's directory", "[a](other.md)", `href="/demo/blob/main/docs/other.md"`},
85+ {"parent", "[a](../LICENSE)", `href="/demo/blob/main/LICENSE"`},
86+ {"cannot climb out of the repository", "[a](../../../../etc/passwd)", `href="/demo/blob/main/etc/passwd"`},
87+ {"root relative", "[a](/src/main.go)", `href="/demo/blob/main/src/main.go"`},
88+ {"fragment on a file", "[a](x.md#part)", `href="/demo/blob/main/docs/x.md#part"`},
89+ {"dot is the repository", "[a](../)", `href="/demo"`},
90+ {"escaped segments", "[a](a%20b/c%3Fd.md)", `href="/demo/blob/main/docs/a%20b/c%3Fd.md"`},
91+ {"https is external", "[a](https://example.org/p?q=1)", `href="https://example.org/p?q=1" rel="nofollow noopener noreferrer"`},
92+ {"http too", "[a](http://example.org/)", `rel="nofollow noopener noreferrer"`},
93+ {"heading anchor", "[a](#Install Now)", ``},
94+ {"heading anchor ok", "[a](#install)", `href="#md-install"`},
95+ }
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ out, _ := renderMD(t, tt.src)
99+ if tt.href == "" {
100+ assert.NotContains(t, out, "<a ")
101+ return
102+ }
103+ assert.Contains(t, out, tt.href)
104+ })
105+ }
106+}
107+
108+func TestMarkdownOutline(t *testing.T) {
109+ out, outline := renderMD(t, "# Title\n\n## Install `now`\n\n### Install `now`\n\ntext\n\n#### raw *em*\n")
110+ assert.Equal(t, []Heading{
111+ {Level: 1, ID: "md-title", Text: "Title"},
112+ {Level: 2, ID: "md-install-now", Text: "Install now"},
113+ {Level: 3, ID: "md-install-now-1", Text: "Install now"},
114+ {Level: 4, ID: "md-raw-em", Text: "raw em"},
115+ }, outline, "ids are prefixed and unique, text has no markup")
116+ for _, h := range outline {
117+ assert.Contains(t, out, `id="`+h.ID+`"`)
118+ }
119+ // A heading cannot take the id of a page element.
120+ out, outline = renderMD(t, "# main\n\n# keys\n")
121+ assert.Equal(t, "md-main", outline[0].ID)
122+ assert.NotContains(t, out, `id="main"`)
123+ assert.NotContains(t, out, `id="keys"`)
124+}
125+
126+func TestMarkdownGFM(t *testing.T) {
127+ out, _ := renderMD(t, "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n~~gone~~\n\n- [x] task\n")
128+ assert.Contains(t, out, `<th align="right">b</th>`, "alignment is an attribute, never a style")
129+ assert.NotContains(t, out, "style=")
130+ assert.Contains(t, out, "<del>gone</del>")
131+ assert.Contains(t, out, `type="checkbox"`)
132+}
A internal/adapters/web/overlays.templ +115 -0
@@ -0,0 +1,115 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import "strings"
6+
7+// OverlayHelp is the keyboard map panel.
8+templ OverlayHelp() {
9+ <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
10+ <div class="panel-head"><span>keys</span><button type="button" hx-on:click="closest('dialog').close()">[esc] close</button></div>
11+ <dl class="keys">
12+ <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move cursor down / up</dd></div>
13+ <div><dt><kbd>enter</kbd></dt><dd>open row under cursor</dd></div>
14+ <div><dt><kbd>g</kbd> <kbd>g</kbd> / <kbd>G</kbd></dt><dd>first / last row</dd></div>
15+ <div><dt><kbd>/</kbd></dt><dd>focus the filter</dd></div>
16+ <div><dt><kbd>g h</kbd> <kbd>g t</kbd></dt><dd>go home / tree</dd></div>
17+ <div><dt><kbd>g c</kbd> <kbd>g r</kbd></dt><dd>go commits / refs</dd></div>
18+ <div><dt><kbd>b</kbd></dt><dd>{ "switch" } ref (branches, tags)</dd></div>
19+ <div><dt><kbd>:</kbd> <kbd>f</kbd></dt><dd>go to: section, ref, file, repo</dd></div>
20+ <div><dt><kbd>y</kbd></dt><dd>permalink: ref becomes the full sha</dd></div>
21+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd></dt><dd>repo sections (when shown)</dd></div>
22+ <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>commit: split / unified / collapse all</dd></div>
23+ <div><dt><kbd>esc</kbd></dt><dd>close panel</dd></div>
24+ </dl>
25+ </dialog>
26+}
27+
28+// OverlayRefs is the ref switcher. It exists only inside a repository.
29+templ OverlayRefs(l Layout) {
30+ if l.Ref != "" {
31+ <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
32+ <div class="panel-head">
33+ <label for="refs-filter">ref&gt;</label>
34+ <input
35+ id="refs-filter"
36+ type="search"
37+ placeholder="filter branches and tags"
38+ autocomplete="off"
39+ data-pick
40+ hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"
41+ />
42+ <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
43+ </div>
44+ <div class="panel-cols" data-picklist>
45+ <section aria-label="Branches">
46+ <h2>branches <span class="mute">{ itoa(len(l.Heads)) }</span></h2>
47+ <ul>
48+ for _, r := range l.Heads {
49+ @refItem(r)
50+ }
51+ </ul>
52+ </section>
53+ <section aria-label="Tags">
54+ <h2>tags <span class="mute">{ itoa(len(l.Tags)) }</span></h2>
55+ <ul>
56+ for _, r := range l.Tags {
57+ @refItem(r)
58+ }
59+ </ul>
60+ </section>
61+ </div>
62+ </dialog>
63+ }
64+}
65+
66+templ refItem(r RefItem) {
67+ <li>
68+ <a
69+ href={ templ.SafeURL(r.Href) }
70+ if r.Current {
71+ aria-current="true"
72+ }
73+ >
74+ <span class="mark" aria-hidden="true">
75+ if r.Current {
76+ ●
77+ } else {
78+ &nbsp;
79+ }
80+ </span>{ r.Name }
81+ </a>
82+ </li>
83+}
84+
85+// OverlayCmd is the ":" command line: one filterable list mixing sections,
86+// refs, files of this directory and repos.
87+templ OverlayCmd(items []CmdItem) {
88+ <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
89+ <div class="panel-head">
90+ <label for="cmd-q" class="hi">:</label>
91+ <input
92+ id="cmd-q"
93+ type="search"
94+ placeholder="section, ref, file or repo"
95+ autocomplete="off"
96+ spellcheck="false"
97+ aria-controls="cmd-list"
98+ hx-on:input="let t = this.value.toLowerCase().trim();
99+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
100+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')"
101+ hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
102+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
103+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
104+ 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' })) }
105+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"
106+ />
107+ <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
108+ </div>
109+ <ul id="cmd-list" class="cmd-list">
110+ for i, c := range items {
111+ <li data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a href={ templ.SafeURL(c.Href) } tabindex="-1"><span class="k">{ c.Kind }</span>{ c.Label }</a></li>
112+ }
113+ </ul>
114+ </dialog>
115+}
@@ -0,0 +1,115 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import "strings"
6+
7+// OverlayHelp is the keyboard map panel.
8+templ OverlayHelp() {
9+ <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
10+ <div class="panel-head"><span>keys</span><button type="button" hx-on:click="closest('dialog').close()">[esc] close</button></div>
11+ <dl class="keys">
12+ <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move cursor down / up</dd></div>
13+ <div><dt><kbd>enter</kbd></dt><dd>open row under cursor</dd></div>
14+ <div><dt><kbd>g</kbd> <kbd>g</kbd> / <kbd>G</kbd></dt><dd>first / last row</dd></div>
15+ <div><dt><kbd>/</kbd></dt><dd>focus the filter</dd></div>
16+ <div><dt><kbd>g h</kbd> <kbd>g t</kbd></dt><dd>go home / tree</dd></div>
17+ <div><dt><kbd>g c</kbd> <kbd>g r</kbd></dt><dd>go commits / refs</dd></div>
18+ <div><dt><kbd>b</kbd></dt><dd>{ "switch" } ref (branches, tags)</dd></div>
19+ <div><dt><kbd>:</kbd> <kbd>f</kbd></dt><dd>go to: section, ref, file, repo</dd></div>
20+ <div><dt><kbd>y</kbd></dt><dd>permalink: ref becomes the full sha</dd></div>
21+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd></dt><dd>repo sections (when shown)</dd></div>
22+ <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>commit: split / unified / collapse all</dd></div>
23+ <div><dt><kbd>esc</kbd></dt><dd>close panel</dd></div>
24+ </dl>
25+ </dialog>
26+}
27+
28+// OverlayRefs is the ref switcher. It exists only inside a repository.
29+templ OverlayRefs(l Layout) {
30+ if l.Ref != "" {
31+ <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
32+ <div class="panel-head">
33+ <label for="refs-filter">ref&gt;</label>
34+ <input
35+ id="refs-filter"
36+ type="search"
37+ placeholder="filter branches and tags"
38+ autocomplete="off"
39+ data-pick
40+ hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"
41+ />
42+ <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
43+ </div>
44+ <div class="panel-cols" data-picklist>
45+ <section aria-label="Branches">
46+ <h2>branches <span class="mute">{ itoa(len(l.Heads)) }</span></h2>
47+ <ul>
48+ for _, r := range l.Heads {
49+ @refItem(r)
50+ }
51+ </ul>
52+ </section>
53+ <section aria-label="Tags">
54+ <h2>tags <span class="mute">{ itoa(len(l.Tags)) }</span></h2>
55+ <ul>
56+ for _, r := range l.Tags {
57+ @refItem(r)
58+ }
59+ </ul>
60+ </section>
61+ </div>
62+ </dialog>
63+ }
64+}
65+
66+templ refItem(r RefItem) {
67+ <li>
68+ <a
69+ href={ templ.SafeURL(r.Href) }
70+ if r.Current {
71+ aria-current="true"
72+ }
73+ >
74+ <span class="mark" aria-hidden="true">
75+ if r.Current {
76+ ●
77+ } else {
78+ &nbsp;
79+ }
80+ </span>{ r.Name }
81+ </a>
82+ </li>
83+}
84+
85+// OverlayCmd is the ":" command line: one filterable list mixing sections,
86+// refs, files of this directory and repos.
87+templ OverlayCmd(items []CmdItem) {
88+ <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
89+ <div class="panel-head">
90+ <label for="cmd-q" class="hi">:</label>
91+ <input
92+ id="cmd-q"
93+ type="search"
94+ placeholder="section, ref, file or repo"
95+ autocomplete="off"
96+ spellcheck="false"
97+ aria-controls="cmd-list"
98+ hx-on:input="let t = this.value.toLowerCase().trim();
99+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
100+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')"
101+ hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
102+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
103+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
104+ 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' })) }
105+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"
106+ />
107+ <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
108+ </div>
109+ <ul id="cmd-list" class="cmd-list">
110+ for i, c := range items {
111+ <li data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a href={ templ.SafeURL(c.Href) } tabindex="-1"><span class="k">{ c.Kind }</span>{ c.Label }</a></li>
112+ }
113+ </ul>
114+ </dialog>
115+}
A internal/adapters/web/overlays_templ.go +319 -0 generated file: collapsed
A internal/adapters/web/pages.templ +176 -0
@@ -0,0 +1,176 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Home is the repository list.
6+templ Home(l Layout, p HomePage) {
7+ @Shell(l, nil) {
8+ <div class="filter">
9+ <label for="home-filter">filter&gt;</label>
10+ <input
11+ id="home-filter"
12+ type="search"
13+ data-filter
14+ placeholder="name or description ( / )"
15+ autocomplete="off"
16+ 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])')"
17+ />
18+ </div>
19+ <table class="list home" aria-label="Repositories">
20+ <thead>
21+ <tr>
22+ <th class="cur"></th>
23+ for _, c := range p.Cols {
24+ <th class={ c.Class } aria-sort={ c.Dir }>
25+ <a href={ templ.SafeURL(c.Href) }>
26+ { c.Label }
27+ if c.Mark != "" {
28+ <span aria-hidden="true">{ c.Mark }</span>
29+ }
30+ </a>
31+ </th>
32+ }
33+ </tr>
34+ </thead>
35+ <tbody id="repo-rows" data-rows>
36+ for _, r := range p.Rows {
37+ @HomeRowView(r)
38+ }
39+ </tbody>
40+ </table>
41+ if len(p.Rows) == 0 {
42+ <p class="lede dim">no repositories yet.</p>
43+ }
44+ }
45+}
46+
47+// Summary is the summary of a repository: the README reads in the main
48+// column at the reading measure; what one acts on sits in the rail.
49+templ Summary(l Layout, p SummaryPage) {
50+ @Shell(l, summaryRail(p)) {
51+ if p.Desc != "" {
52+ <p class="lede">{ p.Desc }</p>
53+ }
54+ if p.Readme != nil {
55+ <h2 class="rule-h"><span>{ p.Readme.Name }</span></h2>
56+ @ReadmeView(*p.Readme)
57+ }
58+ }
59+}
60+
61+templ summaryRail(p SummaryPage) {
62+ <section aria-labelledby="r-clone"><h2 id="r-clone">clone</h2>@CloneBlock(p.Clone)
63+</section>
64+ <section aria-labelledby="r-facts"><h2 id="r-facts">facts</h2>@Facts(p.Facts)
65+</section>
66+ if len(p.Recent) > 0 {
67+ <section aria-labelledby="r-recent"><h2 id="r-recent">recent commits <a href={ templ.SafeURL(p.AllHref) }>all</a></h2>@CommitRows(p.Recent)
68+</section>
69+ }
70+ if len(p.Outline) > 0 && p.Readme != nil {
71+ <section aria-labelledby="r-outline"><h2 id="r-outline">{ p.Readme.Name }</h2>@Outline(p.Outline)
72+</section>
73+ }
74+}
75+
76+// Refs lists the branches and tags of a repository.
77+templ Refs(l Layout, p RefsPage) {
78+ @Shell(l, refsRail(p)) {
79+ <div class="filter">
80+ <label for="ref-filter">filter&gt;</label>
81+ <input
82+ id="ref-filter"
83+ type="search"
84+ data-filter
85+ placeholder="branch or tag ( / )"
86+ autocomplete="off"
87+ hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
88+ let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')"
89+ />
90+ </div>
91+ <table class="list refs" aria-label="Branches and tags">
92+ <thead>
93+ <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr>
94+ </thead>
95+ <tbody id="ref-rows" data-rows>
96+ for _, b := range p.Branches {
97+ @refRow("branch", b)
98+ }
99+ for _, t := range p.Tags {
100+ @refRow("tag", t)
101+ }
102+ </tbody>
103+ </table>
104+ if p.Note != "" {
105+ <p class="note">{ p.Note }</p>
106+ }
107+ }
108+}
109+
110+templ refRow(kind string, r RefRow) {
111+ <tr data-cursor?={ r.Cursor }>
112+ <td class="cur" aria-hidden="true"></td>
113+ <td class="c-kind mute">{ kind }</td>
114+ <td class="c-name">
115+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
116+ if r.Default {
117+ <span class="tagged">*</span>
118+ }
119+ </td>
120+ <td class="c-sha">
121+ if r.CHref != "" {
122+ <a href={ templ.SafeURL(r.CHref) }>{ r.Short }</a>
123+ } else {
124+ { r.Short }
125+ }
126+ </td>
127+ <td class="c-sub">{ r.Subject }</td>
128+ <td class="c-when dim">
129+ if r.WhenAt != "" {
130+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
131+ }
132+ </td>
133+ </tr>
134+}
135+
136+templ refsRail(p RefsPage) {
137+ <section aria-labelledby="r-refs">
138+ <h2 id="r-refs">refs</h2>
139+ <dl class="kv">
140+ <dt>default</dt>
141+ <dd>{ p.Default }</dd>
142+ <dt>branches</dt>
143+ <dd>{ itoa(len(p.Branches)) }</dd>
144+ <dt>tags</dt>
145+ <dd>{ itoa(len(p.Tags)) }</dd>
146+ if p.Latest != "" {
147+ <dt>latest tag</dt>
148+ <dd>{ p.Latest }</dd>
149+ }
150+ </dl>
151+ </section>
152+ <section aria-labelledby="r-legend">
153+ <h2 id="r-legend">legend</h2>
154+ <dl class="kv"><dt>*</dt><dd>default branch</dd></dl>
155+ </section>
156+}
157+
158+// State is a 404, 500 or empty-repository page inside the normal shell.
159+templ State(l Layout, p StatePage) {
160+ @Shell(l, nil) {
161+ <section class="state" aria-labelledby="state-title">
162+ <h1 id="state-title"><span class="code">{ p.Code }</span> { p.Title }</h1>
163+ for _, line := range p.Lines {
164+ <p class="dim">{ line }</p>
165+ }
166+ if p.Clone != nil {
167+ @CloneBlock(*p.Clone)
168+ }
169+ <p class="state-back">
170+ for _, b := range p.Back {
171+ <a href={ templ.SafeURL(b.Href) }>{ b.Label }</a>
172+ }
173+ </p>
174+ </section>
175+ }
176+}
@@ -0,0 +1,176 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Home is the repository list.
6+templ Home(l Layout, p HomePage) {
7+ @Shell(l, nil) {
8+ <div class="filter">
9+ <label for="home-filter">filter&gt;</label>
10+ <input
11+ id="home-filter"
12+ type="search"
13+ data-filter
14+ placeholder="name or description ( / )"
15+ autocomplete="off"
16+ 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])')"
17+ />
18+ </div>
19+ <table class="list home" aria-label="Repositories">
20+ <thead>
21+ <tr>
22+ <th class="cur"></th>
23+ for _, c := range p.Cols {
24+ <th class={ c.Class } aria-sort={ c.Dir }>
25+ <a href={ templ.SafeURL(c.Href) }>
26+ { c.Label }
27+ if c.Mark != "" {
28+ <span aria-hidden="true">{ c.Mark }</span>
29+ }
30+ </a>
31+ </th>
32+ }
33+ </tr>
34+ </thead>
35+ <tbody id="repo-rows" data-rows>
36+ for _, r := range p.Rows {
37+ @HomeRowView(r)
38+ }
39+ </tbody>
40+ </table>
41+ if len(p.Rows) == 0 {
42+ <p class="lede dim">no repositories yet.</p>
43+ }
44+ }
45+}
46+
47+// Summary is the summary of a repository: the README reads in the main
48+// column at the reading measure; what one acts on sits in the rail.
49+templ Summary(l Layout, p SummaryPage) {
50+ @Shell(l, summaryRail(p)) {
51+ if p.Desc != "" {
52+ <p class="lede">{ p.Desc }</p>
53+ }
54+ if p.Readme != nil {
55+ <h2 class="rule-h"><span>{ p.Readme.Name }</span></h2>
56+ @ReadmeView(*p.Readme)
57+ }
58+ }
59+}
60+
61+templ summaryRail(p SummaryPage) {
62+ <section aria-labelledby="r-clone"><h2 id="r-clone">clone</h2>@CloneBlock(p.Clone)
63+</section>
64+ <section aria-labelledby="r-facts"><h2 id="r-facts">facts</h2>@Facts(p.Facts)
65+</section>
66+ if len(p.Recent) > 0 {
67+ <section aria-labelledby="r-recent"><h2 id="r-recent">recent commits <a href={ templ.SafeURL(p.AllHref) }>all</a></h2>@CommitRows(p.Recent)
68+</section>
69+ }
70+ if len(p.Outline) > 0 && p.Readme != nil {
71+ <section aria-labelledby="r-outline"><h2 id="r-outline">{ p.Readme.Name }</h2>@Outline(p.Outline)
72+</section>
73+ }
74+}
75+
76+// Refs lists the branches and tags of a repository.
77+templ Refs(l Layout, p RefsPage) {
78+ @Shell(l, refsRail(p)) {
79+ <div class="filter">
80+ <label for="ref-filter">filter&gt;</label>
81+ <input
82+ id="ref-filter"
83+ type="search"
84+ data-filter
85+ placeholder="branch or tag ( / )"
86+ autocomplete="off"
87+ hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
88+ let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')"
89+ />
90+ </div>
91+ <table class="list refs" aria-label="Branches and tags">
92+ <thead>
93+ <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr>
94+ </thead>
95+ <tbody id="ref-rows" data-rows>
96+ for _, b := range p.Branches {
97+ @refRow("branch", b)
98+ }
99+ for _, t := range p.Tags {
100+ @refRow("tag", t)
101+ }
102+ </tbody>
103+ </table>
104+ if p.Note != "" {
105+ <p class="note">{ p.Note }</p>
106+ }
107+ }
108+}
109+
110+templ refRow(kind string, r RefRow) {
111+ <tr data-cursor?={ r.Cursor }>
112+ <td class="cur" aria-hidden="true"></td>
113+ <td class="c-kind mute">{ kind }</td>
114+ <td class="c-name">
115+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
116+ if r.Default {
117+ <span class="tagged">*</span>
118+ }
119+ </td>
120+ <td class="c-sha">
121+ if r.CHref != "" {
122+ <a href={ templ.SafeURL(r.CHref) }>{ r.Short }</a>
123+ } else {
124+ { r.Short }
125+ }
126+ </td>
127+ <td class="c-sub">{ r.Subject }</td>
128+ <td class="c-when dim">
129+ if r.WhenAt != "" {
130+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
131+ }
132+ </td>
133+ </tr>
134+}
135+
136+templ refsRail(p RefsPage) {
137+ <section aria-labelledby="r-refs">
138+ <h2 id="r-refs">refs</h2>
139+ <dl class="kv">
140+ <dt>default</dt>
141+ <dd>{ p.Default }</dd>
142+ <dt>branches</dt>
143+ <dd>{ itoa(len(p.Branches)) }</dd>
144+ <dt>tags</dt>
145+ <dd>{ itoa(len(p.Tags)) }</dd>
146+ if p.Latest != "" {
147+ <dt>latest tag</dt>
148+ <dd>{ p.Latest }</dd>
149+ }
150+ </dl>
151+ </section>
152+ <section aria-labelledby="r-legend">
153+ <h2 id="r-legend">legend</h2>
154+ <dl class="kv"><dt>*</dt><dd>default branch</dd></dl>
155+ </section>
156+}
157+
158+// State is a 404, 500 or empty-repository page inside the normal shell.
159+templ State(l Layout, p StatePage) {
160+ @Shell(l, nil) {
161+ <section class="state" aria-labelledby="state-title">
162+ <h1 id="state-title"><span class="code">{ p.Code }</span> { p.Title }</h1>
163+ for _, line := range p.Lines {
164+ <p class="dim">{ line }</p>
165+ }
166+ if p.Clone != nil {
167+ @CloneBlock(*p.Clone)
168+ }
169+ <p class="state-back">
170+ for _, b := range p.Back {
171+ <a href={ templ.SafeURL(b.Href) }>{ b.Label }</a>
172+ }
173+ </p>
174+ </section>
175+ }
176+}
A internal/adapters/web/pages_templ.go +866 -0 generated file: collapsed
A internal/adapters/web/refs.go +66 -0
@@ -0,0 +1,66 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/url"
7+ "strings"
8+
9+ pb "bnema.dev/ermite-api/gen/ermite"
10+)
11+
12+// resolveRef finds the ref a URL path starts with and the path that follows.
13+// Ref names may contain "/" ("feature/x"), so the longest prefix of the path
14+// that is the short name of a branch or tag wins. A branch wins over a tag of
15+// the same name. rest has no leading slash and is "" when the path is the ref
16+// alone. ok is false when no ref matches; the caller may then try a commit id.
17+func resolveRef(refs []*pb.Ref, p string) (ref *pb.Ref, rest string, ok bool) {
18+ names := refsByName(refs)
19+ p = strings.Trim(p, "/")
20+ for end := len(p); end > 0; end = strings.LastIndex(p[:end], "/") {
21+ if r, found := names[p[:end]]; found {
22+ return r, strings.TrimPrefix(p[end:], "/"), true
23+ }
24+ }
25+ return nil, "", false
26+}
27+
28+// refsByName indexes the branches and tags by short name. A branch wins over
29+// a tag of the same name.
30+func refsByName(refs []*pb.Ref) map[string]*pb.Ref {
31+ names := make(map[string]*pb.Ref, len(refs))
32+ for _, r := range refs {
33+ k := r.GetKind()
34+ if k != pb.RefKind_REF_KIND_BRANCH && k != pb.RefKind_REF_KIND_TAG {
35+ continue
36+ }
37+ cur, dup := names[r.GetShortName()]
38+ if !dup || cur.GetKind() != pb.RefKind_REF_KIND_BRANCH {
39+ names[r.GetShortName()] = r
40+ }
41+ }
42+ return names
43+}
44+
45+// escapePath escapes every segment of a slash-separated path for a URL path,
46+// keeping the slashes.
47+func escapePath(p string) string {
48+ segs := strings.Split(p, "/")
49+ for i, s := range segs {
50+ segs[i] = url.PathEscape(s)
51+ }
52+ return strings.Join(segs, "/")
53+}
54+
55+// repoHref is the URL of a page of a repository: /<repo>/<kind>/<ref>/<path>.
56+// kind, ref and path may be empty.
57+func repoHref(repo string, parts ...string) string {
58+ var b strings.Builder
59+ b.WriteString("/" + url.PathEscape(repo))
60+ for _, p := range parts {
61+ if p != "" {
62+ b.WriteString("/" + escapePath(strings.Trim(p, "/")))
63+ }
64+ }
65+ return b.String()
66+}
@@ -0,0 +1,66 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/url"
7+ "strings"
8+
9+ pb "bnema.dev/ermite-api/gen/ermite"
10+)
11+
12+// resolveRef finds the ref a URL path starts with and the path that follows.
13+// Ref names may contain "/" ("feature/x"), so the longest prefix of the path
14+// that is the short name of a branch or tag wins. A branch wins over a tag of
15+// the same name. rest has no leading slash and is "" when the path is the ref
16+// alone. ok is false when no ref matches; the caller may then try a commit id.
17+func resolveRef(refs []*pb.Ref, p string) (ref *pb.Ref, rest string, ok bool) {
18+ names := refsByName(refs)
19+ p = strings.Trim(p, "/")
20+ for end := len(p); end > 0; end = strings.LastIndex(p[:end], "/") {
21+ if r, found := names[p[:end]]; found {
22+ return r, strings.TrimPrefix(p[end:], "/"), true
23+ }
24+ }
25+ return nil, "", false
26+}
27+
28+// refsByName indexes the branches and tags by short name. A branch wins over
29+// a tag of the same name.
30+func refsByName(refs []*pb.Ref) map[string]*pb.Ref {
31+ names := make(map[string]*pb.Ref, len(refs))
32+ for _, r := range refs {
33+ k := r.GetKind()
34+ if k != pb.RefKind_REF_KIND_BRANCH && k != pb.RefKind_REF_KIND_TAG {
35+ continue
36+ }
37+ cur, dup := names[r.GetShortName()]
38+ if !dup || cur.GetKind() != pb.RefKind_REF_KIND_BRANCH {
39+ names[r.GetShortName()] = r
40+ }
41+ }
42+ return names
43+}
44+
45+// escapePath escapes every segment of a slash-separated path for a URL path,
46+// keeping the slashes.
47+func escapePath(p string) string {
48+ segs := strings.Split(p, "/")
49+ for i, s := range segs {
50+ segs[i] = url.PathEscape(s)
51+ }
52+ return strings.Join(segs, "/")
53+}
54+
55+// repoHref is the URL of a page of a repository: /<repo>/<kind>/<ref>/<path>.
56+// kind, ref and path may be empty.
57+func repoHref(repo string, parts ...string) string {
58+ var b strings.Builder
59+ b.WriteString("/" + url.PathEscape(repo))
60+ for _, p := range parts {
61+ if p != "" {
62+ b.WriteString("/" + escapePath(strings.Trim(p, "/")))
63+ }
64+ }
65+ return b.String()
66+}
A internal/adapters/web/refs_test.go +58 -0
@@ -0,0 +1,58 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+)
12+
13+func ref(kind pb.RefKind, short string) *pb.Ref {
14+ return &pb.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, CommitId: "c-" + short}
15+}
16+
17+// AC3: a ref with slashes is found by the longest matching prefix.
18+func TestResolveRef(t *testing.T) {
19+ b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
20+ refs := []*pb.Ref{
21+ ref(b, "main"), ref(b, "feature"), ref(b, "feature/x"), ref(b, "feature/x/y"), ref(tag, "v1/rc"),
22+ ref(pb.RefKind_REF_KIND_HEAD, "main"), ref(tag, "main"),
23+ }
24+ tests := []struct {
25+ path, ref, rest string
26+ kind pb.RefKind
27+ ok bool
28+ }{
29+ {"main", "main", "", b, true},
30+ {"main/src/a.go", "main", "src/a.go", b, true},
31+ {"feature/x/y/z.txt", "feature/x/y", "z.txt", b, true},
32+ {"feature/x/z.txt", "feature/x", "z.txt", b, true},
33+ {"feature/other", "feature", "other", b, true},
34+ {"/feature/x/", "feature/x", "", b, true},
35+ {"v1/rc/README", "v1/rc", "README", tag, true},
36+ {"v1/README", "", "", 0, false},
37+ {"nope/main", "", "", 0, false},
38+ {"", "", "", 0, false},
39+ }
40+ for _, tt := range tests {
41+ t.Run(tt.path, func(t *testing.T) {
42+ r, rest, ok := resolveRef(refs, tt.path)
43+ assert.Equal(t, tt.ok, ok)
44+ if !ok {
45+ return
46+ }
47+ assert.Equal(t, tt.ref, r.GetShortName())
48+ assert.Equal(t, tt.kind, r.GetKind(), "a branch wins over a tag of the same name")
49+ assert.Equal(t, tt.rest, rest)
50+ })
51+ }
52+}
53+
54+func TestRepoHref(t *testing.T) {
55+ assert.Equal(t, "/demo", repoHref("demo"))
56+ assert.Equal(t, "/demo/tree/feature/x/a%20b/c%23.go", repoHref("demo", "tree", "feature/x", "a b/c#.go"))
57+ assert.Equal(t, "/demo/blob/main/%3Fq", repoHref("demo", "blob", "main", "", "?q"))
58+}
@@ -0,0 +1,58 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+)
12+
13+func ref(kind pb.RefKind, short string) *pb.Ref {
14+ return &pb.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, CommitId: "c-" + short}
15+}
16+
17+// AC3: a ref with slashes is found by the longest matching prefix.
18+func TestResolveRef(t *testing.T) {
19+ b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
20+ refs := []*pb.Ref{
21+ ref(b, "main"), ref(b, "feature"), ref(b, "feature/x"), ref(b, "feature/x/y"), ref(tag, "v1/rc"),
22+ ref(pb.RefKind_REF_KIND_HEAD, "main"), ref(tag, "main"),
23+ }
24+ tests := []struct {
25+ path, ref, rest string
26+ kind pb.RefKind
27+ ok bool
28+ }{
29+ {"main", "main", "", b, true},
30+ {"main/src/a.go", "main", "src/a.go", b, true},
31+ {"feature/x/y/z.txt", "feature/x/y", "z.txt", b, true},
32+ {"feature/x/z.txt", "feature/x", "z.txt", b, true},
33+ {"feature/other", "feature", "other", b, true},
34+ {"/feature/x/", "feature/x", "", b, true},
35+ {"v1/rc/README", "v1/rc", "README", tag, true},
36+ {"v1/README", "", "", 0, false},
37+ {"nope/main", "", "", 0, false},
38+ {"", "", "", 0, false},
39+ }
40+ for _, tt := range tests {
41+ t.Run(tt.path, func(t *testing.T) {
42+ r, rest, ok := resolveRef(refs, tt.path)
43+ assert.Equal(t, tt.ok, ok)
44+ if !ok {
45+ return
46+ }
47+ assert.Equal(t, tt.ref, r.GetShortName())
48+ assert.Equal(t, tt.kind, r.GetKind(), "a branch wins over a tag of the same name")
49+ assert.Equal(t, tt.rest, rest)
50+ })
51+ }
52+}
53+
54+func TestRepoHref(t *testing.T) {
55+ assert.Equal(t, "/demo", repoHref("demo"))
56+ assert.Equal(t, "/demo/tree/feature/x/a%20b/c%23.go", repoHref("demo", "tree", "feature/x", "a b/c#.go"))
57+ assert.Equal(t, "/demo/blob/main/%3Fq", repoHref("demo", "blob", "main", "", "?q"))
58+}
A internal/adapters/web/refspage.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "sort"
9+
10+ "github.com/a-h/templ"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+)
14+
15+// refs lists the branches and the tags of a repository with the commit each
16+// designates: branches by name with the default first, tags newest first.
17+func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, int, error) {
18+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
19+ if err != nil {
20+ return nil, 0, err
21+ }
22+ all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
23+ commits := s.commitsOf(ctx, d.name(), all)
24+
25+ def := d.repo.GetDefaultBranch()
26+ p := RefsPage{Default: def}
27+ branches := append([]*pb.Ref(nil), d.branches...)
28+ sort.SliceStable(branches, func(i, j int) bool {
29+ a, b := branches[i].GetShortName() == def, branches[j].GetShortName() == def
30+ return a && !b
31+ })
32+ for i, b := range branches {
33+ p.Branches = append(p.Branches, s.refRow(d, b, commits, b.GetShortName() == def, i == 0))
34+ }
35+ tags := append([]*pb.Ref(nil), d.tags...)
36+ sort.SliceStable(tags, func(i, j int) bool {
37+ return commits[tags[i].GetCommitId()].at > commits[tags[j].GetCommitId()].at
38+ })
39+ for i, t := range tags {
40+ p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0))
41+ }
42+ if len(tags) > 0 {
43+ p.Latest = tags[0].GetShortName()
44+ }
45+ if d.cut {
46+ p.Note = "this repository has more refs than are listed here."
47+ }
48+
49+ l := s.repoLayout(ctx, d, "refs")
50+ l.Title = "refs · " + d.name() + " · " + s.instance
51+ l.Crumbs[0].Current = false
52+ l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true})
53+ l.Status = Status{
54+ Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"),
55+ Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags),
56+ }
57+ l.HasRail, l.RailSum = true, "refs · legend"
58+ return Refs(l, p), http.StatusOK, nil
59+}
60+
61+func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
62+ c := commits[r.GetCommitId()]
63+ row := RefRow{
64+ Name: r.GetShortName(), Href: repoHref(d.name(), "tree", r.GetShortName()),
65+ Short: short(r.GetCommitId()), Subject: c.subject, When: c.when, WhenAt: c.whenAt,
66+ Default: isDefault, Cursor: cursor,
67+ }
68+ if r.GetCommitId() != "" {
69+ row.CHref = repoHref(d.name(), "commit", r.GetCommitId())
70+ }
71+ return row
72+}
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "sort"
9+
10+ "github.com/a-h/templ"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+)
14+
15+// refs lists the branches and the tags of a repository with the commit each
16+// designates: branches by name with the default first, tags newest first.
17+func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, int, error) {
18+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
19+ if err != nil {
20+ return nil, 0, err
21+ }
22+ all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
23+ commits := s.commitsOf(ctx, d.name(), all)
24+
25+ def := d.repo.GetDefaultBranch()
26+ p := RefsPage{Default: def}
27+ branches := append([]*pb.Ref(nil), d.branches...)
28+ sort.SliceStable(branches, func(i, j int) bool {
29+ a, b := branches[i].GetShortName() == def, branches[j].GetShortName() == def
30+ return a && !b
31+ })
32+ for i, b := range branches {
33+ p.Branches = append(p.Branches, s.refRow(d, b, commits, b.GetShortName() == def, i == 0))
34+ }
35+ tags := append([]*pb.Ref(nil), d.tags...)
36+ sort.SliceStable(tags, func(i, j int) bool {
37+ return commits[tags[i].GetCommitId()].at > commits[tags[j].GetCommitId()].at
38+ })
39+ for i, t := range tags {
40+ p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0))
41+ }
42+ if len(tags) > 0 {
43+ p.Latest = tags[0].GetShortName()
44+ }
45+ if d.cut {
46+ p.Note = "this repository has more refs than are listed here."
47+ }
48+
49+ l := s.repoLayout(ctx, d, "refs")
50+ l.Title = "refs · " + d.name() + " · " + s.instance
51+ l.Crumbs[0].Current = false
52+ l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true})
53+ l.Status = Status{
54+ Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"),
55+ Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags),
56+ }
57+ l.HasRail, l.RailSum = true, "refs · legend"
58+ return Refs(l, p), http.StatusOK, nil
59+}
60+
61+func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
62+ c := commits[r.GetCommitId()]
63+ row := RefRow{
64+ Name: r.GetShortName(), Href: repoHref(d.name(), "tree", r.GetShortName()),
65+ Short: short(r.GetCommitId()), Subject: c.subject, When: c.when, WhenAt: c.whenAt,
66+ Default: isDefault, Cursor: cursor,
67+ }
68+ if r.GetCommitId() != "" {
69+ row.CHref = repoHref(d.name(), "commit", r.GetCommitId())
70+ }
71+ return row
72+}
A internal/adapters/web/repo.go +218 -0
@@ -0,0 +1,218 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "slices"
8+ "sort"
9+ "sync"
10+
11+ "connectrpc.com/connect"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+// maxRefs bounds the refs one page reads. A repository with more has its
17+// lists cut, and the pages say so.
18+const maxRefs = 1000
19+
20+// repoData is what the pages of one repository start from: its metadata and
21+// its refs, read once per request.
22+type repoData struct {
23+ repo *pb.Repo
24+ branches []*pb.Ref
25+ tags []*pb.Ref
26+ // cut is true when the repository has more refs than maxRefs.
27+ cut bool
28+ // head is the branch the repository opens on: its default branch, or the
29+ // first one when the default does not exist. nil for an empty repository.
30+ head *pb.Ref
31+}
32+
33+// loadRepo reads a repository and its refs. A missing, private or invalidly
34+// named repository is a not_found / invalid_argument error of the API, which
35+// the caller turns into the 404 page.
36+func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) {
37+ res, err := s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name}))
38+ if err != nil {
39+ return nil, err
40+ }
41+ d := &repoData{repo: res.Msg.GetRepo()}
42+ token, n := "", 0
43+ for {
44+ page, err := s.cfg.Git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{
45+ Repo: name, PageSize: pageSize, PageToken: token,
46+ Kinds: []pb.RefKind{pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG},
47+ }))
48+ if err != nil {
49+ return nil, err
50+ }
51+ for _, r := range page.Msg.GetRefs() {
52+ switch r.GetKind() {
53+ case pb.RefKind_REF_KIND_BRANCH:
54+ d.branches = append(d.branches, r)
55+ case pb.RefKind_REF_KIND_TAG:
56+ d.tags = append(d.tags, r)
57+ }
58+ n++
59+ }
60+ if token = page.Msg.GetNextPageToken(); token == "" {
61+ break
62+ }
63+ if n >= maxRefs {
64+ d.cut = true
65+ break
66+ }
67+ }
68+ for _, b := range d.branches {
69+ if b.GetShortName() == d.repo.GetDefaultBranch() {
70+ d.head = b
71+ }
72+ }
73+ if d.head == nil && len(d.branches) > 0 {
74+ d.head = d.branches[0]
75+ }
76+ return d, nil
77+}
78+
79+// name is the repository name.
80+func (d *repoData) name() string { return d.repo.GetName() }
81+
82+// layout is the Layout of the pages of a repository: the sections, the ref
83+// switcher on the head ref and the ":" targets. The caller sets the title,
84+// the crumbs, the status and the rail.
85+func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
86+ l := s.base()
87+ l.Repo = d.name()
88+ def := d.repo.GetDefaultBranch()
89+ if d.head != nil {
90+ def = d.head.GetShortName()
91+ l.Ref, l.RefPath = def, def
92+ l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
93+ }
94+ mk := func(key, post, href, id string) Section {
95+ return Section{Key: key, Post: post, Href: href, Current: section == id}
96+ }
97+ l.Sections = []Section{
98+ mk("s", "ummary", repoHref(d.name()), "summary"),
99+ mk("t", "ree", repoHref(d.name(), "tree", def), "tree"),
100+ mk("c", "ommits", repoHref(d.name(), "commits", def), "commits"),
101+ mk("r", "efs", repoHref(d.name(), "refs"), "refs"),
102+ }
103+ l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
104+
105+ l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
106+ for _, sec := range l.Sections {
107+ l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: sec.Key + sec.Post, Href: sec.Href})
108+ }
109+ if l.Ref != "" {
110+ for _, b := range d.branches {
111+ it := RefItem{Name: b.GetShortName(), Href: repoHref(d.name(), "tree", b.GetShortName()), Current: b == d.head}
112+ l.Heads = append(l.Heads, it)
113+ l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href})
114+ }
115+ for _, t := range d.tags {
116+ it := RefItem{Name: t.GetShortName(), Href: repoHref(d.name(), "tree", t.GetShortName())}
117+ l.Tags = append(l.Tags, it)
118+ l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href})
119+ }
120+ }
121+ // Other repositories, when the list is reachable; the page does not
122+ // depend on it.
123+ if repos, err := s.listRepos(ctx); err == nil {
124+ l.Cmd = append(l.Cmd, repoCmds(repos, d.name())...)
125+ }
126+ return l
127+}
128+
129+// cloneURL is the HTTPS clone URL of a repository.
130+func (s *Server) cloneURL(name string) string {
131+ return s.cfg.PublicURL + "/" + name + ".git"
132+}
133+
134+// refCommit is what a ref shows: the commit it designates.
135+type refCommit struct {
136+ short, subject, when, whenAt string
137+ at int64 // unix time, to sort tags
138+}
139+
140+// commitsOf reads the head commit of each ref, a few at a time. A ref whose
141+// commit cannot be read (a tag on a tree, say) has no entry.
142+func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map[string]refCommit {
143+ const workers = 8
144+ var (
145+ mu sync.Mutex
146+ out = make(map[string]refCommit, len(refs))
147+ sem = make(chan struct{}, workers)
148+ wg sync.WaitGroup
149+ )
150+ now := s.cfg.Now()
151+ for _, r := range refs {
152+ id := r.GetCommitId()
153+ if id == "" {
154+ continue
155+ }
156+ mu.Lock()
157+ _, done := out[id]
158+ if !done {
159+ out[id] = refCommit{} // reserved: one lookup per commit
160+ }
161+ mu.Unlock()
162+ if done {
163+ continue
164+ }
165+ sem <- struct{}{}
166+ wg.Add(1)
167+ go func() {
168+ defer func() { <-sem; wg.Done() }()
169+ // By id, not by name: a branch and a tag may share a name.
170+ res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1}))
171+ var rc refCommit
172+ if err == nil && len(res.Msg.GetCommits()) == 1 {
173+ c := res.Msg.GetCommits()[0]
174+ rc.short, rc.subject = short(c.GetId()), c.GetSummary()
175+ if t := c.GetCommitter().GetTime(); t.IsValid() {
176+ rc.at = t.GetSeconds()
177+ rc.when, rc.whenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
178+ }
179+ }
180+ mu.Lock()
181+ out[id] = rc
182+ mu.Unlock()
183+ }()
184+ }
185+ wg.Wait()
186+ return out
187+}
188+
189+// namesAt maps commit ids to the names of the refs that designate them.
190+func namesAt(refs []*pb.Ref) map[string][]string {
191+ m := map[string][]string{}
192+ for _, r := range refs {
193+ m[r.GetCommitId()] = append(m[r.GetCommitId()], r.GetShortName())
194+ }
195+ for _, v := range m {
196+ sort.Strings(v)
197+ }
198+ return m
199+}
200+
201+// emptyState is the page of a repository without a branch.
202+func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage) {
203+ l := s.repoLayout(ctx, d, "summary")
204+ l.Title = d.name() + " · " + s.instance
205+ l.Sections = nil
206+ l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" })
207+ l.Status = Status{Mode: "empty", Context: "empty repository · 0 commits", Hints: "g h home · ? help"}
208+ branch := d.repo.GetDefaultBranch()
209+ return l, StatePage{
210+ Code: "empty", Title: d.name() + " is empty",
211+ Lines: []string{"no commits yet. push a branch to create one."},
212+ Clone: &Clone{ID: "clone-" + d.name(), Lines: []string{
213+ "git remote add origin " + s.cloneURL(d.name()),
214+ "git push -u origin " + branch,
215+ }},
216+ Back: []Link{{Label: "home", Href: "/"}},
217+ }
218+}
@@ -0,0 +1,218 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "slices"
8+ "sort"
9+ "sync"
10+
11+ "connectrpc.com/connect"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+// maxRefs bounds the refs one page reads. A repository with more has its
17+// lists cut, and the pages say so.
18+const maxRefs = 1000
19+
20+// repoData is what the pages of one repository start from: its metadata and
21+// its refs, read once per request.
22+type repoData struct {
23+ repo *pb.Repo
24+ branches []*pb.Ref
25+ tags []*pb.Ref
26+ // cut is true when the repository has more refs than maxRefs.
27+ cut bool
28+ // head is the branch the repository opens on: its default branch, or the
29+ // first one when the default does not exist. nil for an empty repository.
30+ head *pb.Ref
31+}
32+
33+// loadRepo reads a repository and its refs. A missing, private or invalidly
34+// named repository is a not_found / invalid_argument error of the API, which
35+// the caller turns into the 404 page.
36+func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) {
37+ res, err := s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name}))
38+ if err != nil {
39+ return nil, err
40+ }
41+ d := &repoData{repo: res.Msg.GetRepo()}
42+ token, n := "", 0
43+ for {
44+ page, err := s.cfg.Git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{
45+ Repo: name, PageSize: pageSize, PageToken: token,
46+ Kinds: []pb.RefKind{pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG},
47+ }))
48+ if err != nil {
49+ return nil, err
50+ }
51+ for _, r := range page.Msg.GetRefs() {
52+ switch r.GetKind() {
53+ case pb.RefKind_REF_KIND_BRANCH:
54+ d.branches = append(d.branches, r)
55+ case pb.RefKind_REF_KIND_TAG:
56+ d.tags = append(d.tags, r)
57+ }
58+ n++
59+ }
60+ if token = page.Msg.GetNextPageToken(); token == "" {
61+ break
62+ }
63+ if n >= maxRefs {
64+ d.cut = true
65+ break
66+ }
67+ }
68+ for _, b := range d.branches {
69+ if b.GetShortName() == d.repo.GetDefaultBranch() {
70+ d.head = b
71+ }
72+ }
73+ if d.head == nil && len(d.branches) > 0 {
74+ d.head = d.branches[0]
75+ }
76+ return d, nil
77+}
78+
79+// name is the repository name.
80+func (d *repoData) name() string { return d.repo.GetName() }
81+
82+// layout is the Layout of the pages of a repository: the sections, the ref
83+// switcher on the head ref and the ":" targets. The caller sets the title,
84+// the crumbs, the status and the rail.
85+func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
86+ l := s.base()
87+ l.Repo = d.name()
88+ def := d.repo.GetDefaultBranch()
89+ if d.head != nil {
90+ def = d.head.GetShortName()
91+ l.Ref, l.RefPath = def, def
92+ l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
93+ }
94+ mk := func(key, post, href, id string) Section {
95+ return Section{Key: key, Post: post, Href: href, Current: section == id}
96+ }
97+ l.Sections = []Section{
98+ mk("s", "ummary", repoHref(d.name()), "summary"),
99+ mk("t", "ree", repoHref(d.name(), "tree", def), "tree"),
100+ mk("c", "ommits", repoHref(d.name(), "commits", def), "commits"),
101+ mk("r", "efs", repoHref(d.name(), "refs"), "refs"),
102+ }
103+ l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
104+
105+ l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
106+ for _, sec := range l.Sections {
107+ l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: sec.Key + sec.Post, Href: sec.Href})
108+ }
109+ if l.Ref != "" {
110+ for _, b := range d.branches {
111+ it := RefItem{Name: b.GetShortName(), Href: repoHref(d.name(), "tree", b.GetShortName()), Current: b == d.head}
112+ l.Heads = append(l.Heads, it)
113+ l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href})
114+ }
115+ for _, t := range d.tags {
116+ it := RefItem{Name: t.GetShortName(), Href: repoHref(d.name(), "tree", t.GetShortName())}
117+ l.Tags = append(l.Tags, it)
118+ l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href})
119+ }
120+ }
121+ // Other repositories, when the list is reachable; the page does not
122+ // depend on it.
123+ if repos, err := s.listRepos(ctx); err == nil {
124+ l.Cmd = append(l.Cmd, repoCmds(repos, d.name())...)
125+ }
126+ return l
127+}
128+
129+// cloneURL is the HTTPS clone URL of a repository.
130+func (s *Server) cloneURL(name string) string {
131+ return s.cfg.PublicURL + "/" + name + ".git"
132+}
133+
134+// refCommit is what a ref shows: the commit it designates.
135+type refCommit struct {
136+ short, subject, when, whenAt string
137+ at int64 // unix time, to sort tags
138+}
139+
140+// commitsOf reads the head commit of each ref, a few at a time. A ref whose
141+// commit cannot be read (a tag on a tree, say) has no entry.
142+func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map[string]refCommit {
143+ const workers = 8
144+ var (
145+ mu sync.Mutex
146+ out = make(map[string]refCommit, len(refs))
147+ sem = make(chan struct{}, workers)
148+ wg sync.WaitGroup
149+ )
150+ now := s.cfg.Now()
151+ for _, r := range refs {
152+ id := r.GetCommitId()
153+ if id == "" {
154+ continue
155+ }
156+ mu.Lock()
157+ _, done := out[id]
158+ if !done {
159+ out[id] = refCommit{} // reserved: one lookup per commit
160+ }
161+ mu.Unlock()
162+ if done {
163+ continue
164+ }
165+ sem <- struct{}{}
166+ wg.Add(1)
167+ go func() {
168+ defer func() { <-sem; wg.Done() }()
169+ // By id, not by name: a branch and a tag may share a name.
170+ res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1}))
171+ var rc refCommit
172+ if err == nil && len(res.Msg.GetCommits()) == 1 {
173+ c := res.Msg.GetCommits()[0]
174+ rc.short, rc.subject = short(c.GetId()), c.GetSummary()
175+ if t := c.GetCommitter().GetTime(); t.IsValid() {
176+ rc.at = t.GetSeconds()
177+ rc.when, rc.whenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
178+ }
179+ }
180+ mu.Lock()
181+ out[id] = rc
182+ mu.Unlock()
183+ }()
184+ }
185+ wg.Wait()
186+ return out
187+}
188+
189+// namesAt maps commit ids to the names of the refs that designate them.
190+func namesAt(refs []*pb.Ref) map[string][]string {
191+ m := map[string][]string{}
192+ for _, r := range refs {
193+ m[r.GetCommitId()] = append(m[r.GetCommitId()], r.GetShortName())
194+ }
195+ for _, v := range m {
196+ sort.Strings(v)
197+ }
198+ return m
199+}
200+
201+// emptyState is the page of a repository without a branch.
202+func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage) {
203+ l := s.repoLayout(ctx, d, "summary")
204+ l.Title = d.name() + " · " + s.instance
205+ l.Sections = nil
206+ l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" })
207+ l.Status = Status{Mode: "empty", Context: "empty repository · 0 commits", Hints: "g h home · ? help"}
208+ branch := d.repo.GetDefaultBranch()
209+ return l, StatePage{
210+ Code: "empty", Title: d.name() + " is empty",
211+ Lines: []string{"no commits yet. push a branch to create one."},
212+ Clone: &Clone{ID: "clone-" + d.name(), Lines: []string{
213+ "git remote add origin " + s.cloneURL(d.name()),
214+ "git push -u origin " + branch,
215+ }},
216+ Back: []Link{{Label: "home", Href: "/"}},
217+ }
218+}
A internal/adapters/web/shell.templ +158 -0
@@ -0,0 +1,158 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Shell is the page: head, the two bars, the work area (content and rail),
6+// the overlays and the key map. The content is the children.
7+templ Shell(l Layout, rail templ.Component) {
8+ <!DOCTYPE html>
9+ <html lang="en">
10+ <head>
11+ <meta charset="utf-8"/>
12+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
13+ <meta name="color-scheme" content="light dark"/>
14+ <title>{ l.Title }</title>
15+ <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/>
16+ <script src={ l.Assets.HTMX } defer></script>
17+ <script src={ l.Assets.Live } defer></script>
18+ <script src={ l.Assets.SSE } defer></script>
19+ <script src={ l.Assets.App } defer></script>
20+ </head>
21+ <body data-ref={ l.RefPath } data-sha={ l.SHA } data-sel="">
22+ <a class="skip" href="#main">skip to content</a>
23+ @Topbar(l)
24+ <div class={ "work", templ.KV("has-rail", l.HasRail), templ.KV("read", l.Read) }>
25+ <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
26+ { children... }
27+ </main>
28+ if l.HasRail && rail != nil {
29+ <aside class="rail" aria-label="Context">
30+ <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
31+ <summary>{ l.RailSum }</summary>
32+ @rail
33+ </details>
34+ </aside>
35+ }
36+ </div>
37+ @OverlayHelp()
38+ @OverlayRefs(l)
39+ @OverlayCmd(l.Cmd)
40+ @Statusbar(l)
41+ @Keys()
42+ </body>
43+ </html>
44+}
45+
46+// Topbar is the top bar, neferbar style: the one accent block (instance)
47+// fading into the black bar, the path, the repo sections, the ref switcher.
48+templ Topbar(l Layout) {
49+ <header class="bar top">
50+ <a class="blk" href="/" data-g="h" title="home (g h)">{ l.Instance }</a><span class="fade" aria-hidden="true"></span>
51+ <nav class="crumbs" aria-label="Path">
52+ <ol>
53+ for _, c := range l.Crumbs {
54+ <li>
55+ if c.Href != "" {
56+ <a
57+ href={ templ.SafeURL(c.Href) }
58+ if c.Current {
59+ aria-current="page"
60+ }
61+ >{ c.Label }</a>
62+ } else {
63+ <span aria-current="page">{ c.Label }</span>
64+ }
65+ </li>
66+ }
67+ </ol>
68+ </nav>
69+ if len(l.Sections) > 0 {
70+ <nav class="secs" aria-label="Sections">
71+ <ul>
72+ for _, s := range l.Sections {
73+ <li>
74+ <a
75+ href={ templ.SafeURL(s.Href) }
76+ data-key={ s.Key }
77+ aria-keyshortcuts={ "g " + s.Key }
78+ if s.Current {
79+ aria-current="page"
80+ }
81+ >{ s.Key }{ s.Post }</a>
82+ </li>
83+ }
84+ </ul>
85+ </nav>
86+ }
87+ if l.Ref != "" {
88+ <button
89+ type="button"
90+ class="ref"
91+ aria-haspopup="dialog"
92+ aria-controls="panel-refs"
93+ aria-keyshortcuts="b"
94+ title="switch ref (b)"
95+ hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"
96+ >{ l.Ref }<span class="sha">{ "@" + l.Short }</span></button>
97+ }
98+ </header>
99+}
100+
101+// Statusbar is the bottom bar: mode, context, the ":" command prompt, the
102+// source link (AGPL §13), hints, live state and the cursor position. Plain
103+// text only: the top bar carries the accent.
104+templ Statusbar(l Layout) {
105+ <footer class="bar status" aria-label="Status">
106+ <span class="mode">{ l.Status.Mode }</span>
107+ <span class="st-ctx">{ l.Status.Context }</span>
108+ <button
109+ type="button"
110+ class="st-cmd"
111+ aria-haspopup="dialog"
112+ aria-controls="panel-cmd"
113+ aria-keyshortcuts=":"
114+ hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"
115+ ><span class="hi">:</span> go to…</button>
116+ if l.Status.Blob {
117+ <span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
118+ }
119+ <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span>
120+ <span class="st-hints">{ l.Status.Hints }</span>
121+ @Footer(l)
122+ @Live(l.Live)
123+ if l.Status.Cursor {
124+ <span
125+ class="st-pos num"
126+ title="cursor position"
127+ :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()"
128+ >{ cursorText(l.Status.Count) }</span>
129+ }
130+ </footer>
131+}
132+
133+// Footer is the version and the link to the source of the running version,
134+// which the AGPL §13 asks every user of the service to be offered.
135+templ Footer(l Layout) {
136+ <span class="st-src"><span class="st-ver">ermite { l.Version } · </span><a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a></span>
137+}
138+
139+// Live is the connection state of the event stream. Without the stream it
140+// reads "offline" and does nothing.
141+templ Live(on bool) {
142+ if on {
143+ <span
144+ class="st-live"
145+ data-state="offline"
146+ role="status"
147+ hx-sse:connect="/-/events"
148+ hx-swap="none"
149+ :text="data.state"
150+ :title="data.state === 'live' ? 'receiving pushes over SSE' : 'not connected to the event stream'"
151+ hx-on="htmx:sse:after:connection -> data.state = 'live';
152+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
153+ push -> for (let el of new DOMParser().parseFromString('<template>' + event.detail.data + '</template>', 'text/html').querySelector('template').content.children) q('#' + el.id).insert('replace', el.outerHTML)"
154+ >offline</span>
155+ } else {
156+ <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
157+ }
158+}
@@ -0,0 +1,158 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Shell is the page: head, the two bars, the work area (content and rail),
6+// the overlays and the key map. The content is the children.
7+templ Shell(l Layout, rail templ.Component) {
8+ <!DOCTYPE html>
9+ <html lang="en">
10+ <head>
11+ <meta charset="utf-8"/>
12+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
13+ <meta name="color-scheme" content="light dark"/>
14+ <title>{ l.Title }</title>
15+ <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/>
16+ <script src={ l.Assets.HTMX } defer></script>
17+ <script src={ l.Assets.Live } defer></script>
18+ <script src={ l.Assets.SSE } defer></script>
19+ <script src={ l.Assets.App } defer></script>
20+ </head>
21+ <body data-ref={ l.RefPath } data-sha={ l.SHA } data-sel="">
22+ <a class="skip" href="#main">skip to content</a>
23+ @Topbar(l)
24+ <div class={ "work", templ.KV("has-rail", l.HasRail), templ.KV("read", l.Read) }>
25+ <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
26+ { children... }
27+ </main>
28+ if l.HasRail && rail != nil {
29+ <aside class="rail" aria-label="Context">
30+ <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
31+ <summary>{ l.RailSum }</summary>
32+ @rail
33+ </details>
34+ </aside>
35+ }
36+ </div>
37+ @OverlayHelp()
38+ @OverlayRefs(l)
39+ @OverlayCmd(l.Cmd)
40+ @Statusbar(l)
41+ @Keys()
42+ </body>
43+ </html>
44+}
45+
46+// Topbar is the top bar, neferbar style: the one accent block (instance)
47+// fading into the black bar, the path, the repo sections, the ref switcher.
48+templ Topbar(l Layout) {
49+ <header class="bar top">
50+ <a class="blk" href="/" data-g="h" title="home (g h)">{ l.Instance }</a><span class="fade" aria-hidden="true"></span>
51+ <nav class="crumbs" aria-label="Path">
52+ <ol>
53+ for _, c := range l.Crumbs {
54+ <li>
55+ if c.Href != "" {
56+ <a
57+ href={ templ.SafeURL(c.Href) }
58+ if c.Current {
59+ aria-current="page"
60+ }
61+ >{ c.Label }</a>
62+ } else {
63+ <span aria-current="page">{ c.Label }</span>
64+ }
65+ </li>
66+ }
67+ </ol>
68+ </nav>
69+ if len(l.Sections) > 0 {
70+ <nav class="secs" aria-label="Sections">
71+ <ul>
72+ for _, s := range l.Sections {
73+ <li>
74+ <a
75+ href={ templ.SafeURL(s.Href) }
76+ data-key={ s.Key }
77+ aria-keyshortcuts={ "g " + s.Key }
78+ if s.Current {
79+ aria-current="page"
80+ }
81+ >{ s.Key }{ s.Post }</a>
82+ </li>
83+ }
84+ </ul>
85+ </nav>
86+ }
87+ if l.Ref != "" {
88+ <button
89+ type="button"
90+ class="ref"
91+ aria-haspopup="dialog"
92+ aria-controls="panel-refs"
93+ aria-keyshortcuts="b"
94+ title="switch ref (b)"
95+ hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"
96+ >{ l.Ref }<span class="sha">{ "@" + l.Short }</span></button>
97+ }
98+ </header>
99+}
100+
101+// Statusbar is the bottom bar: mode, context, the ":" command prompt, the
102+// source link (AGPL §13), hints, live state and the cursor position. Plain
103+// text only: the top bar carries the accent.
104+templ Statusbar(l Layout) {
105+ <footer class="bar status" aria-label="Status">
106+ <span class="mode">{ l.Status.Mode }</span>
107+ <span class="st-ctx">{ l.Status.Context }</span>
108+ <button
109+ type="button"
110+ class="st-cmd"
111+ aria-haspopup="dialog"
112+ aria-controls="panel-cmd"
113+ aria-keyshortcuts=":"
114+ hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"
115+ ><span class="hi">:</span> go to…</button>
116+ if l.Status.Blob {
117+ <span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
118+ }
119+ <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span>
120+ <span class="st-hints">{ l.Status.Hints }</span>
121+ @Footer(l)
122+ @Live(l.Live)
123+ if l.Status.Cursor {
124+ <span
125+ class="st-pos num"
126+ title="cursor position"
127+ :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()"
128+ >{ cursorText(l.Status.Count) }</span>
129+ }
130+ </footer>
131+}
132+
133+// Footer is the version and the link to the source of the running version,
134+// which the AGPL §13 asks every user of the service to be offered.
135+templ Footer(l Layout) {
136+ <span class="st-src"><span class="st-ver">ermite { l.Version } · </span><a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a></span>
137+}
138+
139+// Live is the connection state of the event stream. Without the stream it
140+// reads "offline" and does nothing.
141+templ Live(on bool) {
142+ if on {
143+ <span
144+ class="st-live"
145+ data-state="offline"
146+ role="status"
147+ hx-sse:connect="/-/events"
148+ hx-swap="none"
149+ :text="data.state"
150+ :title="data.state === 'live' ? 'receiving pushes over SSE' : 'not connected to the event stream'"
151+ hx-on="htmx:sse:after:connection -> data.state = 'live';
152+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
153+ push -> for (let el of new DOMParser().parseFromString('<template>' + event.detail.data + '</template>', 'text/html').querySelector('template').content.children) q('#' + el.id).insert('replace', el.outerHTML)"
154+ >offline</span>
155+ } else {
156+ <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
157+ }
158+}
A internal/adapters/web/shell_templ.go +685 -0 generated file: collapsed
A internal/adapters/web/static/css/00-tokens.css +68 -0
@@ -0,0 +1,68 @@
1+@layer reset, tokens, base, components, utilities;
2+
3+/* Palette: the neferbar "mono" theme, black and grays only.
4+ Page surfaces follow the OS theme; the two bars stay black in both themes,
5+ like neferbar under a light or a dark terminal. */
6+@layer tokens {
7+ :root {
8+ color-scheme: light dark;
9+
10+ /* page */
11+ --paper: light-dark(#f2f2f2, #000000);
12+ --paper-alt: light-dark(#e8e8e8, #0c0c0c);
13+ --row: light-dark(#dddddd, #1a1a1a);
14+ --sel: light-dark(#cbcbcb, #303030);
15+ --ink: light-dark(#161616, #d0d0d0);
16+ --ink-hi: light-dark(#000000, #ffffff);
17+ --ink-dim: light-dark(#4a4a4a, #9a9a9a);
18+ --ink-mute: light-dark(#5c5c5c, #8a8a8a);
19+ --rule: light-dark(#bcbcbc, #303030);
20+ --focus: light-dark(#000000, #ffffff);
21+
22+ /* bars (neferbar mono.conf: background, foreground, color4 accent, color0 ink) */
23+ --bar: #000000;
24+ --bar-ink: #d0d0d0;
25+ --bar-dim: #8a8a8a;
26+ --accent: #c8c8c8;
27+ --on-accent: #000000;
28+ /* lib.sh fade(): four cells from the accent to the bar, each pulled halfway to the bar */
29+ --fade-1: #505050;
30+ --fade-2: #3c3c3c;
31+ --fade-3: #282828;
32+ --fade-4: #141414;
33+
34+ /* Colour, sparingly, from the owner's Despair theme: only code and diffs carry hue.
35+ Dark values are Despair's own; light values are the same hues darkened to AA on --paper. */
36+ --teal: light-dark(#2f6b6b, #7db5b5); /* Despair bright teal: keywords, additions */
37+ --teal-2: light-dark(#355f5f, #5a8585); /* slate teal: types, numbers */
38+ --red: light-dark(#a8404b, #d96c75); /* soft red: deletions */
39+ --rose: light-dark(#6b6570, #a7a2a9); /* rose quartz: strings */
40+
41+ --syn-plain: var(--ink);
42+ --syn-keyword: var(--teal);
43+ --syn-string: var(--rose);
44+ --syn-comment: light-dark(#5c5f63, #6a6e73);
45+ --syn-number: var(--teal-2);
46+ --syn-func: var(--ink-hi);
47+ --syn-type: var(--teal-2);
48+ --syn-punct: var(--ink-dim);
49+
50+ /* diff rows: the line keeps the normal ink; the hue is in a faint row tint and the marker */
51+ --add: light-dark(#d8e5e5, #121919);
52+ --del: light-dark(#eedadc, #231113);
53+
54+ /* one family (the neferbar font when installed), three sizes, two weights */
55+ --font: "Adwaita Mono", "AdwaitaMono Nerd Font Mono", ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
56+ --fs: 14px;
57+ --fs-code: 13px;
58+ --fs-s: 12px;
59+ --lh: 20px;
60+ --lh-code: 18px;
61+
62+ --pad: 2ch;
63+ --bar-h: 20px; /* one text line, like neferbar: no padding above or below */
64+ --stick: var(--bar-h); /* top offset of sticky headers inside the content (0 in the app shell) */
65+ --measure: 80ch;
66+ --rail: 40ch;
67+ }
68+}
@@ -0,0 +1,68 @@
1+@layer reset, tokens, base, components, utilities;
2+
3+/* Palette: the neferbar "mono" theme, black and grays only.
4+ Page surfaces follow the OS theme; the two bars stay black in both themes,
5+ like neferbar under a light or a dark terminal. */
6+@layer tokens {
7+ :root {
8+ color-scheme: light dark;
9+
10+ /* page */
11+ --paper: light-dark(#f2f2f2, #000000);
12+ --paper-alt: light-dark(#e8e8e8, #0c0c0c);
13+ --row: light-dark(#dddddd, #1a1a1a);
14+ --sel: light-dark(#cbcbcb, #303030);
15+ --ink: light-dark(#161616, #d0d0d0);
16+ --ink-hi: light-dark(#000000, #ffffff);
17+ --ink-dim: light-dark(#4a4a4a, #9a9a9a);
18+ --ink-mute: light-dark(#5c5c5c, #8a8a8a);
19+ --rule: light-dark(#bcbcbc, #303030);
20+ --focus: light-dark(#000000, #ffffff);
21+
22+ /* bars (neferbar mono.conf: background, foreground, color4 accent, color0 ink) */
23+ --bar: #000000;
24+ --bar-ink: #d0d0d0;
25+ --bar-dim: #8a8a8a;
26+ --accent: #c8c8c8;
27+ --on-accent: #000000;
28+ /* lib.sh fade(): four cells from the accent to the bar, each pulled halfway to the bar */
29+ --fade-1: #505050;
30+ --fade-2: #3c3c3c;
31+ --fade-3: #282828;
32+ --fade-4: #141414;
33+
34+ /* Colour, sparingly, from the owner's Despair theme: only code and diffs carry hue.
35+ Dark values are Despair's own; light values are the same hues darkened to AA on --paper. */
36+ --teal: light-dark(#2f6b6b, #7db5b5); /* Despair bright teal: keywords, additions */
37+ --teal-2: light-dark(#355f5f, #5a8585); /* slate teal: types, numbers */
38+ --red: light-dark(#a8404b, #d96c75); /* soft red: deletions */
39+ --rose: light-dark(#6b6570, #a7a2a9); /* rose quartz: strings */
40+
41+ --syn-plain: var(--ink);
42+ --syn-keyword: var(--teal);
43+ --syn-string: var(--rose);
44+ --syn-comment: light-dark(#5c5f63, #6a6e73);
45+ --syn-number: var(--teal-2);
46+ --syn-func: var(--ink-hi);
47+ --syn-type: var(--teal-2);
48+ --syn-punct: var(--ink-dim);
49+
50+ /* diff rows: the line keeps the normal ink; the hue is in a faint row tint and the marker */
51+ --add: light-dark(#d8e5e5, #121919);
52+ --del: light-dark(#eedadc, #231113);
53+
54+ /* one family (the neferbar font when installed), three sizes, two weights */
55+ --font: "Adwaita Mono", "AdwaitaMono Nerd Font Mono", ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
56+ --fs: 14px;
57+ --fs-code: 13px;
58+ --fs-s: 12px;
59+ --lh: 20px;
60+ --lh-code: 18px;
61+
62+ --pad: 2ch;
63+ --bar-h: 20px; /* one text line, like neferbar: no padding above or below */
64+ --stick: var(--bar-h); /* top offset of sticky headers inside the content (0 in the app shell) */
65+ --measure: 80ch;
66+ --rail: 40ch;
67+ }
68+}
A internal/adapters/web/static/css/10-base.css +64 -0
@@ -0,0 +1,64 @@
1+@layer reset {
2+ *, *::before, *::after { box-sizing: border-box; border-radius: 0; }
3+ html { -webkit-text-size-adjust: 100%; }
4+ body, h1, h2, h3, h4, p, ul, ol, dl, dd, figure, pre { margin: 0; }
5+ ul, ol { padding: 0; list-style: none; }
6+ table { border-collapse: collapse; border-spacing: 0; }
7+ th, td { padding: 0; text-align: left; font-weight: inherit; }
8+ button, input, select { font: inherit; color: inherit; letter-spacing: inherit; }
9+ button { background: none; border: 0; padding: 0; cursor: pointer; text-align: inherit; }
10+ input { background: transparent; border: 0; border-radius: 0; }
11+ summary { cursor: pointer; }
12+ dialog { color: inherit; border: 0; padding: 0; max-width: none; max-height: none; }
13+ img { max-width: 100%; }
14+}
15+
16+@layer base {
17+ html { background: var(--paper); scrollbar-width: thin; scrollbar-color: var(--rule) transparent; }
18+ body {
19+ min-height: 100dvh;
20+ display: grid;
21+ grid-template-rows: auto auto 1fr auto;
22+ font: var(--fs) / var(--lh) var(--font);
23+ color: var(--ink);
24+ background: var(--paper);
25+ font-variant-ligatures: none;
26+ tab-size: 4;
27+ }
28+ h1, h2, h3, h4 { font-size: inherit; font-weight: bold; }
29+ b, strong { font-weight: bold; }
30+ em, i { font-style: normal; text-decoration: underline; text-underline-offset: 3px; }
31+ small { font-size: var(--fs-s); }
32+ code, pre, kbd, samp { font: inherit; }
33+ a { color: inherit; text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; }
34+ a:hover { text-decoration-color: currentColor; }
35+ hr { border: 0; border-top: 1px solid var(--rule); margin: var(--lh) 0; }
36+ ::selection { background: var(--ink); color: var(--paper); }
37+
38+ :focus { outline: none; }
39+ :focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
40+
41+ [hidden] { display: none !important; }
42+
43+ .skip {
44+ position: absolute; left: 0; top: 0; z-index: 30; padding: 0 1ch;
45+ background: var(--ink); color: var(--paper);
46+ transform: translateY(-100%);
47+ }
48+ .skip:focus { transform: none; }
49+
50+ main { padding: 0 var(--pad) var(--lh); min-width: 0; }
51+
52+ @media (prefers-reduced-motion: no-preference) {
53+ a, button, tr, li, summary { transition: background-color 90ms linear, opacity 90ms linear; }
54+ }
55+}
56+
57+@layer utilities {
58+ .dim { color: var(--ink-dim); }
59+ .mute { color: var(--ink-mute); }
60+ .nowrap { white-space: nowrap; }
61+ .num { text-align: right; font-variant-numeric: tabular-nums; }
62+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
63+ .rule { border-top: 1px solid var(--rule); }
64+}
@@ -0,0 +1,64 @@
1+@layer reset {
2+ *, *::before, *::after { box-sizing: border-box; border-radius: 0; }
3+ html { -webkit-text-size-adjust: 100%; }
4+ body, h1, h2, h3, h4, p, ul, ol, dl, dd, figure, pre { margin: 0; }
5+ ul, ol { padding: 0; list-style: none; }
6+ table { border-collapse: collapse; border-spacing: 0; }
7+ th, td { padding: 0; text-align: left; font-weight: inherit; }
8+ button, input, select { font: inherit; color: inherit; letter-spacing: inherit; }
9+ button { background: none; border: 0; padding: 0; cursor: pointer; text-align: inherit; }
10+ input { background: transparent; border: 0; border-radius: 0; }
11+ summary { cursor: pointer; }
12+ dialog { color: inherit; border: 0; padding: 0; max-width: none; max-height: none; }
13+ img { max-width: 100%; }
14+}
15+
16+@layer base {
17+ html { background: var(--paper); scrollbar-width: thin; scrollbar-color: var(--rule) transparent; }
18+ body {
19+ min-height: 100dvh;
20+ display: grid;
21+ grid-template-rows: auto auto 1fr auto;
22+ font: var(--fs) / var(--lh) var(--font);
23+ color: var(--ink);
24+ background: var(--paper);
25+ font-variant-ligatures: none;
26+ tab-size: 4;
27+ }
28+ h1, h2, h3, h4 { font-size: inherit; font-weight: bold; }
29+ b, strong { font-weight: bold; }
30+ em, i { font-style: normal; text-decoration: underline; text-underline-offset: 3px; }
31+ small { font-size: var(--fs-s); }
32+ code, pre, kbd, samp { font: inherit; }
33+ a { color: inherit; text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; }
34+ a:hover { text-decoration-color: currentColor; }
35+ hr { border: 0; border-top: 1px solid var(--rule); margin: var(--lh) 0; }
36+ ::selection { background: var(--ink); color: var(--paper); }
37+
38+ :focus { outline: none; }
39+ :focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
40+
41+ [hidden] { display: none !important; }
42+
43+ .skip {
44+ position: absolute; left: 0; top: 0; z-index: 30; padding: 0 1ch;
45+ background: var(--ink); color: var(--paper);
46+ transform: translateY(-100%);
47+ }
48+ .skip:focus { transform: none; }
49+
50+ main { padding: 0 var(--pad) var(--lh); min-width: 0; }
51+
52+ @media (prefers-reduced-motion: no-preference) {
53+ a, button, tr, li, summary { transition: background-color 90ms linear, opacity 90ms linear; }
54+ }
55+}
56+
57+@layer utilities {
58+ .dim { color: var(--ink-dim); }
59+ .mute { color: var(--ink-mute); }
60+ .nowrap { white-space: nowrap; }
61+ .num { text-align: right; font-variant-numeric: tabular-nums; }
62+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
63+ .rule { border-top: 1px solid var(--rule); }
64+}
A internal/adapters/web/static/css/20-shell.css +122 -0
@@ -0,0 +1,122 @@
1+@layer base {
2+ body { grid-template-rows: auto 1fr auto; }
3+}
4+
5+@layer components {
6+ /* neferbar: black bars; one accent block (top left) with a 4-cell stepped fade.
7+ The fade is four hard-edged 1ch cells (not a blend): it is the bar's own glyph-grid look. */
8+ .bar {
9+ display: flex; align-items: stretch; column-gap: 1ch;
10+ height: var(--bar-h); line-height: var(--bar-h);
11+ background: var(--bar); color: var(--bar-ink);
12+ white-space: nowrap; overflow: hidden;
13+ position: sticky; z-index: 20;
14+ & a { text-decoration: none; }
15+ & a:hover { text-decoration: underline; }
16+ & :focus-visible { outline-color: var(--accent); outline-offset: -2px; }
17+ & .blk { background: var(--accent); color: var(--on-accent); font-weight: bold; padding: 0 1ch; }
18+ & .fade {
19+ flex: none; width: 4ch; margin-left: -1ch;
20+ background: linear-gradient(to right,
21+ var(--fade-1) 0 25%, var(--fade-2) 25% 50%, var(--fade-3) 50% 75%, var(--fade-4) 75%);
22+ }
23+ }
24+
25+ .top {
26+ top: 0;
27+ & .crumbs ol { display: flex; }
28+ & .crumbs li + li::before { content: "/"; color: var(--bar-dim); padding: 0 .5ch; }
29+ & .crumbs [aria-current] { color: #fff; font-weight: bold; }
30+ & .secs { margin-left: 2ch; overflow: hidden; }
31+ & .secs ul { display: flex; }
32+ & .secs li + li::before { content: "/"; color: var(--bar-dim); }
33+ & .secs a { display: inline-block; padding: 0 1ch; color: var(--bar-dim); }
34+ & .secs a:hover { color: var(--bar-ink); }
35+ & .secs a::first-letter { text-decoration: underline; }
36+ & .secs a[aria-current] { color: #fff; font-weight: bold; }
37+ & .secs a[aria-current]::before { content: "▸ "; }
38+ & .ref { flex: none; margin-left: auto; padding-right: 1ch; color: #fff; font-weight: bold; }
39+ & .ref .sha { font-weight: normal; color: var(--bar-dim); }
40+ & .ref:hover { text-decoration: underline; }
41+ }
42+
43+ .status {
44+ bottom: 0; font-size: var(--fs-s);
45+ padding: 0 1ch;
46+ & > span, & .st-cmd { color: var(--bar-dim); }
47+ & .mode { color: #fff; font-weight: bold; }
48+ & .st-ctx { color: var(--bar-ink); }
49+ & .st-cmd { flex: none; }
50+ & .st-cmd .hi, & .st-cmd:hover { color: #fff; font-weight: bold; }
51+ & .st-sel { color: var(--bar-ink); }
52+ & .st-flash:empty { display: none; }
53+ & .st-flash { color: #fff; font-weight: bold; }
54+ & .st-hints { margin-left: auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
55+ & .st-live::before { content: "○ "; }
56+ & .st-live[data-state="live"] { color: var(--bar-ink); }
57+ & .st-live[data-state="live"]::before { content: "● "; }
58+ & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); }
59+ }
60+
61+ /* work area: content + context rail.
62+ Tables and code take the free width and the rail is fixed; on prose pages the text
63+ is capped at the reading measure and the rail takes the rest, so no column is idle. */
64+ .work { min-width: 0; }
65+ .rail { padding: 0 var(--pad) var(--lh); min-width: 0; }
66+ @media (min-width: 1100px) {
67+ /* app shell: the bars stay put, the content and the rail scroll on their own, so each
68+ column has its scrollbar at its own edge (the README's sits next to the README) */
69+ body { height: 100dvh; overflow: hidden; }
70+ .work { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 0; }
71+ .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); }
72+ .work.has-rail.read { grid-template-columns: minmax(0, calc(var(--measure) + 2 * var(--pad))) minmax(var(--rail), 1fr); }
73+ main, .rail { min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--rule) transparent; }
74+ main:focus { outline: none; }
75+ .rail { border-left: 1px solid var(--rule); }
76+ :root { --stick: 0px; }
77+ }
78+ .rail-d > summary { display: none; }
79+ @media (max-width: 1099.98px) {
80+ /* the rail folds into one line above the content */
81+ .work.has-rail { display: flex; flex-direction: column; }
82+ .rail { order: -1; border-bottom: 1px solid var(--rule); padding-bottom: 0; }
83+ .rail-d > summary {
84+ display: block; list-style: none; color: var(--ink-dim);
85+ overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
86+ }
87+ .rail-d > summary::-webkit-details-marker { display: none; }
88+ .rail-d > summary::before { content: "▸ "; color: var(--ink); }
89+ .rail-d[open] > summary::before { content: "▾ "; }
90+ .rail-d[open] { padding-bottom: var(--lh); }
91+ }
92+ @media (max-width: 700px) {
93+ /* the sections drop to a second bar row instead of being cut off */
94+ .top { flex-wrap: wrap; height: auto; }
95+ .top .secs { order: 10; flex-basis: 100%; margin: 0; border-top: 1px solid #202020; }
96+ .top .crumbs { flex: 1; min-width: 0; overflow: hidden; }
97+ .status .st-hints, .status .st-ctx { display: none; }
98+ .list .c-who, .list .c-desc, .list .c-sub, .list .c-ref, .list .c-size, .list .c-lang { display: none; }
99+ }
100+
101+ /* rail blocks: a dim title rule, then key/value rows on the character grid */
102+ .rail section { margin-top: var(--lh); }
103+ .rail h2 {
104+ display: flex; gap: 1ch; color: var(--ink-dim); font-weight: normal;
105+ &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); }
106+ }
107+ .kv {
108+ display: grid; grid-template-columns: 11ch minmax(0, 1fr); column-gap: 1ch;
109+ & dt { color: var(--ink-mute); }
110+ & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
111+ }
112+ .outline {
113+ & a { display: block; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
114+ & a:hover { text-decoration: underline; }
115+ & .l1 { font-weight: bold; }
116+ & .l3 { padding-left: 2ch; }
117+ & .l4, & .l5, & .l6 { padding-left: 4ch; }
118+ & a[aria-current]::before { content: "▸"; margin-left: -1ch; }
119+ }
120+ .rail .clone { border: 0; padding: 0; margin-top: 0; }
121+ .rail .clone-line { flex-wrap: wrap; }
122+}
@@ -0,0 +1,122 @@
1+@layer base {
2+ body { grid-template-rows: auto 1fr auto; }
3+}
4+
5+@layer components {
6+ /* neferbar: black bars; one accent block (top left) with a 4-cell stepped fade.
7+ The fade is four hard-edged 1ch cells (not a blend): it is the bar's own glyph-grid look. */
8+ .bar {
9+ display: flex; align-items: stretch; column-gap: 1ch;
10+ height: var(--bar-h); line-height: var(--bar-h);
11+ background: var(--bar); color: var(--bar-ink);
12+ white-space: nowrap; overflow: hidden;
13+ position: sticky; z-index: 20;
14+ & a { text-decoration: none; }
15+ & a:hover { text-decoration: underline; }
16+ & :focus-visible { outline-color: var(--accent); outline-offset: -2px; }
17+ & .blk { background: var(--accent); color: var(--on-accent); font-weight: bold; padding: 0 1ch; }
18+ & .fade {
19+ flex: none; width: 4ch; margin-left: -1ch;
20+ background: linear-gradient(to right,
21+ var(--fade-1) 0 25%, var(--fade-2) 25% 50%, var(--fade-3) 50% 75%, var(--fade-4) 75%);
22+ }
23+ }
24+
25+ .top {
26+ top: 0;
27+ & .crumbs ol { display: flex; }
28+ & .crumbs li + li::before { content: "/"; color: var(--bar-dim); padding: 0 .5ch; }
29+ & .crumbs [aria-current] { color: #fff; font-weight: bold; }
30+ & .secs { margin-left: 2ch; overflow: hidden; }
31+ & .secs ul { display: flex; }
32+ & .secs li + li::before { content: "/"; color: var(--bar-dim); }
33+ & .secs a { display: inline-block; padding: 0 1ch; color: var(--bar-dim); }
34+ & .secs a:hover { color: var(--bar-ink); }
35+ & .secs a::first-letter { text-decoration: underline; }
36+ & .secs a[aria-current] { color: #fff; font-weight: bold; }
37+ & .secs a[aria-current]::before { content: "▸ "; }
38+ & .ref { flex: none; margin-left: auto; padding-right: 1ch; color: #fff; font-weight: bold; }
39+ & .ref .sha { font-weight: normal; color: var(--bar-dim); }
40+ & .ref:hover { text-decoration: underline; }
41+ }
42+
43+ .status {
44+ bottom: 0; font-size: var(--fs-s);
45+ padding: 0 1ch;
46+ & > span, & .st-cmd { color: var(--bar-dim); }
47+ & .mode { color: #fff; font-weight: bold; }
48+ & .st-ctx { color: var(--bar-ink); }
49+ & .st-cmd { flex: none; }
50+ & .st-cmd .hi, & .st-cmd:hover { color: #fff; font-weight: bold; }
51+ & .st-sel { color: var(--bar-ink); }
52+ & .st-flash:empty { display: none; }
53+ & .st-flash { color: #fff; font-weight: bold; }
54+ & .st-hints { margin-left: auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
55+ & .st-live::before { content: "○ "; }
56+ & .st-live[data-state="live"] { color: var(--bar-ink); }
57+ & .st-live[data-state="live"]::before { content: "● "; }
58+ & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); }
59+ }
60+
61+ /* work area: content + context rail.
62+ Tables and code take the free width and the rail is fixed; on prose pages the text
63+ is capped at the reading measure and the rail takes the rest, so no column is idle. */
64+ .work { min-width: 0; }
65+ .rail { padding: 0 var(--pad) var(--lh); min-width: 0; }
66+ @media (min-width: 1100px) {
67+ /* app shell: the bars stay put, the content and the rail scroll on their own, so each
68+ column has its scrollbar at its own edge (the README's sits next to the README) */
69+ body { height: 100dvh; overflow: hidden; }
70+ .work { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 0; }
71+ .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); }
72+ .work.has-rail.read { grid-template-columns: minmax(0, calc(var(--measure) + 2 * var(--pad))) minmax(var(--rail), 1fr); }
73+ main, .rail { min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--rule) transparent; }
74+ main:focus { outline: none; }
75+ .rail { border-left: 1px solid var(--rule); }
76+ :root { --stick: 0px; }
77+ }
78+ .rail-d > summary { display: none; }
79+ @media (max-width: 1099.98px) {
80+ /* the rail folds into one line above the content */
81+ .work.has-rail { display: flex; flex-direction: column; }
82+ .rail { order: -1; border-bottom: 1px solid var(--rule); padding-bottom: 0; }
83+ .rail-d > summary {
84+ display: block; list-style: none; color: var(--ink-dim);
85+ overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
86+ }
87+ .rail-d > summary::-webkit-details-marker { display: none; }
88+ .rail-d > summary::before { content: "▸ "; color: var(--ink); }
89+ .rail-d[open] > summary::before { content: "▾ "; }
90+ .rail-d[open] { padding-bottom: var(--lh); }
91+ }
92+ @media (max-width: 700px) {
93+ /* the sections drop to a second bar row instead of being cut off */
94+ .top { flex-wrap: wrap; height: auto; }
95+ .top .secs { order: 10; flex-basis: 100%; margin: 0; border-top: 1px solid #202020; }
96+ .top .crumbs { flex: 1; min-width: 0; overflow: hidden; }
97+ .status .st-hints, .status .st-ctx { display: none; }
98+ .list .c-who, .list .c-desc, .list .c-sub, .list .c-ref, .list .c-size, .list .c-lang { display: none; }
99+ }
100+
101+ /* rail blocks: a dim title rule, then key/value rows on the character grid */
102+ .rail section { margin-top: var(--lh); }
103+ .rail h2 {
104+ display: flex; gap: 1ch; color: var(--ink-dim); font-weight: normal;
105+ &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); }
106+ }
107+ .kv {
108+ display: grid; grid-template-columns: 11ch minmax(0, 1fr); column-gap: 1ch;
109+ & dt { color: var(--ink-mute); }
110+ & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
111+ }
112+ .outline {
113+ & a { display: block; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
114+ & a:hover { text-decoration: underline; }
115+ & .l1 { font-weight: bold; }
116+ & .l3 { padding-left: 2ch; }
117+ & .l4, & .l5, & .l6 { padding-left: 4ch; }
118+ & a[aria-current]::before { content: "▸"; margin-left: -1ch; }
119+ }
120+ .rail .clone { border: 0; padding: 0; margin-top: 0; }
121+ .rail .clone-line { flex-wrap: wrap; }
122+}
A internal/adapters/web/static/css/25-panel.css +41 -0
@@ -0,0 +1,41 @@
1+@layer components {
2+ /* bottom panels above the status bar (help, ref switcher, ":" command line) */
3+ .panel {
4+ position: fixed; inset: auto 0 var(--bar-h) 0; margin: 0; width: 100%; z-index: 15;
5+ background: var(--paper); color: var(--ink);
6+ border-top: 2px solid var(--ink);
7+ max-height: min(46dvh, 28rem); overflow: auto; overscroll-behavior: contain;
8+ padding: 0 var(--pad) var(--lh);
9+ &:not([open]) { display: none; }
10+ & .panel-head {
11+ position: sticky; top: 0; background: var(--paper); display: flex; gap: 1ch;
12+ align-items: baseline; height: calc(var(--lh) + 6px); padding-top: 4px;
13+ border-bottom: 1px solid var(--rule); margin-bottom: 4px;
14+ & input { flex: 1; min-width: 0; outline-offset: 2px; }
15+ & button { margin-left: auto; color: var(--ink-dim); white-space: nowrap; }
16+ & button:hover { color: var(--ink); text-decoration: underline; }
17+ & .hi { font-weight: bold; }
18+ }
19+ & .panel-cols { display: grid; grid-template-columns: repeat(auto-fill, minmax(34ch, 1fr)); column-gap: 3ch; }
20+ & .panel-cols h2 { color: var(--ink-dim); font-weight: normal; }
21+ & li a { display: block; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
22+ & li a:hover, & li a:focus-visible { background: var(--sel); }
23+ & li a[aria-current] { font-weight: bold; }
24+ & .mark { display: inline-block; width: 2ch; }
25+ }
26+ .cmd-list {
27+ columns: 34ch; column-gap: 3ch;
28+ & li { break-inside: avoid; }
29+ & li[data-cursor] a { background: var(--row); }
30+ & li[data-cursor] a::before { content: "▸"; position: absolute; margin-left: -1.5ch; }
31+ & a { padding-left: 1.5ch; }
32+ & .k { display: inline-block; width: 8ch; color: var(--ink-mute); }
33+ }
34+ .keys {
35+ display: grid; grid-template-columns: repeat(auto-fill, minmax(46ch, 1fr)); column-gap: 3ch;
36+ & > div { display: grid; grid-template-columns: 14ch 1fr; }
37+ & dt { white-space: nowrap; }
38+ & dd { color: var(--ink-dim); }
39+ & kbd { border: 1px solid var(--rule); padding: 0 .6ch; }
40+ }
41+}
@@ -0,0 +1,41 @@
1+@layer components {
2+ /* bottom panels above the status bar (help, ref switcher, ":" command line) */
3+ .panel {
4+ position: fixed; inset: auto 0 var(--bar-h) 0; margin: 0; width: 100%; z-index: 15;
5+ background: var(--paper); color: var(--ink);
6+ border-top: 2px solid var(--ink);
7+ max-height: min(46dvh, 28rem); overflow: auto; overscroll-behavior: contain;
8+ padding: 0 var(--pad) var(--lh);
9+ &:not([open]) { display: none; }
10+ & .panel-head {
11+ position: sticky; top: 0; background: var(--paper); display: flex; gap: 1ch;
12+ align-items: baseline; height: calc(var(--lh) + 6px); padding-top: 4px;
13+ border-bottom: 1px solid var(--rule); margin-bottom: 4px;
14+ & input { flex: 1; min-width: 0; outline-offset: 2px; }
15+ & button { margin-left: auto; color: var(--ink-dim); white-space: nowrap; }
16+ & button:hover { color: var(--ink); text-decoration: underline; }
17+ & .hi { font-weight: bold; }
18+ }
19+ & .panel-cols { display: grid; grid-template-columns: repeat(auto-fill, minmax(34ch, 1fr)); column-gap: 3ch; }
20+ & .panel-cols h2 { color: var(--ink-dim); font-weight: normal; }
21+ & li a { display: block; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
22+ & li a:hover, & li a:focus-visible { background: var(--sel); }
23+ & li a[aria-current] { font-weight: bold; }
24+ & .mark { display: inline-block; width: 2ch; }
25+ }
26+ .cmd-list {
27+ columns: 34ch; column-gap: 3ch;
28+ & li { break-inside: avoid; }
29+ & li[data-cursor] a { background: var(--row); }
30+ & li[data-cursor] a::before { content: "▸"; position: absolute; margin-left: -1.5ch; }
31+ & a { padding-left: 1.5ch; }
32+ & .k { display: inline-block; width: 8ch; color: var(--ink-mute); }
33+ }
34+ .keys {
35+ display: grid; grid-template-columns: repeat(auto-fill, minmax(46ch, 1fr)); column-gap: 3ch;
36+ & > div { display: grid; grid-template-columns: 14ch 1fr; }
37+ & dt { white-space: nowrap; }
38+ & dd { color: var(--ink-dim); }
39+ & kbd { border: 1px solid var(--rule); padding: 0 .6ch; }
40+ }
41+}
A internal/adapters/web/static/css/30-list.css +73 -0
@@ -0,0 +1,73 @@
1+@layer components {
2+ /* plain-text tables with a ▸ row cursor */
3+ .list {
4+ width: 100%; table-layout: fixed;
5+ & th { color: var(--ink-mute); font-weight: normal; white-space: nowrap; border-bottom: 1px solid var(--rule); }
6+ & th a { text-decoration: none; }
7+ & th a:hover { text-decoration: underline; color: var(--ink); }
8+ & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink); font-weight: bold; }
9+ & td, & th { padding-right: 2ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10+ & .cur { width: 2ch; padding: 0; }
11+ & tbody tr[data-cursor] { background: var(--row); }
12+ & tbody tr[data-cursor] .cur::before { content: "▸"; font-weight: bold; }
13+ & tbody tr:hover { background: var(--row); }
14+ & tbody tr[hidden] { display: none; }
15+ & td > a { text-decoration: none; }
16+ & tr:hover td > a:first-child, & tr[data-cursor] td > a:first-child { text-decoration: underline; }
17+ & .c-sub { color: var(--ink-dim); }
18+ & .c-sub .refs { color: var(--ink); }
19+ }
20+
21+ .home {
22+ & .c-name { width: 24ch; font-weight: bold; }
23+ & th.c-name { font-weight: normal; }
24+ & th.c-name[aria-sort] { font-weight: bold; }
25+ & .c-desc { width: auto; }
26+ & .c-when { width: 13ch; }
27+ & .c-ref { width: 10ch; }
28+ & .c-lang { width: 12ch; padding-right: 0; }
29+ }
30+ .list.tree {
31+ & .c-kind { width: 2ch; color: var(--ink-mute); padding-right: 0; }
32+ & .c-name { width: 34ch; }
33+ & .c-size { width: 9ch; text-align: right; padding-right: 3ch; }
34+ & .c-when { width: 11ch; }
35+ & .c-sub { width: auto; }
36+ & .c-who { width: 12ch; }
37+ }
38+ .list.commits, .list.refs {
39+ & .c-sha { width: 8ch; }
40+ & .c-who { width: 12ch; }
41+ & .c-when { width: 11ch; }
42+ }
43+
44+ /* filter line (home, tree, commits) */
45+ .filter {
46+ display: flex; gap: 1ch; height: var(--lh); margin: var(--lh) 0 0;
47+ & label { color: var(--ink-mute); }
48+ & input { flex: 1; min-width: 0; }
49+ & input::placeholder { color: var(--ink-mute); opacity: 1; }
50+ }
51+
52+ /* headings inside pages read as pager section rules */
53+ .rule-h {
54+ display: flex; gap: 1ch; margin: var(--lh) 0 0; color: var(--ink-dim);
55+ &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); }
56+ & > :first-child { font-weight: bold; color: var(--ink); }
57+ }
58+
59+ /* empty / 404 / error */
60+ .state {
61+ margin-top: var(--lh); max-width: var(--measure);
62+ & h1 { margin-bottom: var(--lh); }
63+ & .code { display: inline-block; min-width: 6ch; }
64+ & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; }
65+ }
66+ .clone {
67+ margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch;
68+ & .clone-line { display: flex; gap: 1ch; align-items: baseline; }
69+ & code { flex: 1; min-width: 0; overflow-x: auto; white-space: pre; }
70+ & .copy { color: var(--ink-dim); white-space: nowrap; min-width: 9ch; text-align: right; }
71+ & .copy:hover { color: var(--ink); text-decoration: underline; }
72+ }
73+}
@@ -0,0 +1,73 @@
1+@layer components {
2+ /* plain-text tables with a ▸ row cursor */
3+ .list {
4+ width: 100%; table-layout: fixed;
5+ & th { color: var(--ink-mute); font-weight: normal; white-space: nowrap; border-bottom: 1px solid var(--rule); }
6+ & th a { text-decoration: none; }
7+ & th a:hover { text-decoration: underline; color: var(--ink); }
8+ & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink); font-weight: bold; }
9+ & td, & th { padding-right: 2ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10+ & .cur { width: 2ch; padding: 0; }
11+ & tbody tr[data-cursor] { background: var(--row); }
12+ & tbody tr[data-cursor] .cur::before { content: "▸"; font-weight: bold; }
13+ & tbody tr:hover { background: var(--row); }
14+ & tbody tr[hidden] { display: none; }
15+ & td > a { text-decoration: none; }
16+ & tr:hover td > a:first-child, & tr[data-cursor] td > a:first-child { text-decoration: underline; }
17+ & .c-sub { color: var(--ink-dim); }
18+ & .c-sub .refs { color: var(--ink); }
19+ }
20+
21+ .home {
22+ & .c-name { width: 24ch; font-weight: bold; }
23+ & th.c-name { font-weight: normal; }
24+ & th.c-name[aria-sort] { font-weight: bold; }
25+ & .c-desc { width: auto; }
26+ & .c-when { width: 13ch; }
27+ & .c-ref { width: 10ch; }
28+ & .c-lang { width: 12ch; padding-right: 0; }
29+ }
30+ .list.tree {
31+ & .c-kind { width: 2ch; color: var(--ink-mute); padding-right: 0; }
32+ & .c-name { width: 34ch; }
33+ & .c-size { width: 9ch; text-align: right; padding-right: 3ch; }
34+ & .c-when { width: 11ch; }
35+ & .c-sub { width: auto; }
36+ & .c-who { width: 12ch; }
37+ }
38+ .list.commits, .list.refs {
39+ & .c-sha { width: 8ch; }
40+ & .c-who { width: 12ch; }
41+ & .c-when { width: 11ch; }
42+ }
43+
44+ /* filter line (home, tree, commits) */
45+ .filter {
46+ display: flex; gap: 1ch; height: var(--lh); margin: var(--lh) 0 0;
47+ & label { color: var(--ink-mute); }
48+ & input { flex: 1; min-width: 0; }
49+ & input::placeholder { color: var(--ink-mute); opacity: 1; }
50+ }
51+
52+ /* headings inside pages read as pager section rules */
53+ .rule-h {
54+ display: flex; gap: 1ch; margin: var(--lh) 0 0; color: var(--ink-dim);
55+ &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); }
56+ & > :first-child { font-weight: bold; color: var(--ink); }
57+ }
58+
59+ /* empty / 404 / error */
60+ .state {
61+ margin-top: var(--lh); max-width: var(--measure);
62+ & h1 { margin-bottom: var(--lh); }
63+ & .code { display: inline-block; min-width: 6ch; }
64+ & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; }
65+ }
66+ .clone {
67+ margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch;
68+ & .clone-line { display: flex; gap: 1ch; align-items: baseline; }
69+ & code { flex: 1; min-width: 0; overflow-x: auto; white-space: pre; }
70+ & .copy { color: var(--ink-dim); white-space: nowrap; min-width: 9ch; text-align: right; }
71+ & .copy:hover { color: var(--ink); text-decoration: underline; }
72+ }
73+}
A internal/adapters/web/static/css/40-summary.css +39 -0
@@ -0,0 +1,39 @@
1+@layer components {
2+ .lede { margin-top: var(--lh); max-width: var(--measure); }
3+
4+ /* key/value facts, aligned on a 12ch key column */
5+ .facts {
6+ margin-top: var(--lh); display: grid; grid-template-columns: repeat(auto-fill, minmax(38ch, 1fr)); column-gap: 3ch;
7+ & > div { display: grid; grid-template-columns: 12ch 1fr; min-width: 0; }
8+ & dt { color: var(--ink-mute); }
9+ & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10+ }
11+
12+ .tagged { color: var(--ink); font-weight: bold; white-space: nowrap; }
13+ .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; }
14+
15+ /* markdown, rendered from the fixture */
16+ .prose {
17+ max-width: var(--measure); margin-top: var(--lh);
18+ & h1, & h2, & h3, & h4 { margin: var(--lh) 0 0; }
19+ & h1::before { content: "# "; color: var(--ink-mute); }
20+ & h2::before { content: "## "; color: var(--ink-mute); }
21+ & h3::before { content: "### "; color: var(--ink-mute); }
22+ & h4::before { content: "#### "; color: var(--ink-mute); }
23+ & > :first-child { margin-top: 0; }
24+ & p, & ul, & ol, & pre, & table, & blockquote { margin-top: var(--lh); }
25+ & ul { list-style: none; & > li::before { content: "- "; color: var(--ink-mute); margin-left: -2ch; } }
26+ & ul, & ol { padding-left: 2ch; }
27+ & ol { list-style: decimal; }
28+ & li > ul, & li > ol, & li > p { margin-top: 0; }
29+ & pre { padding: 0 1ch; border-left: 1px solid var(--ink-mute); overflow-x: auto; font-size: var(--fs-code); line-height: var(--lh-code); }
30+ & :not(pre) > code { border-bottom: 1px dotted var(--ink-mute); }
31+ & blockquote { padding-left: 2ch; border-left: 1px solid var(--rule); color: var(--ink-dim); }
32+ & table { border-collapse: collapse; display: block; overflow-x: auto; }
33+ & th, & td { padding: 0 2ch 0 0; white-space: nowrap; vertical-align: top; }
34+ & th { border-bottom: 1px solid var(--rule); }
35+ & hr { margin: var(--lh) 0; }
36+ & .alt { color: var(--ink-mute); }
37+ & img { display: none; }
38+ }
39+}
@@ -0,0 +1,39 @@
1+@layer components {
2+ .lede { margin-top: var(--lh); max-width: var(--measure); }
3+
4+ /* key/value facts, aligned on a 12ch key column */
5+ .facts {
6+ margin-top: var(--lh); display: grid; grid-template-columns: repeat(auto-fill, minmax(38ch, 1fr)); column-gap: 3ch;
7+ & > div { display: grid; grid-template-columns: 12ch 1fr; min-width: 0; }
8+ & dt { color: var(--ink-mute); }
9+ & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10+ }
11+
12+ .tagged { color: var(--ink); font-weight: bold; white-space: nowrap; }
13+ .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; }
14+
15+ /* markdown, rendered from the fixture */
16+ .prose {
17+ max-width: var(--measure); margin-top: var(--lh);
18+ & h1, & h2, & h3, & h4 { margin: var(--lh) 0 0; }
19+ & h1::before { content: "# "; color: var(--ink-mute); }
20+ & h2::before { content: "## "; color: var(--ink-mute); }
21+ & h3::before { content: "### "; color: var(--ink-mute); }
22+ & h4::before { content: "#### "; color: var(--ink-mute); }
23+ & > :first-child { margin-top: 0; }
24+ & p, & ul, & ol, & pre, & table, & blockquote { margin-top: var(--lh); }
25+ & ul { list-style: none; & > li::before { content: "- "; color: var(--ink-mute); margin-left: -2ch; } }
26+ & ul, & ol { padding-left: 2ch; }
27+ & ol { list-style: decimal; }
28+ & li > ul, & li > ol, & li > p { margin-top: 0; }
29+ & pre { padding: 0 1ch; border-left: 1px solid var(--ink-mute); overflow-x: auto; font-size: var(--fs-code); line-height: var(--lh-code); }
30+ & :not(pre) > code { border-bottom: 1px dotted var(--ink-mute); }
31+ & blockquote { padding-left: 2ch; border-left: 1px solid var(--rule); color: var(--ink-dim); }
32+ & table { border-collapse: collapse; display: block; overflow-x: auto; }
33+ & th, & td { padding: 0 2ch 0 0; white-space: nowrap; vertical-align: top; }
34+ & th { border-bottom: 1px solid var(--rule); }
35+ & hr { margin: var(--lh) 0; }
36+ & .alt { color: var(--ink-mute); }
37+ & img { display: none; }
38+ }
39+}
A internal/adapters/web/static/css/50-code.css +129 -0
@@ -0,0 +1,129 @@
1+@layer components {
2+ /* page title for a file: name, then its actions as plain words */
3+ .file-h {
4+ display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh);
5+ padding-bottom: 2px; border-bottom: 1px solid var(--rule);
6+ & .acts { margin-left: auto; font-weight: normal; display: flex; gap: 2ch; }
7+ }
8+ .notice { margin-top: var(--lh); border-left: 2px solid var(--ink); padding-left: 1ch; max-width: var(--measure); }
9+ .note { color: var(--ink-dim); }
10+ .pad { padding: 0 1ch; }
11+
12+ /* source view: gutter numbers are links (selectable lines), lines span the full width */
13+ .code {
14+ margin: var(--lh) calc(var(--pad) * -1) 0; overflow-x: auto;
15+ font-size: var(--fs-code); line-height: var(--lh-code);
16+ & code { display: grid; min-width: max-content; }
17+ & .line { padding-right: var(--pad); border-left: 2px solid transparent; }
18+ & .line:hover { background: var(--row); }
19+ & .line.sel { background: var(--sel); border-left-color: var(--focus); }
20+ & .ln {
21+ display: inline-block; width: 8ch; padding-right: 2ch; text-align: right; /* 8ch: a multiple of tab-size, so tabs after the gutter keep their width */
22+ color: var(--ink-mute); text-decoration: none; user-select: none;
23+ }
24+ & .ln:hover, & .sel .ln { color: var(--ink-hi); }
25+ }
26+ .source { margin-top: var(--lh); & > summary { color: var(--ink-dim); } }
27+
28+ /* commit header, like `git show` */
29+ .show {
30+ margin-top: var(--lh);
31+ & h1 { max-width: var(--measure); }
32+ & .msg { margin-top: var(--lh); white-space: pre-wrap; max-width: var(--measure); color: var(--ink-dim); }
33+ & .show-kv { margin-top: var(--lh); }
34+ & .show-kv dd { white-space: normal; }
35+ }
36+ .diff-tools {
37+ display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh);
38+ position: sticky; top: var(--stick); z-index: 5; background: var(--paper);
39+ border-bottom: 1px solid var(--rule);
40+ & .grow { flex: 1; }
41+ & button { color: var(--ink-dim); }
42+ & button:hover { color: var(--ink); text-decoration: underline; }
43+ & button[aria-pressed="true"] { color: var(--ink-hi); font-weight: bold; }
44+ & button[aria-pressed="true"]::before { content: "▸"; }
45+ }
46+
47+ /* one file of a diff */
48+ .dfile {
49+ margin-top: var(--lh); scroll-margin-top: calc(var(--stick) + var(--lh) + 4px);
50+ & > summary {
51+ position: sticky; top: calc(var(--stick) + var(--lh) + 1px); z-index: 4;
52+ background: var(--paper-alt); border: 1px solid var(--rule); padding: 0 1ch;
53+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none;
54+ }
55+ & > summary::-webkit-details-marker { display: none; }
56+ & > summary::before { content: "▸ "; }
57+ &[open] > summary::before { content: "▾ "; }
58+ & .st { font-weight: bold; }
59+ & .p { font-weight: bold; }
60+ & > .note { border: 1px solid var(--rule); border-top: 0; }
61+ }
62+ .dt {
63+ width: max-content; min-width: 100%;
64+ font-size: var(--fs-code); line-height: var(--lh-code);
65+ & td { padding: 0; vertical-align: top; }
66+ & .ln { width: 6ch; min-width: 6ch; padding-right: 1ch; text-align: right; color: var(--ink-mute); user-select: none; }
67+ & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); }
68+ & .tx { white-space: pre; }
69+ & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; }
70+ & .add, & tr.add td { background: var(--add); }
71+ & .del, & tr.del td { background: var(--del); }
72+ & .add.m, & tr.add .m { color: var(--teal); font-weight: bold; }
73+ & .del.m, & tr.del .m { color: var(--red); font-weight: bold; }
74+ & tr.add .ln, & .ln.add { color: var(--teal); }
75+ & tr.del .ln, & .ln.del { color: var(--red); }
76+ & .none { background: var(--paper-alt); }
77+ }
78+ .dscroll { overflow-x: auto; border: 1px solid var(--rule); border-top: 0; }
79+ .spl { width: 100%; table-layout: fixed; & .tx { overflow: hidden; text-overflow: ellipsis; } & .ln { width: 6ch; } & .m { width: 2ch; } }
80+ #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; }
81+
82+ /* diff table of contents in the rail */
83+ .files {
84+ & a { display: grid; grid-template-columns: 2ch minmax(0, 1fr) auto; column-gap: 1ch; text-decoration: none; }
85+ & a:hover .p { text-decoration: underline; }
86+ & a[aria-current="true"] { background: var(--row); font-weight: bold; }
87+ & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
88+ & .n { color: var(--ink-dim); font-size: var(--fs-s); }
89+ & .b { grid-column: 2 / -1; color: var(--ink-mute); font-size: var(--fs-s); letter-spacing: .1ch; line-height: 1; padding-bottom: 4px; }
90+ }
91+
92+ @media (max-width: 700px) {
93+ .diff-tools { flex-wrap: wrap; column-gap: 1ch; }
94+ .diff-tools .grow { flex-basis: 100%; height: 0; }
95+ .show-kv dd { overflow-wrap: anywhere; }
96+ .file-h .acts { gap: 1ch; }
97+ }
98+ .pager { display: flex; gap: 3ch; justify-content: center; margin-top: var(--lh); }
99+ .days section[hidden] { display: none; }
100+ .list.refs {
101+ & .c-kind { width: 8ch; }
102+ & .c-name { width: 34ch; }
103+ & .c-info { width: 11ch; }
104+ & .c-sha { width: 9ch; }
105+ & .c-sub { width: auto; }
106+ }
107+ .rail .list.commits {
108+ & .c-who { display: none; }
109+ & .c-sha { width: 8ch; }
110+ & .c-when { width: 9ch; padding-right: 0; text-align: right; }
111+ }
112+ .rail p + p { margin-top: 0; }
113+ .rail section > p { overflow-wrap: anywhere; }
114+}
115+
116+@layer components {
117+ .gallery section { margin-top: calc(var(--lh) * 2); }
118+ .gallery .rule-h > :last-child { font-weight: normal; color: var(--ink-mute); order: 2; }
119+ .gallery .rule-h::after { order: 1; }
120+ .gallery .files { max-width: var(--rail); }
121+ .linkish { text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; }
122+ .linkish:hover { text-decoration-color: currentColor; }
123+}
124+
125+@layer components {
126+ /* counts and bars: + in teal, - in red, everywhere a change is summarised */
127+ .plus { color: var(--teal); }
128+ .minus { color: var(--red); }
129+}
@@ -0,0 +1,129 @@
1+@layer components {
2+ /* page title for a file: name, then its actions as plain words */
3+ .file-h {
4+ display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh);
5+ padding-bottom: 2px; border-bottom: 1px solid var(--rule);
6+ & .acts { margin-left: auto; font-weight: normal; display: flex; gap: 2ch; }
7+ }
8+ .notice { margin-top: var(--lh); border-left: 2px solid var(--ink); padding-left: 1ch; max-width: var(--measure); }
9+ .note { color: var(--ink-dim); }
10+ .pad { padding: 0 1ch; }
11+
12+ /* source view: gutter numbers are links (selectable lines), lines span the full width */
13+ .code {
14+ margin: var(--lh) calc(var(--pad) * -1) 0; overflow-x: auto;
15+ font-size: var(--fs-code); line-height: var(--lh-code);
16+ & code { display: grid; min-width: max-content; }
17+ & .line { padding-right: var(--pad); border-left: 2px solid transparent; }
18+ & .line:hover { background: var(--row); }
19+ & .line.sel { background: var(--sel); border-left-color: var(--focus); }
20+ & .ln {
21+ display: inline-block; width: 8ch; padding-right: 2ch; text-align: right; /* 8ch: a multiple of tab-size, so tabs after the gutter keep their width */
22+ color: var(--ink-mute); text-decoration: none; user-select: none;
23+ }
24+ & .ln:hover, & .sel .ln { color: var(--ink-hi); }
25+ }
26+ .source { margin-top: var(--lh); & > summary { color: var(--ink-dim); } }
27+
28+ /* commit header, like `git show` */
29+ .show {
30+ margin-top: var(--lh);
31+ & h1 { max-width: var(--measure); }
32+ & .msg { margin-top: var(--lh); white-space: pre-wrap; max-width: var(--measure); color: var(--ink-dim); }
33+ & .show-kv { margin-top: var(--lh); }
34+ & .show-kv dd { white-space: normal; }
35+ }
36+ .diff-tools {
37+ display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh);
38+ position: sticky; top: var(--stick); z-index: 5; background: var(--paper);
39+ border-bottom: 1px solid var(--rule);
40+ & .grow { flex: 1; }
41+ & button { color: var(--ink-dim); }
42+ & button:hover { color: var(--ink); text-decoration: underline; }
43+ & button[aria-pressed="true"] { color: var(--ink-hi); font-weight: bold; }
44+ & button[aria-pressed="true"]::before { content: "▸"; }
45+ }
46+
47+ /* one file of a diff */
48+ .dfile {
49+ margin-top: var(--lh); scroll-margin-top: calc(var(--stick) + var(--lh) + 4px);
50+ & > summary {
51+ position: sticky; top: calc(var(--stick) + var(--lh) + 1px); z-index: 4;
52+ background: var(--paper-alt); border: 1px solid var(--rule); padding: 0 1ch;
53+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none;
54+ }
55+ & > summary::-webkit-details-marker { display: none; }
56+ & > summary::before { content: "▸ "; }
57+ &[open] > summary::before { content: "▾ "; }
58+ & .st { font-weight: bold; }
59+ & .p { font-weight: bold; }
60+ & > .note { border: 1px solid var(--rule); border-top: 0; }
61+ }
62+ .dt {
63+ width: max-content; min-width: 100%;
64+ font-size: var(--fs-code); line-height: var(--lh-code);
65+ & td { padding: 0; vertical-align: top; }
66+ & .ln { width: 6ch; min-width: 6ch; padding-right: 1ch; text-align: right; color: var(--ink-mute); user-select: none; }
67+ & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); }
68+ & .tx { white-space: pre; }
69+ & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; }
70+ & .add, & tr.add td { background: var(--add); }
71+ & .del, & tr.del td { background: var(--del); }
72+ & .add.m, & tr.add .m { color: var(--teal); font-weight: bold; }
73+ & .del.m, & tr.del .m { color: var(--red); font-weight: bold; }
74+ & tr.add .ln, & .ln.add { color: var(--teal); }
75+ & tr.del .ln, & .ln.del { color: var(--red); }
76+ & .none { background: var(--paper-alt); }
77+ }
78+ .dscroll { overflow-x: auto; border: 1px solid var(--rule); border-top: 0; }
79+ .spl { width: 100%; table-layout: fixed; & .tx { overflow: hidden; text-overflow: ellipsis; } & .ln { width: 6ch; } & .m { width: 2ch; } }
80+ #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; }
81+
82+ /* diff table of contents in the rail */
83+ .files {
84+ & a { display: grid; grid-template-columns: 2ch minmax(0, 1fr) auto; column-gap: 1ch; text-decoration: none; }
85+ & a:hover .p { text-decoration: underline; }
86+ & a[aria-current="true"] { background: var(--row); font-weight: bold; }
87+ & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
88+ & .n { color: var(--ink-dim); font-size: var(--fs-s); }
89+ & .b { grid-column: 2 / -1; color: var(--ink-mute); font-size: var(--fs-s); letter-spacing: .1ch; line-height: 1; padding-bottom: 4px; }
90+ }
91+
92+ @media (max-width: 700px) {
93+ .diff-tools { flex-wrap: wrap; column-gap: 1ch; }
94+ .diff-tools .grow { flex-basis: 100%; height: 0; }
95+ .show-kv dd { overflow-wrap: anywhere; }
96+ .file-h .acts { gap: 1ch; }
97+ }
98+ .pager { display: flex; gap: 3ch; justify-content: center; margin-top: var(--lh); }
99+ .days section[hidden] { display: none; }
100+ .list.refs {
101+ & .c-kind { width: 8ch; }
102+ & .c-name { width: 34ch; }
103+ & .c-info { width: 11ch; }
104+ & .c-sha { width: 9ch; }
105+ & .c-sub { width: auto; }
106+ }
107+ .rail .list.commits {
108+ & .c-who { display: none; }
109+ & .c-sha { width: 8ch; }
110+ & .c-when { width: 9ch; padding-right: 0; text-align: right; }
111+ }
112+ .rail p + p { margin-top: 0; }
113+ .rail section > p { overflow-wrap: anywhere; }
114+}
115+
116+@layer components {
117+ .gallery section { margin-top: calc(var(--lh) * 2); }
118+ .gallery .rule-h > :last-child { font-weight: normal; color: var(--ink-mute); order: 2; }
119+ .gallery .rule-h::after { order: 1; }
120+ .gallery .files { max-width: var(--rail); }
121+ .linkish { text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; }
122+ .linkish:hover { text-decoration-color: currentColor; }
123+}
124+
125+@layer components {
126+ /* counts and bars: + in teal, - in red, everywhere a change is summarised */
127+ .plus { color: var(--teal); }
128+ .minus { color: var(--red); }
129+}
A internal/adapters/web/static/css/60-proposal.css +93 -0
@@ -0,0 +1,93 @@
1+@layer components {
2+ /* proposals: the decision gate. Hue only where it carries a verdict:
3+ check state (pass teal, fail red) and open high/medium findings (red). */
4+ .criteria {
5+ margin-top: calc(var(--lh) / 2); max-width: var(--measure);
6+ & li { padding-left: 4ch; text-indent: -4ch; }
7+ & .ck { color: var(--teal); font-weight: bold; }
8+ & li[data-met="false"] .ck { color: var(--red); }
9+ & .mute { font-size: var(--fs-s); overflow-wrap: anywhere; }
10+ }
11+ .prop-h .show-kv { margin-top: 0; }
12+ .prop-h + .rule-h { margin-top: var(--lh); }
13+
14+ .since {
15+ display: flex; flex-wrap: wrap; column-gap: 3ch; margin-top: var(--lh);
16+ & a { text-decoration: none; color: var(--ink-dim); }
17+ & a:hover { color: var(--ink); text-decoration: underline; }
18+ & a[aria-current] { color: var(--ink-hi); font-weight: bold; }
19+ & a[aria-current]::before { content: "▸ "; }
20+ & .mute { font-weight: normal; }
21+ }
22+
23+ /* decision block: first thing in the rail */
24+ .decide {
25+ & .d-state { font-weight: bold; color: var(--ink-hi); text-transform: uppercase; letter-spacing: .1ch; }
26+ &[data-state="ready"] .d-state::before { content: "● "; color: var(--teal); }
27+ &[data-state="accepted"] .d-state::before { content: "✓ "; color: var(--teal); }
28+ &[data-state="refused"] .d-state::before { content: "✗ "; color: var(--red); }
29+ & p + p { margin-top: 0; }
30+ & .kv { grid-template-columns: 9ch minmax(0, 1fr); }
31+ & .kv dd { white-space: normal; }
32+ & .d-acts { margin-top: calc(var(--lh) / 2); }
33+ & textarea {
34+ display: block; width: 100%; resize: vertical; font: inherit; color: inherit;
35+ background: var(--paper-alt); border: 1px solid var(--rule); padding: 0 1ch;
36+ }
37+ & textarea::placeholder { color: var(--ink-mute); opacity: 1; }
38+ & .d-btns { display: flex; gap: 2ch; margin-top: 4px; }
39+ & .d-btns button { color: var(--ink-dim); }
40+ & .d-btns button:hover { color: var(--ink); text-decoration: underline; }
41+ & .d-btns .d-main { background: var(--ink); color: var(--paper); font-weight: bold; padding: 0 1ch; }
42+ & .d-btns .d-main:hover { background: var(--ink-hi); text-decoration: none; }
43+ & .d-or { margin-top: calc(var(--lh) / 2); }
44+ & .clone { margin-top: 0; }
45+ & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
46+ }
47+ .closed { margin-top: calc(var(--lh) / 2); & > summary { color: var(--ink-dim); } }
48+
49+ .checks li { display: grid; grid-template-columns: 5ch minmax(0, 1fr) auto; column-gap: 1ch; }
50+ .checks .c-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
51+ .checks .c-st { font-weight: bold; color: var(--teal); }
52+ .checks [data-state="fail"] .c-st { color: var(--red); }
53+ .checks [data-state="run"] .c-st { color: var(--ink-dim); }
54+
55+ .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
56+ .pcommits li[data-new] { background: var(--row); }
57+ .pcommits .s { color: var(--ink); }
58+
59+ /* a finding: a ruled note, in the rail and under its line in the diff */
60+ .finding {
61+ border-left: 2px solid var(--ink); padding-left: 1ch; margin-top: calc(var(--lh) / 2);
62+ & p { white-space: normal; }
63+ & .f-sev { font-weight: bold; text-transform: uppercase; }
64+ & .f-state { color: var(--ink-dim); }
65+ & .f-where { font-size: var(--fs-s); overflow-wrap: anywhere; }
66+ & .f-reason { color: var(--ink-dim); }
67+ &[data-state="open"][data-sev="high"], &[data-state="open"][data-sev="medium"] { border-left-color: var(--red); }
68+ &[data-state="open"][data-sev="high"] .f-sev, &[data-state="open"][data-sev="medium"] .f-sev { color: var(--red); }
69+ &[data-state="fixed"], &[data-state="dismissed"] { border-left-color: var(--rule); }
70+ &[data-state="fixed"] .f-text, &[data-state="dismissed"] .f-text { color: var(--ink-dim); }
71+ &[data-state="fixed"] .f-sev, &[data-state="dismissed"] .f-sev { font-weight: normal; color: var(--ink-mute); }
72+ }
73+ .dt .fnote td { background: var(--paper-alt); padding: 2px 0 4px; white-space: normal; }
74+ .dt .fnote .finding { margin: 0; max-width: var(--measure); font-size: var(--fs); line-height: var(--lh); }
75+ .dfile .fcount { color: var(--red); font-weight: bold; }
76+
77+ .list.props {
78+ & .c-n { width: 5ch; }
79+ & .c-state { width: 18ch; }
80+ & .c-subject { overflow: hidden; text-overflow: ellipsis; }
81+ & .c-chk { width: 6ch; }
82+ & .c-fnd { width: 9ch; }
83+ & .c-who { width: 12ch; }
84+ & .c-when { width: 9ch; }
85+ & tr[data-state="ready"] .c-state { font-weight: bold; }
86+ & tr[data-state="ready"] .c-state::before { content: "● "; color: var(--teal); }
87+ }
88+ .rail code { border-bottom: 1px dotted var(--ink-mute); }
89+
90+ @media (max-width: 700px) {
91+ .list.props .c-chk, .list.props .c-fnd { display: none; }
92+ }
93+}
@@ -0,0 +1,93 @@
1+@layer components {
2+ /* proposals: the decision gate. Hue only where it carries a verdict:
3+ check state (pass teal, fail red) and open high/medium findings (red). */
4+ .criteria {
5+ margin-top: calc(var(--lh) / 2); max-width: var(--measure);
6+ & li { padding-left: 4ch; text-indent: -4ch; }
7+ & .ck { color: var(--teal); font-weight: bold; }
8+ & li[data-met="false"] .ck { color: var(--red); }
9+ & .mute { font-size: var(--fs-s); overflow-wrap: anywhere; }
10+ }
11+ .prop-h .show-kv { margin-top: 0; }
12+ .prop-h + .rule-h { margin-top: var(--lh); }
13+
14+ .since {
15+ display: flex; flex-wrap: wrap; column-gap: 3ch; margin-top: var(--lh);
16+ & a { text-decoration: none; color: var(--ink-dim); }
17+ & a:hover { color: var(--ink); text-decoration: underline; }
18+ & a[aria-current] { color: var(--ink-hi); font-weight: bold; }
19+ & a[aria-current]::before { content: "▸ "; }
20+ & .mute { font-weight: normal; }
21+ }
22+
23+ /* decision block: first thing in the rail */
24+ .decide {
25+ & .d-state { font-weight: bold; color: var(--ink-hi); text-transform: uppercase; letter-spacing: .1ch; }
26+ &[data-state="ready"] .d-state::before { content: "● "; color: var(--teal); }
27+ &[data-state="accepted"] .d-state::before { content: "✓ "; color: var(--teal); }
28+ &[data-state="refused"] .d-state::before { content: "✗ "; color: var(--red); }
29+ & p + p { margin-top: 0; }
30+ & .kv { grid-template-columns: 9ch minmax(0, 1fr); }
31+ & .kv dd { white-space: normal; }
32+ & .d-acts { margin-top: calc(var(--lh) / 2); }
33+ & textarea {
34+ display: block; width: 100%; resize: vertical; font: inherit; color: inherit;
35+ background: var(--paper-alt); border: 1px solid var(--rule); padding: 0 1ch;
36+ }
37+ & textarea::placeholder { color: var(--ink-mute); opacity: 1; }
38+ & .d-btns { display: flex; gap: 2ch; margin-top: 4px; }
39+ & .d-btns button { color: var(--ink-dim); }
40+ & .d-btns button:hover { color: var(--ink); text-decoration: underline; }
41+ & .d-btns .d-main { background: var(--ink); color: var(--paper); font-weight: bold; padding: 0 1ch; }
42+ & .d-btns .d-main:hover { background: var(--ink-hi); text-decoration: none; }
43+ & .d-or { margin-top: calc(var(--lh) / 2); }
44+ & .clone { margin-top: 0; }
45+ & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
46+ }
47+ .closed { margin-top: calc(var(--lh) / 2); & > summary { color: var(--ink-dim); } }
48+
49+ .checks li { display: grid; grid-template-columns: 5ch minmax(0, 1fr) auto; column-gap: 1ch; }
50+ .checks .c-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
51+ .checks .c-st { font-weight: bold; color: var(--teal); }
52+ .checks [data-state="fail"] .c-st { color: var(--red); }
53+ .checks [data-state="run"] .c-st { color: var(--ink-dim); }
54+
55+ .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
56+ .pcommits li[data-new] { background: var(--row); }
57+ .pcommits .s { color: var(--ink); }
58+
59+ /* a finding: a ruled note, in the rail and under its line in the diff */
60+ .finding {
61+ border-left: 2px solid var(--ink); padding-left: 1ch; margin-top: calc(var(--lh) / 2);
62+ & p { white-space: normal; }
63+ & .f-sev { font-weight: bold; text-transform: uppercase; }
64+ & .f-state { color: var(--ink-dim); }
65+ & .f-where { font-size: var(--fs-s); overflow-wrap: anywhere; }
66+ & .f-reason { color: var(--ink-dim); }
67+ &[data-state="open"][data-sev="high"], &[data-state="open"][data-sev="medium"] { border-left-color: var(--red); }
68+ &[data-state="open"][data-sev="high"] .f-sev, &[data-state="open"][data-sev="medium"] .f-sev { color: var(--red); }
69+ &[data-state="fixed"], &[data-state="dismissed"] { border-left-color: var(--rule); }
70+ &[data-state="fixed"] .f-text, &[data-state="dismissed"] .f-text { color: var(--ink-dim); }
71+ &[data-state="fixed"] .f-sev, &[data-state="dismissed"] .f-sev { font-weight: normal; color: var(--ink-mute); }
72+ }
73+ .dt .fnote td { background: var(--paper-alt); padding: 2px 0 4px; white-space: normal; }
74+ .dt .fnote .finding { margin: 0; max-width: var(--measure); font-size: var(--fs); line-height: var(--lh); }
75+ .dfile .fcount { color: var(--red); font-weight: bold; }
76+
77+ .list.props {
78+ & .c-n { width: 5ch; }
79+ & .c-state { width: 18ch; }
80+ & .c-subject { overflow: hidden; text-overflow: ellipsis; }
81+ & .c-chk { width: 6ch; }
82+ & .c-fnd { width: 9ch; }
83+ & .c-who { width: 12ch; }
84+ & .c-when { width: 9ch; }
85+ & tr[data-state="ready"] .c-state { font-weight: bold; }
86+ & tr[data-state="ready"] .c-state::before { content: "● "; color: var(--teal); }
87+ }
88+ .rail code { border-bottom: 1px dotted var(--ink-mute); }
89+
90+ @media (max-width: 700px) {
91+ .list.props .c-chk, .list.props .c-fnd { display: none; }
92+ }
93+}
A internal/adapters/web/static/css/70-footer.css +12 -0
@@ -0,0 +1,12 @@
1+@layer components {
2+ /* AGPL §13: the version and the link to the source sit in the status bar, always visible */
3+ .status .st-src {
4+ flex: none;
5+ & a { color: var(--bar-ink); text-decoration: underline; text-decoration-color: var(--bar-dim); text-underline-offset: 3px; }
6+ & a:hover { color: #fff; text-decoration-color: currentColor; }
7+ }
8+ @media (max-width: 700px) {
9+ .status .st-ver { display: none; }
10+ .status .st-hints { display: none; }
11+ }
12+}
@@ -0,0 +1,12 @@
1+@layer components {
2+ /* AGPL §13: the version and the link to the source sit in the status bar, always visible */
3+ .status .st-src {
4+ flex: none;
5+ & a { color: var(--bar-ink); text-decoration: underline; text-decoration-color: var(--bar-dim); text-underline-offset: 3px; }
6+ & a:hover { color: #fff; text-decoration-color: currentColor; }
7+ }
8+ @media (max-width: 700px) {
9+ .status .st-ver { display: none; }
10+ .status .st-hints { display: none; }
11+ }
12+}
A internal/adapters/web/static/js/ermite.js +32 -0
@@ -0,0 +1,32 @@
1+// ermite.js: the one thing hx-live cannot do is a global key *sequence* state machine
2+// ("g" then "h"), nor ignore keystrokes aimed at inputs before any element is focused.
3+// It only translates keys into DOM events named "k-<name>" on <body>; every reaction
4+// (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live.
5+(() => {
6+ const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
7+ s: 's', t: 't', c: 'c', r: 'r', p: 'p', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' };
8+ const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', p: 'gp', g: 'top' };
9+ let pending = 0;
10+ const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
11+ addEventListener('keydown', (e) => {
12+ if (e.ctrlKey || e.metaKey || e.altKey) return;
13+ const typing = e.target.closest('input, textarea, select, [contenteditable]');
14+ if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); }
15+ if (typing) return;
16+ if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
17+ if (pending) {
18+ const name = goto[e.key];
19+ pending = 0;
20+ if (name) { e.preventDefault(); fire(name); }
21+ return;
22+ }
23+ if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; }
24+ const name = single[e.key];
25+ if (!name) return;
26+ if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault();
27+ fire(name);
28+ });
29+ // In the app shell the page does not scroll, <main> does: give it focus so arrows,
30+ // PageUp/PageDown and space scroll the content without a click first.
31+ if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
32+})();
@@ -0,0 +1,32 @@
1+// ermite.js: the one thing hx-live cannot do is a global key *sequence* state machine
2+// ("g" then "h"), nor ignore keystrokes aimed at inputs before any element is focused.
3+// It only translates keys into DOM events named "k-<name>" on <body>; every reaction
4+// (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live.
5+(() => {
6+ const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
7+ s: 's', t: 't', c: 'c', r: 'r', p: 'p', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' };
8+ const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', p: 'gp', g: 'top' };
9+ let pending = 0;
10+ const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
11+ addEventListener('keydown', (e) => {
12+ if (e.ctrlKey || e.metaKey || e.altKey) return;
13+ const typing = e.target.closest('input, textarea, select, [contenteditable]');
14+ if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); }
15+ if (typing) return;
16+ if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
17+ if (pending) {
18+ const name = goto[e.key];
19+ pending = 0;
20+ if (name) { e.preventDefault(); fire(name); }
21+ return;
22+ }
23+ if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; }
24+ const name = single[e.key];
25+ if (!name) return;
26+ if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault();
27+ fire(name);
28+ });
29+ // In the app shell the page does not scroll, <main> does: give it focus so arrows,
30+ // PageUp/PageDown and space scroll the content without a click first.
31+ if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
32+})();
A internal/adapters/web/static/js/htmx-LICENSE +13 -0
@@ -0,0 +1,13 @@
1+Zero-Clause BSD
2+=============
3+
4+Permission to use, copy, modify, and/or distribute this software for
5+any purpose with or without fee is hereby granted.
6+
7+THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
8+WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
9+OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
10+FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
11+DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
12+AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
13+OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,13 @@
1+Zero-Clause BSD
2+=============
3+
4+Permission to use, copy, modify, and/or distribute this software for
5+any purpose with or without fee is hereby granted.
6+
7+THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
8+WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
9+OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
10+FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
11+DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
12+AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
13+OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
A internal/adapters/web/static/js/htmx.min.js +1 -0 generated file: collapsed
A internal/adapters/web/static/js/hx-live.min.js +1 -0 generated file: collapsed
A internal/adapters/web/static/js/hx-sse.min.js +1 -0
@@ -0,0 +1,1 @@
1+(()=>{let e,t=new Set;function n(e){for(let t of Object.keys(e))"last-event-id"===t.toLowerCase()&&delete e[t]}async function*r(e){let t=e.reader,n=e.lastEventId,r=new TextDecoder,s="",a=!1,i=!1,o={data:"",event:"",retry:null},c=!0;try{for(;;){let{done:e,value:l}=await t.read();if(e)break;let m=r.decode(l,{stream:!0});c&&(65279===m.charCodeAt(0)&&(m=m.slice(1)),c=!1),s+=m;let d=s.split(/\r\n|\r|\n/);s=d.pop()||"";for(let e of d){if(!e){(a||i||o.event)&&(yield{...o,id:n,hasData:a,hasId:i}),a=!1,i=!1,o={data:"",event:"",retry:null};continue}let t,r,s=e.indexOf(":");if(0!==s)if(s<0?(t=e,r=""):(t=e.slice(0,s),r=e.slice(s+1)," "===r[0]&&(r=r.slice(1))),"data"===t)o.data+=(a?"\n":"")+r,a=!0;else if("event"===t)o.event=r;else if("id"===t)r.includes("\0")||(n=r,i=!0);else if("retry"===t){let e=parseInt(r,10);isNaN(e)||(o.retry=e)}}}}finally{t.releaseLock(),e.reader=null}}async function s(t,s){let i=t.sourceElement,o=function(t){let n=null!=e.attributeValue(t,"hx-sse:connect"),r=e.HCON.parse(e.attributeValue(t,"hx-config")).sse||{};return{reconnect:n,reconnectDelay:500,reconnectMaxDelay:6e4,reconnectMaxAttempts:1/0,reconnectJitter:.3,pauseOnBackground:n,releaseOn:n?"immediate":"end",...htmx.config.sse,...r}}(i),c=!1;function l(){s&&(s(),s=null)}"immediate"===o.releaseOn&&l();let m={url:t.request.action,config:o,abortController:{abort:t.request.abort,signal:t.request.signal},reader:null,lastEventId:"",delayCanceller:null,visibilityHandler:null,attempt:0,cancelled:!1,status:null};e.htmxProp(i).sse=m;let d=!1,u=null;if(o.pauseOnBackground){let e=()=>{document.hidden?(d=!0,m.reader?.cancel()):d&&(d=!1,u&&u())};document.addEventListener("visibilitychange",e),m.visibilityHandler=e}if(m.cancelled=!1,!e.triggerHtmxEvent(i,"htmx:sse:before:connection",{connection:m})||m.cancelled)return void a(i,"cancelled");m.status=t.response.status,e.triggerHtmxEvent(i,"htmx:sse:after:connection",{connection:m});let h=t.response.raw;try{for(;i.isConnected;){if(m.attempt>0){if(d){if(await new Promise(e=>{u=e}),u=null,!i.isConnected)break;m.attempt=1,c=!0}if(!c&&(!o.reconnect||m.attempt>o.reconnectMaxAttempts))break;let r=htmx.parseInterval(o.reconnectDelay)??o.reconnectDelay,s=htmx.parseInterval(o.reconnectMaxDelay)??o.reconnectMaxDelay,a=Math.min(r*Math.pow(2,m.attempt-1),s);if(o.reconnectJitter>0){let e=a*o.reconnectJitter;a=Math.max(0,a+(2*Math.random()-1)*e)}if(m.cancelled=!1,!e.triggerHtmxEvent(i,"htmx:sse:before:connection",{connection:m})||m.cancelled)break;if(await new Promise(e=>{m.delayCanceller=e,setTimeout(e,a)}),m.delayCanceller=null,!i.isConnected)break;let l=new AbortController;m.abortController=l;try{n(t.request.headers),m.lastEventId&&(t.request.headers["Last-Event-ID"]=m.lastEventId),h=await fetch(t.request.action,{...t.request,signal:l.signal})}catch(t){if(l.signal.aborted)break;e.triggerHtmxEvent(i,"htmx:sse:error",{connection:m,error:t}),c=!1,m.attempt++;continue}if(!h.ok){e.triggerHtmxEvent(i,"htmx:sse:error",{connection:m,error:new Error(`SSE reconnect failed with status ${h.status}`),status:h.status}),c=!1,m.attempt++;continue}m.status=h.status,e.triggerHtmxEvent(i,"htmx:sse:after:connection",{connection:m}),m.attempt=0}c=!1;try{m.reader=h.body.getReader();for await(let s of r(m)){if(!i.isConnected||c)break;if(s.hasId&&(m.lastEventId=s.id,s.id||n(t.request.headers)),!s.hasData&&!s.event)continue;let r=[],d={connection:m,message:{data:s.data,event:s.event,id:s.id},cancelled:!1,waitUntil:e=>r.push(Promise.resolve(e))},u=e.triggerHtmxEvent(i,"htmx:sse:before:message",d);if(await Promise.all(r),u&&!d.cancelled){if(null!=s.retry&&(o.reconnectDelay=s.retry),d.message.event){"hx:release"===d.message.event&&l(),htmx.trigger(i,d.message.event,{data:d.message.data,id:d.message.id}),e.triggerHtmxEvent(i,"htmx:sse:after:message",{connection:m,message:d.message});let t=e.attributeValue(i,"hx-sse:close");if(t&&d.message.event===t)return void a(i,"message");continue}t.text=d.message.data,t.swap.includes("swapEmpty")||(t.swap+=" swapEmpty:false"),await htmx.swap(t),"first"===o.releaseOn&&l(),e.triggerHtmxEvent(i,"htmx:sse:after:message",{connection:m,message:d.message})}}}catch(t){m.abortController?.signal?.aborted||"AbortError"===t.name||e.triggerHtmxEvent(i,"htmx:sse:error",{connection:m,error:t})}if(!i.isConnected)break;m.attempt++}}finally{l(),a(i,i.isConnected?"ended":"removed")}}function a(t,n){let r=t?._htmx?.sse;r&&(r.abortController?.abort(),r.reader?.cancel?.(),r.delayCanceller&&r.delayCanceller(),r.visibilityHandler&&document.removeEventListener("visibilitychange",r.visibilityHandler),e.triggerHtmxEvent(t,"htmx:sse:close",{connection:r,reason:n||"cleanup"}),delete t._htmx.sse)}htmx.registerExtension("sse",{init:t=>{e=t},htmx_config_request:(e,{ctx:{request:t}})=>{t.headers.Accept=`${t.headers.Accept??t.headers.accept??"text/html"}, text/event-stream`},htmx_before_response:(t,n)=>{let r,a=n.ctx,i=a.response.raw.headers.get("Content-Type");if(i?.includes("text/event-stream"))return clearTimeout(a.requestTimeout),a.extensionPromise=new Promise(e=>r=e),s(a,r).catch(n=>{"AbortError"!==n.name&&e.triggerHtmxEvent(t,"htmx:sse:error",{error:n,url:a.request.action})}),!1},htmx_after_process:n=>{let r=htmx.config.metaCharacter||":",s=n=>{!function(e){for(let n of["sse-connect","sse-close","sse-swap"])e.hasAttribute(n)&&!t.has(n)&&("sse-swap"===n?console.warn("htmx: [hx-sse] sse-swap is removed in htmx 4. Unnamed SSE messages are swapped automatically. Named events are dispatched as DOM events."):console.warn(`htmx: [hx-sse] legacy attribute ${n} is deprecated; use hx-sse:${n.slice(4)} instead`),t.add(n))}(n);for(let e of["connect","close"]){let t=`sse-${e}`;if(!n.hasAttribute(t))continue;let s=(htmx.config.prefix||"hx-")+"sse"+r+e;n.hasAttribute(s)||n.setAttribute(s,n.getAttribute(t))}!function(t){let n=e.attributeValue(t,"hx-sse:connect");if(!n)return;if(t._htmx?.sse)return;let r=e.attributeValue(t,"hx-trigger")||"load";e.onTrigger(t,r,()=>{t._htmx?.sse||htmx.ajax("GET",n,{source:t})})}(n)};s(n);let a=`[${CSS.escape("hx-sse"+r+"connect")}]`;htmx.config.prefix&&(a+=`,[${CSS.escape(htmx.config.prefix+"sse"+r+"connect")}]`),n.querySelectorAll(`${a},[sse-connect],[sse-close],[sse-swap]`).forEach(s)},htmx_before_cleanup:e=>{a(e)}})})();⏎̸
@@ -0,0 +1,1 @@
1+(()=>{let e,t=new Set;function n(e){for(let t of Object.keys(e))"last-event-id"===t.toLowerCase()&&delete e[t]}async function*r(e){let t=e.reader,n=e.lastEventId,r=new TextDecoder,s="",a=!1,i=!1,o={data:"",event:"",retry:null},c=!0;try{for(;;){let{done:e,value:l}=await t.read();if(e)break;let m=r.decode(l,{stream:!0});c&&(65279===m.charCodeAt(0)&&(m=m.slice(1)),c=!1),s+=m;let d=s.split(/\r\n|\r|\n/);s=d.pop()||"";for(let e of d){if(!e){(a||i||o.event)&&(yield{...o,id:n,hasData:a,hasId:i}),a=!1,i=!1,o={data:"",event:"",retry:null};continue}let t,r,s=e.indexOf(":");if(0!==s)if(s<0?(t=e,r=""):(t=e.slice(0,s),r=e.slice(s+1)," "===r[0]&&(r=r.slice(1))),"data"===t)o.data+=(a?"\n":"")+r,a=!0;else if("event"===t)o.event=r;else if("id"===t)r.includes("\0")||(n=r,i=!0);else if("retry"===t){let e=parseInt(r,10);isNaN(e)||(o.retry=e)}}}}finally{t.releaseLock(),e.reader=null}}async function s(t,s){let i=t.sourceElement,o=function(t){let n=null!=e.attributeValue(t,"hx-sse:connect"),r=e.HCON.parse(e.attributeValue(t,"hx-config")).sse||{};return{reconnect:n,reconnectDelay:500,reconnectMaxDelay:6e4,reconnectMaxAttempts:1/0,reconnectJitter:.3,pauseOnBackground:n,releaseOn:n?"immediate":"end",...htmx.config.sse,...r}}(i),c=!1;function l(){s&&(s(),s=null)}"immediate"===o.releaseOn&&l();let m={url:t.request.action,config:o,abortController:{abort:t.request.abort,signal:t.request.signal},reader:null,lastEventId:"",delayCanceller:null,visibilityHandler:null,attempt:0,cancelled:!1,status:null};e.htmxProp(i).sse=m;let d=!1,u=null;if(o.pauseOnBackground){let e=()=>{document.hidden?(d=!0,m.reader?.cancel()):d&&(d=!1,u&&u())};document.addEventListener("visibilitychange",e),m.visibilityHandler=e}if(m.cancelled=!1,!e.triggerHtmxEvent(i,"htmx:sse:before:connection",{connection:m})||m.cancelled)return void a(i,"cancelled");m.status=t.response.status,e.triggerHtmxEvent(i,"htmx:sse:after:connection",{connection:m});let h=t.response.raw;try{for(;i.isConnected;){if(m.attempt>0){if(d){if(await new Promise(e=>{u=e}),u=null,!i.isConnected)break;m.attempt=1,c=!0}if(!c&&(!o.reconnect||m.attempt>o.reconnectMaxAttempts))break;let r=htmx.parseInterval(o.reconnectDelay)??o.reconnectDelay,s=htmx.parseInterval(o.reconnectMaxDelay)??o.reconnectMaxDelay,a=Math.min(r*Math.pow(2,m.attempt-1),s);if(o.reconnectJitter>0){let e=a*o.reconnectJitter;a=Math.max(0,a+(2*Math.random()-1)*e)}if(m.cancelled=!1,!e.triggerHtmxEvent(i,"htmx:sse:before:connection",{connection:m})||m.cancelled)break;if(await new Promise(e=>{m.delayCanceller=e,setTimeout(e,a)}),m.delayCanceller=null,!i.isConnected)break;let l=new AbortController;m.abortController=l;try{n(t.request.headers),m.lastEventId&&(t.request.headers["Last-Event-ID"]=m.lastEventId),h=await fetch(t.request.action,{...t.request,signal:l.signal})}catch(t){if(l.signal.aborted)break;e.triggerHtmxEvent(i,"htmx:sse:error",{connection:m,error:t}),c=!1,m.attempt++;continue}if(!h.ok){e.triggerHtmxEvent(i,"htmx:sse:error",{connection:m,error:new Error(`SSE reconnect failed with status ${h.status}`),status:h.status}),c=!1,m.attempt++;continue}m.status=h.status,e.triggerHtmxEvent(i,"htmx:sse:after:connection",{connection:m}),m.attempt=0}c=!1;try{m.reader=h.body.getReader();for await(let s of r(m)){if(!i.isConnected||c)break;if(s.hasId&&(m.lastEventId=s.id,s.id||n(t.request.headers)),!s.hasData&&!s.event)continue;let r=[],d={connection:m,message:{data:s.data,event:s.event,id:s.id},cancelled:!1,waitUntil:e=>r.push(Promise.resolve(e))},u=e.triggerHtmxEvent(i,"htmx:sse:before:message",d);if(await Promise.all(r),u&&!d.cancelled){if(null!=s.retry&&(o.reconnectDelay=s.retry),d.message.event){"hx:release"===d.message.event&&l(),htmx.trigger(i,d.message.event,{data:d.message.data,id:d.message.id}),e.triggerHtmxEvent(i,"htmx:sse:after:message",{connection:m,message:d.message});let t=e.attributeValue(i,"hx-sse:close");if(t&&d.message.event===t)return void a(i,"message");continue}t.text=d.message.data,t.swap.includes("swapEmpty")||(t.swap+=" swapEmpty:false"),await htmx.swap(t),"first"===o.releaseOn&&l(),e.triggerHtmxEvent(i,"htmx:sse:after:message",{connection:m,message:d.message})}}}catch(t){m.abortController?.signal?.aborted||"AbortError"===t.name||e.triggerHtmxEvent(i,"htmx:sse:error",{connection:m,error:t})}if(!i.isConnected)break;m.attempt++}}finally{l(),a(i,i.isConnected?"ended":"removed")}}function a(t,n){let r=t?._htmx?.sse;r&&(r.abortController?.abort(),r.reader?.cancel?.(),r.delayCanceller&&r.delayCanceller(),r.visibilityHandler&&document.removeEventListener("visibilitychange",r.visibilityHandler),e.triggerHtmxEvent(t,"htmx:sse:close",{connection:r,reason:n||"cleanup"}),delete t._htmx.sse)}htmx.registerExtension("sse",{init:t=>{e=t},htmx_config_request:(e,{ctx:{request:t}})=>{t.headers.Accept=`${t.headers.Accept??t.headers.accept??"text/html"}, text/event-stream`},htmx_before_response:(t,n)=>{let r,a=n.ctx,i=a.response.raw.headers.get("Content-Type");if(i?.includes("text/event-stream"))return clearTimeout(a.requestTimeout),a.extensionPromise=new Promise(e=>r=e),s(a,r).catch(n=>{"AbortError"!==n.name&&e.triggerHtmxEvent(t,"htmx:sse:error",{error:n,url:a.request.action})}),!1},htmx_after_process:n=>{let r=htmx.config.metaCharacter||":",s=n=>{!function(e){for(let n of["sse-connect","sse-close","sse-swap"])e.hasAttribute(n)&&!t.has(n)&&("sse-swap"===n?console.warn("htmx: [hx-sse] sse-swap is removed in htmx 4. Unnamed SSE messages are swapped automatically. Named events are dispatched as DOM events."):console.warn(`htmx: [hx-sse] legacy attribute ${n} is deprecated; use hx-sse:${n.slice(4)} instead`),t.add(n))}(n);for(let e of["connect","close"]){let t=`sse-${e}`;if(!n.hasAttribute(t))continue;let s=(htmx.config.prefix||"hx-")+"sse"+r+e;n.hasAttribute(s)||n.setAttribute(s,n.getAttribute(t))}!function(t){let n=e.attributeValue(t,"hx-sse:connect");if(!n)return;if(t._htmx?.sse)return;let r=e.attributeValue(t,"hx-trigger")||"load";e.onTrigger(t,r,()=>{t._htmx?.sse||htmx.ajax("GET",n,{source:t})})}(n)};s(n);let a=`[${CSS.escape("hx-sse"+r+"connect")}]`;htmx.config.prefix&&(a+=`,[${CSS.escape(htmx.config.prefix+"sse"+r+"connect")}]`),n.querySelectorAll(`${a},[sse-connect],[sse-close],[sse-swap]`).forEach(s)},htmx_before_cleanup:e=>{a(e)}})})();⏎̸
A internal/adapters/web/summary.go +221 -0
@@ -0,0 +1,221 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "fmt"
8+ "html"
9+ "html/template"
10+ "net/http"
11+ "strings"
12+ "time"
13+
14+ "connectrpc.com/connect"
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ pb "bnema.dev/ermite-api/gen/ermite"
19+)
20+
21+// maxReadmeBytes is the largest README that is rendered in the page; a
22+// larger one is replaced by a note.
23+const maxReadmeBytes = 512 << 10
24+
25+// recentCommits is how many commits the rail of the summary lists.
26+const recentCommits = 5
27+
28+// readmeNames are the README file names in order of preference (compared
29+// lowercase): Markdown first.
30+var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
31+
32+// summary is the page of a repository: description, README, and in the rail
33+// the clone URL, the facts, the recent commits and the README outline.
34+func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, int, error) {
35+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
36+ if err != nil {
37+ return nil, 0, err
38+ }
39+ if d.head == nil {
40+ l, st := s.emptyState(ctx, d)
41+ return State(l, st), http.StatusOK, nil
42+ }
43+ ref := d.head.GetShortName()
44+ now := s.cfg.Now()
45+
46+ p := SummaryPage{
47+ Desc: d.repo.GetDescription(),
48+ AllHref: repoHref(d.name(), "commits", ref),
49+ Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
50+ }
51+
52+ commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
53+ Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits,
54+ }))
55+ if err != nil {
56+ return nil, 0, err
57+ }
58+ branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
59+ for i, c := range commits.Msg.GetCommits() {
60+ row := CommitRow{
61+ Short: short(c.GetId()), Subject: c.GetSummary(), CHref: repoHref(d.name(), "commit", c.GetId()),
62+ Author: c.GetAuthor().GetName(), Merge: len(c.GetParentIds()) > 1,
63+ Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0,
64+ }
65+ if t := c.GetCommitter().GetTime(); t.IsValid() {
66+ row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
67+ }
68+ p.Recent = append(p.Recent, row)
69+ }
70+
71+ p.Facts = s.facts(ctx, d, now)
72+
73+ if rd, err := s.readme(ctx, d, ref); err != nil {
74+ return nil, 0, err
75+ } else if rd != nil {
76+ p.Readme, p.Outline = rd.readme, rd.outline
77+ }
78+
79+ l := s.repoLayout(ctx, d, "summary")
80+ l.Title = d.name() + " · " + s.instance
81+ l.Status = Status{
82+ Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"),
83+ Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent),
84+ }
85+ l.HasRail, l.Read, l.RailSum = true, true, "clone · facts · recent commits · outline"
86+ return Summary(l, p), http.StatusOK, nil
87+}
88+
89+// maxTagDates is the most tags whose commit dates are read to name the
90+// latest one; with more, the fact shows the count only.
91+const maxTagDates = 20
92+
93+// facts are the key/value rows of the rail.
94+func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact {
95+ last := Fact{Key: "last push", Val: "never", ID: "last-push-" + d.name()}
96+ if t := d.repo.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
97+ last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
98+ last.Time = rfc3339(t.AsTime())
99+ }
100+ fs := []Fact{
101+ last,
102+ {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")},
103+ }
104+ if n := len(d.tags); n > 0 {
105+ val := plural(n, "tag", "tags")
106+ if n <= maxTagDates {
107+ if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" {
108+ val = name + " · " + c.when + " · " + val
109+ }
110+ }
111+ fs = append(fs, Fact{Key: "tag", Val: val})
112+ }
113+ if c := d.repo.GetCreatedAt(); c.IsValid() {
114+ fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())})
115+ }
116+ return fs
117+}
118+
119+// latestTag is the tag whose commit is the newest.
120+func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) {
121+ var name string
122+ var best refCommit
123+ for _, t := range tags {
124+ c, ok := commits[t.GetCommitId()]
125+ if ok && c.short != "" && (name == "" || c.at > best.at) {
126+ name, best = t.GetShortName(), c
127+ }
128+ }
129+ return name, best
130+}
131+
132+// renderedReadme is the README of a repository, ready for the page.
133+type renderedReadme struct {
134+ readme *Readme
135+ outline []Heading
136+}
137+
138+// readme finds the README at the root of the tree of the head commit, reads
139+// it and renders it. It is nil when the repository has none. Rendering
140+// problems degrade to plain text; only API failures are errors.
141+func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
142+ tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()}))
143+ if err != nil {
144+ return nil, err
145+ }
146+ var best *pb.TreeEntry
147+ rank := len(readmeNames)
148+ for _, e := range tree.Msg.GetEntries() {
149+ if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE {
150+ continue
151+ }
152+ for i, n := range readmeNames {
153+ if i < rank && strings.EqualFold(e.GetName(), n) {
154+ best, rank = e, i
155+ }
156+ }
157+ }
158+ if best == nil {
159+ return nil, nil
160+ }
161+ rd := &Readme{Name: best.GetName()}
162+ if best.GetSize() > maxReadmeBytes {
163+ rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")."
164+ return &renderedReadme{readme: rd}, nil
165+ }
166+ body, binary, err := s.readBlob(ctx, d.name(), d.head.GetCommitId(), best.GetPath(), maxReadmeBytes)
167+ if err != nil {
168+ return nil, err
169+ }
170+ if binary {
171+ rd.Note = best.GetName() + " is not text."
172+ return &renderedReadme{readme: rd}, nil
173+ }
174+ name := strings.ToLower(best.GetName())
175+ if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") {
176+ h, outline, err := s.md.render(body, d.name(), ref, "")
177+ if err == nil {
178+ rd.HTML = h
179+ return &renderedReadme{readme: rd, outline: outline}, nil
180+ }
181+ log := zerowrap.FromCtx(ctx)
182+ log.Warn().Err(err).Str("repo", d.name()).Msg("render README, showing plain text")
183+ }
184+ rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
185+ return &renderedReadme{readme: rd}, nil
186+}
187+
188+// readBlob reads a file through the GetBlob stream, at most limit bytes. It
189+// reports a binary file without its content.
190+func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) {
191+ stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path}))
192+ if err != nil {
193+ return nil, false, err
194+ }
195+ defer func() { _ = stream.Close() }()
196+ for stream.Receive() {
197+ m := stream.Msg()
198+ if m.GetInfo().GetBinary() {
199+ return nil, true, nil
200+ }
201+ body = append(body, m.GetChunk()...)
202+ if int64(len(body)) > limit {
203+ return body[:limit], false, nil
204+ }
205+ }
206+ if err := stream.Err(); err != nil {
207+ return nil, false, err
208+ }
209+ return body, false, nil
210+}
211+
212+// humanSize formats a size in bytes.
213+func humanSize(n int64) string {
214+ switch {
215+ case n < 1024:
216+ return itoa(int(n)) + " B"
217+ case n < 1<<20:
218+ return fmt.Sprintf("%.1f KiB", float64(n)/1024)
219+ }
220+ return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
221+}
@@ -0,0 +1,221 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "fmt"
8+ "html"
9+ "html/template"
10+ "net/http"
11+ "strings"
12+ "time"
13+
14+ "connectrpc.com/connect"
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ pb "bnema.dev/ermite-api/gen/ermite"
19+)
20+
21+// maxReadmeBytes is the largest README that is rendered in the page; a
22+// larger one is replaced by a note.
23+const maxReadmeBytes = 512 << 10
24+
25+// recentCommits is how many commits the rail of the summary lists.
26+const recentCommits = 5
27+
28+// readmeNames are the README file names in order of preference (compared
29+// lowercase): Markdown first.
30+var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
31+
32+// summary is the page of a repository: description, README, and in the rail
33+// the clone URL, the facts, the recent commits and the README outline.
34+func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, int, error) {
35+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
36+ if err != nil {
37+ return nil, 0, err
38+ }
39+ if d.head == nil {
40+ l, st := s.emptyState(ctx, d)
41+ return State(l, st), http.StatusOK, nil
42+ }
43+ ref := d.head.GetShortName()
44+ now := s.cfg.Now()
45+
46+ p := SummaryPage{
47+ Desc: d.repo.GetDescription(),
48+ AllHref: repoHref(d.name(), "commits", ref),
49+ Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
50+ }
51+
52+ commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
53+ Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits,
54+ }))
55+ if err != nil {
56+ return nil, 0, err
57+ }
58+ branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
59+ for i, c := range commits.Msg.GetCommits() {
60+ row := CommitRow{
61+ Short: short(c.GetId()), Subject: c.GetSummary(), CHref: repoHref(d.name(), "commit", c.GetId()),
62+ Author: c.GetAuthor().GetName(), Merge: len(c.GetParentIds()) > 1,
63+ Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0,
64+ }
65+ if t := c.GetCommitter().GetTime(); t.IsValid() {
66+ row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
67+ }
68+ p.Recent = append(p.Recent, row)
69+ }
70+
71+ p.Facts = s.facts(ctx, d, now)
72+
73+ if rd, err := s.readme(ctx, d, ref); err != nil {
74+ return nil, 0, err
75+ } else if rd != nil {
76+ p.Readme, p.Outline = rd.readme, rd.outline
77+ }
78+
79+ l := s.repoLayout(ctx, d, "summary")
80+ l.Title = d.name() + " · " + s.instance
81+ l.Status = Status{
82+ Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"),
83+ Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent),
84+ }
85+ l.HasRail, l.Read, l.RailSum = true, true, "clone · facts · recent commits · outline"
86+ return Summary(l, p), http.StatusOK, nil
87+}
88+
89+// maxTagDates is the most tags whose commit dates are read to name the
90+// latest one; with more, the fact shows the count only.
91+const maxTagDates = 20
92+
93+// facts are the key/value rows of the rail.
94+func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact {
95+ last := Fact{Key: "last push", Val: "never", ID: "last-push-" + d.name()}
96+ if t := d.repo.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
97+ last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
98+ last.Time = rfc3339(t.AsTime())
99+ }
100+ fs := []Fact{
101+ last,
102+ {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")},
103+ }
104+ if n := len(d.tags); n > 0 {
105+ val := plural(n, "tag", "tags")
106+ if n <= maxTagDates {
107+ if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" {
108+ val = name + " · " + c.when + " · " + val
109+ }
110+ }
111+ fs = append(fs, Fact{Key: "tag", Val: val})
112+ }
113+ if c := d.repo.GetCreatedAt(); c.IsValid() {
114+ fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())})
115+ }
116+ return fs
117+}
118+
119+// latestTag is the tag whose commit is the newest.
120+func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) {
121+ var name string
122+ var best refCommit
123+ for _, t := range tags {
124+ c, ok := commits[t.GetCommitId()]
125+ if ok && c.short != "" && (name == "" || c.at > best.at) {
126+ name, best = t.GetShortName(), c
127+ }
128+ }
129+ return name, best
130+}
131+
132+// renderedReadme is the README of a repository, ready for the page.
133+type renderedReadme struct {
134+ readme *Readme
135+ outline []Heading
136+}
137+
138+// readme finds the README at the root of the tree of the head commit, reads
139+// it and renders it. It is nil when the repository has none. Rendering
140+// problems degrade to plain text; only API failures are errors.
141+func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
142+ tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()}))
143+ if err != nil {
144+ return nil, err
145+ }
146+ var best *pb.TreeEntry
147+ rank := len(readmeNames)
148+ for _, e := range tree.Msg.GetEntries() {
149+ if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE {
150+ continue
151+ }
152+ for i, n := range readmeNames {
153+ if i < rank && strings.EqualFold(e.GetName(), n) {
154+ best, rank = e, i
155+ }
156+ }
157+ }
158+ if best == nil {
159+ return nil, nil
160+ }
161+ rd := &Readme{Name: best.GetName()}
162+ if best.GetSize() > maxReadmeBytes {
163+ rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")."
164+ return &renderedReadme{readme: rd}, nil
165+ }
166+ body, binary, err := s.readBlob(ctx, d.name(), d.head.GetCommitId(), best.GetPath(), maxReadmeBytes)
167+ if err != nil {
168+ return nil, err
169+ }
170+ if binary {
171+ rd.Note = best.GetName() + " is not text."
172+ return &renderedReadme{readme: rd}, nil
173+ }
174+ name := strings.ToLower(best.GetName())
175+ if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") {
176+ h, outline, err := s.md.render(body, d.name(), ref, "")
177+ if err == nil {
178+ rd.HTML = h
179+ return &renderedReadme{readme: rd, outline: outline}, nil
180+ }
181+ log := zerowrap.FromCtx(ctx)
182+ log.Warn().Err(err).Str("repo", d.name()).Msg("render README, showing plain text")
183+ }
184+ rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
185+ return &renderedReadme{readme: rd}, nil
186+}
187+
188+// readBlob reads a file through the GetBlob stream, at most limit bytes. It
189+// reports a binary file without its content.
190+func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) {
191+ stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path}))
192+ if err != nil {
193+ return nil, false, err
194+ }
195+ defer func() { _ = stream.Close() }()
196+ for stream.Receive() {
197+ m := stream.Msg()
198+ if m.GetInfo().GetBinary() {
199+ return nil, true, nil
200+ }
201+ body = append(body, m.GetChunk()...)
202+ if int64(len(body)) > limit {
203+ return body[:limit], false, nil
204+ }
205+ }
206+ if err := stream.Err(); err != nil {
207+ return nil, false, err
208+ }
209+ return body, false, nil
210+}
211+
212+// humanSize formats a size in bytes.
213+func humanSize(n int64) string {
214+ switch {
215+ case n < 1024:
216+ return itoa(int(n)) + " B"
217+ case n < 1<<20:
218+ return fmt.Sprintf("%.1f KiB", float64(n)/1024)
219+ }
220+ return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
221+}
A internal/adapters/web/view.go +177 -0
@@ -0,0 +1,177 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import "html/template"
6+
7+// View models handed to the templ components. Components only loop and
8+// branch on these; the handlers build them from the API messages.
9+
10+// Crumb is one segment of the path in the top bar.
11+type Crumb struct {
12+ Label string
13+ Href string
14+ Current bool
15+}
16+
17+// Section is a repository section in the top bar, reached by its key.
18+type Section struct {
19+ Key string // mnemonic key
20+ Post string // rest of the label after the key
21+ Href string
22+ Current bool
23+}
24+
25+// Status is the content of the bottom bar.
26+type Status struct {
27+ Mode string // "home", "summary", "refs"…
28+ Context string // "3 repos · sort push ▼"
29+ Hints string // "j/k move · enter open · ? help"
30+ Cursor bool // show the row cursor position n/N
31+ Blob bool // show the selected line range
32+ Count int // number of rows under the cursor
33+}
34+
35+// RefItem is a branch or tag in the ref switcher.
36+type RefItem struct {
37+ Name string
38+ Href string
39+ Current bool
40+}
41+
42+// CmdItem is one target of the ":" command line.
43+type CmdItem struct{ Kind, Label, Href string }
44+
45+// Layout is the input of the page shell.
46+type Layout struct {
47+ Title string
48+ Instance string
49+ Version string
50+ SourceURL string
51+ Assets Assets
52+ Crumbs []Crumb
53+ Ref string // ref shown in the top bar, "" outside a repo view
54+ Short string // short sha shown after the ref
55+ Sections []Section
56+ Status Status
57+ Repo string
58+ SHA string // full sha of the ref, for the permalink key
59+ RefPath string // the URL segment that `y` rewrites: the ref name
60+ Heads []RefItem
61+ Tags []RefItem
62+ Cmd []CmdItem
63+ HasRail bool
64+ RailSum string // the rail as one line when it folds above the content (< 1100px)
65+ Read bool // prose page: content capped at the reading measure
66+ // Live connects the status bar to the event stream (/-/events).
67+ Live bool
68+}
69+
70+// Link is a plain label + href pair.
71+type Link struct{ Label, Href string }
72+
73+// HomeRow is one repository in the home list.
74+type HomeRow struct {
75+ ID string
76+ Name string
77+ Href string
78+ Desc string
79+ Pushed string
80+ PushedAt string // RFC 3339, "" when never pushed
81+ Branch string
82+ Tag string
83+ Cursor bool
84+}
85+
86+// SortCol is a column header of the home list; it links to its sort order.
87+type SortCol struct {
88+ Label string
89+ Href string
90+ Dir string // "ascending" | "descending" | "none"
91+ Mark string
92+ Class string
93+}
94+
95+// HomePage is the repository list.
96+type HomePage struct {
97+ Cols []SortCol
98+ Rows []HomeRow
99+}
100+
101+// Fact is one key/value row of the facts block.
102+type Fact struct {
103+ Key, Val, ID, Time string
104+}
105+
106+// Clone is a block of copyable shell lines, one [copy] button per line.
107+type Clone struct {
108+ ID string
109+ Lines []string
110+}
111+
112+// Heading is one README outline entry.
113+type Heading struct {
114+ Level int
115+ ID string
116+ Text string
117+}
118+
119+// Readme is a rendered README. HTML comes from the Markdown renderer (raw
120+// HTML dropped, URLs filtered) or is escaped plain text.
121+type Readme struct {
122+ Name string
123+ HTML template.HTML
124+ // Note replaces the content when the file is not rendered (too large).
125+ Note string
126+}
127+
128+// CommitRow is one commit in the commit table.
129+type CommitRow struct {
130+ ID string
131+ Short string
132+ Subject string
133+ CHref string
134+ Author string
135+ When string
136+ WhenAt string
137+ Merge bool
138+ Branches []string
139+ Tags []string
140+ Cursor bool
141+}
142+
143+// SummaryPage is the repository summary.
144+type SummaryPage struct {
145+ Desc string
146+ Facts []Fact
147+ Clone Clone
148+ Recent []CommitRow
149+ Readme *Readme
150+ Outline []Heading
151+ AllHref string
152+}
153+
154+// RefRow is a branch or a tag in the refs table.
155+type RefRow struct {
156+ Name, Href, Short, CHref, Subject, When, WhenAt string
157+ Default, Cursor bool
158+}
159+
160+// RefsPage lists the branches and tags of a repository.
161+type RefsPage struct {
162+ Branches []RefRow
163+ Tags []RefRow
164+ Latest string
165+ Default string
166+ // Note says the lists were cut.
167+ Note string
168+}
169+
170+// StatePage is a 404, 500 or empty-repository state.
171+type StatePage struct {
172+ Code string // "404", "500", "empty"
173+ Title string
174+ Lines []string
175+ Clone *Clone
176+ Back []Link
177+}
@@ -0,0 +1,177 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import "html/template"
6+
7+// View models handed to the templ components. Components only loop and
8+// branch on these; the handlers build them from the API messages.
9+
10+// Crumb is one segment of the path in the top bar.
11+type Crumb struct {
12+ Label string
13+ Href string
14+ Current bool
15+}
16+
17+// Section is a repository section in the top bar, reached by its key.
18+type Section struct {
19+ Key string // mnemonic key
20+ Post string // rest of the label after the key
21+ Href string
22+ Current bool
23+}
24+
25+// Status is the content of the bottom bar.
26+type Status struct {
27+ Mode string // "home", "summary", "refs"…
28+ Context string // "3 repos · sort push ▼"
29+ Hints string // "j/k move · enter open · ? help"
30+ Cursor bool // show the row cursor position n/N
31+ Blob bool // show the selected line range
32+ Count int // number of rows under the cursor
33+}
34+
35+// RefItem is a branch or tag in the ref switcher.
36+type RefItem struct {
37+ Name string
38+ Href string
39+ Current bool
40+}
41+
42+// CmdItem is one target of the ":" command line.
43+type CmdItem struct{ Kind, Label, Href string }
44+
45+// Layout is the input of the page shell.
46+type Layout struct {
47+ Title string
48+ Instance string
49+ Version string
50+ SourceURL string
51+ Assets Assets
52+ Crumbs []Crumb
53+ Ref string // ref shown in the top bar, "" outside a repo view
54+ Short string // short sha shown after the ref
55+ Sections []Section
56+ Status Status
57+ Repo string
58+ SHA string // full sha of the ref, for the permalink key
59+ RefPath string // the URL segment that `y` rewrites: the ref name
60+ Heads []RefItem
61+ Tags []RefItem
62+ Cmd []CmdItem
63+ HasRail bool
64+ RailSum string // the rail as one line when it folds above the content (< 1100px)
65+ Read bool // prose page: content capped at the reading measure
66+ // Live connects the status bar to the event stream (/-/events).
67+ Live bool
68+}
69+
70+// Link is a plain label + href pair.
71+type Link struct{ Label, Href string }
72+
73+// HomeRow is one repository in the home list.
74+type HomeRow struct {
75+ ID string
76+ Name string
77+ Href string
78+ Desc string
79+ Pushed string
80+ PushedAt string // RFC 3339, "" when never pushed
81+ Branch string
82+ Tag string
83+ Cursor bool
84+}
85+
86+// SortCol is a column header of the home list; it links to its sort order.
87+type SortCol struct {
88+ Label string
89+ Href string
90+ Dir string // "ascending" | "descending" | "none"
91+ Mark string
92+ Class string
93+}
94+
95+// HomePage is the repository list.
96+type HomePage struct {
97+ Cols []SortCol
98+ Rows []HomeRow
99+}
100+
101+// Fact is one key/value row of the facts block.
102+type Fact struct {
103+ Key, Val, ID, Time string
104+}
105+
106+// Clone is a block of copyable shell lines, one [copy] button per line.
107+type Clone struct {
108+ ID string
109+ Lines []string
110+}
111+
112+// Heading is one README outline entry.
113+type Heading struct {
114+ Level int
115+ ID string
116+ Text string
117+}
118+
119+// Readme is a rendered README. HTML comes from the Markdown renderer (raw
120+// HTML dropped, URLs filtered) or is escaped plain text.
121+type Readme struct {
122+ Name string
123+ HTML template.HTML
124+ // Note replaces the content when the file is not rendered (too large).
125+ Note string
126+}
127+
128+// CommitRow is one commit in the commit table.
129+type CommitRow struct {
130+ ID string
131+ Short string
132+ Subject string
133+ CHref string
134+ Author string
135+ When string
136+ WhenAt string
137+ Merge bool
138+ Branches []string
139+ Tags []string
140+ Cursor bool
141+}
142+
143+// SummaryPage is the repository summary.
144+type SummaryPage struct {
145+ Desc string
146+ Facts []Fact
147+ Clone Clone
148+ Recent []CommitRow
149+ Readme *Readme
150+ Outline []Heading
151+ AllHref string
152+}
153+
154+// RefRow is a branch or a tag in the refs table.
155+type RefRow struct {
156+ Name, Href, Short, CHref, Subject, When, WhenAt string
157+ Default, Cursor bool
158+}
159+
160+// RefsPage lists the branches and tags of a repository.
161+type RefsPage struct {
162+ Branches []RefRow
163+ Tags []RefRow
164+ Latest string
165+ Default string
166+ // Note says the lists were cut.
167+ Note string
168+}
169+
170+// StatePage is a 404, 500 or empty-repository state.
171+type StatePage struct {
172+ Code string // "404", "500", "empty"
173+ Title string
174+ Lines []string
175+ Clone *Clone
176+ Back []Link
177+}
A internal/adapters/web/web.go +225 -0
@@ -0,0 +1,225 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package web is the HTML front of ermite: the pages of the read-only
4+// showcase, rendered with templ from the data of the ermite API.
5+//
6+// The package is a client of the API like any other. It talks to the
7+// generated ermite-api Connect clients, which cmd binds in process to the
8+// handlers of the rpc adapter (ADR 0020), and it knows nothing of the use
9+// cases or the domain. Who the visitor is comes with the request context, set
10+// by the middleware cmd puts in front of the handler: anonymous in P5. The
11+// API then shows public repositories only; a private repository is
12+// indistinguishable from a missing one, here as everywhere.
13+//
14+// Adding a page: a handler on Server, a route in routes, a templ page in
15+// pages.templ and its view model in view.go.
16+package web
17+
18+import (
19+ "bytes"
20+ "context"
21+ "crypto/rand"
22+ "encoding/hex"
23+ "errors"
24+ "fmt"
25+ "net/http"
26+ "net/url"
27+ "time"
28+
29+ "connectrpc.com/connect"
30+ "github.com/a-h/templ"
31+ "github.com/bnema/zerowrap"
32+
33+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
34+)
35+
36+// Patterns are the ServeMux patterns the handler serves: everything. The
37+// router of the package answers the paths it does not know with its 404
38+// page, and the more specific patterns of the server (health, API, Git) win
39+// over this one.
40+var Patterns = []string{"/"}
41+
42+// requestTimeout bounds the API calls that build one page.
43+const requestTimeout = 30 * time.Second
44+
45+// Config configures a Server.
46+type Config struct {
47+ // Repos and Git are the API clients the pages are built from.
48+ Repos ermiteconnect.RepoServiceClient
49+ Git ermiteconnect.GitServiceClient
50+ // PublicURL is the base URL of the instance, without a trailing slash:
51+ // the clone URLs are built on it, and its host is the instance name.
52+ PublicURL string
53+ // SourceURL is the link to the source of the running version (AGPL §13).
54+ SourceURL string
55+ // Version is the version of the running server.
56+ Version string
57+ // Now is the clock of relative dates; time.Now when nil.
58+ Now func() time.Time
59+}
60+
61+// Server serves the pages. It is an http.Handler.
62+type Server struct {
63+ cfg Config
64+ instance string
65+ assets *assetSet
66+ md *markdown
67+ mux *http.ServeMux
68+}
69+
70+// New builds the web handler.
71+func New(cfg Config) (*Server, error) {
72+ if cfg.Repos == nil || cfg.Git == nil {
73+ return nil, errors.New("web: the repository and Git API clients are required")
74+ }
75+ u, err := url.Parse(cfg.PublicURL)
76+ if err != nil || u.Host == "" {
77+ return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL)
78+ }
79+ if cfg.Now == nil {
80+ cfg.Now = time.Now
81+ }
82+ assets, err := loadAssets()
83+ if err != nil {
84+ return nil, fmt.Errorf("web: load assets: %w", err)
85+ }
86+ s := &Server{cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown()}
87+ s.mux = s.routes()
88+ return s, nil
89+}
90+
91+// routes is the router of the web. The pages of the next phases (tree, blob,
92+// raw, commits, commit, events) are added here; until then they, and any
93+// other path, answer with the 404 page.
94+func (s *Server) routes() *http.ServeMux {
95+ mux := http.NewServeMux()
96+ mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
97+ mux.HandleFunc("GET /{$}", s.page(s.home))
98+ mux.HandleFunc("GET /{repo}", s.page(s.summary))
99+ mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
100+ mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))
101+ mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
102+ return mux
103+}
104+
105+// ServeHTTP implements http.Handler.
106+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
107+ s.mux.ServeHTTP(w, r)
108+}
109+
110+// handler builds a page. It returns the component and its status, or an
111+// error, which becomes the 404 or 500 page.
112+type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
113+
114+// page adapts a handler: it bounds the API calls, renders the component into
115+// a buffer (so that a failure never leaves half a page), and maps errors to
116+// the state pages.
117+func (s *Server) page(h handler) http.HandlerFunc {
118+ return func(w http.ResponseWriter, r *http.Request) {
119+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
120+ defer cancel()
121+ c, status, err := h(ctx, r)
122+ if err != nil {
123+ s.fail(ctx, w, r, err)
124+ return
125+ }
126+ s.write(ctx, w, r, status, c)
127+ }
128+}
129+
130+// write renders c with the given status.
131+func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
132+ var buf bytes.Buffer
133+ if err := c.Render(ctx, &buf); err != nil {
134+ // The error page itself failed: a bare answer is all that is left.
135+ log := zerowrap.FromCtx(ctx)
136+ log.Error().Err(err).Str("path", r.URL.Path).Msg("render page")
137+ http.Error(w, "internal error", http.StatusInternalServerError)
138+ return
139+ }
140+ h := w.Header()
141+ h.Set("Content-Type", "text/html; charset=utf-8")
142+ // Public pages: the browser and any proxy revalidate each time.
143+ h.Set("Cache-Control", "no-cache")
144+ w.WriteHeader(status)
145+ if r.Method != http.MethodHead {
146+ _, _ = w.Write(buf.Bytes())
147+ }
148+}
149+
150+// fail answers an error of the API: not found (and a name or revision the
151+// API refuses, which no page can show either) gives the 404 page, a visitor
152+// that left gives nothing, anything else gives the 500 page with an id to
153+// find the cause in the log.
154+func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
155+ switch code := connect.CodeOf(err); {
156+ case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument:
157+ s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
158+ case errors.Is(err, context.Canceled), code == connect.CodeCanceled:
159+ // The client is gone; there is nobody to answer.
160+ default:
161+ id := requestID()
162+ log := zerowrap.FromCtx(ctx)
163+ log.Error().Err(err).Str("request_id", id).Str("path", r.URL.Path).Msg("web request failed")
164+ s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
165+ }
166+}
167+
168+// errNotFound is returned by handlers for a resource that does not exist
169+// although the API did not say so (a ref or a path that is not there).
170+var errNotFound = errors.New("not found")
171+
172+// requestID is a short random id, shown on the 500 page and logged.
173+func requestID() string {
174+ var b [4]byte
175+ _, _ = rand.Read(b[:])
176+ return hex.EncodeToString(b[:])
177+}
178+
179+// notFound is the handler of the paths without a page.
180+func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) {
181+ return nil, 0, errNotFound
182+}
183+
184+// base is the Layout every page starts from.
185+func (s *Server) base() Layout {
186+ return Layout{
187+ Instance: s.instance,
188+ Version: s.cfg.Version,
189+ SourceURL: s.cfg.SourceURL,
190+ Assets: s.assets.urls,
191+ }
192+}
193+
194+// stateLayout is the layout of a state page: only the way home.
195+func (s *Server) stateLayout(title, context string, crumbs []Crumb) Layout {
196+ l := s.base()
197+ l.Title = title + " · " + s.instance
198+ l.Crumbs = crumbs
199+ l.Sections = []Section{{Key: "h", Post: "ome", Href: "/"}}
200+ l.Status = Status{Mode: "state", Context: context, Hints: "g h home · ? help"}
201+ l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
202+ return l
203+}
204+
205+func (s *Server) notFoundPage() templ.Component {
206+ l := s.stateLayout("404", "404 · not found", []Crumb{{Label: "-"}, {Label: "404"}})
207+ return State(l, StatePage{
208+ Code: "404", Title: "not found",
209+ Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."},
210+ Back: []Link{{Label: "home", Href: "/"}},
211+ })
212+}
213+
214+func (s *Server) errorPage(id string) templ.Component {
215+ l := s.stateLayout("error", "500 · internal error", []Crumb{{Label: "-"}, {Label: "error"}})
216+ return State(l, StatePage{
217+ Code: "500", Title: "internal error",
218+ Lines: []string{
219+ "the server failed to answer this request.",
220+ "request id: " + id,
221+ "retry in a moment; if it persists, the owner has the log.",
222+ },
223+ Back: []Link{{Label: "home", Href: "/"}},
224+ })
225+}
@@ -0,0 +1,225 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package web is the HTML front of ermite: the pages of the read-only
4+// showcase, rendered with templ from the data of the ermite API.
5+//
6+// The package is a client of the API like any other. It talks to the
7+// generated ermite-api Connect clients, which cmd binds in process to the
8+// handlers of the rpc adapter (ADR 0020), and it knows nothing of the use
9+// cases or the domain. Who the visitor is comes with the request context, set
10+// by the middleware cmd puts in front of the handler: anonymous in P5. The
11+// API then shows public repositories only; a private repository is
12+// indistinguishable from a missing one, here as everywhere.
13+//
14+// Adding a page: a handler on Server, a route in routes, a templ page in
15+// pages.templ and its view model in view.go.
16+package web
17+
18+import (
19+ "bytes"
20+ "context"
21+ "crypto/rand"
22+ "encoding/hex"
23+ "errors"
24+ "fmt"
25+ "net/http"
26+ "net/url"
27+ "time"
28+
29+ "connectrpc.com/connect"
30+ "github.com/a-h/templ"
31+ "github.com/bnema/zerowrap"
32+
33+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
34+)
35+
36+// Patterns are the ServeMux patterns the handler serves: everything. The
37+// router of the package answers the paths it does not know with its 404
38+// page, and the more specific patterns of the server (health, API, Git) win
39+// over this one.
40+var Patterns = []string{"/"}
41+
42+// requestTimeout bounds the API calls that build one page.
43+const requestTimeout = 30 * time.Second
44+
45+// Config configures a Server.
46+type Config struct {
47+ // Repos and Git are the API clients the pages are built from.
48+ Repos ermiteconnect.RepoServiceClient
49+ Git ermiteconnect.GitServiceClient
50+ // PublicURL is the base URL of the instance, without a trailing slash:
51+ // the clone URLs are built on it, and its host is the instance name.
52+ PublicURL string
53+ // SourceURL is the link to the source of the running version (AGPL §13).
54+ SourceURL string
55+ // Version is the version of the running server.
56+ Version string
57+ // Now is the clock of relative dates; time.Now when nil.
58+ Now func() time.Time
59+}
60+
61+// Server serves the pages. It is an http.Handler.
62+type Server struct {
63+ cfg Config
64+ instance string
65+ assets *assetSet
66+ md *markdown
67+ mux *http.ServeMux
68+}
69+
70+// New builds the web handler.
71+func New(cfg Config) (*Server, error) {
72+ if cfg.Repos == nil || cfg.Git == nil {
73+ return nil, errors.New("web: the repository and Git API clients are required")
74+ }
75+ u, err := url.Parse(cfg.PublicURL)
76+ if err != nil || u.Host == "" {
77+ return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL)
78+ }
79+ if cfg.Now == nil {
80+ cfg.Now = time.Now
81+ }
82+ assets, err := loadAssets()
83+ if err != nil {
84+ return nil, fmt.Errorf("web: load assets: %w", err)
85+ }
86+ s := &Server{cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown()}
87+ s.mux = s.routes()
88+ return s, nil
89+}
90+
91+// routes is the router of the web. The pages of the next phases (tree, blob,
92+// raw, commits, commit, events) are added here; until then they, and any
93+// other path, answer with the 404 page.
94+func (s *Server) routes() *http.ServeMux {
95+ mux := http.NewServeMux()
96+ mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
97+ mux.HandleFunc("GET /{$}", s.page(s.home))
98+ mux.HandleFunc("GET /{repo}", s.page(s.summary))
99+ mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
100+ mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))
101+ mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
102+ return mux
103+}
104+
105+// ServeHTTP implements http.Handler.
106+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
107+ s.mux.ServeHTTP(w, r)
108+}
109+
110+// handler builds a page. It returns the component and its status, or an
111+// error, which becomes the 404 or 500 page.
112+type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
113+
114+// page adapts a handler: it bounds the API calls, renders the component into
115+// a buffer (so that a failure never leaves half a page), and maps errors to
116+// the state pages.
117+func (s *Server) page(h handler) http.HandlerFunc {
118+ return func(w http.ResponseWriter, r *http.Request) {
119+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
120+ defer cancel()
121+ c, status, err := h(ctx, r)
122+ if err != nil {
123+ s.fail(ctx, w, r, err)
124+ return
125+ }
126+ s.write(ctx, w, r, status, c)
127+ }
128+}
129+
130+// write renders c with the given status.
131+func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
132+ var buf bytes.Buffer
133+ if err := c.Render(ctx, &buf); err != nil {
134+ // The error page itself failed: a bare answer is all that is left.
135+ log := zerowrap.FromCtx(ctx)
136+ log.Error().Err(err).Str("path", r.URL.Path).Msg("render page")
137+ http.Error(w, "internal error", http.StatusInternalServerError)
138+ return
139+ }
140+ h := w.Header()
141+ h.Set("Content-Type", "text/html; charset=utf-8")
142+ // Public pages: the browser and any proxy revalidate each time.
143+ h.Set("Cache-Control", "no-cache")
144+ w.WriteHeader(status)
145+ if r.Method != http.MethodHead {
146+ _, _ = w.Write(buf.Bytes())
147+ }
148+}
149+
150+// fail answers an error of the API: not found (and a name or revision the
151+// API refuses, which no page can show either) gives the 404 page, a visitor
152+// that left gives nothing, anything else gives the 500 page with an id to
153+// find the cause in the log.
154+func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
155+ switch code := connect.CodeOf(err); {
156+ case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument:
157+ s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
158+ case errors.Is(err, context.Canceled), code == connect.CodeCanceled:
159+ // The client is gone; there is nobody to answer.
160+ default:
161+ id := requestID()
162+ log := zerowrap.FromCtx(ctx)
163+ log.Error().Err(err).Str("request_id", id).Str("path", r.URL.Path).Msg("web request failed")
164+ s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
165+ }
166+}
167+
168+// errNotFound is returned by handlers for a resource that does not exist
169+// although the API did not say so (a ref or a path that is not there).
170+var errNotFound = errors.New("not found")
171+
172+// requestID is a short random id, shown on the 500 page and logged.
173+func requestID() string {
174+ var b [4]byte
175+ _, _ = rand.Read(b[:])
176+ return hex.EncodeToString(b[:])
177+}
178+
179+// notFound is the handler of the paths without a page.
180+func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) {
181+ return nil, 0, errNotFound
182+}
183+
184+// base is the Layout every page starts from.
185+func (s *Server) base() Layout {
186+ return Layout{
187+ Instance: s.instance,
188+ Version: s.cfg.Version,
189+ SourceURL: s.cfg.SourceURL,
190+ Assets: s.assets.urls,
191+ }
192+}
193+
194+// stateLayout is the layout of a state page: only the way home.
195+func (s *Server) stateLayout(title, context string, crumbs []Crumb) Layout {
196+ l := s.base()
197+ l.Title = title + " · " + s.instance
198+ l.Crumbs = crumbs
199+ l.Sections = []Section{{Key: "h", Post: "ome", Href: "/"}}
200+ l.Status = Status{Mode: "state", Context: context, Hints: "g h home · ? help"}
201+ l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
202+ return l
203+}
204+
205+func (s *Server) notFoundPage() templ.Component {
206+ l := s.stateLayout("404", "404 · not found", []Crumb{{Label: "-"}, {Label: "404"}})
207+ return State(l, StatePage{
208+ Code: "404", Title: "not found",
209+ Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."},
210+ Back: []Link{{Label: "home", Href: "/"}},
211+ })
212+}
213+
214+func (s *Server) errorPage(id string) templ.Component {
215+ l := s.stateLayout("error", "500 · internal error", []Crumb{{Label: "-"}, {Label: "error"}})
216+ return State(l, StatePage{
217+ Code: "500", Title: "internal error",
218+ Lines: []string{
219+ "the server failed to answer this request.",
220+ "request id: " + id,
221+ "retry in a moment; if it persists, the owner has the log.",
222+ },
223+ Back: []Link{{Label: "home", Href: "/"}},
224+ })
225+}

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 3

Tags 43

↑↓ moveenter openesc close