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
@@ -0,0 +1,1 @@
1+var htmx=(()=>{const e={parse(t){if(!t)return{};if(t.startsWith("{"))return JSON.parse(t);let r=/(?:"([^"]+)"|'([^']+)'|([^\s,:]+))(?:\s*:\s*(?:"([^"]*)"|'([^']*)'|<((?:[^/]|\/(?!>))+)\/>|([^\s,]+)))?(?=\s|,|$)/g,i={};for(let s of t.matchAll(r)){let[,t,r,n,o,a,l,h]=s,c=t??r??n,u=(o??a??l??h??"true").trim();try{u=JSON.parse(u)}catch{}let d=n?.includes("."),f=d?c.split(".").reduceRight((e,t)=>({[t]:e}),u):{[c]:u};e.merge(f,i)}return i},split:e=>e.split(/,(?![^\[]*\])(?![^(]*\))(?![^<]*\/>)(?=(?:[^"']|"[^"]*"|'[^']*')*$)/),merge(t,r){"string"==typeof t&&(t=e.parse(t));for(let[i,s]of Object.entries(t)){if(["__proto__","constructor","prototype"].includes(i))continue;let t=s?.constructor===Object,n=r[i]?.constructor===Object;t&&n?e.merge(s,r[i]):r[i]=s}return r}};class t{#e=null;#t=[];admit(e,t,r){if(!this.#e)return this.#e={strategy:e,abort:r},"run";if("replace"===e||"abort"!==e&&"abort"===this.#e.strategy)return this.#t=[],this.#e.abort?.(),this.#e={strategy:e,abort:r},"run";if("queue all"===e)this.#t.push(t);else if("queue last"===e)this.#t=[t];else{if("abort"===e||"drop"===e||0!==this.#t.length)return"dropped";this.#t.push(t)}return"queued"}continue(){this.#e=null,this.#t.shift()?.()}abort(){this.#e?.abort?.()}}return new class{#r=e;#i=new Map;#s="";#n=new Set;_loc=window.location;#o;#a=Function;#l=Object.getPrototypeOf(async function(){}).constructor;#h={createHTML:e=>e,createScript:e=>e};#c;#u="a,form";#d=["get","post","put","patch","delete","query"];#f;#m;#p;#g;#x;constructor(){this.#b(),this.#y(),this.#c=this.#S("[hx-action],[hx-get],[hx-post],[hx-put],[hx-patch],[hx-delete],[hx-query]"),this.#f=(new XPathEvaluator).createExpression(`.//*[@*[${this.#v("hx-on").map(e=>`starts-with(name(), "${e}")`).join(" or ")}]]`),this.#o={HCON:e,attributeValue:this.#E.bind(this),parseTriggerSpecs:this.#w.bind(this),determineMethodAndAction:this.#A.bind(this),createRequestContext:this.#T.bind(this),collectFormData:this.#C.bind(this),getAttributeObject:this.#q.bind(this),insertContent:this.#N.bind(this),morph:this.#I.bind(this),isSoftMatch:this.#M.bind(this),initSecurity:(e,t,r)=>{e&&(this.#h=e),t&&(this.#a=t),r&&(this.#l=r)},onTrigger:this.#O.bind(this),htmxProp:this.#H.bind(this),triggerHtmxEvent:this.#L.bind(this),executeJavaScript:this.#k.bind(this)};let t=()=>this.initialize();"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):setTimeout(t)}#b(){this.version="4.0.0",this.config={logAll:!1,prefix:"data-hx-",transitions:!1,history:!0,mode:"same-origin",defaultSwap:"innerHTML",defaultFocusScroll:!1,indicatorClass:"htmx-indicator",requestClass:"htmx-request",includeIndicatorCSS:!0,defaultTimeout:6e4,extensions:"",morphIgnore:["data-htmx-powered"],morphSkip:"[hx-morph-skip]",morphSkipChildren:"[hx-morph-skip-children]",morphScanLimit:10,noSwap:[204,304],implicitInheritance:!1,defaultSettleDelay:1,allowEmptySwapAfterOOB:!1};let t=document.querySelector('meta[name="htmx-config"]');t&&e.merge(t.content,this.config),this.#s=this.config.extensions}#y(){if(!1!==this.config.includeIndicatorCSS){let e=this.config.indicatorClass,t=this.config.requestClass,r=new CSSStyleSheet;r.replaceSync(`.${e}{opacity:0;visibility: hidden} .${t} .${e}, .${t}.${e}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`),document.adoptedStyleSheets=[...document.adoptedStyleSheets,r]}}registerExtension(e,t){return!(this.#s&&!this.#s.split(/,\s*/).includes(e))&&(!this.#n.has(e)&&(this.#n.add(e),t.init&&t.init(this.#o),void Object.entries(t).forEach(([e,t])=>{this.#i.get(e)?.push(t)||this.#i.set(e,[t])})))}#V(e){let t=this.config.prefix;return!e.closest||null!=e.closest("[hx-ignore]")||t&&null!=e.closest(`[${t}ignore]`)}#P(e,t){let r=this.config.prefix;return e.getAttribute(t)??(r?e.getAttribute(t.replace("hx-",r)):null)}#R(e,t){let r=this.config.prefix&&t.replace("hx-",this.config.prefix);return e.hasAttribute(t)?t:r&&e.hasAttribute(r)?r:null}#S(e){return this.#v(e).join(",")}#v(e){let t=[e];return this.config.prefix&&t.push(e.replaceAll("hx-",this.config.prefix)),t}#j(e,t){let r=[...e.querySelectorAll?.(t)??[]];return e.matches?.(t)&&r.unshift(e),r}#B(e){return"before"===e?"beforebegin":"after"===e?"afterend":"prepend"===e?"afterbegin":"append"===e?"beforeend":e}#F(e,t){let r=[];return this.#E(e,t,void 0,(e,t)=>{e?.split(/\s*[,:]\s*/).includes("this")&&r.push(t)}),r}#E(e,t,r,i){t=this.#z(t);let s=this.#z(":inherited"),n=this.#z(":append"),o=this.#P(e,t)??this.#P(e,t+s);if(null!=o)return i?i(o,e):o;let a=CSS.escape(this.config.implicitInheritance?t:t+s),l=CSS.escape(t+s+n),h=this.#S(`[${a}],[${l}]`),c=this.#R(e,t+n)??this.#R(e,t+s+n);if(c){let r=e.getAttribute(c),s=e.parentNode?.closest?.(h);if(i&&i(r,e),s){let e=this.#E(s,t,void 0,i);return e?(e+","+r).replace(/[{}]/g,""):r}return r}let u=e.parentNode?.closest?.(h);return u?(o=this.#E(u,t,void 0,i),!i&&o&&this.config.implicitInheritance&&this.#_(e,"htmx:after:implicitInheritance",{elt:e,name:t,parent:u}),o):r}#w(t){return e.split(t).flatMap(t=>{let[,r,i]=t.match(/^\s*(\S+\[[^\]]*\]|\S+)\s*(.*?)\s*$/)??[];if(!r)return[];if(/\[[^\]]*$/.test(r))throw"unterminated:"+r;return[{name:r,...e.parse(i)}]})}#A(e,t){let r=this.#E(e,"hx-method"),i=this.#E(e,"hx-action");if(!i)for(let t of this.#d){let s=this.#E(e,"hx-"+t);if(null!=s){i=s,r=t;break}}return this.#D(e)&&(i||=t.submitter?.getAttribute?.("formAction")||e.getAttribute(e.matches("a")?"href":"action")),r||=t.submitter?.getAttribute?.("formmethod")||e.getAttribute("method")||"GET",{action:i,method:r.toUpperCase()}}#H(e){return e._htmx||(e._htmx={listeners:[],triggerSpecs:[]},e.setAttribute("data-htmx-powered","true")),e._htmx}#$(e){return e._htmx_state||={}}#W(e){if(this.#U(e)&&this.#L(e,"htmx:before:init",{},!0)){let t=this.#H(e);t.initialized=!0,t.eventHandler=this.#Q(e),this.#J(e),this.#L(e,"htmx:after:init",{},!0)}}#Q(e){return async t=>{try{let r=this.#T(e,t);await this.#X(r)}catch(t){this.#L(e,"htmx:error",{error:t})}}}#T(t,r){let{action:i,method:s}=this.#A(t,r),[n,o]=(i||"").split("#"),a=new AbortController,l={sourceElement:t,sourceEvent:r,status:"created",select:this.#E(t,"hx-select"),selectOOB:this.#E(t,"hx-select-oob"),target:this.#E(t,"hx-target"),swap:this.#E(t,"hx-swap")??this.config.defaultSwap,push:this.#E(t,"hx-push-url"),replace:this.#E(t,"hx-replace-url"),transition:this.config.transitions,confirm:this.#E(t,"hx-confirm"),request:{validate:"true"===this.#E(t,"hx-validate",!t.matches("form")||t.noValidate||r.submitter?.formNoValidate?"false":"true"),action:n,anchor:o,method:s,headers:this.#G(t),abort:a.abort.bind(a),credentials:"same-origin",signal:a.signal,mode:this.config.mode}};t._htmx?.boosted&&e.merge(t._htmx.boosted,l),l.target=this.#K(t,l.target),l.target&&(l.request.headers["HX-Target"]=this.#Y(l.target));let h=this.#E(t,"hx-config");return h&&(e.merge(h,l.request),l.request.mode=this.config.mode),l}#Y(e){return`${e.tagName.toLowerCase()}${e.id?"#"+encodeURI(e.id):""}`}#G(e){let t={"HX-Request":"true","HX-Source":this.#Y(e),"HX-Current-URL":location.href,Accept:"text/html"};return this.#D(e)&&(t["HX-Boosted"]="true"),t}#Z(e,t){return this.#q(e,"hx-headers",e=>{for(let r in e)t.request.headers[r]=String(e[r])},{ctx:t})}#K(e,t){return t instanceof Element?t:null!=t?this.#ee(e,t,"hx-target"):this.#D(e)?document.body:e}#D(e){return e?._htmx?.boosted}async#X(e){let t=e.sourceElement,r=e.sourceEvent;if(!t.isConnected)return;if(this.#te(r))return;this.#re(r)&&r.preventDefault();let i=/GET|DELETE/.test(e.request.method),s=i?t.matches("form")?t:null:t.form||t.closest("form"),n=this.#C(t,s,r.submitter,e.request.validate,i);if(!n)return;let o=this.#q(t,"hx-vals",t=>{e.vals=t;for(let e in t)n.set(e,t[e])},{ctx:e});if(o&&await o,e.values)for(let t in e.values)n.delete(t),n.append(t,e.values[t]);let a=this.#Z(t,e);if(a&&await a,Object.assign(e.request,{form:s,submitter:r.submitter,body:n}),!this.#L(t,"htmx:config:request",{ctx:e}))return;if("DIALOG"===e.request.method)return;let l=this.#ie(e.request.action);if(null!=l){let t=Object.fromEntries(e.request.body);return void await this.#k(e.sourceElement,t,l,!1)}if(i){let t=new URL(e.request.action,document.baseURI);for(let r of e.request.body.keys())t.searchParams.delete(r);for(let[r,i]of e.request.body)t.searchParams.append(r,i);t.origin===location.origin?e.request.action=t.pathname+t.search:e.request.action=t.href,e.request.body=null}else"multipart/form-data"!==(this.#E(t,"hx-encoding")??s?.enctype)&&(e.request.body=new URLSearchParams(e.request.body));await this.#se(e)}async#se(e){let t=e.sourceElement,r=this.#ne(t),i=this.#oe(t);if(this.#ae(t),"run"!==i.admit(r,()=>this.#se(e),()=>e.request?.abort?.()))return;e.status="issuing";let s=[],n=[];try{if(e.confirm){if(!await new Promise(r=>{let i={ctx:e,issueRequest:()=>r(!0),dropRequest:()=>r(!1)};if(this.#L(t,"htmx:confirm",i)){let i=this.#ie(e.confirm);r(i?this.#k(t,{ctx:e},i,!0):window.confirm(e.confirm))}}))return}if(this.#le(e),s=this.#he(t),n=this.#ce(t),e.fetch||=window.fetch.bind(window),e.request.headers["HX-Request-Type"]=e.target===document.body||e.select?"full":"partial",!this.#L(t,"htmx:before:request",{ctx:e}))return;let r=await e.fetch(e.request.action,e.request);if(e.response={raw:r,status:r.status,headers:r.headers},this.#ue(e),!this.#L(t,"htmx:before:response",{ctx:e}))return;if(e.text=await r.text(),!this.#L(t,"htmx:after:request",{ctx:e}))return;if(e.response.status>=400&&this.#L(t,"htmx:response:error",{ctx:e}),this.#de(e))return void(e.keepIndicators=!0);"issuing"===e.status&&(e.hx.retarget&&(e.target=e.hx.retarget),e.hx.reswap&&(e.swap=e.hx.reswap),e.hx.reselect&&(e.select=e.hx.reselect),e.status="response received",this.#fe(e),await this.swap(e),e.status="swapped")}catch(r){e.status="error: "+r,this.#L(t,"htmx:error",{ctx:e,error:r})}finally{await(e.extensionPromise?.catch(()=>{})),clearTimeout(e.requestTimeout),e.hx?.trigger&&this.#me(e.hx.trigger,e.sourceElement),this.#L(t,"htmx:finally:request",{ctx:e}),e.keepIndicators||(this.#pe(s),this.#ge(n)),i.continue()}}#ue(e){e.hx={};for(let[t,r]of e.response.raw.headers)t.toLowerCase().startsWith("hx-")&&(e.hx[t.slice(3).toLowerCase().replace(/-/g,"")]=r)}#de(t){if("true"===t.hx.refresh)return this._loc.reload(),!0;if(t.hx.redirect)return this._loc.href=t.hx.redirect,!0;if(t.hx.location){let r=t.hx.location,i={},s=e.parse(r);return"{"!==r[0]&&null==s.path||(i=s,r=i.path,delete i.path),null==i.push&&null==i.replace&&(i.push="true"),this.ajax("GET",r,i),!0}}#le(e){let t=null!=e.request.timeout?this.parseInterval(e.request.timeout):this.config.defaultTimeout;t&&(e.requestTimeout=setTimeout(()=>e.request?.abort?.(),t))}#ne(e){let t=this.#E(e,"hx-sync");if(!t)return"queue first";let r=t.split(":").pop().trim();return/^(drop|abort|replace|queue)/.test(r)?r:"queue first"}#oe(e){let r=this.#E(e,"hx-sync"),i=e;if(r){let t=r.includes(":")?r.slice(0,r.lastIndexOf(":")).trim():/^(drop|abort|replace|queue)/.test(r)?null:r;t&&(i=this.#ee(e,t,"hx-sync")||e)}return this.#$(i).rq||=new t}#te(e){return"click"===e.type&&(e.ctrlKey||e.metaKey||e.shiftKey)&&!!e.currentTarget?.closest?.("a[href]")}#re(e){let t=e.currentTarget;if("submit"===e.type&&"FORM"===t?.tagName)return!0;if(!("click"===e.type&&0===e.button))return!1;let r=t?.closest?.('button, input[type="submit"], input[type="image"]'),i=r?.form||r?.closest("form");if(r&&!r.disabled&&i&&("submit"===r.type||"image"===r.type||!r.type&&"BUTTON"===r.tagName))return!0;let s=t?.closest?.("a");if(!s||!s.href)return!1;let n=s.getAttribute("href");return!(n&&n.startsWith("#")&&n.length>1)}#J(e,t=e._htmx.eventHandler){let r=this.#E(e,"hx-trigger")||(e.matches("form")?"submit":e.matches("input:not([type=button]):not([type=submit]),select,textarea")?"change":"click");this.#O(e,r,t)}#O(e,t,r){let i=this.#w(t);this.#H(e).triggerSpecs.push(...i);for(let t of i){t.listeners=[];let[i,s]=this.#xe(t.name),n=[e];"outside"===t.from?n=[document]:t.from&&"self"!==t.from&&(n=this.#be(e,t.from));let o=e=>{if((t.halt||t.prevent)&&e.preventDefault(),(t.halt||t.stop||t.consume)&&e.stopPropagation(),t.once)for(let e of t.listeners)e.fromElt.removeEventListener(e.eventName,e.handler,e);r(e)},a=o;if(t.delay?a=e=>{clearTimeout(t.timeout),t.timeout=setTimeout(()=>o(e),this.parseInterval(t.delay))}:t.throttle&&(a=e=>{t.throttled?t.throttledEvent=e:(t.throttled=!0,o(e),t.throttleTimeout=setTimeout(()=>{if(t.throttled=!1,t.throttledEvent){let e=t.throttledEvent;t.throttledEvent=null,a(e)}},this.parseInterval(t.throttle)))}),t.handler=r=>{if(("self"!==t.from||r.target===e)&&("outside"!==t.from||!e.contains(r.target))&&(!t.target||r.target?.matches?.(t.target))){if(t.changed){let e=t.values??=new WeakMap,r=!1;for(let t of n)e.get(t)!==t.value&&(r=!0,e.set(t,t.value));if(!r)return}if(s){this.#re(r)&&r.preventDefault();let t={};for(let e in r)t[e]=r[e];if(!this.#k(e,t,s,!0,!1))return}a(r)}},"intersect"===i||"revealed"===i){let r={rootMargin:t.rootMargin};t.root&&(r.root=this.#ee(e,t.root)),t.threshold&&(r.threshold=parseFloat(t.threshold));let s="revealed"===i;t.observer=new IntersectionObserver(r=>{for(let i=0;i<r.length;i++)if(r[i].isIntersecting){this.trigger(e,"intersect",{},!1),s&&t.observer.disconnect();break}},r),i="intersect",t.observer.observe(e)}if("every"===i){let r=Object.keys(t).find(e=>"name"!==e);t.interval=setInterval(()=>{e.isConnected?this.#L(e,"every",{},!1):clearInterval(t.interval)},this.parseInterval(r))}if("load"!==i)for(let r of n){let s={fromElt:r,eventName:i,handler:t.handler,capture:!!t.capture,passive:!!t.passive};e._htmx.listeners.push(s),t.listeners.push(s),r.addEventListener(i,t.handler,s)}else t.handler(new CustomEvent("load"))}}#xe(e){let t=e.match(/^([^\[]*)\[([^\]]*)]/);return t?[t[1],t[2]]:[e,null]}#me(t,r){if("{"===t[0]){let i=e.parse(t);for(let e in i){let t=i[e],s=r;t?.target&&(s=this.find(t.target)),this.trigger(s,e,"object"==typeof t?t:{value:t})}}else t.split(",").forEach(e=>this.trigger(r,e.trim(),{}))}#ye(e){let t={},r=Object.getPrototypeOf(this);for(let i of Object.getOwnPropertyNames(r))"constructor"!==i&&"function"==typeof this[i]&&(["find","findAll"].includes(i)?t[i]=(t,r)=>void 0===r?this[i](e,t):this[i](t,r):t[i]=this[i].bind(this));return t}#k(e,t,r,i=!0,s=!0,n=!1){let o={};Object.assign(o,this.#ye(e));let a={},l={scope:a,code:r};this.#_(e,"htmx:scope",l),r=l.code,Object.assign(o,a),Object.assign(o,t);let h=Object.keys(o),c=Object.values(o),u=new(s?this.#l:this.#a)(...h,i?`return (${r})`:r);return n?()=>u.call(e,...c):u.call(e,...c)}process(e,t){if(!e?.isConnected)return;if(!(e instanceof Element)){for(let r of e.children||[])this.process(r,t);return}if(t&&this.#Se(e,!0),this.#V(e))return;if(!this.#L(e,"htmx:before:process"))return;let r=[e],i=this.#f.evaluate(e),s=null;for(;s=i.iterateNext();)r.push(s);for(let e of r)!this.#V(e)&&this.#L(e,"htmx:before:on:init",{},!0)&&this.#ve(e);for(let t of this.#j(e,this.#c))this.#W(t);for(let t of this.#j(e,this.#u))this.#Ee(t);this.#L(e,"htmx:after:process")}#Ee(e){let t=this.#E(e,"hx-boost");if(t&&"false"!==t&&this.#we(e)&&this.#L(e,"htmx:before:init",{},!0)){let r=this.#H(e);r.initialized=!0,r.eventHandler=this.#Q(e),r.boosted=t;let i=e.matches("a")?"click":"submit";e._htmx.listeners.push({fromElt:e,eventName:i,handler:e._htmx.eventHandler}),e.addEventListener(i,e._htmx.eventHandler),this.#L(e,"htmx:after:init",{},!0)}}#we(e){if(this.#U(e))if("A"===e.tagName){if(""===e.target||"_self"===e.target)return!e.hasAttribute("download")&&!e.getAttribute("href")?.startsWith?.("#")&&this.#Ae(e.href)}else if("FORM"===e.tagName)return"dialog"!==e.method&&this.#Ae(e.action)}#Ae(e){try{return new URL(e,window.location.href).origin===window.location.origin}catch(e){return!1}}#U(e){return!e._htmx?.initialized&&!this.#V(e)}#Se(e,t){let r=[e,...e.querySelectorAll?.("[data-htmx-powered]")??[]];for(let e of r)if(e._htmx){this.#L(e,"htmx:before:cleanup");for(let t of e._htmx.triggerSpecs||[])t.interval&&clearInterval(t.interval),t.timeout&&clearTimeout(t.timeout),t.throttleTimeout&&clearTimeout(t.throttleTimeout),t.observer?.disconnect();for(let t of e._htmx.listeners||[])t.fromElt.removeEventListener(t.eventName,t.handler,t);e.removeAttribute("data-htmx-powered"),this.#L(e,"htmx:after:cleanup"),t&&delete e._htmx}}#Te(e){let t=document.createElement("div");t.hidden=!0,document.body.insertAdjacentElement("afterend",t);let r=e.querySelectorAll?.(this.#S("[hx-preserve]"))||[];for(let e of r){let r=document.getElementById(e.id);r&&this.#Ce(t,r,null)}return t}#qe(e){for(let t of[...e.children]){let e=document.getElementById(t.id);e&&(this.#Ce(e.parentNode,t,e),this.#Se(e),e.remove())}e.remove()}#Ne(e){let t=this.#h.createHTML(e);return Document.parseHTMLUnsafe?.(t)||(new DOMParser).parseFromString(t,"text/html")}#Ie(e){let t=e.replace(/<hx-([a-z]+)(\s+|>)/gi,'<template hx type="$1"$2').replace(/<\/hx-[a-z]+>/gi,"</template>"),r="";t=t.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,e=>(r=this.#Ne(e).title,""));let i,s,n=t.match(/<([a-z][^\/>\x20\t\r\n\f]*)/i)?.[1]?.toLowerCase();if("html"===n||"body"===n?(i=this.#Ne(t),s=document.createDocumentFragment(),s.append(i.body)):(i=this.#Ne(`<template>${t}</template>`),s=i.querySelector("template").content),!r){let e=s.querySelector("title:not(svg title)");e&&(r=e.textContent,e.remove())}return this.#Me(s),{fragment:s,title:r}}#Oe(e,t,r,i){let s=t.id?"#"+CSS.escape(t.id):null;"true"!==r&&r&&!r.includes(" ")&&([r,s=s]=r.split(/:(.*)/)),"true"!==r&&r||(r="outerHTML");let n=this.#He(r);if(s=n.target||s,n.strip??=!n.style.startsWith("outer"),!s)return;let o=[...document.querySelectorAll(s)];for(let r of o){let s=document.createDocumentFragment();s.append(t.cloneNode(!0)),e.push({type:"oob",fragment:s,target:r,swapSpec:n,sourceElement:i})}t.remove()}#Le(e,t,r){let i=[];if(r)for(let s of r.split(",")){let[r,n="true"]=s.split(/:(.*)/);for(let s of e.querySelectorAll(r))this.#Oe(i,s,n,t)}for(let r of e.querySelectorAll(this.#S("[hx-swap-oob]"))){let e=this.#R(r,"hx-swap-oob"),s=r.getAttribute(e);r.removeAttribute(e),this.#Oe(i,r,s,t)}return i}#ke(e,t,r){t?t.before(...r.childNodes):e.append(...r.childNodes)}#He(t){t=t.trim();let r=this.config.defaultSwap;if(t&&!/^\S*:/.test(t)){let e=t.match(/^(\S+)\s*(.*)$/);r=e[1],t=e[2]}return{style:this.#B(r),...e.parse(t)}}#Ve(e,t){let r=[];for(let i of e.querySelectorAll("template[hx]")){let e=i.getAttribute("type");if("partial"===e){let e=this.#P(i,"hx-target")||(i.id?"#"+CSS.escape(i.id):null);if(e){this.#Me(i.content);let s=this.#He(this.#P(i,"hx-swap")||this.config.defaultSwap),n=this.#be(t.sourceElement,e);for(let e of n.length?n:[null])r.push({type:"partial",fragment:i.content.cloneNode(!0),target:e,swapSpec:s,sourceElement:t.sourceElement})}}else this.#_(i,"htmx:process:"+e,{ctx:t,tasks:r});i.remove()}return r}#Pe(e,t,r,i){try{null!=r&&e.setSelectionRange&&e.setSelectionRange(r,i),e.focus(t)}catch(e){}}#Re(e){let t=this.#j(e,"[autofocus]")[0];t&&this.#Pe(t)}#je(e,t){if(e.scroll){let r=e.scrollTarget?this.#Be(e.scrollTarget):t;r&&("top"===e.scroll?r.scrollTop=0:"bottom"===e.scroll&&(r.scrollTop=r.scrollHeight))}if("top"===e.show||"bottom"===e.show){let r=e.showTarget?this.#Be(e.showTarget):t;r?.scrollIntoView?.("top"===e.show)}}#Fe(e){e.request?.anchor&&document.getElementById(e.request.anchor)?.scrollIntoView({block:"start",behavior:"auto"})}#Me(e){let t=this.#j(e,"script");for(let e of t){let t=document.createElement("script");for(let r of e.attributes)t.setAttribute(r.name,r.value);this.config.inlineScriptNonce&&(t.nonce=this.config.inlineScriptNonce),t.textContent=this.#h.createScript(e.textContent),e.replaceWith(t)}}initialize(){this.config.history&&!this.#g&&(this.#g=!0,history.state||history.replaceState({htmx:!0},"",location.href),window.navigation&&!/firefox/i.test(navigator.userAgent)?navigation.addEventListener("navigate",e=>{"traverse"===e.navigationType&&e.canIntercept&&!e.hashChange&&e.intercept({handler:()=>this.#ze()})}):window.addEventListener("popstate",e=>this.#ze(e.state))),this.process(document.body)}async swap(e){try{this.#_e(e);let{fragment:t,title:r}=this.#Ie(e.text);e.title=r;let i=[],s=this.#Le(t,e.sourceElement,e.selectOOB),n=this.#Ve(t,e);i.push(...s,...n);let o=n.length||s.length&&!this.config.allowEmptySwapAfterOOB,a=this.#De(e,t,o);if(a&&i.unshift(a),!this.#L(e.sourceElement,"htmx:before:swap",{ctx:e,tasks:i}))return;let l=[],h=[];for(let t of i)t.swapSpec?.transition??a?.transition??e.transition?h.push(t):l.push(this.#N(t));if(h.length>0){let t=async()=>{for(let e of h)await this.#N(e,!1)};l.push(this.#$e(t,e))}await Promise.all(l),!e.sourceElement?.isConnected&&a?.target?.isConnected&&(e.sourceElement=a.target),this.#L(e.sourceElement,"htmx:after:swap",{ctx:e}),e.title&&!a?.swapSpec?.ignoreTitle&&(document.title=e.title),this.#Fe(e)}finally{this.#L(e.sourceElement,"htmx:finally:swap",{ctx:e})}}#De(e,t,r){let i=this.#He(e.swap||this.config.defaultSwap);if("delete"===i.style||t.childElementCount>0||t.textContent.trim()||(i.swapEmpty??!r)){if(e.select){let r=t.querySelectorAll(e.select);(t=document.createDocumentFragment()).append(...r)}return this.#D(e.sourceElement)&&(i.show||="top"),{type:"main",fragment:t,target:this.#K(e.sourceElement||document.body,i.target||e.target),swapSpec:i,sourceElement:e.sourceElement,transition:e.transition&&!1!==i.transition}}}async#N(e,t=!0){let{target:r,swapSpec:i,fragment:s}=e;if("string"==typeof r&&(r=document.querySelector(r)),!r)return;"string"==typeof i&&(i=this.#He(i));let n,o=i.style;if("none"===o)return;if("BODY"===s.firstElementChild?.tagName){const e=r===document.body&&o.startsWith("outer");e&&"outerHTML"===o&&(o="outerSync"),i.strip??=!e}if(i.strip&&s.firstElementChild&&(s=document.createDocumentFragment(),s.append(...(e.fragment.firstElementChild.content||e.fragment.firstElementChild).childNodes)),this.#We(r,"htmx-swapping"),t&&e.swapSpec?.swap&&await this.timeout(e.swapSpec?.swap),"delete"===o)return void(r.parentNode&&(this.#Se(r),r.parentNode.removeChild(r)));let a=[],l=i.settle??this.config.defaultSettleDelay,h=r.parentNode;if("innerHTML"===o||"outerHTML"===o&&h){let e=document.activeElement;if(e?.id){let t,r;try{t=e.selectionStart,r=e.selectionEnd}catch(e){}n={elt:e,start:t,end:r}}a=t&&l?this.#Ue(s,r):[]}let c=this.#Te(s),u=[...s.childNodes];try{if("innerHTML"===o){for(const e of r.children)this.#Se(e);r.replaceChildren(...s.childNodes)}else if("textContent"===o){for(const e of r.querySelectorAll("[data-htmx-powered]"))this.#Se(e);r.textContent=s.textContent}else if("outerHTML"===o)h&&(this.#ke(h,r,s),this.#Se(r),h.removeChild(r),r=u[0]||h);else if("outerSync"===o){this.#Qe(r,s.firstElementChild);for(const e of r.children)this.#Se(e);r.replaceChildren(...s.firstElementChild.childNodes),u=[r]}else if("innerMorph"===o)this.#I(r,s,!0),u=[...r.childNodes];else if("outerMorph"===o)this.#I(r,s,!1),u.push(r);else if("beforebegin"===o)h&&this.#ke(h,r,s);else if("afterbegin"===o)this.#ke(r,r.firstChild,s);else if("beforeend"===o)this.#ke(r,null,s);else if("afterend"===o)h&&this.#ke(h,r.nextSibling,s);else{let e=this.#i.get("handle_swap")||[],t=!1;for(const n of e){let e=n(o,r,s,i);if(e){t=!0,Array.isArray(e)&&(u=e);break}}if(!t)throw new Error(`Unknown swap style: ${o}`)}}finally{this.#Je(r,"htmx-swapping")}if(e.target=r,this.#qe(c),n&&!n.elt.matches(":focus")){let e=document.getElementById(n.elt.id);if(e){let t={preventScroll:void 0!==i.focusScroll?!i.focusScroll:!this.config.defaultFocusScroll};this.#Pe(e,t,n.start,n.end)}}this.#L(r,"htmx:before:settle",{task:e,newContent:u,settleTasks:a});for(const e of u)this.#We(e,"htmx-added");if(t&&a.length>0){this.#We(r,"htmx-settling"),await this.timeout(l);for(let e of a)e();this.#Je(r,"htmx-settling")}this.#L(r,"htmx:after:settle",{task:e,newContent:u,settleTasks:a});for(const e of u)this.#Je(e,"htmx-added"),this.process(e),this.#Re(e);this.#je(i,r)}#L(e,t,r={},i=!0){if(r.error){let i=`htmx: ${t}: ${r.error.message??r.error}`;r.error instanceof Error?console.error(i,r.error,{elt:e,detail:r}):console.error(i,{elt:e,detail:r})}else r.warn?console.warn(`htmx: ${t}: ${r.warn}`,{elt:e,detail:r}):this.config.logAll&&console.log(`htmx: ${t}`,{elt:e,detail:r});return e=this.#Xe(e),this.#_(e,t,r),this.trigger(e,this.#z(t),r,i)}#_(e,t,r={}){let i=this.#i.get(t.replace(/:/g,"_"));if(i){r.cancelled=!1;for(const t of i)if(!1===t(e,r)||r.cancelled)return r.cancelled=!0,!1}return!0}timeout(e){if((e=this.parseInterval(e))>0)return new Promise(t=>setTimeout(t,e))}onLoad(e){this.on(this.#z("htmx:after:process"),t=>{e(t.target)})}on(e,t,r){let i,s=document;return void 0===r?(i=e,r=t):(s=this.#Xe(e),i=t),s.addEventListener(i,r),r}find(e,t){return this.#Be(e,t)}findAll(e,t){return this.#be(e,t)}parseInterval(e){if("number"==typeof e)return e;let[,t,r]=e?.match(/^([\d.]+)(ms|s|m)?$/)||[],i=parseFloat(t)*({ms:1,s:1e3,m:6e4}[r]||1);return isNaN(i)?void 0:i}trigger(e,t,r={},i=!0){e=this.#Xe(e);let s=new CustomEvent(t,{detail:r,cancelable:!0,bubbles:i,composed:!0}),n=e?.isConnected?e:document;return!r.cancelled&&n.dispatchEvent(s)}ajax(e,t,r){(!r||r instanceof Element||"string"==typeof r)&&(r={target:r});let i="string"==typeof r.source?document.querySelector(r.source):r.source;if("string"==typeof r.source&&!i)return Promise.reject(new Error("Source not found"));if(r.target){let e=this.#K(document.body,r.target);if(!e)return Promise.reject(new Error("Target not found"));i||=e}i||=document.body;let s=this.#T(i,r.event||{});return Object.assign(s,r),r.target&&(s.target=this.#K(document.body,r.target)),Object.assign(s.request,{action:t,method:e.toUpperCase()}),r.headers&&Object.assign(s.request.headers,r.headers),this.#X(s)}#Ge(e){this.config.history&&(history.state||history.replaceState({htmx:!0},"",location.href),history.pushState({htmx:!0},"",e),this.#L(document,"htmx:after:history:push",{path:e}))}#Ke(e){this.config.history&&(history.replaceState({htmx:!0},"",e),this.#L(document,"htmx:after:history:replace",{path:e}))}async#ze(e,t){if(await this.timeout(1),e??=history.state,!e?.htmx)return;this.#p?.abort(),t=t||location.pathname+location.search;let r=document.querySelector(this.#S("[hx-history-elt]"))||document.body;if(this.#L(document,"htmx:before:history:restore",{path:t,cacheMiss:!0})){if("reload"!==this.config.history)return this.#p=new AbortController,this.ajax("GET",t,{target:r,swap:"outerSync",select:r!==document.body?this.#S("[hx-history-elt]"):void 0,request:{headers:{"HX-History-Restore-Request":"true"},signal:this.#p.signal}});this._loc.reload()}}#Ye(e){let{sourceElement:t,push:r,replace:i,hx:s,response:n}=e;if((s?.pushurl||s?.replaceurl)&&(r=s.pushurl,i=s.replaceurl),null==r&&null==i&&this.#D(t)&&(r="true"),"false"!==r&&!1!==r||(r=null),"false"!==i&&!1!==i||(i=null),!r&&!i)return null;let o=r||i;if("true"===o){let t=n?.raw?.url||e.request.action,r=new URL(t,location.href);o=r.pathname+r.search+(e.request.anchor?"#"+e.request.anchor:"")}return{type:r?"push":"replace",path:o}}#_e(e){if(!this.config.history)return;let t=this.#Ye(e);if(!t)return;let r={history:t,sourceElement:e.sourceElement,response:e.response};this.#L(document,"htmx:before:history:update",r)&&("push"===t.type?this.#Ge(t.path):this.#Ke(t.path),this.#L(document,"htmx:after:history:update",r))}#ve(e){if(e._htmx?.onInitialized)return;let t=this.#v("hx-on"),r=this.config.metaCharacter||":",i=t=>async r=>{try{await this.#k(e,{event:r},`with(event?.detail||{}){${t}}`,!1)}catch(t){"symbol"!=typeof t&&this.#L(e,"htmx:error",{error:t})}};for(let s of e.getAttributeNames()){let n=t.find(e=>s.startsWith(e));if(!n)continue;this.#H(e).onInitialized=!0;let o=s.substring(n.length),a=e.getAttribute(s);if(!o){for(let t of a.split(/;(?=[^;]*->)/)){let r=t.indexOf("->");-1!==r&&this.#O(e,t.substring(0,r).trim(),i(t.substring(r+2).trim()))}continue}if(o[0]!==r)continue;let l=o.substring(1);l.startsWith(r)&&(l="htmx"+r+l.substring(1)),this.#O(e,l,i(a))}}#he(e){let t,r=this.#E(e,"hx-indicator");if(r)t=this.#be(e,r,"hx-indicator");else{if(e===document.body)return[];t=[e]}for(const e of t){let t=this.#$(e);t.rc=(t.rc||0)+1,this.#We(e,this.config.requestClass)}return t}#pe(e){for(let t of e){let e=this.#$(t);e.rc&&--e.rc<=0&&(this.#Je(t,this.config.requestClass),delete e.rc)}}#ce(e){let t=this.#E(e,"hx-disable"),r=[];if(t){r=this.#be(e,t,"hx-disable");for(let e of r){let t=this.#$(e);t.dc=(t.dc||0)+1,e.disabled=!0}}return r}#ge(e){for(const t of e){let e=this.#$(t);e.dc&&--e.dc<=0&&(t.disabled=!1,delete e.dc)}}#C(e,t,r,i,s){if(i&&t&&!t.reportValidity())return;let n=t?new FormData(t):new FormData,o=t?new Set(t.elements):new Set;if(!t){if(i&&e.reportValidity&&!e.reportValidity())return;this.#Ze(e,o,n,s)}r&&r.name&&(n.append(r.name,r.value),o.add(r));let a=this.#E(e,"hx-include");if(a)for(let t of this.#be(e,a)){if(i&&t.reportValidity&&!t.reportValidity())return;this.#Ze(t,o,n)}return n}#Ze(e,t,r,i){let s=e.tagName,n=[];"BUTTON"===s||s.includes("-")?n=[e]:!["INPUT","SELECT","TEXTAREA","FIELDSET"].includes(s)&&i||(n=this.#j(e,"[name]:not(button)"));for(let e of n){let i=e.name||e.getAttribute?.("name");if(!i||e.matches(":disabled")||t.has(e))continue;t.add(e);let s=e.type;if("checkbox"===s||"radio"===s||"INPUT"!==e.tagName&&"checked"in e)e.checked&&r.append(i,e.value);else if("file"===s)for(let t of e.files)r.append(i,t);else if("select-multiple"===s)for(let t of e.selectedOptions)r.append(i,t.value);else if(Array.isArray(e.value))for(let t of e.value)r.append(i,t);else r.append(i,e.value)}}#q(t,r,i,s={}){let n=this.#E(t,r);if(!n)return null;let o=this.#ie(n);if(o)return 0!==o.indexOf("{")&&(o="{"+o+"}"),this.#k(t,s,o,!0).then(e=>{i(e)});i(e.parse(n))}#et(e){let t=e.trim();return t.startsWith("<")&&t.endsWith("/>")?t.slice(1,-2):t}#be(t,r,i,s){let n=r??t,o=r?this.#Xe(t)||document.body:document;if(n.startsWith("global "))return this.#be(o,n.slice(7),i,!0);let a=n?e.split(n):[],l=[],h=[];for(const e of a){let t,r=this.#et(e);if(r.startsWith("closest "))t=o.closest(r.slice(8));else if(r.startsWith("find "))t=o.querySelector(r.slice(5));else if(r.startsWith("findAll "))l.push(...o.querySelectorAll(r.slice(8)));else if("next"===r||"nextElementSibling"===r)t=o.nextElementSibling;else if(r.startsWith("next "))t=this.#tt(o,r.slice(5),!!s);else if("previous"===r||"previousElementSibling"===r)t=o.previousElementSibling;else if(r.startsWith("previous "))t=this.#rt(o,r.slice(9),!!s);else if("document"===r)t=document;else if("window"===r)t=window;else if("body"===r)t=document.body;else if("host"===r)t=o.getRootNode().host;else if("this"===r){if(i){l.push(...this.#F(o,i));continue}t=o}else h.push(r);t&&l.push(t)}if(h.length>0){let e=h.join(","),t=this.#it(o,!!s);l.push(...t.querySelectorAll(e))}return[...new Set(l)]}#tt(e,t,r){return this.#st(this.#it(e,r).querySelectorAll(t),e,Node.DOCUMENT_POSITION_PRECEDING)}#rt(e,t,r){let i=[...this.#it(e,r).querySelectorAll(t)].reverse();return this.#st(i,e,Node.DOCUMENT_POSITION_FOLLOWING)}#st(e,t,r){for(const i of e)if(i.compareDocumentPosition(t)===r)return i}#it(e,t){return e.isConnected&&e.getRootNode?e.getRootNode?.({composed:t}):document}#ee(e,t,r){let i=this.#be(e,t,r)[0];return i||console.warn(`htmx: '${t}' on ${r} did not match any element`,{elt:e,selector:t,attr:r}),i}#Be(e,t,r){return this.#be(e,t,r)[0]}#ie(e){if(null!=e){if(e.startsWith("js:"))return e.substring(3);if(e.startsWith("javascript:"))return e.substring(11)}}#ae(e){let t=this.#H(e);if(t.abortInitialized)return;t.abortInitialized=!0;let r=()=>{this.#oe(e).abort()};e.addEventListener("htmx:abort",r),t.listeners.push({fromElt:e,eventName:"htmx:abort",handler:r})}#I(e,t,r){let{persistentIds:i,idMap:s}=this.#nt(e,t),n=document.createElement("div");n.hidden=!0,document.body.after(n);let o={target:e,idMap:s,persistentIds:i,pantry:n,futureMatches:new WeakSet};r?this.#ot(o,e,t):this.#ot(o,e.parentNode,t,e,e.nextSibling),this.#Se(n),n.remove()}#ot(e,t,r,i=null,s=null){t instanceof HTMLTemplateElement&&r instanceof HTMLTemplateElement&&(t=t.content,r=r.content),i||=t.firstChild;let n=r.firstChild;for(;n;){let r;if(i&&i!=s&&(r=this.#at(e,n,i,s),r&&r!==i)){let o=i;for(;o&&o!==r;){let r=o;o=o.nextSibling,r instanceof Element&&(e.idMap.has(r)||this.#lt(e,r,n))?this.#Ce(t,r,s):this.#ht(e,r)}}if(!r&&n instanceof Element&&e.persistentIds.has(n.id)){let s=CSS.escape(n.id);r=e.target.id===n.id&&e.target||e.target.querySelector(`[id="${s}"]`)||e.pantry.querySelector(`[id="${s}"]`);let o=r;for(;o=o.parentNode;){let t=e.idMap.get(o);t&&(t.delete(r.id),t.size||e.idMap.delete(o))}this.#Ce(t,r,i)}if(r){this.#ct(r,n,e),i=r.nextSibling,n=n.nextSibling;continue}let o=n.nextSibling;if(e.idMap.has(n)){let r=document.createElement(n.tagName);t.insertBefore(r,i),this.#ct(r,n,e),this.process(r),i=r.nextSibling}else t.insertBefore(n,i),i=n.nextSibling;n=o}for(;i&&i!=s;){let t=i;i=i.nextSibling,this.#ht(e,t)}}#lt(e,t,r){if(e.futureMatches.has(t))return!0;for(let i=r.nextSibling,s=0;i&&s<this.config.morphScanLimit;i=i.nextSibling,s++)if(i instanceof Element&&t.isEqualNode(i))return e.futureMatches.add(t),!0;return!1}#at(e,t,r,i){if(3===t.nodeType)return 3===r?.nodeType?r:null;if(!(t instanceof Element))return null;let s=null,n=0,o=this.config.morphScanLimit,a=e.idMap.get(t),l=a?.size||0;if(t.id&&!a)return null;let h=r;for(;h&&h!=i;){let r=e.idMap.get(h);if(this.#o.isSoftMatch(h,t)){if(r&&a&&[...r].some(e=>a.has(e)))return h;if(!r){if(o>0&&h.isEqualNode(t))return h;s||(s=h)}}if(n+=r?.size||0,n>l)break;if(null!=document.activeElement?.selectionStart&&h.contains(document.activeElement))break;if(--o<1&&0===l)break;h=h.nextSibling}return s&&this.#lt(e,s,t)?null:s}#M(e,t){return e instanceof Element&&e.tagName===t.tagName&&(!("SCRIPT"===e.tagName&&!e.isEqualNode(t))&&(!e.id||e.id===t.id))}#ht(e,t){e.idMap.has(t)?this.#Ce(e.pantry,t,null):(this.#Se(t),t.remove())}#Ce(e,t,r){if(e.moveBefore)try{return void e.moveBefore(t,r)}catch(e){}e.insertBefore(t,r)}#ct(e,t,r){if(3===e.nodeType)return void(e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue));if(this.config.morphSkip&&e.matches?.(this.config.morphSkip))return;if(!this.#_(e,"htmx:before:morph:node",{oldNode:e,newNode:t}))return;this.#Qe(e,t),e instanceof HTMLTextAreaElement&&document.activeElement!==e&&e.defaultValue!=t.defaultValue&&(e.value=t.value),this.config.morphSkipChildren&&e.matches?.(this.config.morphSkipChildren)||e.isEqualNode(t)&&"TEMPLATE"!==t.tagName&&!t.querySelector?.("template")||this.#ot(r,e,t)}#Qe(e,t){let r=this.config.morphIgnore||[],i=!1,s=e=>this.#v("hx-").some(t=>e.startsWith(t));for(const n of t.attributes)if(!r.some(e=>n.name.startsWith(e))&&e.getAttribute(n.name)!==n.value){if(s(n.name)&&(i=!0),!this.#_(e,"htmx:before:morph:attr",{attrName:n.name,newValue:n.value}))continue;e.setAttribute(n.name,n.value),"value"===n.name&&e instanceof HTMLInputElement&&"file"!==e.type&&document.activeElement!==e&&(e.value=n.value)}for(let n=e.attributes.length-1;n>=0;n--){let o=e.attributes[n];if(o&&!t.hasAttribute(o.name)&&!r.some(e=>o.name.startsWith(e))){if(s(o.name)&&(i=!0),!this.#_(e,"htmx:before:morph:attr",{attrName:o.name,newValue:null}))continue;e.removeAttribute(o.name)}}i&&this.#Se(e,!0)}#ut(e,t,r,i){for(const s of i)if(t.has(s.id)){let t=s;for(;t&&t!==r;){let r=e.get(t);null==r&&(r=new Set,e.set(t,r)),r.add(s.id),t=t.parentElement}}}#nt(e,t){let r=this.#j(e,"[id]"),i=t.querySelectorAll("[id]"),s=this.#dt(r,i),n=new Map;return this.#ut(n,s,e.parentElement,r),this.#ut(n,s,t,i),{persistentIds:s,idMap:n}}#dt(e,t){let r=new Set,i=new Map;for(const{id:t,tagName:s}of e)i.has(t)?r.add(t):t&&i.set(t,s);let s=new Set;for(const{id:e,tagName:n}of t)s.has(e)?r.add(e):i.get(e)===n&&s.add(e);for(const e of r)s.delete(e);return s}#fe(t){let r=t.response.raw.status,i=this.config.noSwap.map(e=>e+""),s=r+"";for(let r of[s,s.slice(0,2)+"x",s[0]+"xx"]){if(i.includes(r))return void(t.swap="none");let s=this.#E(t.sourceElement,"hx-status:"+r);if(s)return void e.merge(s,t)}}#$e(e,t){return new Promise(r=>{this.#m||=[],this.#m.push({task:e,resolve:r,ctx:t}),this.#x||this.#ft()})}async#ft(){if(0===this.#m.length||this.#x)return;this.#x=!0;let{task:e,resolve:t,ctx:r}=this.#m.shift();try{if(document.startViewTransition){let t={task:e,ctx:r};this.#L(r.sourceElement,"htmx:before:viewTransition",t),await document.startViewTransition(t.task).finished,this.#L(r.sourceElement,"htmx:after:viewTransition",t)}else await e()}catch(e){}finally{this.#x=!1,t(),this.#ft()}}#Ue(e,t){let r=t.querySelectorAll("[id]"),i=Object.fromEntries([...r].map(e=>[e.id,e])),s=e.querySelectorAll("[id]"),n=[];for(let e of s){let t=i[e.id];if(t?.tagName===e.tagName){let r=e.cloneNode(!1);this.#Qe(e,t),n.push(()=>{this.#Qe(e,r)})}}return n}#We(e,t){e?.classList?.add?.(t)}#Je(e,t){e?.classList?.remove?.(t),0===e?.classList?.length&&e.removeAttribute("class")}#Xe(e){return"string"==typeof e?this.find(e):e}#z(e){return this.config.metaCharacter?e.replace(/:/g,this.config.metaCharacter):e}}})();⏎̸
@@ -0,0 +1,1 @@
1+var htmx=(()=>{const e={parse(t){if(!t)return{};if(t.startsWith("{"))return JSON.parse(t);let r=/(?:"([^"]+)"|'([^']+)'|([^\s,:]+))(?:\s*:\s*(?:"([^"]*)"|'([^']*)'|<((?:[^/]|\/(?!>))+)\/>|([^\s,]+)))?(?=\s|,|$)/g,i={};for(let s of t.matchAll(r)){let[,t,r,n,o,a,l,h]=s,c=t??r??n,u=(o??a??l??h??"true").trim();try{u=JSON.parse(u)}catch{}let d=n?.includes("."),f=d?c.split(".").reduceRight((e,t)=>({[t]:e}),u):{[c]:u};e.merge(f,i)}return i},split:e=>e.split(/,(?![^\[]*\])(?![^(]*\))(?![^<]*\/>)(?=(?:[^"']|"[^"]*"|'[^']*')*$)/),merge(t,r){"string"==typeof t&&(t=e.parse(t));for(let[i,s]of Object.entries(t)){if(["__proto__","constructor","prototype"].includes(i))continue;let t=s?.constructor===Object,n=r[i]?.constructor===Object;t&&n?e.merge(s,r[i]):r[i]=s}return r}};class t{#e=null;#t=[];admit(e,t,r){if(!this.#e)return this.#e={strategy:e,abort:r},"run";if("replace"===e||"abort"!==e&&"abort"===this.#e.strategy)return this.#t=[],this.#e.abort?.(),this.#e={strategy:e,abort:r},"run";if("queue all"===e)this.#t.push(t);else if("queue last"===e)this.#t=[t];else{if("abort"===e||"drop"===e||0!==this.#t.length)return"dropped";this.#t.push(t)}return"queued"}continue(){this.#e=null,this.#t.shift()?.()}abort(){this.#e?.abort?.()}}return new class{#r=e;#i=new Map;#s="";#n=new Set;_loc=window.location;#o;#a=Function;#l=Object.getPrototypeOf(async function(){}).constructor;#h={createHTML:e=>e,createScript:e=>e};#c;#u="a,form";#d=["get","post","put","patch","delete","query"];#f;#m;#p;#g;#x;constructor(){this.#b(),this.#y(),this.#c=this.#S("[hx-action],[hx-get],[hx-post],[hx-put],[hx-patch],[hx-delete],[hx-query]"),this.#f=(new XPathEvaluator).createExpression(`.//*[@*[${this.#v("hx-on").map(e=>`starts-with(name(), "${e}")`).join(" or ")}]]`),this.#o={HCON:e,attributeValue:this.#E.bind(this),parseTriggerSpecs:this.#w.bind(this),determineMethodAndAction:this.#A.bind(this),createRequestContext:this.#T.bind(this),collectFormData:this.#C.bind(this),getAttributeObject:this.#q.bind(this),insertContent:this.#N.bind(this),morph:this.#I.bind(this),isSoftMatch:this.#M.bind(this),initSecurity:(e,t,r)=>{e&&(this.#h=e),t&&(this.#a=t),r&&(this.#l=r)},onTrigger:this.#O.bind(this),htmxProp:this.#H.bind(this),triggerHtmxEvent:this.#L.bind(this),executeJavaScript:this.#k.bind(this)};let t=()=>this.initialize();"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):setTimeout(t)}#b(){this.version="4.0.0",this.config={logAll:!1,prefix:"data-hx-",transitions:!1,history:!0,mode:"same-origin",defaultSwap:"innerHTML",defaultFocusScroll:!1,indicatorClass:"htmx-indicator",requestClass:"htmx-request",includeIndicatorCSS:!0,defaultTimeout:6e4,extensions:"",morphIgnore:["data-htmx-powered"],morphSkip:"[hx-morph-skip]",morphSkipChildren:"[hx-morph-skip-children]",morphScanLimit:10,noSwap:[204,304],implicitInheritance:!1,defaultSettleDelay:1,allowEmptySwapAfterOOB:!1};let t=document.querySelector('meta[name="htmx-config"]');t&&e.merge(t.content,this.config),this.#s=this.config.extensions}#y(){if(!1!==this.config.includeIndicatorCSS){let e=this.config.indicatorClass,t=this.config.requestClass,r=new CSSStyleSheet;r.replaceSync(`.${e}{opacity:0;visibility: hidden} .${t} .${e}, .${t}.${e}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`),document.adoptedStyleSheets=[...document.adoptedStyleSheets,r]}}registerExtension(e,t){return!(this.#s&&!this.#s.split(/,\s*/).includes(e))&&(!this.#n.has(e)&&(this.#n.add(e),t.init&&t.init(this.#o),void Object.entries(t).forEach(([e,t])=>{this.#i.get(e)?.push(t)||this.#i.set(e,[t])})))}#V(e){let t=this.config.prefix;return!e.closest||null!=e.closest("[hx-ignore]")||t&&null!=e.closest(`[${t}ignore]`)}#P(e,t){let r=this.config.prefix;return e.getAttribute(t)??(r?e.getAttribute(t.replace("hx-",r)):null)}#R(e,t){let r=this.config.prefix&&t.replace("hx-",this.config.prefix);return e.hasAttribute(t)?t:r&&e.hasAttribute(r)?r:null}#S(e){return this.#v(e).join(",")}#v(e){let t=[e];return this.config.prefix&&t.push(e.replaceAll("hx-",this.config.prefix)),t}#j(e,t){let r=[...e.querySelectorAll?.(t)??[]];return e.matches?.(t)&&r.unshift(e),r}#B(e){return"before"===e?"beforebegin":"after"===e?"afterend":"prepend"===e?"afterbegin":"append"===e?"beforeend":e}#F(e,t){let r=[];return this.#E(e,t,void 0,(e,t)=>{e?.split(/\s*[,:]\s*/).includes("this")&&r.push(t)}),r}#E(e,t,r,i){t=this.#z(t);let s=this.#z(":inherited"),n=this.#z(":append"),o=this.#P(e,t)??this.#P(e,t+s);if(null!=o)return i?i(o,e):o;let a=CSS.escape(this.config.implicitInheritance?t:t+s),l=CSS.escape(t+s+n),h=this.#S(`[${a}],[${l}]`),c=this.#R(e,t+n)??this.#R(e,t+s+n);if(c){let r=e.getAttribute(c),s=e.parentNode?.closest?.(h);if(i&&i(r,e),s){let e=this.#E(s,t,void 0,i);return e?(e+","+r).replace(/[{}]/g,""):r}return r}let u=e.parentNode?.closest?.(h);return u?(o=this.#E(u,t,void 0,i),!i&&o&&this.config.implicitInheritance&&this.#_(e,"htmx:after:implicitInheritance",{elt:e,name:t,parent:u}),o):r}#w(t){return e.split(t).flatMap(t=>{let[,r,i]=t.match(/^\s*(\S+\[[^\]]*\]|\S+)\s*(.*?)\s*$/)??[];if(!r)return[];if(/\[[^\]]*$/.test(r))throw"unterminated:"+r;return[{name:r,...e.parse(i)}]})}#A(e,t){let r=this.#E(e,"hx-method"),i=this.#E(e,"hx-action");if(!i)for(let t of this.#d){let s=this.#E(e,"hx-"+t);if(null!=s){i=s,r=t;break}}return this.#D(e)&&(i||=t.submitter?.getAttribute?.("formAction")||e.getAttribute(e.matches("a")?"href":"action")),r||=t.submitter?.getAttribute?.("formmethod")||e.getAttribute("method")||"GET",{action:i,method:r.toUpperCase()}}#H(e){return e._htmx||(e._htmx={listeners:[],triggerSpecs:[]},e.setAttribute("data-htmx-powered","true")),e._htmx}#$(e){return e._htmx_state||={}}#W(e){if(this.#U(e)&&this.#L(e,"htmx:before:init",{},!0)){let t=this.#H(e);t.initialized=!0,t.eventHandler=this.#Q(e),this.#J(e),this.#L(e,"htmx:after:init",{},!0)}}#Q(e){return async t=>{try{let r=this.#T(e,t);await this.#X(r)}catch(t){this.#L(e,"htmx:error",{error:t})}}}#T(t,r){let{action:i,method:s}=this.#A(t,r),[n,o]=(i||"").split("#"),a=new AbortController,l={sourceElement:t,sourceEvent:r,status:"created",select:this.#E(t,"hx-select"),selectOOB:this.#E(t,"hx-select-oob"),target:this.#E(t,"hx-target"),swap:this.#E(t,"hx-swap")??this.config.defaultSwap,push:this.#E(t,"hx-push-url"),replace:this.#E(t,"hx-replace-url"),transition:this.config.transitions,confirm:this.#E(t,"hx-confirm"),request:{validate:"true"===this.#E(t,"hx-validate",!t.matches("form")||t.noValidate||r.submitter?.formNoValidate?"false":"true"),action:n,anchor:o,method:s,headers:this.#G(t),abort:a.abort.bind(a),credentials:"same-origin",signal:a.signal,mode:this.config.mode}};t._htmx?.boosted&&e.merge(t._htmx.boosted,l),l.target=this.#K(t,l.target),l.target&&(l.request.headers["HX-Target"]=this.#Y(l.target));let h=this.#E(t,"hx-config");return h&&(e.merge(h,l.request),l.request.mode=this.config.mode),l}#Y(e){return`${e.tagName.toLowerCase()}${e.id?"#"+encodeURI(e.id):""}`}#G(e){let t={"HX-Request":"true","HX-Source":this.#Y(e),"HX-Current-URL":location.href,Accept:"text/html"};return this.#D(e)&&(t["HX-Boosted"]="true"),t}#Z(e,t){return this.#q(e,"hx-headers",e=>{for(let r in e)t.request.headers[r]=String(e[r])},{ctx:t})}#K(e,t){return t instanceof Element?t:null!=t?this.#ee(e,t,"hx-target"):this.#D(e)?document.body:e}#D(e){return e?._htmx?.boosted}async#X(e){let t=e.sourceElement,r=e.sourceEvent;if(!t.isConnected)return;if(this.#te(r))return;this.#re(r)&&r.preventDefault();let i=/GET|DELETE/.test(e.request.method),s=i?t.matches("form")?t:null:t.form||t.closest("form"),n=this.#C(t,s,r.submitter,e.request.validate,i);if(!n)return;let o=this.#q(t,"hx-vals",t=>{e.vals=t;for(let e in t)n.set(e,t[e])},{ctx:e});if(o&&await o,e.values)for(let t in e.values)n.delete(t),n.append(t,e.values[t]);let a=this.#Z(t,e);if(a&&await a,Object.assign(e.request,{form:s,submitter:r.submitter,body:n}),!this.#L(t,"htmx:config:request",{ctx:e}))return;if("DIALOG"===e.request.method)return;let l=this.#ie(e.request.action);if(null!=l){let t=Object.fromEntries(e.request.body);return void await this.#k(e.sourceElement,t,l,!1)}if(i){let t=new URL(e.request.action,document.baseURI);for(let r of e.request.body.keys())t.searchParams.delete(r);for(let[r,i]of e.request.body)t.searchParams.append(r,i);t.origin===location.origin?e.request.action=t.pathname+t.search:e.request.action=t.href,e.request.body=null}else"multipart/form-data"!==(this.#E(t,"hx-encoding")??s?.enctype)&&(e.request.body=new URLSearchParams(e.request.body));await this.#se(e)}async#se(e){let t=e.sourceElement,r=this.#ne(t),i=this.#oe(t);if(this.#ae(t),"run"!==i.admit(r,()=>this.#se(e),()=>e.request?.abort?.()))return;e.status="issuing";let s=[],n=[];try{if(e.confirm){if(!await new Promise(r=>{let i={ctx:e,issueRequest:()=>r(!0),dropRequest:()=>r(!1)};if(this.#L(t,"htmx:confirm",i)){let i=this.#ie(e.confirm);r(i?this.#k(t,{ctx:e},i,!0):window.confirm(e.confirm))}}))return}if(this.#le(e),s=this.#he(t),n=this.#ce(t),e.fetch||=window.fetch.bind(window),e.request.headers["HX-Request-Type"]=e.target===document.body||e.select?"full":"partial",!this.#L(t,"htmx:before:request",{ctx:e}))return;let r=await e.fetch(e.request.action,e.request);if(e.response={raw:r,status:r.status,headers:r.headers},this.#ue(e),!this.#L(t,"htmx:before:response",{ctx:e}))return;if(e.text=await r.text(),!this.#L(t,"htmx:after:request",{ctx:e}))return;if(e.response.status>=400&&this.#L(t,"htmx:response:error",{ctx:e}),this.#de(e))return void(e.keepIndicators=!0);"issuing"===e.status&&(e.hx.retarget&&(e.target=e.hx.retarget),e.hx.reswap&&(e.swap=e.hx.reswap),e.hx.reselect&&(e.select=e.hx.reselect),e.status="response received",this.#fe(e),await this.swap(e),e.status="swapped")}catch(r){e.status="error: "+r,this.#L(t,"htmx:error",{ctx:e,error:r})}finally{await(e.extensionPromise?.catch(()=>{})),clearTimeout(e.requestTimeout),e.hx?.trigger&&this.#me(e.hx.trigger,e.sourceElement),this.#L(t,"htmx:finally:request",{ctx:e}),e.keepIndicators||(this.#pe(s),this.#ge(n)),i.continue()}}#ue(e){e.hx={};for(let[t,r]of e.response.raw.headers)t.toLowerCase().startsWith("hx-")&&(e.hx[t.slice(3).toLowerCase().replace(/-/g,"")]=r)}#de(t){if("true"===t.hx.refresh)return this._loc.reload(),!0;if(t.hx.redirect)return this._loc.href=t.hx.redirect,!0;if(t.hx.location){let r=t.hx.location,i={},s=e.parse(r);return"{"!==r[0]&&null==s.path||(i=s,r=i.path,delete i.path),null==i.push&&null==i.replace&&(i.push="true"),this.ajax("GET",r,i),!0}}#le(e){let t=null!=e.request.timeout?this.parseInterval(e.request.timeout):this.config.defaultTimeout;t&&(e.requestTimeout=setTimeout(()=>e.request?.abort?.(),t))}#ne(e){let t=this.#E(e,"hx-sync");if(!t)return"queue first";let r=t.split(":").pop().trim();return/^(drop|abort|replace|queue)/.test(r)?r:"queue first"}#oe(e){let r=this.#E(e,"hx-sync"),i=e;if(r){let t=r.includes(":")?r.slice(0,r.lastIndexOf(":")).trim():/^(drop|abort|replace|queue)/.test(r)?null:r;t&&(i=this.#ee(e,t,"hx-sync")||e)}return this.#$(i).rq||=new t}#te(e){return"click"===e.type&&(e.ctrlKey||e.metaKey||e.shiftKey)&&!!e.currentTarget?.closest?.("a[href]")}#re(e){let t=e.currentTarget;if("submit"===e.type&&"FORM"===t?.tagName)return!0;if(!("click"===e.type&&0===e.button))return!1;let r=t?.closest?.('button, input[type="submit"], input[type="image"]'),i=r?.form||r?.closest("form");if(r&&!r.disabled&&i&&("submit"===r.type||"image"===r.type||!r.type&&"BUTTON"===r.tagName))return!0;let s=t?.closest?.("a");if(!s||!s.href)return!1;let n=s.getAttribute("href");return!(n&&n.startsWith("#")&&n.length>1)}#J(e,t=e._htmx.eventHandler){let r=this.#E(e,"hx-trigger")||(e.matches("form")?"submit":e.matches("input:not([type=button]):not([type=submit]),select,textarea")?"change":"click");this.#O(e,r,t)}#O(e,t,r){let i=this.#w(t);this.#H(e).triggerSpecs.push(...i);for(let t of i){t.listeners=[];let[i,s]=this.#xe(t.name),n=[e];"outside"===t.from?n=[document]:t.from&&"self"!==t.from&&(n=this.#be(e,t.from));let o=e=>{if((t.halt||t.prevent)&&e.preventDefault(),(t.halt||t.stop||t.consume)&&e.stopPropagation(),t.once)for(let e of t.listeners)e.fromElt.removeEventListener(e.eventName,e.handler,e);r(e)},a=o;if(t.delay?a=e=>{clearTimeout(t.timeout),t.timeout=setTimeout(()=>o(e),this.parseInterval(t.delay))}:t.throttle&&(a=e=>{t.throttled?t.throttledEvent=e:(t.throttled=!0,o(e),t.throttleTimeout=setTimeout(()=>{if(t.throttled=!1,t.throttledEvent){let e=t.throttledEvent;t.throttledEvent=null,a(e)}},this.parseInterval(t.throttle)))}),t.handler=r=>{if(("self"!==t.from||r.target===e)&&("outside"!==t.from||!e.contains(r.target))&&(!t.target||r.target?.matches?.(t.target))){if(t.changed){let e=t.values??=new WeakMap,r=!1;for(let t of n)e.get(t)!==t.value&&(r=!0,e.set(t,t.value));if(!r)return}if(s){this.#re(r)&&r.preventDefault();let t={};for(let e in r)t[e]=r[e];if(!this.#k(e,t,s,!0,!1))return}a(r)}},"intersect"===i||"revealed"===i){let r={rootMargin:t.rootMargin};t.root&&(r.root=this.#ee(e,t.root)),t.threshold&&(r.threshold=parseFloat(t.threshold));let s="revealed"===i;t.observer=new IntersectionObserver(r=>{for(let i=0;i<r.length;i++)if(r[i].isIntersecting){this.trigger(e,"intersect",{},!1),s&&t.observer.disconnect();break}},r),i="intersect",t.observer.observe(e)}if("every"===i){let r=Object.keys(t).find(e=>"name"!==e);t.interval=setInterval(()=>{e.isConnected?this.#L(e,"every",{},!1):clearInterval(t.interval)},this.parseInterval(r))}if("load"!==i)for(let r of n){let s={fromElt:r,eventName:i,handler:t.handler,capture:!!t.capture,passive:!!t.passive};e._htmx.listeners.push(s),t.listeners.push(s),r.addEventListener(i,t.handler,s)}else t.handler(new CustomEvent("load"))}}#xe(e){let t=e.match(/^([^\[]*)\[([^\]]*)]/);return t?[t[1],t[2]]:[e,null]}#me(t,r){if("{"===t[0]){let i=e.parse(t);for(let e in i){let t=i[e],s=r;t?.target&&(s=this.find(t.target)),this.trigger(s,e,"object"==typeof t?t:{value:t})}}else t.split(",").forEach(e=>this.trigger(r,e.trim(),{}))}#ye(e){let t={},r=Object.getPrototypeOf(this);for(let i of Object.getOwnPropertyNames(r))"constructor"!==i&&"function"==typeof this[i]&&(["find","findAll"].includes(i)?t[i]=(t,r)=>void 0===r?this[i](e,t):this[i](t,r):t[i]=this[i].bind(this));return t}#k(e,t,r,i=!0,s=!0,n=!1){let o={};Object.assign(o,this.#ye(e));let a={},l={scope:a,code:r};this.#_(e,"htmx:scope",l),r=l.code,Object.assign(o,a),Object.assign(o,t);let h=Object.keys(o),c=Object.values(o),u=new(s?this.#l:this.#a)(...h,i?`return (${r})`:r);return n?()=>u.call(e,...c):u.call(e,...c)}process(e,t){if(!e?.isConnected)return;if(!(e instanceof Element)){for(let r of e.children||[])this.process(r,t);return}if(t&&this.#Se(e,!0),this.#V(e))return;if(!this.#L(e,"htmx:before:process"))return;let r=[e],i=this.#f.evaluate(e),s=null;for(;s=i.iterateNext();)r.push(s);for(let e of r)!this.#V(e)&&this.#L(e,"htmx:before:on:init",{},!0)&&this.#ve(e);for(let t of this.#j(e,this.#c))this.#W(t);for(let t of this.#j(e,this.#u))this.#Ee(t);this.#L(e,"htmx:after:process")}#Ee(e){let t=this.#E(e,"hx-boost");if(t&&"false"!==t&&this.#we(e)&&this.#L(e,"htmx:before:init",{},!0)){let r=this.#H(e);r.initialized=!0,r.eventHandler=this.#Q(e),r.boosted=t;let i=e.matches("a")?"click":"submit";e._htmx.listeners.push({fromElt:e,eventName:i,handler:e._htmx.eventHandler}),e.addEventListener(i,e._htmx.eventHandler),this.#L(e,"htmx:after:init",{},!0)}}#we(e){if(this.#U(e))if("A"===e.tagName){if(""===e.target||"_self"===e.target)return!e.hasAttribute("download")&&!e.getAttribute("href")?.startsWith?.("#")&&this.#Ae(e.href)}else if("FORM"===e.tagName)return"dialog"!==e.method&&this.#Ae(e.action)}#Ae(e){try{return new URL(e,window.location.href).origin===window.location.origin}catch(e){return!1}}#U(e){return!e._htmx?.initialized&&!this.#V(e)}#Se(e,t){let r=[e,...e.querySelectorAll?.("[data-htmx-powered]")??[]];for(let e of r)if(e._htmx){this.#L(e,"htmx:before:cleanup");for(let t of e._htmx.triggerSpecs||[])t.interval&&clearInterval(t.interval),t.timeout&&clearTimeout(t.timeout),t.throttleTimeout&&clearTimeout(t.throttleTimeout),t.observer?.disconnect();for(let t of e._htmx.listeners||[])t.fromElt.removeEventListener(t.eventName,t.handler,t);e.removeAttribute("data-htmx-powered"),this.#L(e,"htmx:after:cleanup"),t&&delete e._htmx}}#Te(e){let t=document.createElement("div");t.hidden=!0,document.body.insertAdjacentElement("afterend",t);let r=e.querySelectorAll?.(this.#S("[hx-preserve]"))||[];for(let e of r){let r=document.getElementById(e.id);r&&this.#Ce(t,r,null)}return t}#qe(e){for(let t of[...e.children]){let e=document.getElementById(t.id);e&&(this.#Ce(e.parentNode,t,e),this.#Se(e),e.remove())}e.remove()}#Ne(e){let t=this.#h.createHTML(e);return Document.parseHTMLUnsafe?.(t)||(new DOMParser).parseFromString(t,"text/html")}#Ie(e){let t=e.replace(/<hx-([a-z]+)(\s+|>)/gi,'<template hx type="$1"$2').replace(/<\/hx-[a-z]+>/gi,"</template>"),r="";t=t.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,e=>(r=this.#Ne(e).title,""));let i,s,n=t.match(/<([a-z][^\/>\x20\t\r\n\f]*)/i)?.[1]?.toLowerCase();if("html"===n||"body"===n?(i=this.#Ne(t),s=document.createDocumentFragment(),s.append(i.body)):(i=this.#Ne(`<template>${t}</template>`),s=i.querySelector("template").content),!r){let e=s.querySelector("title:not(svg title)");e&&(r=e.textContent,e.remove())}return this.#Me(s),{fragment:s,title:r}}#Oe(e,t,r,i){let s=t.id?"#"+CSS.escape(t.id):null;"true"!==r&&r&&!r.includes(" ")&&([r,s=s]=r.split(/:(.*)/)),"true"!==r&&r||(r="outerHTML");let n=this.#He(r);if(s=n.target||s,n.strip??=!n.style.startsWith("outer"),!s)return;let o=[...document.querySelectorAll(s)];for(let r of o){let s=document.createDocumentFragment();s.append(t.cloneNode(!0)),e.push({type:"oob",fragment:s,target:r,swapSpec:n,sourceElement:i})}t.remove()}#Le(e,t,r){let i=[];if(r)for(let s of r.split(",")){let[r,n="true"]=s.split(/:(.*)/);for(let s of e.querySelectorAll(r))this.#Oe(i,s,n,t)}for(let r of e.querySelectorAll(this.#S("[hx-swap-oob]"))){let e=this.#R(r,"hx-swap-oob"),s=r.getAttribute(e);r.removeAttribute(e),this.#Oe(i,r,s,t)}return i}#ke(e,t,r){t?t.before(...r.childNodes):e.append(...r.childNodes)}#He(t){t=t.trim();let r=this.config.defaultSwap;if(t&&!/^\S*:/.test(t)){let e=t.match(/^(\S+)\s*(.*)$/);r=e[1],t=e[2]}return{style:this.#B(r),...e.parse(t)}}#Ve(e,t){let r=[];for(let i of e.querySelectorAll("template[hx]")){let e=i.getAttribute("type");if("partial"===e){let e=this.#P(i,"hx-target")||(i.id?"#"+CSS.escape(i.id):null);if(e){this.#Me(i.content);let s=this.#He(this.#P(i,"hx-swap")||this.config.defaultSwap),n=this.#be(t.sourceElement,e);for(let e of n.length?n:[null])r.push({type:"partial",fragment:i.content.cloneNode(!0),target:e,swapSpec:s,sourceElement:t.sourceElement})}}else this.#_(i,"htmx:process:"+e,{ctx:t,tasks:r});i.remove()}return r}#Pe(e,t,r,i){try{null!=r&&e.setSelectionRange&&e.setSelectionRange(r,i),e.focus(t)}catch(e){}}#Re(e){let t=this.#j(e,"[autofocus]")[0];t&&this.#Pe(t)}#je(e,t){if(e.scroll){let r=e.scrollTarget?this.#Be(e.scrollTarget):t;r&&("top"===e.scroll?r.scrollTop=0:"bottom"===e.scroll&&(r.scrollTop=r.scrollHeight))}if("top"===e.show||"bottom"===e.show){let r=e.showTarget?this.#Be(e.showTarget):t;r?.scrollIntoView?.("top"===e.show)}}#Fe(e){e.request?.anchor&&document.getElementById(e.request.anchor)?.scrollIntoView({block:"start",behavior:"auto"})}#Me(e){let t=this.#j(e,"script");for(let e of t){let t=document.createElement("script");for(let r of e.attributes)t.setAttribute(r.name,r.value);this.config.inlineScriptNonce&&(t.nonce=this.config.inlineScriptNonce),t.textContent=this.#h.createScript(e.textContent),e.replaceWith(t)}}initialize(){this.config.history&&!this.#g&&(this.#g=!0,history.state||history.replaceState({htmx:!0},"",location.href),window.navigation&&!/firefox/i.test(navigator.userAgent)?navigation.addEventListener("navigate",e=>{"traverse"===e.navigationType&&e.canIntercept&&!e.hashChange&&e.intercept({handler:()=>this.#ze()})}):window.addEventListener("popstate",e=>this.#ze(e.state))),this.process(document.body)}async swap(e){try{this.#_e(e);let{fragment:t,title:r}=this.#Ie(e.text);e.title=r;let i=[],s=this.#Le(t,e.sourceElement,e.selectOOB),n=this.#Ve(t,e);i.push(...s,...n);let o=n.length||s.length&&!this.config.allowEmptySwapAfterOOB,a=this.#De(e,t,o);if(a&&i.unshift(a),!this.#L(e.sourceElement,"htmx:before:swap",{ctx:e,tasks:i}))return;let l=[],h=[];for(let t of i)t.swapSpec?.transition??a?.transition??e.transition?h.push(t):l.push(this.#N(t));if(h.length>0){let t=async()=>{for(let e of h)await this.#N(e,!1)};l.push(this.#$e(t,e))}await Promise.all(l),!e.sourceElement?.isConnected&&a?.target?.isConnected&&(e.sourceElement=a.target),this.#L(e.sourceElement,"htmx:after:swap",{ctx:e}),e.title&&!a?.swapSpec?.ignoreTitle&&(document.title=e.title),this.#Fe(e)}finally{this.#L(e.sourceElement,"htmx:finally:swap",{ctx:e})}}#De(e,t,r){let i=this.#He(e.swap||this.config.defaultSwap);if("delete"===i.style||t.childElementCount>0||t.textContent.trim()||(i.swapEmpty??!r)){if(e.select){let r=t.querySelectorAll(e.select);(t=document.createDocumentFragment()).append(...r)}return this.#D(e.sourceElement)&&(i.show||="top"),{type:"main",fragment:t,target:this.#K(e.sourceElement||document.body,i.target||e.target),swapSpec:i,sourceElement:e.sourceElement,transition:e.transition&&!1!==i.transition}}}async#N(e,t=!0){let{target:r,swapSpec:i,fragment:s}=e;if("string"==typeof r&&(r=document.querySelector(r)),!r)return;"string"==typeof i&&(i=this.#He(i));let n,o=i.style;if("none"===o)return;if("BODY"===s.firstElementChild?.tagName){const e=r===document.body&&o.startsWith("outer");e&&"outerHTML"===o&&(o="outerSync"),i.strip??=!e}if(i.strip&&s.firstElementChild&&(s=document.createDocumentFragment(),s.append(...(e.fragment.firstElementChild.content||e.fragment.firstElementChild).childNodes)),this.#We(r,"htmx-swapping"),t&&e.swapSpec?.swap&&await this.timeout(e.swapSpec?.swap),"delete"===o)return void(r.parentNode&&(this.#Se(r),r.parentNode.removeChild(r)));let a=[],l=i.settle??this.config.defaultSettleDelay,h=r.parentNode;if("innerHTML"===o||"outerHTML"===o&&h){let e=document.activeElement;if(e?.id){let t,r;try{t=e.selectionStart,r=e.selectionEnd}catch(e){}n={elt:e,start:t,end:r}}a=t&&l?this.#Ue(s,r):[]}let c=this.#Te(s),u=[...s.childNodes];try{if("innerHTML"===o){for(const e of r.children)this.#Se(e);r.replaceChildren(...s.childNodes)}else if("textContent"===o){for(const e of r.querySelectorAll("[data-htmx-powered]"))this.#Se(e);r.textContent=s.textContent}else if("outerHTML"===o)h&&(this.#ke(h,r,s),this.#Se(r),h.removeChild(r),r=u[0]||h);else if("outerSync"===o){this.#Qe(r,s.firstElementChild);for(const e of r.children)this.#Se(e);r.replaceChildren(...s.firstElementChild.childNodes),u=[r]}else if("innerMorph"===o)this.#I(r,s,!0),u=[...r.childNodes];else if("outerMorph"===o)this.#I(r,s,!1),u.push(r);else if("beforebegin"===o)h&&this.#ke(h,r,s);else if("afterbegin"===o)this.#ke(r,r.firstChild,s);else if("beforeend"===o)this.#ke(r,null,s);else if("afterend"===o)h&&this.#ke(h,r.nextSibling,s);else{let e=this.#i.get("handle_swap")||[],t=!1;for(const n of e){let e=n(o,r,s,i);if(e){t=!0,Array.isArray(e)&&(u=e);break}}if(!t)throw new Error(`Unknown swap style: ${o}`)}}finally{this.#Je(r,"htmx-swapping")}if(e.target=r,this.#qe(c),n&&!n.elt.matches(":focus")){let e=document.getElementById(n.elt.id);if(e){let t={preventScroll:void 0!==i.focusScroll?!i.focusScroll:!this.config.defaultFocusScroll};this.#Pe(e,t,n.start,n.end)}}this.#L(r,"htmx:before:settle",{task:e,newContent:u,settleTasks:a});for(const e of u)this.#We(e,"htmx-added");if(t&&a.length>0){this.#We(r,"htmx-settling"),await this.timeout(l);for(let e of a)e();this.#Je(r,"htmx-settling")}this.#L(r,"htmx:after:settle",{task:e,newContent:u,settleTasks:a});for(const e of u)this.#Je(e,"htmx-added"),this.process(e),this.#Re(e);this.#je(i,r)}#L(e,t,r={},i=!0){if(r.error){let i=`htmx: ${t}: ${r.error.message??r.error}`;r.error instanceof Error?console.error(i,r.error,{elt:e,detail:r}):console.error(i,{elt:e,detail:r})}else r.warn?console.warn(`htmx: ${t}: ${r.warn}`,{elt:e,detail:r}):this.config.logAll&&console.log(`htmx: ${t}`,{elt:e,detail:r});return e=this.#Xe(e),this.#_(e,t,r),this.trigger(e,this.#z(t),r,i)}#_(e,t,r={}){let i=this.#i.get(t.replace(/:/g,"_"));if(i){r.cancelled=!1;for(const t of i)if(!1===t(e,r)||r.cancelled)return r.cancelled=!0,!1}return!0}timeout(e){if((e=this.parseInterval(e))>0)return new Promise(t=>setTimeout(t,e))}onLoad(e){this.on(this.#z("htmx:after:process"),t=>{e(t.target)})}on(e,t,r){let i,s=document;return void 0===r?(i=e,r=t):(s=this.#Xe(e),i=t),s.addEventListener(i,r),r}find(e,t){return this.#Be(e,t)}findAll(e,t){return this.#be(e,t)}parseInterval(e){if("number"==typeof e)return e;let[,t,r]=e?.match(/^([\d.]+)(ms|s|m)?$/)||[],i=parseFloat(t)*({ms:1,s:1e3,m:6e4}[r]||1);return isNaN(i)?void 0:i}trigger(e,t,r={},i=!0){e=this.#Xe(e);let s=new CustomEvent(t,{detail:r,cancelable:!0,bubbles:i,composed:!0}),n=e?.isConnected?e:document;return!r.cancelled&&n.dispatchEvent(s)}ajax(e,t,r){(!r||r instanceof Element||"string"==typeof r)&&(r={target:r});let i="string"==typeof r.source?document.querySelector(r.source):r.source;if("string"==typeof r.source&&!i)return Promise.reject(new Error("Source not found"));if(r.target){let e=this.#K(document.body,r.target);if(!e)return Promise.reject(new Error("Target not found"));i||=e}i||=document.body;let s=this.#T(i,r.event||{});return Object.assign(s,r),r.target&&(s.target=this.#K(document.body,r.target)),Object.assign(s.request,{action:t,method:e.toUpperCase()}),r.headers&&Object.assign(s.request.headers,r.headers),this.#X(s)}#Ge(e){this.config.history&&(history.state||history.replaceState({htmx:!0},"",location.href),history.pushState({htmx:!0},"",e),this.#L(document,"htmx:after:history:push",{path:e}))}#Ke(e){this.config.history&&(history.replaceState({htmx:!0},"",e),this.#L(document,"htmx:after:history:replace",{path:e}))}async#ze(e,t){if(await this.timeout(1),e??=history.state,!e?.htmx)return;this.#p?.abort(),t=t||location.pathname+location.search;let r=document.querySelector(this.#S("[hx-history-elt]"))||document.body;if(this.#L(document,"htmx:before:history:restore",{path:t,cacheMiss:!0})){if("reload"!==this.config.history)return this.#p=new AbortController,this.ajax("GET",t,{target:r,swap:"outerSync",select:r!==document.body?this.#S("[hx-history-elt]"):void 0,request:{headers:{"HX-History-Restore-Request":"true"},signal:this.#p.signal}});this._loc.reload()}}#Ye(e){let{sourceElement:t,push:r,replace:i,hx:s,response:n}=e;if((s?.pushurl||s?.replaceurl)&&(r=s.pushurl,i=s.replaceurl),null==r&&null==i&&this.#D(t)&&(r="true"),"false"!==r&&!1!==r||(r=null),"false"!==i&&!1!==i||(i=null),!r&&!i)return null;let o=r||i;if("true"===o){let t=n?.raw?.url||e.request.action,r=new URL(t,location.href);o=r.pathname+r.search+(e.request.anchor?"#"+e.request.anchor:"")}return{type:r?"push":"replace",path:o}}#_e(e){if(!this.config.history)return;let t=this.#Ye(e);if(!t)return;let r={history:t,sourceElement:e.sourceElement,response:e.response};this.#L(document,"htmx:before:history:update",r)&&("push"===t.type?this.#Ge(t.path):this.#Ke(t.path),this.#L(document,"htmx:after:history:update",r))}#ve(e){if(e._htmx?.onInitialized)return;let t=this.#v("hx-on"),r=this.config.metaCharacter||":",i=t=>async r=>{try{await this.#k(e,{event:r},`with(event?.detail||{}){${t}}`,!1)}catch(t){"symbol"!=typeof t&&this.#L(e,"htmx:error",{error:t})}};for(let s of e.getAttributeNames()){let n=t.find(e=>s.startsWith(e));if(!n)continue;this.#H(e).onInitialized=!0;let o=s.substring(n.length),a=e.getAttribute(s);if(!o){for(let t of a.split(/;(?=[^;]*->)/)){let r=t.indexOf("->");-1!==r&&this.#O(e,t.substring(0,r).trim(),i(t.substring(r+2).trim()))}continue}if(o[0]!==r)continue;let l=o.substring(1);l.startsWith(r)&&(l="htmx"+r+l.substring(1)),this.#O(e,l,i(a))}}#he(e){let t,r=this.#E(e,"hx-indicator");if(r)t=this.#be(e,r,"hx-indicator");else{if(e===document.body)return[];t=[e]}for(const e of t){let t=this.#$(e);t.rc=(t.rc||0)+1,this.#We(e,this.config.requestClass)}return t}#pe(e){for(let t of e){let e=this.#$(t);e.rc&&--e.rc<=0&&(this.#Je(t,this.config.requestClass),delete e.rc)}}#ce(e){let t=this.#E(e,"hx-disable"),r=[];if(t){r=this.#be(e,t,"hx-disable");for(let e of r){let t=this.#$(e);t.dc=(t.dc||0)+1,e.disabled=!0}}return r}#ge(e){for(const t of e){let e=this.#$(t);e.dc&&--e.dc<=0&&(t.disabled=!1,delete e.dc)}}#C(e,t,r,i,s){if(i&&t&&!t.reportValidity())return;let n=t?new FormData(t):new FormData,o=t?new Set(t.elements):new Set;if(!t){if(i&&e.reportValidity&&!e.reportValidity())return;this.#Ze(e,o,n,s)}r&&r.name&&(n.append(r.name,r.value),o.add(r));let a=this.#E(e,"hx-include");if(a)for(let t of this.#be(e,a)){if(i&&t.reportValidity&&!t.reportValidity())return;this.#Ze(t,o,n)}return n}#Ze(e,t,r,i){let s=e.tagName,n=[];"BUTTON"===s||s.includes("-")?n=[e]:!["INPUT","SELECT","TEXTAREA","FIELDSET"].includes(s)&&i||(n=this.#j(e,"[name]:not(button)"));for(let e of n){let i=e.name||e.getAttribute?.("name");if(!i||e.matches(":disabled")||t.has(e))continue;t.add(e);let s=e.type;if("checkbox"===s||"radio"===s||"INPUT"!==e.tagName&&"checked"in e)e.checked&&r.append(i,e.value);else if("file"===s)for(let t of e.files)r.append(i,t);else if("select-multiple"===s)for(let t of e.selectedOptions)r.append(i,t.value);else if(Array.isArray(e.value))for(let t of e.value)r.append(i,t);else r.append(i,e.value)}}#q(t,r,i,s={}){let n=this.#E(t,r);if(!n)return null;let o=this.#ie(n);if(o)return 0!==o.indexOf("{")&&(o="{"+o+"}"),this.#k(t,s,o,!0).then(e=>{i(e)});i(e.parse(n))}#et(e){let t=e.trim();return t.startsWith("<")&&t.endsWith("/>")?t.slice(1,-2):t}#be(t,r,i,s){let n=r??t,o=r?this.#Xe(t)||document.body:document;if(n.startsWith("global "))return this.#be(o,n.slice(7),i,!0);let a=n?e.split(n):[],l=[],h=[];for(const e of a){let t,r=this.#et(e);if(r.startsWith("closest "))t=o.closest(r.slice(8));else if(r.startsWith("find "))t=o.querySelector(r.slice(5));else if(r.startsWith("findAll "))l.push(...o.querySelectorAll(r.slice(8)));else if("next"===r||"nextElementSibling"===r)t=o.nextElementSibling;else if(r.startsWith("next "))t=this.#tt(o,r.slice(5),!!s);else if("previous"===r||"previousElementSibling"===r)t=o.previousElementSibling;else if(r.startsWith("previous "))t=this.#rt(o,r.slice(9),!!s);else if("document"===r)t=document;else if("window"===r)t=window;else if("body"===r)t=document.body;else if("host"===r)t=o.getRootNode().host;else if("this"===r){if(i){l.push(...this.#F(o,i));continue}t=o}else h.push(r);t&&l.push(t)}if(h.length>0){let e=h.join(","),t=this.#it(o,!!s);l.push(...t.querySelectorAll(e))}return[...new Set(l)]}#tt(e,t,r){return this.#st(this.#it(e,r).querySelectorAll(t),e,Node.DOCUMENT_POSITION_PRECEDING)}#rt(e,t,r){let i=[...this.#it(e,r).querySelectorAll(t)].reverse();return this.#st(i,e,Node.DOCUMENT_POSITION_FOLLOWING)}#st(e,t,r){for(const i of e)if(i.compareDocumentPosition(t)===r)return i}#it(e,t){return e.isConnected&&e.getRootNode?e.getRootNode?.({composed:t}):document}#ee(e,t,r){let i=this.#be(e,t,r)[0];return i||console.warn(`htmx: '${t}' on ${r} did not match any element`,{elt:e,selector:t,attr:r}),i}#Be(e,t,r){return this.#be(e,t,r)[0]}#ie(e){if(null!=e){if(e.startsWith("js:"))return e.substring(3);if(e.startsWith("javascript:"))return e.substring(11)}}#ae(e){let t=this.#H(e);if(t.abortInitialized)return;t.abortInitialized=!0;let r=()=>{this.#oe(e).abort()};e.addEventListener("htmx:abort",r),t.listeners.push({fromElt:e,eventName:"htmx:abort",handler:r})}#I(e,t,r){let{persistentIds:i,idMap:s}=this.#nt(e,t),n=document.createElement("div");n.hidden=!0,document.body.after(n);let o={target:e,idMap:s,persistentIds:i,pantry:n,futureMatches:new WeakSet};r?this.#ot(o,e,t):this.#ot(o,e.parentNode,t,e,e.nextSibling),this.#Se(n),n.remove()}#ot(e,t,r,i=null,s=null){t instanceof HTMLTemplateElement&&r instanceof HTMLTemplateElement&&(t=t.content,r=r.content),i||=t.firstChild;let n=r.firstChild;for(;n;){let r;if(i&&i!=s&&(r=this.#at(e,n,i,s),r&&r!==i)){let o=i;for(;o&&o!==r;){let r=o;o=o.nextSibling,r instanceof Element&&(e.idMap.has(r)||this.#lt(e,r,n))?this.#Ce(t,r,s):this.#ht(e,r)}}if(!r&&n instanceof Element&&e.persistentIds.has(n.id)){let s=CSS.escape(n.id);r=e.target.id===n.id&&e.target||e.target.querySelector(`[id="${s}"]`)||e.pantry.querySelector(`[id="${s}"]`);let o=r;for(;o=o.parentNode;){let t=e.idMap.get(o);t&&(t.delete(r.id),t.size||e.idMap.delete(o))}this.#Ce(t,r,i)}if(r){this.#ct(r,n,e),i=r.nextSibling,n=n.nextSibling;continue}let o=n.nextSibling;if(e.idMap.has(n)){let r=document.createElement(n.tagName);t.insertBefore(r,i),this.#ct(r,n,e),this.process(r),i=r.nextSibling}else t.insertBefore(n,i),i=n.nextSibling;n=o}for(;i&&i!=s;){let t=i;i=i.nextSibling,this.#ht(e,t)}}#lt(e,t,r){if(e.futureMatches.has(t))return!0;for(let i=r.nextSibling,s=0;i&&s<this.config.morphScanLimit;i=i.nextSibling,s++)if(i instanceof Element&&t.isEqualNode(i))return e.futureMatches.add(t),!0;return!1}#at(e,t,r,i){if(3===t.nodeType)return 3===r?.nodeType?r:null;if(!(t instanceof Element))return null;let s=null,n=0,o=this.config.morphScanLimit,a=e.idMap.get(t),l=a?.size||0;if(t.id&&!a)return null;let h=r;for(;h&&h!=i;){let r=e.idMap.get(h);if(this.#o.isSoftMatch(h,t)){if(r&&a&&[...r].some(e=>a.has(e)))return h;if(!r){if(o>0&&h.isEqualNode(t))return h;s||(s=h)}}if(n+=r?.size||0,n>l)break;if(null!=document.activeElement?.selectionStart&&h.contains(document.activeElement))break;if(--o<1&&0===l)break;h=h.nextSibling}return s&&this.#lt(e,s,t)?null:s}#M(e,t){return e instanceof Element&&e.tagName===t.tagName&&(!("SCRIPT"===e.tagName&&!e.isEqualNode(t))&&(!e.id||e.id===t.id))}#ht(e,t){e.idMap.has(t)?this.#Ce(e.pantry,t,null):(this.#Se(t),t.remove())}#Ce(e,t,r){if(e.moveBefore)try{return void e.moveBefore(t,r)}catch(e){}e.insertBefore(t,r)}#ct(e,t,r){if(3===e.nodeType)return void(e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue));if(this.config.morphSkip&&e.matches?.(this.config.morphSkip))return;if(!this.#_(e,"htmx:before:morph:node",{oldNode:e,newNode:t}))return;this.#Qe(e,t),e instanceof HTMLTextAreaElement&&document.activeElement!==e&&e.defaultValue!=t.defaultValue&&(e.value=t.value),this.config.morphSkipChildren&&e.matches?.(this.config.morphSkipChildren)||e.isEqualNode(t)&&"TEMPLATE"!==t.tagName&&!t.querySelector?.("template")||this.#ot(r,e,t)}#Qe(e,t){let r=this.config.morphIgnore||[],i=!1,s=e=>this.#v("hx-").some(t=>e.startsWith(t));for(const n of t.attributes)if(!r.some(e=>n.name.startsWith(e))&&e.getAttribute(n.name)!==n.value){if(s(n.name)&&(i=!0),!this.#_(e,"htmx:before:morph:attr",{attrName:n.name,newValue:n.value}))continue;e.setAttribute(n.name,n.value),"value"===n.name&&e instanceof HTMLInputElement&&"file"!==e.type&&document.activeElement!==e&&(e.value=n.value)}for(let n=e.attributes.length-1;n>=0;n--){let o=e.attributes[n];if(o&&!t.hasAttribute(o.name)&&!r.some(e=>o.name.startsWith(e))){if(s(o.name)&&(i=!0),!this.#_(e,"htmx:before:morph:attr",{attrName:o.name,newValue:null}))continue;e.removeAttribute(o.name)}}i&&this.#Se(e,!0)}#ut(e,t,r,i){for(const s of i)if(t.has(s.id)){let t=s;for(;t&&t!==r;){let r=e.get(t);null==r&&(r=new Set,e.set(t,r)),r.add(s.id),t=t.parentElement}}}#nt(e,t){let r=this.#j(e,"[id]"),i=t.querySelectorAll("[id]"),s=this.#dt(r,i),n=new Map;return this.#ut(n,s,e.parentElement,r),this.#ut(n,s,t,i),{persistentIds:s,idMap:n}}#dt(e,t){let r=new Set,i=new Map;for(const{id:t,tagName:s}of e)i.has(t)?r.add(t):t&&i.set(t,s);let s=new Set;for(const{id:e,tagName:n}of t)s.has(e)?r.add(e):i.get(e)===n&&s.add(e);for(const e of r)s.delete(e);return s}#fe(t){let r=t.response.raw.status,i=this.config.noSwap.map(e=>e+""),s=r+"";for(let r of[s,s.slice(0,2)+"x",s[0]+"xx"]){if(i.includes(r))return void(t.swap="none");let s=this.#E(t.sourceElement,"hx-status:"+r);if(s)return void e.merge(s,t)}}#$e(e,t){return new Promise(r=>{this.#m||=[],this.#m.push({task:e,resolve:r,ctx:t}),this.#x||this.#ft()})}async#ft(){if(0===this.#m.length||this.#x)return;this.#x=!0;let{task:e,resolve:t,ctx:r}=this.#m.shift();try{if(document.startViewTransition){let t={task:e,ctx:r};this.#L(r.sourceElement,"htmx:before:viewTransition",t),await document.startViewTransition(t.task).finished,this.#L(r.sourceElement,"htmx:after:viewTransition",t)}else await e()}catch(e){}finally{this.#x=!1,t(),this.#ft()}}#Ue(e,t){let r=t.querySelectorAll("[id]"),i=Object.fromEntries([...r].map(e=>[e.id,e])),s=e.querySelectorAll("[id]"),n=[];for(let e of s){let t=i[e.id];if(t?.tagName===e.tagName){let r=e.cloneNode(!1);this.#Qe(e,t),n.push(()=>{this.#Qe(e,r)})}}return n}#We(e,t){e?.classList?.add?.(t)}#Je(e,t){e?.classList?.remove?.(t),0===e?.classList?.length&&e.removeAttribute("class")}#Xe(e){return"string"==typeof e?this.find(e):e}#z(e){return this.config.metaCharacter?e.replace(/:/g,this.config.metaCharacter):e}}})();⏎̸
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 generated file: collapsed
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