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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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. | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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", "  \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{"&", """}, | |
| 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></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 | + | | |
| 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></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></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
| @@ -0,0 +1,866 @@ | |||
| 1 | + | // Code generated by templ - DO NOT EDIT. | |
| 2 | + | ||
| 3 | + | // templ: version: v0.3.1020 | |
| 4 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 5 | + | ||
| 6 | + | package web | |
| 7 | + | ||
| 8 | + | //lint:file-ignore SA4006 This context is only used if a nested component is present. | |
| 9 | + | ||
| 10 | + | import "github.com/a-h/templ" | |
| 11 | + | import templruntime "github.com/a-h/templ/runtime" | |
| 12 | + | ||
| 13 | + | // Home is the repository list. | |
| 14 | + | func Home(l Layout, p HomePage) templ.Component { | |
| 15 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 16 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 17 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 18 | + | return templ_7745c5c3_CtxErr | |
| 19 | + | } | |
| 20 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 21 | + | if !templ_7745c5c3_IsBuffer { | |
| 22 | + | defer func() { | |
| 23 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 24 | + | if templ_7745c5c3_Err == nil { | |
| 25 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 26 | + | } | |
| 27 | + | }() | |
| 28 | + | } | |
| 29 | + | ctx = templ.InitializeContext(ctx) | |
| 30 | + | templ_7745c5c3_Var1 := templ.GetChildren(ctx) | |
| 31 | + | if templ_7745c5c3_Var1 == nil { | |
| 32 | + | templ_7745c5c3_Var1 = templ.NopComponent | |
| 33 | + | } | |
| 34 | + | ctx = templ.ClearChildren(ctx) | |
| 35 | + | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 36 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 37 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 38 | + | if !templ_7745c5c3_IsBuffer { | |
| 39 | + | defer func() { | |
| 40 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 41 | + | if templ_7745c5c3_Err == nil { | |
| 42 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 43 | + | } | |
| 44 | + | }() | |
| 45 | + | } | |
| 46 | + | ctx = templ.InitializeContext(ctx) | |
| 47 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"filter\"><label for=\"home-filter\">filter></label> <input id=\"home-filter\" type=\"search\" data-filter placeholder=\"name or description ( / )\" autocomplete=\"off\" hx-on:input=\"let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')\"></div><table class=\"list home\" aria-label=\"Repositories\"><thead><tr><th class=\"cur\"></th>") | |
| 48 | + | if templ_7745c5c3_Err != nil { | |
| 49 | + | return templ_7745c5c3_Err | |
| 50 | + | } | |
| 51 | + | for _, c := range p.Cols { | |
| 52 | + | var templ_7745c5c3_Var3 = []any{c.Class} | |
| 53 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...) | |
| 54 | + | if templ_7745c5c3_Err != nil { | |
| 55 | + | return templ_7745c5c3_Err | |
| 56 | + | } | |
| 57 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<th class=\"") | |
| 58 | + | if templ_7745c5c3_Err != nil { | |
| 59 | + | return templ_7745c5c3_Err | |
| 60 | + | } | |
| 61 | + | var templ_7745c5c3_Var4 string | |
| 62 | + | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String()) | |
| 63 | + | if templ_7745c5c3_Err != nil { | |
| 64 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 1, Col: 0} | |
| 65 | + | } | |
| 66 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) | |
| 67 | + | if templ_7745c5c3_Err != nil { | |
| 68 | + | return templ_7745c5c3_Err | |
| 69 | + | } | |
| 70 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" aria-sort=\"") | |
| 71 | + | if templ_7745c5c3_Err != nil { | |
| 72 | + | return templ_7745c5c3_Err | |
| 73 | + | } | |
| 74 | + | var templ_7745c5c3_Var5 string | |
| 75 | + | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Dir) | |
| 76 | + | if templ_7745c5c3_Err != nil { | |
| 77 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 24, Col: 45} | |
| 78 | + | } | |
| 79 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) | |
| 80 | + | if templ_7745c5c3_Err != nil { | |
| 81 | + | return templ_7745c5c3_Err | |
| 82 | + | } | |
| 83 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"><a href=\"") | |
| 84 | + | if templ_7745c5c3_Err != nil { | |
| 85 | + | return templ_7745c5c3_Err | |
| 86 | + | } | |
| 87 | + | var templ_7745c5c3_Var6 templ.SafeURL | |
| 88 | + | templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href)) | |
| 89 | + | if templ_7745c5c3_Err != nil { | |
| 90 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 25, Col: 38} | |
| 91 | + | } | |
| 92 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) | |
| 93 | + | if templ_7745c5c3_Err != nil { | |
| 94 | + | return templ_7745c5c3_Err | |
| 95 | + | } | |
| 96 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">") | |
| 97 | + | if templ_7745c5c3_Err != nil { | |
| 98 | + | return templ_7745c5c3_Err | |
| 99 | + | } | |
| 100 | + | var templ_7745c5c3_Var7 string | |
| 101 | + | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 102 | + | if templ_7745c5c3_Err != nil { | |
| 103 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 26, Col: 17} | |
| 104 | + | } | |
| 105 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) | |
| 106 | + | if templ_7745c5c3_Err != nil { | |
| 107 | + | return templ_7745c5c3_Err | |
| 108 | + | } | |
| 109 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") | |
| 110 | + | if templ_7745c5c3_Err != nil { | |
| 111 | + | return templ_7745c5c3_Err | |
| 112 | + | } | |
| 113 | + | if c.Mark != "" { | |
| 114 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span aria-hidden=\"true\">") | |
| 115 | + | if templ_7745c5c3_Err != nil { | |
| 116 | + | return templ_7745c5c3_Err | |
| 117 | + | } | |
| 118 | + | var templ_7745c5c3_Var8 string | |
| 119 | + | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(c.Mark) | |
| 120 | + | if templ_7745c5c3_Err != nil { | |
| 121 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 28, Col: 42} | |
| 122 | + | } | |
| 123 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | |
| 124 | + | if templ_7745c5c3_Err != nil { | |
| 125 | + | return templ_7745c5c3_Err | |
| 126 | + | } | |
| 127 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span>") | |
| 128 | + | if templ_7745c5c3_Err != nil { | |
| 129 | + | return templ_7745c5c3_Err | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</a></th>") | |
| 133 | + | if templ_7745c5c3_Err != nil { | |
| 134 | + | return templ_7745c5c3_Err | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</tr></thead> <tbody id=\"repo-rows\" data-rows>") | |
| 138 | + | if templ_7745c5c3_Err != nil { | |
| 139 | + | return templ_7745c5c3_Err | |
| 140 | + | } | |
| 141 | + | for _, r := range p.Rows { | |
| 142 | + | templ_7745c5c3_Err = HomeRowView(r).Render(ctx, templ_7745c5c3_Buffer) | |
| 143 | + | if templ_7745c5c3_Err != nil { | |
| 144 | + | return templ_7745c5c3_Err | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</tbody></table>") | |
| 148 | + | if templ_7745c5c3_Err != nil { | |
| 149 | + | return templ_7745c5c3_Err | |
| 150 | + | } | |
| 151 | + | if len(p.Rows) == 0 { | |
| 152 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<p class=\"lede dim\">no repositories yet.</p>") | |
| 153 | + | if templ_7745c5c3_Err != nil { | |
| 154 | + | return templ_7745c5c3_Err | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | return nil | |
| 158 | + | }) | |
| 159 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) | |
| 160 | + | if templ_7745c5c3_Err != nil { | |
| 161 | + | return templ_7745c5c3_Err | |
| 162 | + | } | |
| 163 | + | return nil | |
| 164 | + | }) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // Summary is the summary of a repository: the README reads in the main | |
| 168 | + | // column at the reading measure; what one acts on sits in the rail. | |
| 169 | + | func Summary(l Layout, p SummaryPage) templ.Component { | |
| 170 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 171 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 172 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 173 | + | return templ_7745c5c3_CtxErr | |
| 174 | + | } | |
| 175 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 176 | + | if !templ_7745c5c3_IsBuffer { | |
| 177 | + | defer func() { | |
| 178 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 179 | + | if templ_7745c5c3_Err == nil { | |
| 180 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 181 | + | } | |
| 182 | + | }() | |
| 183 | + | } | |
| 184 | + | ctx = templ.InitializeContext(ctx) | |
| 185 | + | templ_7745c5c3_Var9 := templ.GetChildren(ctx) | |
| 186 | + | if templ_7745c5c3_Var9 == nil { | |
| 187 | + | templ_7745c5c3_Var9 = templ.NopComponent | |
| 188 | + | } | |
| 189 | + | ctx = templ.ClearChildren(ctx) | |
| 190 | + | templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 191 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 192 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 193 | + | if !templ_7745c5c3_IsBuffer { | |
| 194 | + | defer func() { | |
| 195 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 196 | + | if templ_7745c5c3_Err == nil { | |
| 197 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 198 | + | } | |
| 199 | + | }() | |
| 200 | + | } | |
| 201 | + | ctx = templ.InitializeContext(ctx) | |
| 202 | + | if p.Desc != "" { | |
| 203 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"lede\">") | |
| 204 | + | if templ_7745c5c3_Err != nil { | |
| 205 | + | return templ_7745c5c3_Err | |
| 206 | + | } | |
| 207 | + | var templ_7745c5c3_Var11 string | |
| 208 | + | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(p.Desc) | |
| 209 | + | if templ_7745c5c3_Err != nil { | |
| 210 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 52, Col: 27} | |
| 211 | + | } | |
| 212 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) | |
| 213 | + | if templ_7745c5c3_Err != nil { | |
| 214 | + | return templ_7745c5c3_Err | |
| 215 | + | } | |
| 216 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>") | |
| 217 | + | if templ_7745c5c3_Err != nil { | |
| 218 | + | return templ_7745c5c3_Err | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") | |
| 222 | + | if templ_7745c5c3_Err != nil { | |
| 223 | + | return templ_7745c5c3_Err | |
| 224 | + | } | |
| 225 | + | if p.Readme != nil { | |
| 226 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<h2 class=\"rule-h\"><span>") | |
| 227 | + | if templ_7745c5c3_Err != nil { | |
| 228 | + | return templ_7745c5c3_Err | |
| 229 | + | } | |
| 230 | + | var templ_7745c5c3_Var12 string | |
| 231 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(p.Readme.Name) | |
| 232 | + | if templ_7745c5c3_Err != nil { | |
| 233 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 55, Col: 43} | |
| 234 | + | } | |
| 235 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 236 | + | if templ_7745c5c3_Err != nil { | |
| 237 | + | return templ_7745c5c3_Err | |
| 238 | + | } | |
| 239 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span></h2>") | |
| 240 | + | if templ_7745c5c3_Err != nil { | |
| 241 | + | return templ_7745c5c3_Err | |
| 242 | + | } | |
| 243 | + | templ_7745c5c3_Err = ReadmeView(*p.Readme).Render(ctx, templ_7745c5c3_Buffer) | |
| 244 | + | if templ_7745c5c3_Err != nil { | |
| 245 | + | return templ_7745c5c3_Err | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | return nil | |
| 249 | + | }) | |
| 250 | + | templ_7745c5c3_Err = Shell(l, summaryRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer) | |
| 251 | + | if templ_7745c5c3_Err != nil { | |
| 252 | + | return templ_7745c5c3_Err | |
| 253 | + | } | |
| 254 | + | return nil | |
| 255 | + | }) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func summaryRail(p SummaryPage) templ.Component { | |
| 259 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 260 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 261 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 262 | + | return templ_7745c5c3_CtxErr | |
| 263 | + | } | |
| 264 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 265 | + | if !templ_7745c5c3_IsBuffer { | |
| 266 | + | defer func() { | |
| 267 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 268 | + | if templ_7745c5c3_Err == nil { | |
| 269 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 270 | + | } | |
| 271 | + | }() | |
| 272 | + | } | |
| 273 | + | ctx = templ.InitializeContext(ctx) | |
| 274 | + | templ_7745c5c3_Var13 := templ.GetChildren(ctx) | |
| 275 | + | if templ_7745c5c3_Var13 == nil { | |
| 276 | + | templ_7745c5c3_Var13 = templ.NopComponent | |
| 277 | + | } | |
| 278 | + | ctx = templ.ClearChildren(ctx) | |
| 279 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<section aria-labelledby=\"r-clone\"><h2 id=\"r-clone\">clone</h2>") | |
| 280 | + | if templ_7745c5c3_Err != nil { | |
| 281 | + | return templ_7745c5c3_Err | |
| 282 | + | } | |
| 283 | + | templ_7745c5c3_Err = CloneBlock(p.Clone).Render(ctx, templ_7745c5c3_Buffer) | |
| 284 | + | if templ_7745c5c3_Err != nil { | |
| 285 | + | return templ_7745c5c3_Err | |
| 286 | + | } | |
| 287 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</section><section aria-labelledby=\"r-facts\"><h2 id=\"r-facts\">facts</h2>") | |
| 288 | + | if templ_7745c5c3_Err != nil { | |
| 289 | + | return templ_7745c5c3_Err | |
| 290 | + | } | |
| 291 | + | templ_7745c5c3_Err = Facts(p.Facts).Render(ctx, templ_7745c5c3_Buffer) | |
| 292 | + | if templ_7745c5c3_Err != nil { | |
| 293 | + | return templ_7745c5c3_Err | |
| 294 | + | } | |
| 295 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section>") | |
| 296 | + | if templ_7745c5c3_Err != nil { | |
| 297 | + | return templ_7745c5c3_Err | |
| 298 | + | } | |
| 299 | + | if len(p.Recent) > 0 { | |
| 300 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">recent commits <a href=\"") | |
| 301 | + | if templ_7745c5c3_Err != nil { | |
| 302 | + | return templ_7745c5c3_Err | |
| 303 | + | } | |
| 304 | + | var templ_7745c5c3_Var14 templ.SafeURL | |
| 305 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref)) | |
| 306 | + | if templ_7745c5c3_Err != nil { | |
| 307 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 67, Col: 105} | |
| 308 | + | } | |
| 309 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) | |
| 310 | + | if templ_7745c5c3_Err != nil { | |
| 311 | + | return templ_7745c5c3_Err | |
| 312 | + | } | |
| 313 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">all</a></h2>") | |
| 314 | + | if templ_7745c5c3_Err != nil { | |
| 315 | + | return templ_7745c5c3_Err | |
| 316 | + | } | |
| 317 | + | templ_7745c5c3_Err = CommitRows(p.Recent).Render(ctx, templ_7745c5c3_Buffer) | |
| 318 | + | if templ_7745c5c3_Err != nil { | |
| 319 | + | return templ_7745c5c3_Err | |
| 320 | + | } | |
| 321 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</section>") | |
| 322 | + | if templ_7745c5c3_Err != nil { | |
| 323 | + | return templ_7745c5c3_Err | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | if len(p.Outline) > 0 && p.Readme != nil { | |
| 327 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">") | |
| 328 | + | if templ_7745c5c3_Err != nil { | |
| 329 | + | return templ_7745c5c3_Err | |
| 330 | + | } | |
| 331 | + | var templ_7745c5c3_Var15 string | |
| 332 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Readme.Name) | |
| 333 | + | if templ_7745c5c3_Err != nil { | |
| 334 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 71, Col: 73} | |
| 335 | + | } | |
| 336 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) | |
| 337 | + | if templ_7745c5c3_Err != nil { | |
| 338 | + | return templ_7745c5c3_Err | |
| 339 | + | } | |
| 340 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</h2>") | |
| 341 | + | if templ_7745c5c3_Err != nil { | |
| 342 | + | return templ_7745c5c3_Err | |
| 343 | + | } | |
| 344 | + | templ_7745c5c3_Err = Outline(p.Outline).Render(ctx, templ_7745c5c3_Buffer) | |
| 345 | + | if templ_7745c5c3_Err != nil { | |
| 346 | + | return templ_7745c5c3_Err | |
| 347 | + | } | |
| 348 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</section>") | |
| 349 | + | if templ_7745c5c3_Err != nil { | |
| 350 | + | return templ_7745c5c3_Err | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | return nil | |
| 354 | + | }) | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | // Refs lists the branches and tags of a repository. | |
| 358 | + | func Refs(l Layout, p RefsPage) templ.Component { | |
| 359 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 360 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 361 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 362 | + | return templ_7745c5c3_CtxErr | |
| 363 | + | } | |
| 364 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 365 | + | if !templ_7745c5c3_IsBuffer { | |
| 366 | + | defer func() { | |
| 367 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 368 | + | if templ_7745c5c3_Err == nil { | |
| 369 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 370 | + | } | |
| 371 | + | }() | |
| 372 | + | } | |
| 373 | + | ctx = templ.InitializeContext(ctx) | |
| 374 | + | templ_7745c5c3_Var16 := templ.GetChildren(ctx) | |
| 375 | + | if templ_7745c5c3_Var16 == nil { | |
| 376 | + | templ_7745c5c3_Var16 = templ.NopComponent | |
| 377 | + | } | |
| 378 | + | ctx = templ.ClearChildren(ctx) | |
| 379 | + | templ_7745c5c3_Var17 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 380 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 381 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 382 | + | if !templ_7745c5c3_IsBuffer { | |
| 383 | + | defer func() { | |
| 384 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 385 | + | if templ_7745c5c3_Err == nil { | |
| 386 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 387 | + | } | |
| 388 | + | }() | |
| 389 | + | } | |
| 390 | + | ctx = templ.InitializeContext(ctx) | |
| 391 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"filter\"><label for=\"ref-filter\">filter></label> <input id=\"ref-filter\" type=\"search\" data-filter placeholder=\"branch or tag ( / )\" autocomplete=\"off\" hx-on:input=\"let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));\n let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><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></thead> <tbody id=\"ref-rows\" data-rows>") | |
| 392 | + | if templ_7745c5c3_Err != nil { | |
| 393 | + | return templ_7745c5c3_Err | |
| 394 | + | } | |
| 395 | + | for _, b := range p.Branches { | |
| 396 | + | templ_7745c5c3_Err = refRow("branch", b).Render(ctx, templ_7745c5c3_Buffer) | |
| 397 | + | if templ_7745c5c3_Err != nil { | |
| 398 | + | return templ_7745c5c3_Err | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | for _, t := range p.Tags { | |
| 402 | + | templ_7745c5c3_Err = refRow("tag", t).Render(ctx, templ_7745c5c3_Buffer) | |
| 403 | + | if templ_7745c5c3_Err != nil { | |
| 404 | + | return templ_7745c5c3_Err | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</tbody></table>") | |
| 408 | + | if templ_7745c5c3_Err != nil { | |
| 409 | + | return templ_7745c5c3_Err | |
| 410 | + | } | |
| 411 | + | if p.Note != "" { | |
| 412 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<p class=\"note\">") | |
| 413 | + | if templ_7745c5c3_Err != nil { | |
| 414 | + | return templ_7745c5c3_Err | |
| 415 | + | } | |
| 416 | + | var templ_7745c5c3_Var18 string | |
| 417 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note) | |
| 418 | + | if templ_7745c5c3_Err != nil { | |
| 419 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 105, Col: 27} | |
| 420 | + | } | |
| 421 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | |
| 422 | + | if templ_7745c5c3_Err != nil { | |
| 423 | + | return templ_7745c5c3_Err | |
| 424 | + | } | |
| 425 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p>") | |
| 426 | + | if templ_7745c5c3_Err != nil { | |
| 427 | + | return templ_7745c5c3_Err | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | return nil | |
| 431 | + | }) | |
| 432 | + | templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var17), templ_7745c5c3_Buffer) | |
| 433 | + | if templ_7745c5c3_Err != nil { | |
| 434 | + | return templ_7745c5c3_Err | |
| 435 | + | } | |
| 436 | + | return nil | |
| 437 | + | }) | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | func refRow(kind string, r RefRow) templ.Component { | |
| 441 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 442 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 443 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 444 | + | return templ_7745c5c3_CtxErr | |
| 445 | + | } | |
| 446 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 447 | + | if !templ_7745c5c3_IsBuffer { | |
| 448 | + | defer func() { | |
| 449 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 450 | + | if templ_7745c5c3_Err == nil { | |
| 451 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 452 | + | } | |
| 453 | + | }() | |
| 454 | + | } | |
| 455 | + | ctx = templ.InitializeContext(ctx) | |
| 456 | + | templ_7745c5c3_Var19 := templ.GetChildren(ctx) | |
| 457 | + | if templ_7745c5c3_Var19 == nil { | |
| 458 | + | templ_7745c5c3_Var19 = templ.NopComponent | |
| 459 | + | } | |
| 460 | + | ctx = templ.ClearChildren(ctx) | |
| 461 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<tr") | |
| 462 | + | if templ_7745c5c3_Err != nil { | |
| 463 | + | return templ_7745c5c3_Err | |
| 464 | + | } | |
| 465 | + | if r.Cursor { | |
| 466 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " data-cursor") | |
| 467 | + | if templ_7745c5c3_Err != nil { | |
| 468 | + | return templ_7745c5c3_Err | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-kind mute\">") | |
| 472 | + | if templ_7745c5c3_Err != nil { | |
| 473 | + | return templ_7745c5c3_Err | |
| 474 | + | } | |
| 475 | + | var templ_7745c5c3_Var20 string | |
| 476 | + | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(kind) | |
| 477 | + | if templ_7745c5c3_Err != nil { | |
| 478 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 113, Col: 32} | |
| 479 | + | } | |
| 480 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) | |
| 481 | + | if templ_7745c5c3_Err != nil { | |
| 482 | + | return templ_7745c5c3_Err | |
| 483 | + | } | |
| 484 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</td><td class=\"c-name\"><a href=\"") | |
| 485 | + | if templ_7745c5c3_Err != nil { | |
| 486 | + | return templ_7745c5c3_Err | |
| 487 | + | } | |
| 488 | + | var templ_7745c5c3_Var21 templ.SafeURL | |
| 489 | + | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 490 | + | if templ_7745c5c3_Err != nil { | |
| 491 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 115, Col: 34} | |
| 492 | + | } | |
| 493 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | |
| 494 | + | if templ_7745c5c3_Err != nil { | |
| 495 | + | return templ_7745c5c3_Err | |
| 496 | + | } | |
| 497 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">") | |
| 498 | + | if templ_7745c5c3_Err != nil { | |
| 499 | + | return templ_7745c5c3_Err | |
| 500 | + | } | |
| 501 | + | var templ_7745c5c3_Var22 string | |
| 502 | + | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 503 | + | if templ_7745c5c3_Err != nil { | |
| 504 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 115, Col: 45} | |
| 505 | + | } | |
| 506 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 507 | + | if templ_7745c5c3_Err != nil { | |
| 508 | + | return templ_7745c5c3_Err | |
| 509 | + | } | |
| 510 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</a> ") | |
| 511 | + | if templ_7745c5c3_Err != nil { | |
| 512 | + | return templ_7745c5c3_Err | |
| 513 | + | } | |
| 514 | + | if r.Default { | |
| 515 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span class=\"tagged\">*</span>") | |
| 516 | + | if templ_7745c5c3_Err != nil { | |
| 517 | + | return templ_7745c5c3_Err | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</td><td class=\"c-sha\">") | |
| 521 | + | if templ_7745c5c3_Err != nil { | |
| 522 | + | return templ_7745c5c3_Err | |
| 523 | + | } | |
| 524 | + | if r.CHref != "" { | |
| 525 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<a href=\"") | |
| 526 | + | if templ_7745c5c3_Err != nil { | |
| 527 | + | return templ_7745c5c3_Err | |
| 528 | + | } | |
| 529 | + | var templ_7745c5c3_Var23 templ.SafeURL | |
| 530 | + | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref)) | |
| 531 | + | if templ_7745c5c3_Err != nil { | |
| 532 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 36} | |
| 533 | + | } | |
| 534 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) | |
| 535 | + | if templ_7745c5c3_Err != nil { | |
| 536 | + | return templ_7745c5c3_Err | |
| 537 | + | } | |
| 538 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">") | |
| 539 | + | if templ_7745c5c3_Err != nil { | |
| 540 | + | return templ_7745c5c3_Err | |
| 541 | + | } | |
| 542 | + | var templ_7745c5c3_Var24 string | |
| 543 | + | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short) | |
| 544 | + | if templ_7745c5c3_Err != nil { | |
| 545 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 48} | |
| 546 | + | } | |
| 547 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) | |
| 548 | + | if templ_7745c5c3_Err != nil { | |
| 549 | + | return templ_7745c5c3_Err | |
| 550 | + | } | |
| 551 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</a>") | |
| 552 | + | if templ_7745c5c3_Err != nil { | |
| 553 | + | return templ_7745c5c3_Err | |
| 554 | + | } | |
| 555 | + | } else { | |
| 556 | + | var templ_7745c5c3_Var25 string | |
| 557 | + | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short) | |
| 558 | + | if templ_7745c5c3_Err != nil { | |
| 559 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 124, Col: 13} | |
| 560 | + | } | |
| 561 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) | |
| 562 | + | if templ_7745c5c3_Err != nil { | |
| 563 | + | return templ_7745c5c3_Err | |
| 564 | + | } | |
| 565 | + | } | |
| 566 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</td><td class=\"c-sub\">") | |
| 567 | + | if templ_7745c5c3_Err != nil { | |
| 568 | + | return templ_7745c5c3_Err | |
| 569 | + | } | |
| 570 | + | var templ_7745c5c3_Var26 string | |
| 571 | + | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject) | |
| 572 | + | if templ_7745c5c3_Err != nil { | |
| 573 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 127, Col: 31} | |
| 574 | + | } | |
| 575 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | |
| 576 | + | if templ_7745c5c3_Err != nil { | |
| 577 | + | return templ_7745c5c3_Err | |
| 578 | + | } | |
| 579 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</td><td class=\"c-when dim\">") | |
| 580 | + | if templ_7745c5c3_Err != nil { | |
| 581 | + | return templ_7745c5c3_Err | |
| 582 | + | } | |
| 583 | + | if r.WhenAt != "" { | |
| 584 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<time datetime=\"") | |
| 585 | + | if templ_7745c5c3_Err != nil { | |
| 586 | + | return templ_7745c5c3_Err | |
| 587 | + | } | |
| 588 | + | var templ_7745c5c3_Var27 string | |
| 589 | + | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 590 | + | if templ_7745c5c3_Err != nil { | |
| 591 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 130, Col: 29} | |
| 592 | + | } | |
| 593 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) | |
| 594 | + | if templ_7745c5c3_Err != nil { | |
| 595 | + | return templ_7745c5c3_Err | |
| 596 | + | } | |
| 597 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" title=\"") | |
| 598 | + | if templ_7745c5c3_Err != nil { | |
| 599 | + | return templ_7745c5c3_Err | |
| 600 | + | } | |
| 601 | + | var templ_7745c5c3_Var28 string | |
| 602 | + | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 603 | + | if templ_7745c5c3_Err != nil { | |
| 604 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 130, Col: 48} | |
| 605 | + | } | |
| 606 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) | |
| 607 | + | if templ_7745c5c3_Err != nil { | |
| 608 | + | return templ_7745c5c3_Err | |
| 609 | + | } | |
| 610 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">") | |
| 611 | + | if templ_7745c5c3_Err != nil { | |
| 612 | + | return templ_7745c5c3_Err | |
| 613 | + | } | |
| 614 | + | var templ_7745c5c3_Var29 string | |
| 615 | + | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.When) | |
| 616 | + | if templ_7745c5c3_Err != nil { | |
| 617 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 130, Col: 59} | |
| 618 | + | } | |
| 619 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) | |
| 620 | + | if templ_7745c5c3_Err != nil { | |
| 621 | + | return templ_7745c5c3_Err | |
| 622 | + | } | |
| 623 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</time>") | |
| 624 | + | if templ_7745c5c3_Err != nil { | |
| 625 | + | return templ_7745c5c3_Err | |
| 626 | + | } | |
| 627 | + | } | |
| 628 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</td></tr>") | |
| 629 | + | if templ_7745c5c3_Err != nil { | |
| 630 | + | return templ_7745c5c3_Err | |
| 631 | + | } | |
| 632 | + | return nil | |
| 633 | + | }) | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | func refsRail(p RefsPage) templ.Component { | |
| 637 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 638 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 639 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 640 | + | return templ_7745c5c3_CtxErr | |
| 641 | + | } | |
| 642 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 643 | + | if !templ_7745c5c3_IsBuffer { | |
| 644 | + | defer func() { | |
| 645 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 646 | + | if templ_7745c5c3_Err == nil { | |
| 647 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 648 | + | } | |
| 649 | + | }() | |
| 650 | + | } | |
| 651 | + | ctx = templ.InitializeContext(ctx) | |
| 652 | + | templ_7745c5c3_Var30 := templ.GetChildren(ctx) | |
| 653 | + | if templ_7745c5c3_Var30 == nil { | |
| 654 | + | templ_7745c5c3_Var30 = templ.NopComponent | |
| 655 | + | } | |
| 656 | + | ctx = templ.ClearChildren(ctx) | |
| 657 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">refs</h2><dl class=\"kv\"><dt>default</dt><dd>") | |
| 658 | + | if templ_7745c5c3_Err != nil { | |
| 659 | + | return templ_7745c5c3_Err | |
| 660 | + | } | |
| 661 | + | var templ_7745c5c3_Var31 string | |
| 662 | + | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(p.Default) | |
| 663 | + | if templ_7745c5c3_Err != nil { | |
| 664 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 141, Col: 18} | |
| 665 | + | } | |
| 666 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) | |
| 667 | + | if templ_7745c5c3_Err != nil { | |
| 668 | + | return templ_7745c5c3_Err | |
| 669 | + | } | |
| 670 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</dd><dt>branches</dt><dd>") | |
| 671 | + | if templ_7745c5c3_Err != nil { | |
| 672 | + | return templ_7745c5c3_Err | |
| 673 | + | } | |
| 674 | + | var templ_7745c5c3_Var32 string | |
| 675 | + | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Branches))) | |
| 676 | + | if templ_7745c5c3_Err != nil { | |
| 677 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 143, Col: 30} | |
| 678 | + | } | |
| 679 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | |
| 680 | + | if templ_7745c5c3_Err != nil { | |
| 681 | + | return templ_7745c5c3_Err | |
| 682 | + | } | |
| 683 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</dd><dt>tags</dt><dd>") | |
| 684 | + | if templ_7745c5c3_Err != nil { | |
| 685 | + | return templ_7745c5c3_Err | |
| 686 | + | } | |
| 687 | + | var templ_7745c5c3_Var33 string | |
| 688 | + | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Tags))) | |
| 689 | + | if templ_7745c5c3_Err != nil { | |
| 690 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 145, Col: 26} | |
| 691 | + | } | |
| 692 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) | |
| 693 | + | if templ_7745c5c3_Err != nil { | |
| 694 | + | return templ_7745c5c3_Err | |
| 695 | + | } | |
| 696 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</dd>") | |
| 697 | + | if templ_7745c5c3_Err != nil { | |
| 698 | + | return templ_7745c5c3_Err | |
| 699 | + | } | |
| 700 | + | if p.Latest != "" { | |
| 701 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<dt>latest tag</dt><dd>") | |
| 702 | + | if templ_7745c5c3_Err != nil { | |
| 703 | + | return templ_7745c5c3_Err | |
| 704 | + | } | |
| 705 | + | var templ_7745c5c3_Var34 string | |
| 706 | + | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(p.Latest) | |
| 707 | + | if templ_7745c5c3_Err != nil { | |
| 708 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 148, Col: 18} | |
| 709 | + | } | |
| 710 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) | |
| 711 | + | if templ_7745c5c3_Err != nil { | |
| 712 | + | return templ_7745c5c3_Err | |
| 713 | + | } | |
| 714 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</dd>") | |
| 715 | + | if templ_7745c5c3_Err != nil { | |
| 716 | + | return templ_7745c5c3_Err | |
| 717 | + | } | |
| 718 | + | } | |
| 719 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</dl></section><section aria-labelledby=\"r-legend\"><h2 id=\"r-legend\">legend</h2><dl class=\"kv\"><dt>*</dt><dd>default branch</dd></dl></section>") | |
| 720 | + | if templ_7745c5c3_Err != nil { | |
| 721 | + | return templ_7745c5c3_Err | |
| 722 | + | } | |
| 723 | + | return nil | |
| 724 | + | }) | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | // State is a 404, 500 or empty-repository page inside the normal shell. | |
| 728 | + | func State(l Layout, p StatePage) templ.Component { | |
| 729 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 730 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 731 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 732 | + | return templ_7745c5c3_CtxErr | |
| 733 | + | } | |
| 734 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 735 | + | if !templ_7745c5c3_IsBuffer { | |
| 736 | + | defer func() { | |
| 737 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 738 | + | if templ_7745c5c3_Err == nil { | |
| 739 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 740 | + | } | |
| 741 | + | }() | |
| 742 | + | } | |
| 743 | + | ctx = templ.InitializeContext(ctx) | |
| 744 | + | templ_7745c5c3_Var35 := templ.GetChildren(ctx) | |
| 745 | + | if templ_7745c5c3_Var35 == nil { | |
| 746 | + | templ_7745c5c3_Var35 = templ.NopComponent | |
| 747 | + | } | |
| 748 | + | ctx = templ.ClearChildren(ctx) | |
| 749 | + | templ_7745c5c3_Var36 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 750 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 751 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 752 | + | if !templ_7745c5c3_IsBuffer { | |
| 753 | + | defer func() { | |
| 754 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 755 | + | if templ_7745c5c3_Err == nil { | |
| 756 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 757 | + | } | |
| 758 | + | }() | |
| 759 | + | } | |
| 760 | + | ctx = templ.InitializeContext(ctx) | |
| 761 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\"><span class=\"code\">") | |
| 762 | + | if templ_7745c5c3_Err != nil { | |
| 763 | + | return templ_7745c5c3_Err | |
| 764 | + | } | |
| 765 | + | var templ_7745c5c3_Var37 string | |
| 766 | + | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code) | |
| 767 | + | if templ_7745c5c3_Err != nil { | |
| 768 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 162, Col: 51} | |
| 769 | + | } | |
| 770 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) | |
| 771 | + | if templ_7745c5c3_Err != nil { | |
| 772 | + | return templ_7745c5c3_Err | |
| 773 | + | } | |
| 774 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span> ") | |
| 775 | + | if templ_7745c5c3_Err != nil { | |
| 776 | + | return templ_7745c5c3_Err | |
| 777 | + | } | |
| 778 | + | var templ_7745c5c3_Var38 string | |
| 779 | + | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |
| 780 | + | if templ_7745c5c3_Err != nil { | |
| 781 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 162, Col: 70} | |
| 782 | + | } | |
| 783 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) | |
| 784 | + | if templ_7745c5c3_Err != nil { | |
| 785 | + | return templ_7745c5c3_Err | |
| 786 | + | } | |
| 787 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</h1>") | |
| 788 | + | if templ_7745c5c3_Err != nil { | |
| 789 | + | return templ_7745c5c3_Err | |
| 790 | + | } | |
| 791 | + | for _, line := range p.Lines { | |
| 792 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<p class=\"dim\">") | |
| 793 | + | if templ_7745c5c3_Err != nil { | |
| 794 | + | return templ_7745c5c3_Err | |
| 795 | + | } | |
| 796 | + | var templ_7745c5c3_Var39 string | |
| 797 | + | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |
| 798 | + | if templ_7745c5c3_Err != nil { | |
| 799 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 164, Col: 25} | |
| 800 | + | } | |
| 801 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) | |
| 802 | + | if templ_7745c5c3_Err != nil { | |
| 803 | + | return templ_7745c5c3_Err | |
| 804 | + | } | |
| 805 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</p>") | |
| 806 | + | if templ_7745c5c3_Err != nil { | |
| 807 | + | return templ_7745c5c3_Err | |
| 808 | + | } | |
| 809 | + | } | |
| 810 | + | if p.Clone != nil { | |
| 811 | + | templ_7745c5c3_Err = CloneBlock(*p.Clone).Render(ctx, templ_7745c5c3_Buffer) | |
| 812 | + | if templ_7745c5c3_Err != nil { | |
| 813 | + | return templ_7745c5c3_Err | |
| 814 | + | } | |
| 815 | + | } | |
| 816 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<p class=\"state-back\">") | |
| 817 | + | if templ_7745c5c3_Err != nil { | |
| 818 | + | return templ_7745c5c3_Err | |
| 819 | + | } | |
| 820 | + | for _, b := range p.Back { | |
| 821 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<a href=\"") | |
| 822 | + | if templ_7745c5c3_Err != nil { | |
| 823 | + | return templ_7745c5c3_Err | |
| 824 | + | } | |
| 825 | + | var templ_7745c5c3_Var40 templ.SafeURL | |
| 826 | + | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href)) | |
| 827 | + | if templ_7745c5c3_Err != nil { | |
| 828 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 36} | |
| 829 | + | } | |
| 830 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 831 | + | if templ_7745c5c3_Err != nil { | |
| 832 | + | return templ_7745c5c3_Err | |
| 833 | + | } | |
| 834 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">") | |
| 835 | + | if templ_7745c5c3_Err != nil { | |
| 836 | + | return templ_7745c5c3_Err | |
| 837 | + | } | |
| 838 | + | var templ_7745c5c3_Var41 string | |
| 839 | + | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label) | |
| 840 | + | if templ_7745c5c3_Err != nil { | |
| 841 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 48} | |
| 842 | + | } | |
| 843 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |
| 844 | + | if templ_7745c5c3_Err != nil { | |
| 845 | + | return templ_7745c5c3_Err | |
| 846 | + | } | |
| 847 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</a>") | |
| 848 | + | if templ_7745c5c3_Err != nil { | |
| 849 | + | return templ_7745c5c3_Err | |
| 850 | + | } | |
| 851 | + | } | |
| 852 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</p></section>") | |
| 853 | + | if templ_7745c5c3_Err != nil { | |
| 854 | + | return templ_7745c5c3_Err | |
| 855 | + | } | |
| 856 | + | return nil | |
| 857 | + | }) | |
| 858 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var36), templ_7745c5c3_Buffer) | |
| 859 | + | if templ_7745c5c3_Err != nil { | |
| 860 | + | return templ_7745c5c3_Err | |
| 861 | + | } | |
| 862 | + | return nil | |
| 863 | + | }) | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | var _ = templruntime.GeneratedTemplate | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | })(); | |
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. | |
A internal/adapters/web/static/js/htmx.min.js +1 -0 generated file: collapsed
A internal/adapters/web/static/js/hx-live.min.js +1 -0 generated file: collapsed
A internal/adapters/web/static/js/hx-sse.min.js +1 -0 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 | + | } | |
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 | + | } | |
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 | + | } | |