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. | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | # Vendored web assets | |||
| 2 | + | ||||
| 3 | + | `static/` is embedded in the binary and served under `/-/static/` with | |||
| 4 | + | content-hashed names. Nothing is fetched at runtime. | |||
| 5 | + | ||||
| 6 | + | | File | What | Source | SHA-256 | | |||
| 7 | + | | --- | --- | --- | --- | | |||
| 8 | + | | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` | | |||
| 9 | + | | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` | | |||
| 10 | + | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | |||
| 11 | + | | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | | | |||
| 12 | + | | `static/css/*.css` | native CSS of design D, served as one stylesheet in name order | `mockups/d/css/` (+ `70-footer.css`, the AGPL §13 source link) | | | |||
| 13 | + | ||||
| 14 | + | The three htmx files are the exact files the approved mockup was built with. | |||
| 15 | + | To upgrade, replace the file, update the checksum here, and run the web tests | |||
| 16 | + | (`go test ./cmd/ermite -run TestWeb`). | |||
| 17 | + | ||||
| 18 | + | `*_templ.go` files are generated by `go tool templ generate` (templ is pinned | |||
| 19 | + | in `go.mod`) and committed; `scripts/templ-drift.sh` fails when they are stale. | |||
A internal/adapters/web/assets.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "embed" | |
| 8 | + | "encoding/hex" | |
| 9 | + | "io/fs" | |
| 10 | + | "net/http" | |
| 11 | + | "path" | |
| 12 | + | "sort" | |
| 13 | + | "strings" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | //go:embed static | |
| 17 | + | var staticFS embed.FS | |
| 18 | + | ||
| 19 | + | // StaticPrefix is where the embedded assets are served. | |
| 20 | + | const StaticPrefix = "/-/static/" | |
| 21 | + | ||
| 22 | + | // Assets are the URLs of the assets a page loads. Their names carry a hash of | |
| 23 | + | // the content, so they can be cached for ever. | |
| 24 | + | type Assets struct { | |
| 25 | + | CSS, HTMX, Live, SSE, App string | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // asset is one embedded file under its content-hashed name. | |
| 29 | + | type asset struct { | |
| 30 | + | body []byte | |
| 31 | + | typ string | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // assetSet is the embedded files by hashed URL path (under StaticPrefix). | |
| 35 | + | type assetSet struct { | |
| 36 | + | byName map[string]asset | |
| 37 | + | urls Assets | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // scripts are the JavaScript files of the page, in embedded static/js: the | |
| 41 | + | // file to serve, as it is named in Assets. | |
| 42 | + | var scripts = []struct { | |
| 43 | + | file string | |
| 44 | + | set func(*Assets, string) | |
| 45 | + | }{ | |
| 46 | + | {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }}, | |
| 47 | + | {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }}, | |
| 48 | + | {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }}, | |
| 49 | + | {"ermite.js", func(a *Assets, u string) { a.App = u }}, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // loadAssets hashes the embedded files. The stylesheet is the concatenation | |
| 53 | + | // of static/css/*.css in name order, as in the mockup. | |
| 54 | + | func loadAssets() (*assetSet, error) { | |
| 55 | + | s := &assetSet{byName: map[string]asset{}} | |
| 56 | + | put := func(dir, file string, body []byte, typ string) string { | |
| 57 | + | sum := sha256.Sum256(body) | |
| 58 | + | ext := path.Ext(file) | |
| 59 | + | name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext | |
| 60 | + | s.byName[name] = asset{body: body, typ: typ} | |
| 61 | + | return StaticPrefix + name | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | files, err := fs.Glob(staticFS, "static/css/*.css") | |
| 65 | + | if err != nil { | |
| 66 | + | return nil, err | |
| 67 | + | } | |
| 68 | + | sort.Strings(files) | |
| 69 | + | var css strings.Builder | |
| 70 | + | for _, f := range files { | |
| 71 | + | b, err := staticFS.ReadFile(f) | |
| 72 | + | if err != nil { | |
| 73 | + | return nil, err | |
| 74 | + | } | |
| 75 | + | css.WriteString("/* " + path.Base(f) + " */\n") | |
| 76 | + | css.Write(b) | |
| 77 | + | } | |
| 78 | + | s.urls.CSS = put("css", "ermite.css", []byte(css.String()), "text/css; charset=utf-8") | |
| 79 | + | ||
| 80 | + | for _, sc := range scripts { | |
| 81 | + | b, err := staticFS.ReadFile("static/js/" + sc.file) | |
| 82 | + | if err != nil { | |
| 83 | + | return nil, err | |
| 84 | + | } | |
| 85 | + | sc.set(&s.urls, put("js", sc.file, b, "text/javascript; charset=utf-8")) | |
| 86 | + | } | |
| 87 | + | return s, nil | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // serve answers GET /-/static/<hashed name>. A name that does not match a | |
| 91 | + | // current asset is a 404, so a stale hash never serves the wrong content. | |
| 92 | + | func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) { | |
| 93 | + | a, ok := s.byName[strings.TrimPrefix(r.URL.Path, StaticPrefix)] | |
| 94 | + | if !ok { | |
| 95 | + | http.NotFound(w, r) | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | h := w.Header() | |
| 99 | + | h.Set("Content-Type", a.typ) | |
| 100 | + | h.Set("Cache-Control", "public, max-age=31536000, immutable") | |
| 101 | + | h.Set("Content-Length", itoa(len(a.body))) | |
| 102 | + | if r.Method != http.MethodHead { | |
| 103 | + | _, _ = w.Write(a.body) | |
| 104 | + | } | |
| 105 | + | } | |
| @@ -0,0 +1,105 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "crypto/sha256" | |||
| 7 | + | "embed" | |||
| 8 | + | "encoding/hex" | |||
| 9 | + | "io/fs" | |||
| 10 | + | "net/http" | |||
| 11 | + | "path" | |||
| 12 | + | "sort" | |||
| 13 | + | "strings" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | //go:embed static | |||
| 17 | + | var staticFS embed.FS | |||
| 18 | + | ||||
| 19 | + | // StaticPrefix is where the embedded assets are served. | |||
| 20 | + | const StaticPrefix = "/-/static/" | |||
| 21 | + | ||||
| 22 | + | // Assets are the URLs of the assets a page loads. Their names carry a hash of | |||
| 23 | + | // the content, so they can be cached for ever. | |||
| 24 | + | type Assets struct { | |||
| 25 | + | CSS, HTMX, Live, SSE, App string | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // asset is one embedded file under its content-hashed name. | |||
| 29 | + | type asset struct { | |||
| 30 | + | body []byte | |||
| 31 | + | typ string | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // assetSet is the embedded files by hashed URL path (under StaticPrefix). | |||
| 35 | + | type assetSet struct { | |||
| 36 | + | byName map[string]asset | |||
| 37 | + | urls Assets | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // scripts are the JavaScript files of the page, in embedded static/js: the | |||
| 41 | + | // file to serve, as it is named in Assets. | |||
| 42 | + | var scripts = []struct { | |||
| 43 | + | file string | |||
| 44 | + | set func(*Assets, string) | |||
| 45 | + | }{ | |||
| 46 | + | {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }}, | |||
| 47 | + | {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }}, | |||
| 48 | + | {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }}, | |||
| 49 | + | {"ermite.js", func(a *Assets, u string) { a.App = u }}, | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // loadAssets hashes the embedded files. The stylesheet is the concatenation | |||
| 53 | + | // of static/css/*.css in name order, as in the mockup. | |||
| 54 | + | func loadAssets() (*assetSet, error) { | |||
| 55 | + | s := &assetSet{byName: map[string]asset{}} | |||
| 56 | + | put := func(dir, file string, body []byte, typ string) string { | |||
| 57 | + | sum := sha256.Sum256(body) | |||
| 58 | + | ext := path.Ext(file) | |||
| 59 | + | name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext | |||
| 60 | + | s.byName[name] = asset{body: body, typ: typ} | |||
| 61 | + | return StaticPrefix + name | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | files, err := fs.Glob(staticFS, "static/css/*.css") | |||
| 65 | + | if err != nil { | |||
| 66 | + | return nil, err | |||
| 67 | + | } | |||
| 68 | + | sort.Strings(files) | |||
| 69 | + | var css strings.Builder | |||
| 70 | + | for _, f := range files { | |||
| 71 | + | b, err := staticFS.ReadFile(f) | |||
| 72 | + | if err != nil { | |||
| 73 | + | return nil, err | |||
| 74 | + | } | |||
| 75 | + | css.WriteString("/* " + path.Base(f) + " */\n") | |||
| 76 | + | css.Write(b) | |||
| 77 | + | } | |||
| 78 | + | s.urls.CSS = put("css", "ermite.css", []byte(css.String()), "text/css; charset=utf-8") | |||
| 79 | + | ||||
| 80 | + | for _, sc := range scripts { | |||
| 81 | + | b, err := staticFS.ReadFile("static/js/" + sc.file) | |||
| 82 | + | if err != nil { | |||
| 83 | + | return nil, err | |||
| 84 | + | } | |||
| 85 | + | sc.set(&s.urls, put("js", sc.file, b, "text/javascript; charset=utf-8")) | |||
| 86 | + | } | |||
| 87 | + | return s, nil | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // serve answers GET /-/static/<hashed name>. A name that does not match a | |||
| 91 | + | // current asset is a 404, so a stale hash never serves the wrong content. | |||
| 92 | + | func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) { | |||
| 93 | + | a, ok := s.byName[strings.TrimPrefix(r.URL.Path, StaticPrefix)] | |||
| 94 | + | if !ok { | |||
| 95 | + | http.NotFound(w, r) | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | h := w.Header() | |||
| 99 | + | h.Set("Content-Type", a.typ) | |||
| 100 | + | h.Set("Cache-Control", "public, max-age=31536000, immutable") | |||
| 101 | + | h.Set("Content-Length", itoa(len(a.body))) | |||
| 102 | + | if r.Method != http.MethodHead { | |||
| 103 | + | _, _ = w.Write(a.body) | |||
| 104 | + | } | |||
| 105 | + | } | |||
A internal/adapters/web/components.templ +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // Facts is a key/value block on an 11ch key column. | |
| 6 | + | templ Facts(facts []Fact) { | |
| 7 | + | <dl class="kv"> | |
| 8 | + | for _, f := range facts { | |
| 9 | + | <dt>{ f.Key }</dt> | |
| 10 | + | <dd | |
| 11 | + | if f.ID != "" { | |
| 12 | + | id={ f.ID } | |
| 13 | + | } | |
| 14 | + | > | |
| 15 | + | if f.Time != "" { | |
| 16 | + | <time datetime={ f.Time }>{ f.Val }</time> | |
| 17 | + | } else { | |
| 18 | + | { f.Val } | |
| 19 | + | } | |
| 20 | + | </dd> | |
| 21 | + | } | |
| 22 | + | </dl> | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // CloneBlock is a block of copyable shell lines; each has its own [copy] | |
| 26 | + | // button, whose label says "copied" for 1.5 s. | |
| 27 | + | templ CloneBlock(c Clone) { | |
| 28 | + | <div class="clone" id={ c.ID }> | |
| 29 | + | for _, line := range c.Lines { | |
| 30 | + | <div class="clone-line"> | |
| 31 | + | <span class="mute" aria-hidden="true">$ </span><code>{ line }</code> | |
| 32 | + | <button | |
| 33 | + | type="button" | |
| 34 | + | class="copy" | |
| 35 | + | data-label="[copy]" | |
| 36 | + | aria-label={ "copy: " + line } | |
| 37 | + | hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label" | |
| 38 | + | >[copy]</button> | |
| 39 | + | </div> | |
| 40 | + | } | |
| 41 | + | </div> | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // HomeRow is one row of the home list. Its id is the target of SSE swaps. | |
| 45 | + | templ HomeRowView(r HomeRow) { | |
| 46 | + | <tr id={ r.ID } data-cursor?={ r.Cursor }> | |
| 47 | + | <td class="cur" aria-hidden="true"></td> | |
| 48 | + | <td class="c-name"><a href={ templ.SafeURL(r.Href) }>{ r.Name }</a></td> | |
| 49 | + | <td class="c-desc dim">{ r.Desc }</td> | |
| 50 | + | <td class="c-when nowrap"> | |
| 51 | + | <time | |
| 52 | + | if r.PushedAt != "" { | |
| 53 | + | datetime={ r.PushedAt } | |
| 54 | + | } | |
| 55 | + | >{ r.Pushed }</time> | |
| 56 | + | </td> | |
| 57 | + | <td class="c-ref">{ r.Branch }</td> | |
| 58 | + | </tr> | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Outline is the README outline; levels indent by 2ch, the h1 is bold. | |
| 62 | + | templ Outline(headings []Heading) { | |
| 63 | + | <nav class="outline" aria-label="Outline"> | |
| 64 | + | for _, h := range headings { | |
| 65 | + | <a class={ "l" + itoa(h.Level) } href={ templ.SafeURL("#" + h.ID) }>{ h.Text }</a> | |
| 66 | + | } | |
| 67 | + | </nav> | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // ReadmeView is a rendered README. Its HTML comes from the Markdown | |
| 71 | + | // renderer, which drops raw HTML and filters URLs. | |
| 72 | + | templ ReadmeView(r Readme) { | |
| 73 | + | <article class="prose" aria-label={ r.Name }> | |
| 74 | + | if r.Note != "" { | |
| 75 | + | <p class="dim">{ r.Note }</p> | |
| 76 | + | } else { | |
| 77 | + | @templ.Raw(r.HTML) | |
| 78 | + | } | |
| 79 | + | </article> | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // CommitRows is a cursor table of commits: sha, subject with inline refs, | |
| 83 | + | // author, relative date (absolute in the title). | |
| 84 | + | templ CommitRows(rows []CommitRow) { | |
| 85 | + | <table class="list commits" aria-label="Commits"> | |
| 86 | + | <tbody data-rows> | |
| 87 | + | for _, c := range rows { | |
| 88 | + | <tr | |
| 89 | + | if c.ID != "" { | |
| 90 | + | id={ c.ID } | |
| 91 | + | } | |
| 92 | + | data-cursor?={ c.Cursor } | |
| 93 | + | > | |
| 94 | + | <td class="cur" aria-hidden="true"></td> | |
| 95 | + | <td class="c-sha mute">{ c.Short }</td> | |
| 96 | + | <td class="c-subject"> | |
| 97 | + | <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a> | |
| 98 | + | if c.Merge { | |
| 99 | + | <span class="tagged">(merge)</span> | |
| 100 | + | } | |
| 101 | + | for _, b := range c.Branches { | |
| 102 | + | <span class="tagged">[{ b }]</span> | |
| 103 | + | } | |
| 104 | + | for _, t := range c.Tags { | |
| 105 | + | <span class="tagged">[tag: { t }]</span> | |
| 106 | + | } | |
| 107 | + | </td> | |
| 108 | + | <td class="c-who dim">{ c.Author }</td> | |
| 109 | + | <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td> | |
| 110 | + | </tr> | |
| 111 | + | } | |
| 112 | + | </tbody> | |
| 113 | + | </table> | |
| 114 | + | } | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | // Facts is a key/value block on an 11ch key column. | |||
| 6 | + | templ Facts(facts []Fact) { | |||
| 7 | + | <dl class="kv"> | |||
| 8 | + | for _, f := range facts { | |||
| 9 | + | <dt>{ f.Key }</dt> | |||
| 10 | + | <dd | |||
| 11 | + | if f.ID != "" { | |||
| 12 | + | id={ f.ID } | |||
| 13 | + | } | |||
| 14 | + | > | |||
| 15 | + | if f.Time != "" { | |||
| 16 | + | <time datetime={ f.Time }>{ f.Val }</time> | |||
| 17 | + | } else { | |||
| 18 | + | { f.Val } | |||
| 19 | + | } | |||
| 20 | + | </dd> | |||
| 21 | + | } | |||
| 22 | + | </dl> | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // CloneBlock is a block of copyable shell lines; each has its own [copy] | |||
| 26 | + | // button, whose label says "copied" for 1.5 s. | |||
| 27 | + | templ CloneBlock(c Clone) { | |||
| 28 | + | <div class="clone" id={ c.ID }> | |||
| 29 | + | for _, line := range c.Lines { | |||
| 30 | + | <div class="clone-line"> | |||
| 31 | + | <span class="mute" aria-hidden="true">$ </span><code>{ line }</code> | |||
| 32 | + | <button | |||
| 33 | + | type="button" | |||
| 34 | + | class="copy" | |||
| 35 | + | data-label="[copy]" | |||
| 36 | + | aria-label={ "copy: " + line } | |||
| 37 | + | hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label" | |||
| 38 | + | >[copy]</button> | |||
| 39 | + | </div> | |||
| 40 | + | } | |||
| 41 | + | </div> | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // HomeRow is one row of the home list. Its id is the target of SSE swaps. | |||
| 45 | + | templ HomeRowView(r HomeRow) { | |||
| 46 | + | <tr id={ r.ID } data-cursor?={ r.Cursor }> | |||
| 47 | + | <td class="cur" aria-hidden="true"></td> | |||
| 48 | + | <td class="c-name"><a href={ templ.SafeURL(r.Href) }>{ r.Name }</a></td> | |||
| 49 | + | <td class="c-desc dim">{ r.Desc }</td> | |||
| 50 | + | <td class="c-when nowrap"> | |||
| 51 | + | <time | |||
| 52 | + | if r.PushedAt != "" { | |||
| 53 | + | datetime={ r.PushedAt } | |||
| 54 | + | } | |||
| 55 | + | >{ r.Pushed }</time> | |||
| 56 | + | </td> | |||
| 57 | + | <td class="c-ref">{ r.Branch }</td> | |||
| 58 | + | </tr> | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Outline is the README outline; levels indent by 2ch, the h1 is bold. | |||
| 62 | + | templ Outline(headings []Heading) { | |||
| 63 | + | <nav class="outline" aria-label="Outline"> | |||
| 64 | + | for _, h := range headings { | |||
| 65 | + | <a class={ "l" + itoa(h.Level) } href={ templ.SafeURL("#" + h.ID) }>{ h.Text }</a> | |||
| 66 | + | } | |||
| 67 | + | </nav> | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // ReadmeView is a rendered README. Its HTML comes from the Markdown | |||
| 71 | + | // renderer, which drops raw HTML and filters URLs. | |||
| 72 | + | templ ReadmeView(r Readme) { | |||
| 73 | + | <article class="prose" aria-label={ r.Name }> | |||
| 74 | + | if r.Note != "" { | |||
| 75 | + | <p class="dim">{ r.Note }</p> | |||
| 76 | + | } else { | |||
| 77 | + | @templ.Raw(r.HTML) | |||
| 78 | + | } | |||
| 79 | + | </article> | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // CommitRows is a cursor table of commits: sha, subject with inline refs, | |||
| 83 | + | // author, relative date (absolute in the title). | |||
| 84 | + | templ CommitRows(rows []CommitRow) { | |||
| 85 | + | <table class="list commits" aria-label="Commits"> | |||
| 86 | + | <tbody data-rows> | |||
| 87 | + | for _, c := range rows { | |||
| 88 | + | <tr | |||
| 89 | + | if c.ID != "" { | |||
| 90 | + | id={ c.ID } | |||
| 91 | + | } | |||
| 92 | + | data-cursor?={ c.Cursor } | |||
| 93 | + | > | |||
| 94 | + | <td class="cur" aria-hidden="true"></td> | |||
| 95 | + | <td class="c-sha mute">{ c.Short }</td> | |||
| 96 | + | <td class="c-subject"> | |||
| 97 | + | <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a> | |||
| 98 | + | if c.Merge { | |||
| 99 | + | <span class="tagged">(merge)</span> | |||
| 100 | + | } | |||
| 101 | + | for _, b := range c.Branches { | |||
| 102 | + | <span class="tagged">[{ b }]</span> | |||
| 103 | + | } | |||
| 104 | + | for _, t := range c.Tags { | |||
| 105 | + | <span class="tagged">[tag: { t }]</span> | |||
| 106 | + | } | |||
| 107 | + | </td> | |||
| 108 | + | <td class="c-who dim">{ c.Author }</td> | |||
| 109 | + | <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td> | |||
| 110 | + | </tr> | |||
| 111 | + | } | |||
| 112 | + | </tbody> | |||
| 113 | + | </table> | |||
| 114 | + | } | |||
A internal/adapters/web/components_templ.go +721 -0 generated file: collapsed
A internal/adapters/web/errors_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "net/http" | |
| 10 | + | "net/http/httptest" | |
| 11 | + | "regexp" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "connectrpc.com/connect" | |
| 15 | + | "github.com/bnema/zerowrap" | |
| 16 | + | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ||
| 19 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 20 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // failing is a repository client that fails every call with err. | |
| 24 | + | type failing struct { | |
| 25 | + | ermiteconnect.RepoServiceClient | |
| 26 | + | err error | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (f failing) ListRepos(context.Context, *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) { | |
| 30 | + | return nil, f.err | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (f failing) GetRepo(context.Context, *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { | |
| 34 | + | return nil, f.err | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) { | |
| 38 | + | t.Helper() | |
| 39 | + | srv, nerr := New(Config{ | |
| 40 | + | Repos: failing{err: err}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 41 | + | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |
| 42 | + | }) | |
| 43 | + | require.NoError(t, nerr) | |
| 44 | + | var logs bytes.Buffer | |
| 45 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | |
| 46 | + | rec := httptest.NewRecorder() | |
| 47 | + | srv.ServeHTTP(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil)) | |
| 48 | + | return rec, logs.String() | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Errors of the API: not found (and an invalid name) are the 404 page, any | |
| 52 | + | // other is the 500 page with an id that the log carries. | |
| 53 | + | func TestErrorPages(t *testing.T) { | |
| 54 | + | rec, logs := serve(t, connect.NewError(connect.CodeNotFound, errors.New("repository not found")), "/gone") | |
| 55 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 56 | + | assert.Contains(t, rec.Body.String(), "not found") | |
| 57 | + | assert.Empty(t, logs, "a 404 is not an error") | |
| 58 | + | ||
| 59 | + | rec, _ = serve(t, connect.NewError(connect.CodeInvalidArgument, errors.New("bad name")), "/Bad") | |
| 60 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 61 | + | assert.NotContains(t, rec.Body.String(), "bad name") | |
| 62 | + | ||
| 63 | + | secret := "dial tcp 10.0.0.5: kv exploded" | |
| 64 | + | rec, logs = serve(t, connect.NewError(connect.CodeInternal, errors.New(secret)), "/demo") | |
| 65 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 66 | + | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | |
| 67 | + | assert.Contains(t, rec.Body.String(), "internal error") | |
| 68 | + | assert.NotContains(t, rec.Body.String(), secret, "the cause stays in the log") | |
| 69 | + | m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String()) | |
| 70 | + | require.Len(t, m, 2, "the page shows a request id") | |
| 71 | + | assert.Contains(t, logs, m[1], "the log carries the same id") | |
| 72 | + | assert.Contains(t, logs, "kv exploded") | |
| 73 | + | ||
| 74 | + | rec, _ = serve(t, connect.NewError(connect.CodeInternal, errors.New("x")), "/") | |
| 75 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code, "the home page too") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestNewValidates(t *testing.T) { | |
| 79 | + | _, err := New(Config{PublicURL: "https://forge.example"}) | |
| 80 | + | assert.Error(t, err, "clients are required") | |
| 81 | + | git := ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid") | |
| 82 | + | _, err = New(Config{Repos: failing{}, Git: git, PublicURL: "not a url"}) | |
| 83 | + | assert.Error(t, err) | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "net/http" | |||
| 10 | + | "net/http/httptest" | |||
| 11 | + | "regexp" | |||
| 12 | + | "testing" | |||
| 13 | + | ||||
| 14 | + | "connectrpc.com/connect" | |||
| 15 | + | "github.com/bnema/zerowrap" | |||
| 16 | + | "github.com/stretchr/testify/assert" | |||
| 17 | + | "github.com/stretchr/testify/require" | |||
| 18 | + | ||||
| 19 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 20 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // failing is a repository client that fails every call with err. | |||
| 24 | + | type failing struct { | |||
| 25 | + | ermiteconnect.RepoServiceClient | |||
| 26 | + | err error | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (f failing) ListRepos(context.Context, *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) { | |||
| 30 | + | return nil, f.err | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (f failing) GetRepo(context.Context, *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { | |||
| 34 | + | return nil, f.err | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) { | |||
| 38 | + | t.Helper() | |||
| 39 | + | srv, nerr := New(Config{ | |||
| 40 | + | Repos: failing{err: err}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |||
| 41 | + | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 42 | + | }) | |||
| 43 | + | require.NoError(t, nerr) | |||
| 44 | + | var logs bytes.Buffer | |||
| 45 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | |||
| 46 | + | rec := httptest.NewRecorder() | |||
| 47 | + | srv.ServeHTTP(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil)) | |||
| 48 | + | return rec, logs.String() | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Errors of the API: not found (and an invalid name) are the 404 page, any | |||
| 52 | + | // other is the 500 page with an id that the log carries. | |||
| 53 | + | func TestErrorPages(t *testing.T) { | |||
| 54 | + | rec, logs := serve(t, connect.NewError(connect.CodeNotFound, errors.New("repository not found")), "/gone") | |||
| 55 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 56 | + | assert.Contains(t, rec.Body.String(), "not found") | |||
| 57 | + | assert.Empty(t, logs, "a 404 is not an error") | |||
| 58 | + | ||||
| 59 | + | rec, _ = serve(t, connect.NewError(connect.CodeInvalidArgument, errors.New("bad name")), "/Bad") | |||
| 60 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 61 | + | assert.NotContains(t, rec.Body.String(), "bad name") | |||
| 62 | + | ||||
| 63 | + | secret := "dial tcp 10.0.0.5: kv exploded" | |||
| 64 | + | rec, logs = serve(t, connect.NewError(connect.CodeInternal, errors.New(secret)), "/demo") | |||
| 65 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 66 | + | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | |||
| 67 | + | assert.Contains(t, rec.Body.String(), "internal error") | |||
| 68 | + | assert.NotContains(t, rec.Body.String(), secret, "the cause stays in the log") | |||
| 69 | + | m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String()) | |||
| 70 | + | require.Len(t, m, 2, "the page shows a request id") | |||
| 71 | + | assert.Contains(t, logs, m[1], "the log carries the same id") | |||
| 72 | + | assert.Contains(t, logs, "kv exploded") | |||
| 73 | + | ||||
| 74 | + | rec, _ = serve(t, connect.NewError(connect.CodeInternal, errors.New("x")), "/") | |||
| 75 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code, "the home page too") | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestNewValidates(t *testing.T) { | |||
| 79 | + | _, err := New(Config{PublicURL: "https://forge.example"}) | |||
| 80 | + | assert.Error(t, err, "clients are required") | |||
| 81 | + | git := ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid") | |||
| 82 | + | _, err = New(Config{Repos: failing{}, Git: git, PublicURL: "not a url"}) | |||
| 83 | + | assert.Error(t, err) | |||
| 84 | + | } | |||
A internal/adapters/web/format.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strconv" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func itoa(n int) string { return strconv.Itoa(n) } | |
| 12 | + | ||
| 13 | + | // cursorText is the initial text of the cursor position in the status bar. | |
| 14 | + | func cursorText(n int) string { | |
| 15 | + | if n == 0 { | |
| 16 | + | return "0/0" | |
| 17 | + | } | |
| 18 | + | return "1/" + itoa(n) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // ago renders a short relative date: now, 5m ago, 3h ago, 12d ago, 5mo ago, | |
| 22 | + | // 2y ago. | |
| 23 | + | func ago(now, t time.Time) string { | |
| 24 | + | if t.IsZero() { | |
| 25 | + | return "never" | |
| 26 | + | } | |
| 27 | + | d := now.Sub(t) | |
| 28 | + | switch { | |
| 29 | + | case d < time.Minute: | |
| 30 | + | return "now" | |
| 31 | + | case d < time.Hour: | |
| 32 | + | return fmt.Sprintf("%dm ago", int(d.Minutes())) | |
| 33 | + | case d < 48*time.Hour: | |
| 34 | + | return fmt.Sprintf("%dh ago", int(d.Hours())) | |
| 35 | + | case d < 60*24*time.Hour: | |
| 36 | + | return fmt.Sprintf("%dd ago", int(d.Hours()/24)) | |
| 37 | + | case d < 700*24*time.Hour: | |
| 38 | + | return fmt.Sprintf("%dmo ago", int(d.Hours()/24/30)) | |
| 39 | + | } | |
| 40 | + | return fmt.Sprintf("%dy ago", int(d.Hours()/24/365)) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // absolute is the absolute form shown on hover. | |
| 44 | + | func absolute(t time.Time) string { | |
| 45 | + | return t.UTC().Format("2006-01-02 15:04 UTC") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // rfc3339 is the machine form of a time, "" for the zero time. | |
| 49 | + | func rfc3339(t time.Time) string { | |
| 50 | + | if t.IsZero() { | |
| 51 | + | return "" | |
| 52 | + | } | |
| 53 | + | return t.UTC().Format(time.RFC3339) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func plural(n int, one, many string) string { | |
| 57 | + | if n == 1 { | |
| 58 | + | return "1 " + one | |
| 59 | + | } | |
| 60 | + | return itoa(n) + " " + many | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // short is the abbreviated form of a commit id. | |
| 64 | + | func short(id string) string { | |
| 65 | + | if len(id) > 7 { | |
| 66 | + | return id[:7] | |
| 67 | + | } | |
| 68 | + | return id | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strconv" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func itoa(n int) string { return strconv.Itoa(n) } | |||
| 12 | + | ||||
| 13 | + | // cursorText is the initial text of the cursor position in the status bar. | |||
| 14 | + | func cursorText(n int) string { | |||
| 15 | + | if n == 0 { | |||
| 16 | + | return "0/0" | |||
| 17 | + | } | |||
| 18 | + | return "1/" + itoa(n) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // ago renders a short relative date: now, 5m ago, 3h ago, 12d ago, 5mo ago, | |||
| 22 | + | // 2y ago. | |||
| 23 | + | func ago(now, t time.Time) string { | |||
| 24 | + | if t.IsZero() { | |||
| 25 | + | return "never" | |||
| 26 | + | } | |||
| 27 | + | d := now.Sub(t) | |||
| 28 | + | switch { | |||
| 29 | + | case d < time.Minute: | |||
| 30 | + | return "now" | |||
| 31 | + | case d < time.Hour: | |||
| 32 | + | return fmt.Sprintf("%dm ago", int(d.Minutes())) | |||
| 33 | + | case d < 48*time.Hour: | |||
| 34 | + | return fmt.Sprintf("%dh ago", int(d.Hours())) | |||
| 35 | + | case d < 60*24*time.Hour: | |||
| 36 | + | return fmt.Sprintf("%dd ago", int(d.Hours()/24)) | |||
| 37 | + | case d < 700*24*time.Hour: | |||
| 38 | + | return fmt.Sprintf("%dmo ago", int(d.Hours()/24/30)) | |||
| 39 | + | } | |||
| 40 | + | return fmt.Sprintf("%dy ago", int(d.Hours()/24/365)) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // absolute is the absolute form shown on hover. | |||
| 44 | + | func absolute(t time.Time) string { | |||
| 45 | + | return t.UTC().Format("2006-01-02 15:04 UTC") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // rfc3339 is the machine form of a time, "" for the zero time. | |||
| 49 | + | func rfc3339(t time.Time) string { | |||
| 50 | + | if t.IsZero() { | |||
| 51 | + | return "" | |||
| 52 | + | } | |||
| 53 | + | return t.UTC().Format(time.RFC3339) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func plural(n int, one, many string) string { | |||
| 57 | + | if n == 1 { | |||
| 58 | + | return "1 " + one | |||
| 59 | + | } | |||
| 60 | + | return itoa(n) + " " + many | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // short is the abbreviated form of a commit id. | |||
| 64 | + | func short(id string) string { | |||
| 65 | + | if len(id) > 7 { | |||
| 66 | + | return id[:7] | |||
| 67 | + | } | |||
| 68 | + | return id | |||
| 69 | + | } | |||
A internal/adapters/web/home.go +149 -0
| @@ -0,0 +1,149 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "sort" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "connectrpc.com/connect" | |
| 13 | + | "github.com/a-h/templ" | |
| 14 | + | ||
| 15 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // maxRepos bounds the repositories one page lists. | |
| 19 | + | const maxRepos = 1000 | |
| 20 | + | ||
| 21 | + | // pageSize is the page size asked from the list RPCs: the largest the API | |
| 22 | + | // accepts. | |
| 23 | + | const pageSize = 100 | |
| 24 | + | ||
| 25 | + | // listRepos reads the repositories the visitor may see, ordered by name. | |
| 26 | + | func (s *Server) listRepos(ctx context.Context) ([]*pb.Repo, error) { | |
| 27 | + | var repos []*pb.Repo | |
| 28 | + | token := "" | |
| 29 | + | for len(repos) < maxRepos { | |
| 30 | + | res, err := s.cfg.Repos.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{PageSize: pageSize, PageToken: token})) | |
| 31 | + | if err != nil { | |
| 32 | + | return nil, err | |
| 33 | + | } | |
| 34 | + | repos = append(repos, res.Msg.GetRepos()...) | |
| 35 | + | if token = res.Msg.GetNextPageToken(); token == "" { | |
| 36 | + | break | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | return repos, nil | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // homeCols are the sortable columns of the home list, in order. | |
| 43 | + | var homeCols = []struct{ key, label, class string }{ | |
| 44 | + | {"name", "name", "c-name"}, | |
| 45 | + | {"desc", "description", "c-desc"}, | |
| 46 | + | {"push", "last push", "c-when"}, | |
| 47 | + | {"branch", "branch", "c-ref"}, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // sortRepos orders repos by the column key; ties keep the order by name. | |
| 51 | + | func sortRepos(repos []*pb.Repo, key string, desc bool) { | |
| 52 | + | less := func(a, b *pb.Repo) bool { | |
| 53 | + | switch key { | |
| 54 | + | case "desc": | |
| 55 | + | return strings.ToLower(a.GetDescription()) < strings.ToLower(b.GetDescription()) | |
| 56 | + | case "branch": | |
| 57 | + | return a.GetDefaultBranch() < b.GetDefaultBranch() | |
| 58 | + | case "push": | |
| 59 | + | return a.GetPushedAt().AsTime().Before(b.GetPushedAt().AsTime()) | |
| 60 | + | } | |
| 61 | + | return a.GetName() < b.GetName() | |
| 62 | + | } | |
| 63 | + | sort.SliceStable(repos, func(i, j int) bool { | |
| 64 | + | if desc { | |
| 65 | + | return less(repos[j], repos[i]) | |
| 66 | + | } | |
| 67 | + | return less(repos[i], repos[j]) | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // home lists the repositories: /?sort=<col>&dir=asc|desc, newest push first | |
| 72 | + | // by default. | |
| 73 | + | func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 74 | + | repos, err := s.listRepos(ctx) | |
| 75 | + | if err != nil { | |
| 76 | + | return nil, 0, err | |
| 77 | + | } | |
| 78 | + | key, desc := "push", true | |
| 79 | + | for _, c := range homeCols { | |
| 80 | + | if r.URL.Query().Get("sort") == c.key { | |
| 81 | + | key, desc = c.key, c.key == "push" | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" { | |
| 85 | + | desc = d == "desc" | |
| 86 | + | } | |
| 87 | + | sortRepos(repos, key, desc) | |
| 88 | + | ||
| 89 | + | now := s.cfg.Now() | |
| 90 | + | p := HomePage{} | |
| 91 | + | for _, c := range homeCols { | |
| 92 | + | sc := SortCol{Label: c.label, Class: c.class, Dir: "none"} | |
| 93 | + | next := "asc" | |
| 94 | + | if c.key == key { | |
| 95 | + | sc.Dir, sc.Mark = "ascending", "▲" | |
| 96 | + | if desc { | |
| 97 | + | sc.Dir, sc.Mark = "descending", "▼" | |
| 98 | + | } else { | |
| 99 | + | next = "desc" | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | sc.Href = "/?sort=" + c.key + "&dir=" + next | |
| 103 | + | p.Cols = append(p.Cols, sc) | |
| 104 | + | } | |
| 105 | + | for i, repo := range repos { | |
| 106 | + | p.Rows = append(p.Rows, homeRow(repo, now, i == 0)) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | l := s.base() | |
| 110 | + | l.Title = s.instance | |
| 111 | + | l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}} | |
| 112 | + | dir := "▲" | |
| 113 | + | if desc { | |
| 114 | + | dir = "▼" | |
| 115 | + | } | |
| 116 | + | l.Status = Status{ | |
| 117 | + | Mode: "home", | |
| 118 | + | Context: plural(len(repos), "repo", "repos") + " · sort " + key + " " + dir, | |
| 119 | + | Hints: "j/k move · enter open · / filter · ? help", | |
| 120 | + | Cursor: true, Count: len(repos), | |
| 121 | + | } | |
| 122 | + | l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...) | |
| 123 | + | return Home(l, p), http.StatusOK, nil | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // homeRow is the row of a repository in the home list. | |
| 127 | + | func homeRow(r *pb.Repo, now time.Time, cursor bool) HomeRow { | |
| 128 | + | row := HomeRow{ | |
| 129 | + | ID: "repo-" + r.GetName(), Name: r.GetName(), Href: repoHref(r.GetName()), | |
| 130 | + | Desc: r.GetDescription(), Pushed: "never", Branch: r.GetDefaultBranch(), Cursor: cursor, | |
| 131 | + | } | |
| 132 | + | if r.GetPushedAt().IsValid() && r.GetPushedAt().GetSeconds() != 0 { | |
| 133 | + | t := r.GetPushedAt().AsTime() | |
| 134 | + | row.Pushed, row.PushedAt = ago(now, t), rfc3339(t) | |
| 135 | + | } | |
| 136 | + | return row | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // repoCmds are the repositories as targets of the ":" command line, but the | |
| 140 | + | // one being viewed. | |
| 141 | + | func repoCmds(repos []*pb.Repo, except string) []CmdItem { | |
| 142 | + | var out []CmdItem | |
| 143 | + | for _, r := range repos { | |
| 144 | + | if r.GetName() != except { | |
| 145 | + | out = append(out, CmdItem{Kind: "repo", Label: r.GetName(), Href: repoHref(r.GetName())}) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | return out | |
| 149 | + | } | |
| @@ -0,0 +1,149 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net/http" | |||
| 8 | + | "sort" | |||
| 9 | + | "strings" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "connectrpc.com/connect" | |||
| 13 | + | "github.com/a-h/templ" | |||
| 14 | + | ||||
| 15 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // maxRepos bounds the repositories one page lists. | |||
| 19 | + | const maxRepos = 1000 | |||
| 20 | + | ||||
| 21 | + | // pageSize is the page size asked from the list RPCs: the largest the API | |||
| 22 | + | // accepts. | |||
| 23 | + | const pageSize = 100 | |||
| 24 | + | ||||
| 25 | + | // listRepos reads the repositories the visitor may see, ordered by name. | |||
| 26 | + | func (s *Server) listRepos(ctx context.Context) ([]*pb.Repo, error) { | |||
| 27 | + | var repos []*pb.Repo | |||
| 28 | + | token := "" | |||
| 29 | + | for len(repos) < maxRepos { | |||
| 30 | + | res, err := s.cfg.Repos.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{PageSize: pageSize, PageToken: token})) | |||
| 31 | + | if err != nil { | |||
| 32 | + | return nil, err | |||
| 33 | + | } | |||
| 34 | + | repos = append(repos, res.Msg.GetRepos()...) | |||
| 35 | + | if token = res.Msg.GetNextPageToken(); token == "" { | |||
| 36 | + | break | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | return repos, nil | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // homeCols are the sortable columns of the home list, in order. | |||
| 43 | + | var homeCols = []struct{ key, label, class string }{ | |||
| 44 | + | {"name", "name", "c-name"}, | |||
| 45 | + | {"desc", "description", "c-desc"}, | |||
| 46 | + | {"push", "last push", "c-when"}, | |||
| 47 | + | {"branch", "branch", "c-ref"}, | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // sortRepos orders repos by the column key; ties keep the order by name. | |||
| 51 | + | func sortRepos(repos []*pb.Repo, key string, desc bool) { | |||
| 52 | + | less := func(a, b *pb.Repo) bool { | |||
| 53 | + | switch key { | |||
| 54 | + | case "desc": | |||
| 55 | + | return strings.ToLower(a.GetDescription()) < strings.ToLower(b.GetDescription()) | |||
| 56 | + | case "branch": | |||
| 57 | + | return a.GetDefaultBranch() < b.GetDefaultBranch() | |||
| 58 | + | case "push": | |||
| 59 | + | return a.GetPushedAt().AsTime().Before(b.GetPushedAt().AsTime()) | |||
| 60 | + | } | |||
| 61 | + | return a.GetName() < b.GetName() | |||
| 62 | + | } | |||
| 63 | + | sort.SliceStable(repos, func(i, j int) bool { | |||
| 64 | + | if desc { | |||
| 65 | + | return less(repos[j], repos[i]) | |||
| 66 | + | } | |||
| 67 | + | return less(repos[i], repos[j]) | |||
| 68 | + | }) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // home lists the repositories: /?sort=<col>&dir=asc|desc, newest push first | |||
| 72 | + | // by default. | |||
| 73 | + | func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 74 | + | repos, err := s.listRepos(ctx) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return nil, 0, err | |||
| 77 | + | } | |||
| 78 | + | key, desc := "push", true | |||
| 79 | + | for _, c := range homeCols { | |||
| 80 | + | if r.URL.Query().Get("sort") == c.key { | |||
| 81 | + | key, desc = c.key, c.key == "push" | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" { | |||
| 85 | + | desc = d == "desc" | |||
| 86 | + | } | |||
| 87 | + | sortRepos(repos, key, desc) | |||
| 88 | + | ||||
| 89 | + | now := s.cfg.Now() | |||
| 90 | + | p := HomePage{} | |||
| 91 | + | for _, c := range homeCols { | |||
| 92 | + | sc := SortCol{Label: c.label, Class: c.class, Dir: "none"} | |||
| 93 | + | next := "asc" | |||
| 94 | + | if c.key == key { | |||
| 95 | + | sc.Dir, sc.Mark = "ascending", "▲" | |||
| 96 | + | if desc { | |||
| 97 | + | sc.Dir, sc.Mark = "descending", "▼" | |||
| 98 | + | } else { | |||
| 99 | + | next = "desc" | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | sc.Href = "/?sort=" + c.key + "&dir=" + next | |||
| 103 | + | p.Cols = append(p.Cols, sc) | |||
| 104 | + | } | |||
| 105 | + | for i, repo := range repos { | |||
| 106 | + | p.Rows = append(p.Rows, homeRow(repo, now, i == 0)) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | l := s.base() | |||
| 110 | + | l.Title = s.instance | |||
| 111 | + | l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}} | |||
| 112 | + | dir := "▲" | |||
| 113 | + | if desc { | |||
| 114 | + | dir = "▼" | |||
| 115 | + | } | |||
| 116 | + | l.Status = Status{ | |||
| 117 | + | Mode: "home", | |||
| 118 | + | Context: plural(len(repos), "repo", "repos") + " · sort " + key + " " + dir, | |||
| 119 | + | Hints: "j/k move · enter open · / filter · ? help", | |||
| 120 | + | Cursor: true, Count: len(repos), | |||
| 121 | + | } | |||
| 122 | + | l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...) | |||
| 123 | + | return Home(l, p), http.StatusOK, nil | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // homeRow is the row of a repository in the home list. | |||
| 127 | + | func homeRow(r *pb.Repo, now time.Time, cursor bool) HomeRow { | |||
| 128 | + | row := HomeRow{ | |||
| 129 | + | ID: "repo-" + r.GetName(), Name: r.GetName(), Href: repoHref(r.GetName()), | |||
| 130 | + | Desc: r.GetDescription(), Pushed: "never", Branch: r.GetDefaultBranch(), Cursor: cursor, | |||
| 131 | + | } | |||
| 132 | + | if r.GetPushedAt().IsValid() && r.GetPushedAt().GetSeconds() != 0 { | |||
| 133 | + | t := r.GetPushedAt().AsTime() | |||
| 134 | + | row.Pushed, row.PushedAt = ago(now, t), rfc3339(t) | |||
| 135 | + | } | |||
| 136 | + | return row | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // repoCmds are the repositories as targets of the ":" command line, but the | |||
| 140 | + | // one being viewed. | |||
| 141 | + | func repoCmds(repos []*pb.Repo, except string) []CmdItem { | |||
| 142 | + | var out []CmdItem | |||
| 143 | + | for _, r := range repos { | |||
| 144 | + | if r.GetName() != except { | |||
| 145 | + | out = append(out, CmdItem{Kind: "repo", Label: r.GetName(), Href: repoHref(r.GetName())}) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | return out | |||
| 149 | + | } | |||
A internal/adapters/web/keys.templ +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // Keys is the keyboard map. ermite.js turns keys into k-<name> events on | |
| 6 | + | // <body>; this element (hx-on + hx-live helpers) is the only place that | |
| 7 | + | // reacts to them. | |
| 8 | + | templ Keys() { | |
| 9 | + | <div | |
| 10 | + | id="keys" | |
| 11 | + | hidden | |
| 12 | + | hx-on="k-j from:body -> ((d) => { | |
| 13 | + | if (q('dialog[open]').count) return; | |
| 14 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 15 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 16 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 17 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 18 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 19 | + | })(1); | |
| 20 | + | k-k from:body -> ((d) => { | |
| 21 | + | if (q('dialog[open]').count) return; | |
| 22 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 23 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 24 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 25 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 26 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 27 | + | })(-1); | |
| 28 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 29 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 30 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 31 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 32 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 33 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 34 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 35 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 36 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 37 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 38 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 39 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 40 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 41 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 42 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 43 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 44 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 45 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 46 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 47 | + | k-permalink from:body -> { | |
| 48 | + | let b = document.body.dataset; | |
| 49 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 50 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 51 | + | navigator.clipboard?.writeText(location.href); | |
| 52 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 53 | + | }" | |
| 54 | + | ></div> | |
| 55 | + | } | |
| @@ -0,0 +1,55 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | // Keys is the keyboard map. ermite.js turns keys into k-<name> events on | |||
| 6 | + | // <body>; this element (hx-on + hx-live helpers) is the only place that | |||
| 7 | + | // reacts to them. | |||
| 8 | + | templ Keys() { | |||
| 9 | + | <div | |||
| 10 | + | id="keys" | |||
| 11 | + | hidden | |||
| 12 | + | hx-on="k-j from:body -> ((d) => { | |||
| 13 | + | if (q('dialog[open]').count) return; | |||
| 14 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 15 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 16 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 17 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 18 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 19 | + | })(1); | |||
| 20 | + | k-k from:body -> ((d) => { | |||
| 21 | + | if (q('dialog[open]').count) return; | |||
| 22 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 23 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 24 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 25 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 26 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 27 | + | })(-1); | |||
| 28 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 29 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 30 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 31 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 32 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 33 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |||
| 34 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 35 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 36 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 37 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 38 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 39 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 40 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 41 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 42 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 43 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 44 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 45 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 46 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 47 | + | k-permalink from:body -> { | |||
| 48 | + | let b = document.body.dataset; | |||
| 49 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 50 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 51 | + | navigator.clipboard?.writeText(location.href); | |||
| 52 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 53 | + | }" | |||
| 54 | + | ></div> | |||
| 55 | + | } | |||
A internal/adapters/web/keys_templ.go +45 -0 generated file: collapsed
A internal/adapters/web/markdown.go +281 -0
| @@ -0,0 +1,281 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "html/template" | |
| 8 | + | "net/url" | |
| 9 | + | "path" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "github.com/yuin/goldmark" | |
| 13 | + | "github.com/yuin/goldmark/ast" | |
| 14 | + | "github.com/yuin/goldmark/extension" | |
| 15 | + | "github.com/yuin/goldmark/parser" | |
| 16 | + | "github.com/yuin/goldmark/renderer" | |
| 17 | + | "github.com/yuin/goldmark/text" | |
| 18 | + | "github.com/yuin/goldmark/util" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // headingIDPrefix keeps the ids of README headings apart from the ids of the | |
| 22 | + | // page (main, keys, cmd-list…): a heading must not be able to take one. | |
| 23 | + | const headingIDPrefix = "md-" | |
| 24 | + | ||
| 25 | + | // markdown renders the Markdown of a repository. Its output is safe to put in | |
| 26 | + | // a page: raw HTML is dropped, and links and images follow the policy below. | |
| 27 | + | // | |
| 28 | + | // Link policy: | |
| 29 | + | // - http and https links are kept, with rel="nofollow noopener noreferrer"; | |
| 30 | + | // - relative links and links to the root of the repository are rewritten to | |
| 31 | + | // the blob route of the ref being viewed, never above the repository root; | |
| 32 | + | // - #fragments point at the (prefixed) heading ids; | |
| 33 | + | // - everything else (javascript:, data:, vbscript:, file:, mailto:, | |
| 34 | + | // protocol-relative //host, empty targets…) loses its link: the text stays; | |
| 35 | + | // - images are never loaded (the CSP forbids other origins, and the design | |
| 36 | + | // shows an image as its alt text): each becomes <span class="alt">[alt]</span>. | |
| 37 | + | type markdown struct { | |
| 38 | + | md goldmark.Markdown | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func newMarkdown() *markdown { | |
| 42 | + | return &markdown{md: goldmark.New( | |
| 43 | + | goldmark.WithExtensions(extension.GFM), | |
| 44 | + | goldmark.WithParserOptions(parser.WithAutoHeadingID()), | |
| 45 | + | goldmark.WithRendererOptions( | |
| 46 | + | renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100)), | |
| 47 | + | // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped. | |
| 48 | + | // Table alignment as an align attribute: the CSP forbids style="". | |
| 49 | + | extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute), | |
| 50 | + | ), | |
| 51 | + | )} | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // render turns source into HTML and the outline of its headings. repo and ref | |
| 55 | + | // are where relative links point; dir is the directory of the Markdown file | |
| 56 | + | // in the repository, "" for the root. | |
| 57 | + | func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, []Heading, error) { | |
| 58 | + | pc := parser.NewContext(parser.WithIDs(&prefixedIDs{inner: parser.NewContext().IDs()})) | |
| 59 | + | doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc)) | |
| 60 | + | pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source} | |
| 61 | + | pol.apply(doc) | |
| 62 | + | ||
| 63 | + | var buf bytes.Buffer | |
| 64 | + | if err := m.md.Renderer().Render(&buf, source, doc); err != nil { | |
| 65 | + | return "", nil, err | |
| 66 | + | } | |
| 67 | + | // #nosec G203 -- goldmark without WithUnsafe escapes text and drops raw | |
| 68 | + | // HTML; linkPolicy has removed every URL it does not allow. | |
| 69 | + | return template.HTML(buf.String()), outline(doc, source), nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // prefixedIDs prefixes the heading ids that goldmark generates. | |
| 73 | + | type prefixedIDs struct{ inner parser.IDs } | |
| 74 | + | ||
| 75 | + | func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte { | |
| 76 | + | return append([]byte(headingIDPrefix), p.inner.Generate(value, kind)...) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) } | |
| 80 | + | ||
| 81 | + | // outline lists the headings of a document in order. | |
| 82 | + | func outline(doc ast.Node, source []byte) []Heading { | |
| 83 | + | var out []Heading | |
| 84 | + | _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { | |
| 85 | + | h, ok := n.(*ast.Heading) | |
| 86 | + | if !ok || !entering { | |
| 87 | + | return ast.WalkContinue, nil | |
| 88 | + | } | |
| 89 | + | id, _ := h.AttributeString("id") | |
| 90 | + | idb, _ := id.([]byte) | |
| 91 | + | out = append(out, Heading{Level: h.Level, ID: string(idb), Text: plainText(h, source)}) | |
| 92 | + | return ast.WalkSkipChildren, nil | |
| 93 | + | }) | |
| 94 | + | return out | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // plainText is the text of a node without markup. | |
| 98 | + | func plainText(n ast.Node, source []byte) string { | |
| 99 | + | var b strings.Builder | |
| 100 | + | _ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) { | |
| 101 | + | if !entering { | |
| 102 | + | return ast.WalkContinue, nil | |
| 103 | + | } | |
| 104 | + | switch t := c.(type) { | |
| 105 | + | case *ast.Text: | |
| 106 | + | b.Write(t.Value(source)) | |
| 107 | + | if t.SoftLineBreak() || t.HardLineBreak() { | |
| 108 | + | b.WriteByte(' ') | |
| 109 | + | } | |
| 110 | + | case *ast.String: | |
| 111 | + | b.Write(t.Value) | |
| 112 | + | } | |
| 113 | + | return ast.WalkContinue, nil | |
| 114 | + | }) | |
| 115 | + | return strings.TrimSpace(b.String()) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // linkPolicy applies the link and image policy to a parsed document. | |
| 119 | + | type linkPolicy struct { | |
| 120 | + | repo, ref, dir string | |
| 121 | + | source []byte | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (p linkPolicy) apply(doc ast.Node) { | |
| 125 | + | // Collect first, change after: the walk must not see the tree move. | |
| 126 | + | var links, images, autos []ast.Node | |
| 127 | + | _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { | |
| 128 | + | if !entering { | |
| 129 | + | return ast.WalkContinue, nil | |
| 130 | + | } | |
| 131 | + | switch n.(type) { | |
| 132 | + | case *ast.Link: | |
| 133 | + | links = append(links, n) | |
| 134 | + | case *ast.Image: | |
| 135 | + | images = append(images, n) | |
| 136 | + | case *ast.AutoLink: | |
| 137 | + | autos = append(autos, n) | |
| 138 | + | } | |
| 139 | + | return ast.WalkContinue, nil | |
| 140 | + | }) | |
| 141 | + | for _, n := range images { | |
| 142 | + | p.image(n.(*ast.Image)) | |
| 143 | + | } | |
| 144 | + | for _, n := range links { | |
| 145 | + | l := n.(*ast.Link) | |
| 146 | + | dest, kind := p.target(string(l.Destination)) | |
| 147 | + | if kind == dropped { | |
| 148 | + | unwrap(l) | |
| 149 | + | continue | |
| 150 | + | } | |
| 151 | + | l.Destination = []byte(dest) | |
| 152 | + | l.Title = nil | |
| 153 | + | if kind == external { | |
| 154 | + | l.SetAttributeString("rel", []byte("nofollow noopener noreferrer")) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | for _, n := range autos { | |
| 158 | + | a := n.(*ast.AutoLink) | |
| 159 | + | if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) { | |
| 160 | + | a.SetAttributeString("rel", []byte("nofollow noopener noreferrer")) | |
| 161 | + | continue | |
| 162 | + | } | |
| 163 | + | // An email address or another scheme: the label as plain text. | |
| 164 | + | a.Parent().ReplaceChild(a.Parent(), a, ast.NewString(append([]byte(nil), a.Label(p.source)...))) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // unwrap replaces a node by its children. | |
| 169 | + | func unwrap(n ast.Node) { | |
| 170 | + | parent := n.Parent() | |
| 171 | + | for c := n.FirstChild(); c != nil; c = n.FirstChild() { | |
| 172 | + | parent.InsertBefore(parent, n, c) | |
| 173 | + | } | |
| 174 | + | parent.RemoveChild(parent, n) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // image replaces an image by its alt text. | |
| 178 | + | func (p linkPolicy) image(img *ast.Image) { | |
| 179 | + | alt := plainText(img, p.source) | |
| 180 | + | if alt == "" { | |
| 181 | + | alt = "image" | |
| 182 | + | } | |
| 183 | + | img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt}) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | type linkKind int | |
| 187 | + | ||
| 188 | + | const ( | |
| 189 | + | dropped linkKind = iota | |
| 190 | + | external | |
| 191 | + | internal | |
| 192 | + | ) | |
| 193 | + | ||
| 194 | + | // target applies the link policy to a destination. | |
| 195 | + | func (p linkPolicy) target(dest string) (string, linkKind) { | |
| 196 | + | dest = strings.TrimSpace(dest) | |
| 197 | + | if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") { | |
| 198 | + | return "", dropped | |
| 199 | + | } | |
| 200 | + | if strings.HasPrefix(dest, "#") { | |
| 201 | + | frag := strings.ToLower(strings.TrimPrefix(dest, "#")) | |
| 202 | + | if frag == "" || strings.ContainsAny(frag, " \"'<>") { | |
| 203 | + | return "", dropped | |
| 204 | + | } | |
| 205 | + | return "#" + headingIDPrefix + frag, internal | |
| 206 | + | } | |
| 207 | + | if strings.HasPrefix(dest, "//") { | |
| 208 | + | return "", dropped | |
| 209 | + | } | |
| 210 | + | u, err := url.Parse(dest) | |
| 211 | + | if err != nil { | |
| 212 | + | return "", dropped | |
| 213 | + | } | |
| 214 | + | switch strings.ToLower(u.Scheme) { | |
| 215 | + | case "http", "https": | |
| 216 | + | if u.Host == "" { | |
| 217 | + | return "", dropped | |
| 218 | + | } | |
| 219 | + | return u.String(), external | |
| 220 | + | case "": | |
| 221 | + | default: | |
| 222 | + | return "", dropped | |
| 223 | + | } | |
| 224 | + | if u.Host != "" || u.Opaque != "" || u.Path == "" { | |
| 225 | + | return "", dropped | |
| 226 | + | } | |
| 227 | + | // A path in the repository: from the directory of the file, or from the | |
| 228 | + | // root when it starts with a slash. path.Clean on a rooted path never | |
| 229 | + | // leaves the root, so ../../ cannot climb out of the repository. | |
| 230 | + | var rel string | |
| 231 | + | if strings.HasPrefix(u.Path, "/") { | |
| 232 | + | rel = path.Clean(u.Path) | |
| 233 | + | } else { | |
| 234 | + | rel = path.Clean("/" + path.Join(p.dir, u.Path)) | |
| 235 | + | } | |
| 236 | + | rel = strings.TrimPrefix(rel, "/") | |
| 237 | + | href := repoHref(p.repo) | |
| 238 | + | if rel != "" { | |
| 239 | + | href = repoHref(p.repo, "blob", p.ref, rel) | |
| 240 | + | } | |
| 241 | + | if u.Fragment != "" { | |
| 242 | + | href += "#" + url.PathEscape(u.Fragment) | |
| 243 | + | } | |
| 244 | + | return href, internal | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func isWebURL(s string) bool { | |
| 248 | + | u, err := url.Parse(s) | |
| 249 | + | if err != nil || u.Host == "" { | |
| 250 | + | return false | |
| 251 | + | } | |
| 252 | + | sc := strings.ToLower(u.Scheme) | |
| 253 | + | return sc == "http" || sc == "https" | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // kindAlt is the kind of altSpan. | |
| 257 | + | var kindAlt = ast.NewNodeKind("AltSpan") | |
| 258 | + | ||
| 259 | + | // altSpan stands for an image: its alt text, in a span the stylesheet styles. | |
| 260 | + | type altSpan struct { | |
| 261 | + | ast.BaseInline | |
| 262 | + | alt string | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func (n *altSpan) Kind() ast.NodeKind { return kindAlt } | |
| 266 | + | ||
| 267 | + | func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } | |
| 268 | + | ||
| 269 | + | // altRenderer renders altSpan nodes. | |
| 270 | + | type altRenderer struct{} | |
| 271 | + | ||
| 272 | + | func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) { | |
| 273 | + | r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { | |
| 274 | + | if entering { | |
| 275 | + | _, _ = w.WriteString(`<span class="alt">[`) | |
| 276 | + | _, _ = w.Write(util.EscapeHTML([]byte(n.(*altSpan).alt))) | |
| 277 | + | _, _ = w.WriteString(`]</span>`) | |
| 278 | + | } | |
| 279 | + | return ast.WalkContinue, nil | |
| 280 | + | }) | |
| 281 | + | } | |
| @@ -0,0 +1,281 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "html/template" | |||
| 8 | + | "net/url" | |||
| 9 | + | "path" | |||
| 10 | + | "strings" | |||
| 11 | + | ||||
| 12 | + | "github.com/yuin/goldmark" | |||
| 13 | + | "github.com/yuin/goldmark/ast" | |||
| 14 | + | "github.com/yuin/goldmark/extension" | |||
| 15 | + | "github.com/yuin/goldmark/parser" | |||
| 16 | + | "github.com/yuin/goldmark/renderer" | |||
| 17 | + | "github.com/yuin/goldmark/text" | |||
| 18 | + | "github.com/yuin/goldmark/util" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // headingIDPrefix keeps the ids of README headings apart from the ids of the | |||
| 22 | + | // page (main, keys, cmd-list…): a heading must not be able to take one. | |||
| 23 | + | const headingIDPrefix = "md-" | |||
| 24 | + | ||||
| 25 | + | // markdown renders the Markdown of a repository. Its output is safe to put in | |||
| 26 | + | // a page: raw HTML is dropped, and links and images follow the policy below. | |||
| 27 | + | // | |||
| 28 | + | // Link policy: | |||
| 29 | + | // - http and https links are kept, with rel="nofollow noopener noreferrer"; | |||
| 30 | + | // - relative links and links to the root of the repository are rewritten to | |||
| 31 | + | // the blob route of the ref being viewed, never above the repository root; | |||
| 32 | + | // - #fragments point at the (prefixed) heading ids; | |||
| 33 | + | // - everything else (javascript:, data:, vbscript:, file:, mailto:, | |||
| 34 | + | // protocol-relative //host, empty targets…) loses its link: the text stays; | |||
| 35 | + | // - images are never loaded (the CSP forbids other origins, and the design | |||
| 36 | + | // shows an image as its alt text): each becomes <span class="alt">[alt]</span>. | |||
| 37 | + | type markdown struct { | |||
| 38 | + | md goldmark.Markdown | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func newMarkdown() *markdown { | |||
| 42 | + | return &markdown{md: goldmark.New( | |||
| 43 | + | goldmark.WithExtensions(extension.GFM), | |||
| 44 | + | goldmark.WithParserOptions(parser.WithAutoHeadingID()), | |||
| 45 | + | goldmark.WithRendererOptions( | |||
| 46 | + | renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100)), | |||
| 47 | + | // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped. | |||
| 48 | + | // Table alignment as an align attribute: the CSP forbids style="". | |||
| 49 | + | extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute), | |||
| 50 | + | ), | |||
| 51 | + | )} | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // render turns source into HTML and the outline of its headings. repo and ref | |||
| 55 | + | // are where relative links point; dir is the directory of the Markdown file | |||
| 56 | + | // in the repository, "" for the root. | |||
| 57 | + | func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, []Heading, error) { | |||
| 58 | + | pc := parser.NewContext(parser.WithIDs(&prefixedIDs{inner: parser.NewContext().IDs()})) | |||
| 59 | + | doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc)) | |||
| 60 | + | pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source} | |||
| 61 | + | pol.apply(doc) | |||
| 62 | + | ||||
| 63 | + | var buf bytes.Buffer | |||
| 64 | + | if err := m.md.Renderer().Render(&buf, source, doc); err != nil { | |||
| 65 | + | return "", nil, err | |||
| 66 | + | } | |||
| 67 | + | // #nosec G203 -- goldmark without WithUnsafe escapes text and drops raw | |||
| 68 | + | // HTML; linkPolicy has removed every URL it does not allow. | |||
| 69 | + | return template.HTML(buf.String()), outline(doc, source), nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // prefixedIDs prefixes the heading ids that goldmark generates. | |||
| 73 | + | type prefixedIDs struct{ inner parser.IDs } | |||
| 74 | + | ||||
| 75 | + | func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte { | |||
| 76 | + | return append([]byte(headingIDPrefix), p.inner.Generate(value, kind)...) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) } | |||
| 80 | + | ||||
| 81 | + | // outline lists the headings of a document in order. | |||
| 82 | + | func outline(doc ast.Node, source []byte) []Heading { | |||
| 83 | + | var out []Heading | |||
| 84 | + | _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { | |||
| 85 | + | h, ok := n.(*ast.Heading) | |||
| 86 | + | if !ok || !entering { | |||
| 87 | + | return ast.WalkContinue, nil | |||
| 88 | + | } | |||
| 89 | + | id, _ := h.AttributeString("id") | |||
| 90 | + | idb, _ := id.([]byte) | |||
| 91 | + | out = append(out, Heading{Level: h.Level, ID: string(idb), Text: plainText(h, source)}) | |||
| 92 | + | return ast.WalkSkipChildren, nil | |||
| 93 | + | }) | |||
| 94 | + | return out | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // plainText is the text of a node without markup. | |||
| 98 | + | func plainText(n ast.Node, source []byte) string { | |||
| 99 | + | var b strings.Builder | |||
| 100 | + | _ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) { | |||
| 101 | + | if !entering { | |||
| 102 | + | return ast.WalkContinue, nil | |||
| 103 | + | } | |||
| 104 | + | switch t := c.(type) { | |||
| 105 | + | case *ast.Text: | |||
| 106 | + | b.Write(t.Value(source)) | |||
| 107 | + | if t.SoftLineBreak() || t.HardLineBreak() { | |||
| 108 | + | b.WriteByte(' ') | |||
| 109 | + | } | |||
| 110 | + | case *ast.String: | |||
| 111 | + | b.Write(t.Value) | |||
| 112 | + | } | |||
| 113 | + | return ast.WalkContinue, nil | |||
| 114 | + | }) | |||
| 115 | + | return strings.TrimSpace(b.String()) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // linkPolicy applies the link and image policy to a parsed document. | |||
| 119 | + | type linkPolicy struct { | |||
| 120 | + | repo, ref, dir string | |||
| 121 | + | source []byte | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func (p linkPolicy) apply(doc ast.Node) { | |||
| 125 | + | // Collect first, change after: the walk must not see the tree move. | |||
| 126 | + | var links, images, autos []ast.Node | |||
| 127 | + | _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { | |||
| 128 | + | if !entering { | |||
| 129 | + | return ast.WalkContinue, nil | |||
| 130 | + | } | |||
| 131 | + | switch n.(type) { | |||
| 132 | + | case *ast.Link: | |||
| 133 | + | links = append(links, n) | |||
| 134 | + | case *ast.Image: | |||
| 135 | + | images = append(images, n) | |||
| 136 | + | case *ast.AutoLink: | |||
| 137 | + | autos = append(autos, n) | |||
| 138 | + | } | |||
| 139 | + | return ast.WalkContinue, nil | |||
| 140 | + | }) | |||
| 141 | + | for _, n := range images { | |||
| 142 | + | p.image(n.(*ast.Image)) | |||
| 143 | + | } | |||
| 144 | + | for _, n := range links { | |||
| 145 | + | l := n.(*ast.Link) | |||
| 146 | + | dest, kind := p.target(string(l.Destination)) | |||
| 147 | + | if kind == dropped { | |||
| 148 | + | unwrap(l) | |||
| 149 | + | continue | |||
| 150 | + | } | |||
| 151 | + | l.Destination = []byte(dest) | |||
| 152 | + | l.Title = nil | |||
| 153 | + | if kind == external { | |||
| 154 | + | l.SetAttributeString("rel", []byte("nofollow noopener noreferrer")) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | for _, n := range autos { | |||
| 158 | + | a := n.(*ast.AutoLink) | |||
| 159 | + | if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) { | |||
| 160 | + | a.SetAttributeString("rel", []byte("nofollow noopener noreferrer")) | |||
| 161 | + | continue | |||
| 162 | + | } | |||
| 163 | + | // An email address or another scheme: the label as plain text. | |||
| 164 | + | a.Parent().ReplaceChild(a.Parent(), a, ast.NewString(append([]byte(nil), a.Label(p.source)...))) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // unwrap replaces a node by its children. | |||
| 169 | + | func unwrap(n ast.Node) { | |||
| 170 | + | parent := n.Parent() | |||
| 171 | + | for c := n.FirstChild(); c != nil; c = n.FirstChild() { | |||
| 172 | + | parent.InsertBefore(parent, n, c) | |||
| 173 | + | } | |||
| 174 | + | parent.RemoveChild(parent, n) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // image replaces an image by its alt text. | |||
| 178 | + | func (p linkPolicy) image(img *ast.Image) { | |||
| 179 | + | alt := plainText(img, p.source) | |||
| 180 | + | if alt == "" { | |||
| 181 | + | alt = "image" | |||
| 182 | + | } | |||
| 183 | + | img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt}) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | type linkKind int | |||
| 187 | + | ||||
| 188 | + | const ( | |||
| 189 | + | dropped linkKind = iota | |||
| 190 | + | external | |||
| 191 | + | internal | |||
| 192 | + | ) | |||
| 193 | + | ||||
| 194 | + | // target applies the link policy to a destination. | |||
| 195 | + | func (p linkPolicy) target(dest string) (string, linkKind) { | |||
| 196 | + | dest = strings.TrimSpace(dest) | |||
| 197 | + | if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") { | |||
| 198 | + | return "", dropped | |||
| 199 | + | } | |||
| 200 | + | if strings.HasPrefix(dest, "#") { | |||
| 201 | + | frag := strings.ToLower(strings.TrimPrefix(dest, "#")) | |||
| 202 | + | if frag == "" || strings.ContainsAny(frag, " \"'<>") { | |||
| 203 | + | return "", dropped | |||
| 204 | + | } | |||
| 205 | + | return "#" + headingIDPrefix + frag, internal | |||
| 206 | + | } | |||
| 207 | + | if strings.HasPrefix(dest, "//") { | |||
| 208 | + | return "", dropped | |||
| 209 | + | } | |||
| 210 | + | u, err := url.Parse(dest) | |||
| 211 | + | if err != nil { | |||
| 212 | + | return "", dropped | |||
| 213 | + | } | |||
| 214 | + | switch strings.ToLower(u.Scheme) { | |||
| 215 | + | case "http", "https": | |||
| 216 | + | if u.Host == "" { | |||
| 217 | + | return "", dropped | |||
| 218 | + | } | |||
| 219 | + | return u.String(), external | |||
| 220 | + | case "": | |||
| 221 | + | default: | |||
| 222 | + | return "", dropped | |||
| 223 | + | } | |||
| 224 | + | if u.Host != "" || u.Opaque != "" || u.Path == "" { | |||
| 225 | + | return "", dropped | |||
| 226 | + | } | |||
| 227 | + | // A path in the repository: from the directory of the file, or from the | |||
| 228 | + | // root when it starts with a slash. path.Clean on a rooted path never | |||
| 229 | + | // leaves the root, so ../../ cannot climb out of the repository. | |||
| 230 | + | var rel string | |||
| 231 | + | if strings.HasPrefix(u.Path, "/") { | |||
| 232 | + | rel = path.Clean(u.Path) | |||
| 233 | + | } else { | |||
| 234 | + | rel = path.Clean("/" + path.Join(p.dir, u.Path)) | |||
| 235 | + | } | |||
| 236 | + | rel = strings.TrimPrefix(rel, "/") | |||
| 237 | + | href := repoHref(p.repo) | |||
| 238 | + | if rel != "" { | |||
| 239 | + | href = repoHref(p.repo, "blob", p.ref, rel) | |||
| 240 | + | } | |||
| 241 | + | if u.Fragment != "" { | |||
| 242 | + | href += "#" + url.PathEscape(u.Fragment) | |||
| 243 | + | } | |||
| 244 | + | return href, internal | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func isWebURL(s string) bool { | |||
| 248 | + | u, err := url.Parse(s) | |||
| 249 | + | if err != nil || u.Host == "" { | |||
| 250 | + | return false | |||
| 251 | + | } | |||
| 252 | + | sc := strings.ToLower(u.Scheme) | |||
| 253 | + | return sc == "http" || sc == "https" | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // kindAlt is the kind of altSpan. | |||
| 257 | + | var kindAlt = ast.NewNodeKind("AltSpan") | |||
| 258 | + | ||||
| 259 | + | // altSpan stands for an image: its alt text, in a span the stylesheet styles. | |||
| 260 | + | type altSpan struct { | |||
| 261 | + | ast.BaseInline | |||
| 262 | + | alt string | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func (n *altSpan) Kind() ast.NodeKind { return kindAlt } | |||
| 266 | + | ||||
| 267 | + | func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } | |||
| 268 | + | ||||
| 269 | + | // altRenderer renders altSpan nodes. | |||
| 270 | + | type altRenderer struct{} | |||
| 271 | + | ||||
| 272 | + | func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) { | |||
| 273 | + | r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { | |||
| 274 | + | if entering { | |||
| 275 | + | _, _ = w.WriteString(`<span class="alt">[`) | |||
| 276 | + | _, _ = w.Write(util.EscapeHTML([]byte(n.(*altSpan).alt))) | |||
| 277 | + | _, _ = w.WriteString(`]</span>`) | |||
| 278 | + | } | |||
| 279 | + | return ast.WalkContinue, nil | |||
| 280 | + | }) | |||
| 281 | + | } | |||
A internal/adapters/web/markdown_test.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func renderMD(t *testing.T, src string) (string, []Heading) { | |
| 14 | + | t.Helper() | |
| 15 | + | h, outline, err := newMarkdown().render([]byte(src), "demo", "main", "docs") | |
| 16 | + | require.NoError(t, err) | |
| 17 | + | return string(h), outline | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // AC4: raw HTML, javascript: links and SVG are neutralised. | |
| 21 | + | func TestMarkdownNeutralisation(t *testing.T) { | |
| 22 | + | tests := []struct { | |
| 23 | + | name, src string | |
| 24 | + | absent []string | |
| 25 | + | present []string | |
| 26 | + | }{ | |
| 27 | + | { | |
| 28 | + | "raw html block", "<script>alert(1)</script>\n\n<div onclick=\"x()\">hi</div>\n", | |
| 29 | + | []string{"<script", "<div", "onclick", "alert(1)</"}, nil, | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "raw inline html", "a <img src=x onerror=alert(1)> b <b>c</b> <iframe src=//evil></iframe>\n", | |
| 33 | + | []string{"<img", "onerror", "<iframe", "<b>"}, []string{"<p>a "}, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | "javascript link", "[x](javascript:alert(1)) [y](JaVaScRiPt:alert(1)) [z](java\tscript:alert(1))\n", | |
| 37 | + | []string{"javascript", "JaVaScRiPt", "href=\"java"}, []string{"<p>x y "}, | |
| 38 | + | }, | |
| 39 | + | { | |
| 40 | + | "data and other schemes", "[a](data:text/html,<script>1</script>) [b](vbscript:x) [c](file:///etc/passwd) [d](mailto:a@b.c) [e](//evil.example/x) [f](ftp://h/x)\n", | |
| 41 | + | []string{"href=\"data", "vbscript", "file:", "mailto", "evil.example", "ftp:"}, []string{"<p>a b c d e f</p>"}, | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | "autolinks", "<javascript:alert(1)> <https://ok.example/a> <me@example.org>\n", | |
| 45 | + | []string{"href=\"javascript", "mailto:"}, []string{`href="https://ok.example/a"`, "me@example.org"}, | |
| 46 | + | }, | |
| 47 | + | { | |
| 48 | + | "svg and images", "  \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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 generated file: collapsed
A internal/adapters/web/refs.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/url" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // resolveRef finds the ref a URL path starts with and the path that follows. | |
| 13 | + | // Ref names may contain "/" ("feature/x"), so the longest prefix of the path | |
| 14 | + | // that is the short name of a branch or tag wins. A branch wins over a tag of | |
| 15 | + | // the same name. rest has no leading slash and is "" when the path is the ref | |
| 16 | + | // alone. ok is false when no ref matches; the caller may then try a commit id. | |
| 17 | + | func resolveRef(refs []*pb.Ref, p string) (ref *pb.Ref, rest string, ok bool) { | |
| 18 | + | names := refsByName(refs) | |
| 19 | + | p = strings.Trim(p, "/") | |
| 20 | + | for end := len(p); end > 0; end = strings.LastIndex(p[:end], "/") { | |
| 21 | + | if r, found := names[p[:end]]; found { | |
| 22 | + | return r, strings.TrimPrefix(p[end:], "/"), true | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | return nil, "", false | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // refsByName indexes the branches and tags by short name. A branch wins over | |
| 29 | + | // a tag of the same name. | |
| 30 | + | func refsByName(refs []*pb.Ref) map[string]*pb.Ref { | |
| 31 | + | names := make(map[string]*pb.Ref, len(refs)) | |
| 32 | + | for _, r := range refs { | |
| 33 | + | k := r.GetKind() | |
| 34 | + | if k != pb.RefKind_REF_KIND_BRANCH && k != pb.RefKind_REF_KIND_TAG { | |
| 35 | + | continue | |
| 36 | + | } | |
| 37 | + | cur, dup := names[r.GetShortName()] | |
| 38 | + | if !dup || cur.GetKind() != pb.RefKind_REF_KIND_BRANCH { | |
| 39 | + | names[r.GetShortName()] = r | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | return names | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // escapePath escapes every segment of a slash-separated path for a URL path, | |
| 46 | + | // keeping the slashes. | |
| 47 | + | func escapePath(p string) string { | |
| 48 | + | segs := strings.Split(p, "/") | |
| 49 | + | for i, s := range segs { | |
| 50 | + | segs[i] = url.PathEscape(s) | |
| 51 | + | } | |
| 52 | + | return strings.Join(segs, "/") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // repoHref is the URL of a page of a repository: /<repo>/<kind>/<ref>/<path>. | |
| 56 | + | // kind, ref and path may be empty. | |
| 57 | + | func repoHref(repo string, parts ...string) string { | |
| 58 | + | var b strings.Builder | |
| 59 | + | b.WriteString("/" + url.PathEscape(repo)) | |
| 60 | + | for _, p := range parts { | |
| 61 | + | if p != "" { | |
| 62 | + | b.WriteString("/" + escapePath(strings.Trim(p, "/"))) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | return b.String() | |
| 66 | + | } | |
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "net/url" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // resolveRef finds the ref a URL path starts with and the path that follows. | |||
| 13 | + | // Ref names may contain "/" ("feature/x"), so the longest prefix of the path | |||
| 14 | + | // that is the short name of a branch or tag wins. A branch wins over a tag of | |||
| 15 | + | // the same name. rest has no leading slash and is "" when the path is the ref | |||
| 16 | + | // alone. ok is false when no ref matches; the caller may then try a commit id. | |||
| 17 | + | func resolveRef(refs []*pb.Ref, p string) (ref *pb.Ref, rest string, ok bool) { | |||
| 18 | + | names := refsByName(refs) | |||
| 19 | + | p = strings.Trim(p, "/") | |||
| 20 | + | for end := len(p); end > 0; end = strings.LastIndex(p[:end], "/") { | |||
| 21 | + | if r, found := names[p[:end]]; found { | |||
| 22 | + | return r, strings.TrimPrefix(p[end:], "/"), true | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | return nil, "", false | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // refsByName indexes the branches and tags by short name. A branch wins over | |||
| 29 | + | // a tag of the same name. | |||
| 30 | + | func refsByName(refs []*pb.Ref) map[string]*pb.Ref { | |||
| 31 | + | names := make(map[string]*pb.Ref, len(refs)) | |||
| 32 | + | for _, r := range refs { | |||
| 33 | + | k := r.GetKind() | |||
| 34 | + | if k != pb.RefKind_REF_KIND_BRANCH && k != pb.RefKind_REF_KIND_TAG { | |||
| 35 | + | continue | |||
| 36 | + | } | |||
| 37 | + | cur, dup := names[r.GetShortName()] | |||
| 38 | + | if !dup || cur.GetKind() != pb.RefKind_REF_KIND_BRANCH { | |||
| 39 | + | names[r.GetShortName()] = r | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | return names | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // escapePath escapes every segment of a slash-separated path for a URL path, | |||
| 46 | + | // keeping the slashes. | |||
| 47 | + | func escapePath(p string) string { | |||
| 48 | + | segs := strings.Split(p, "/") | |||
| 49 | + | for i, s := range segs { | |||
| 50 | + | segs[i] = url.PathEscape(s) | |||
| 51 | + | } | |||
| 52 | + | return strings.Join(segs, "/") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // repoHref is the URL of a page of a repository: /<repo>/<kind>/<ref>/<path>. | |||
| 56 | + | // kind, ref and path may be empty. | |||
| 57 | + | func repoHref(repo string, parts ...string) string { | |||
| 58 | + | var b strings.Builder | |||
| 59 | + | b.WriteString("/" + url.PathEscape(repo)) | |||
| 60 | + | for _, p := range parts { | |||
| 61 | + | if p != "" { | |||
| 62 | + | b.WriteString("/" + escapePath(strings.Trim(p, "/"))) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | return b.String() | |||
| 66 | + | } | |||
A internal/adapters/web/refs_test.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ||
| 10 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func ref(kind pb.RefKind, short string) *pb.Ref { | |
| 14 | + | return &pb.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, CommitId: "c-" + short} | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // AC3: a ref with slashes is found by the longest matching prefix. | |
| 18 | + | func TestResolveRef(t *testing.T) { | |
| 19 | + | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | |
| 20 | + | refs := []*pb.Ref{ | |
| 21 | + | ref(b, "main"), ref(b, "feature"), ref(b, "feature/x"), ref(b, "feature/x/y"), ref(tag, "v1/rc"), | |
| 22 | + | ref(pb.RefKind_REF_KIND_HEAD, "main"), ref(tag, "main"), | |
| 23 | + | } | |
| 24 | + | tests := []struct { | |
| 25 | + | path, ref, rest string | |
| 26 | + | kind pb.RefKind | |
| 27 | + | ok bool | |
| 28 | + | }{ | |
| 29 | + | {"main", "main", "", b, true}, | |
| 30 | + | {"main/src/a.go", "main", "src/a.go", b, true}, | |
| 31 | + | {"feature/x/y/z.txt", "feature/x/y", "z.txt", b, true}, | |
| 32 | + | {"feature/x/z.txt", "feature/x", "z.txt", b, true}, | |
| 33 | + | {"feature/other", "feature", "other", b, true}, | |
| 34 | + | {"/feature/x/", "feature/x", "", b, true}, | |
| 35 | + | {"v1/rc/README", "v1/rc", "README", tag, true}, | |
| 36 | + | {"v1/README", "", "", 0, false}, | |
| 37 | + | {"nope/main", "", "", 0, false}, | |
| 38 | + | {"", "", "", 0, false}, | |
| 39 | + | } | |
| 40 | + | for _, tt := range tests { | |
| 41 | + | t.Run(tt.path, func(t *testing.T) { | |
| 42 | + | r, rest, ok := resolveRef(refs, tt.path) | |
| 43 | + | assert.Equal(t, tt.ok, ok) | |
| 44 | + | if !ok { | |
| 45 | + | return | |
| 46 | + | } | |
| 47 | + | assert.Equal(t, tt.ref, r.GetShortName()) | |
| 48 | + | assert.Equal(t, tt.kind, r.GetKind(), "a branch wins over a tag of the same name") | |
| 49 | + | assert.Equal(t, tt.rest, rest) | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestRepoHref(t *testing.T) { | |
| 55 | + | assert.Equal(t, "/demo", repoHref("demo")) | |
| 56 | + | assert.Equal(t, "/demo/tree/feature/x/a%20b/c%23.go", repoHref("demo", "tree", "feature/x", "a b/c#.go")) | |
| 57 | + | assert.Equal(t, "/demo/blob/main/%3Fq", repoHref("demo", "blob", "main", "", "?q")) | |
| 58 | + | } | |
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ||||
| 10 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func ref(kind pb.RefKind, short string) *pb.Ref { | |||
| 14 | + | return &pb.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, CommitId: "c-" + short} | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // AC3: a ref with slashes is found by the longest matching prefix. | |||
| 18 | + | func TestResolveRef(t *testing.T) { | |||
| 19 | + | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | |||
| 20 | + | refs := []*pb.Ref{ | |||
| 21 | + | ref(b, "main"), ref(b, "feature"), ref(b, "feature/x"), ref(b, "feature/x/y"), ref(tag, "v1/rc"), | |||
| 22 | + | ref(pb.RefKind_REF_KIND_HEAD, "main"), ref(tag, "main"), | |||
| 23 | + | } | |||
| 24 | + | tests := []struct { | |||
| 25 | + | path, ref, rest string | |||
| 26 | + | kind pb.RefKind | |||
| 27 | + | ok bool | |||
| 28 | + | }{ | |||
| 29 | + | {"main", "main", "", b, true}, | |||
| 30 | + | {"main/src/a.go", "main", "src/a.go", b, true}, | |||
| 31 | + | {"feature/x/y/z.txt", "feature/x/y", "z.txt", b, true}, | |||
| 32 | + | {"feature/x/z.txt", "feature/x", "z.txt", b, true}, | |||
| 33 | + | {"feature/other", "feature", "other", b, true}, | |||
| 34 | + | {"/feature/x/", "feature/x", "", b, true}, | |||
| 35 | + | {"v1/rc/README", "v1/rc", "README", tag, true}, | |||
| 36 | + | {"v1/README", "", "", 0, false}, | |||
| 37 | + | {"nope/main", "", "", 0, false}, | |||
| 38 | + | {"", "", "", 0, false}, | |||
| 39 | + | } | |||
| 40 | + | for _, tt := range tests { | |||
| 41 | + | t.Run(tt.path, func(t *testing.T) { | |||
| 42 | + | r, rest, ok := resolveRef(refs, tt.path) | |||
| 43 | + | assert.Equal(t, tt.ok, ok) | |||
| 44 | + | if !ok { | |||
| 45 | + | return | |||
| 46 | + | } | |||
| 47 | + | assert.Equal(t, tt.ref, r.GetShortName()) | |||
| 48 | + | assert.Equal(t, tt.kind, r.GetKind(), "a branch wins over a tag of the same name") | |||
| 49 | + | assert.Equal(t, tt.rest, rest) | |||
| 50 | + | }) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestRepoHref(t *testing.T) { | |||
| 55 | + | assert.Equal(t, "/demo", repoHref("demo")) | |||
| 56 | + | assert.Equal(t, "/demo/tree/feature/x/a%20b/c%23.go", repoHref("demo", "tree", "feature/x", "a b/c#.go")) | |||
| 57 | + | assert.Equal(t, "/demo/blob/main/%3Fq", repoHref("demo", "blob", "main", "", "?q")) | |||
| 58 | + | } | |||
A internal/adapters/web/refspage.go +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "sort" | |
| 9 | + | ||
| 10 | + | "github.com/a-h/templ" | |
| 11 | + | ||
| 12 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // refs lists the branches and the tags of a repository with the commit each | |
| 16 | + | // designates: branches by name with the default first, tags newest first. | |
| 17 | + | func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 18 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 19 | + | if err != nil { | |
| 20 | + | return nil, 0, err | |
| 21 | + | } | |
| 22 | + | all := append(append([]*pb.Ref(nil), d.branches...), d.tags...) | |
| 23 | + | commits := s.commitsOf(ctx, d.name(), all) | |
| 24 | + | ||
| 25 | + | def := d.repo.GetDefaultBranch() | |
| 26 | + | p := RefsPage{Default: def} | |
| 27 | + | branches := append([]*pb.Ref(nil), d.branches...) | |
| 28 | + | sort.SliceStable(branches, func(i, j int) bool { | |
| 29 | + | a, b := branches[i].GetShortName() == def, branches[j].GetShortName() == def | |
| 30 | + | return a && !b | |
| 31 | + | }) | |
| 32 | + | for i, b := range branches { | |
| 33 | + | p.Branches = append(p.Branches, s.refRow(d, b, commits, b.GetShortName() == def, i == 0)) | |
| 34 | + | } | |
| 35 | + | tags := append([]*pb.Ref(nil), d.tags...) | |
| 36 | + | sort.SliceStable(tags, func(i, j int) bool { | |
| 37 | + | return commits[tags[i].GetCommitId()].at > commits[tags[j].GetCommitId()].at | |
| 38 | + | }) | |
| 39 | + | for i, t := range tags { | |
| 40 | + | p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0)) | |
| 41 | + | } | |
| 42 | + | if len(tags) > 0 { | |
| 43 | + | p.Latest = tags[0].GetShortName() | |
| 44 | + | } | |
| 45 | + | if d.cut { | |
| 46 | + | p.Note = "this repository has more refs than are listed here." | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | l := s.repoLayout(ctx, d, "refs") | |
| 50 | + | l.Title = "refs · " + d.name() + " · " + s.instance | |
| 51 | + | l.Crumbs[0].Current = false | |
| 52 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true}) | |
| 53 | + | l.Status = Status{ | |
| 54 | + | Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"), | |
| 55 | + | Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags), | |
| 56 | + | } | |
| 57 | + | l.HasRail, l.RailSum = true, "refs · legend" | |
| 58 | + | return Refs(l, p), http.StatusOK, nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | |
| 62 | + | c := commits[r.GetCommitId()] | |
| 63 | + | row := RefRow{ | |
| 64 | + | Name: r.GetShortName(), Href: repoHref(d.name(), "tree", r.GetShortName()), | |
| 65 | + | Short: short(r.GetCommitId()), Subject: c.subject, When: c.when, WhenAt: c.whenAt, | |
| 66 | + | Default: isDefault, Cursor: cursor, | |
| 67 | + | } | |
| 68 | + | if r.GetCommitId() != "" { | |
| 69 | + | row.CHref = repoHref(d.name(), "commit", r.GetCommitId()) | |
| 70 | + | } | |
| 71 | + | return row | |
| 72 | + | } | |
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net/http" | |||
| 8 | + | "sort" | |||
| 9 | + | ||||
| 10 | + | "github.com/a-h/templ" | |||
| 11 | + | ||||
| 12 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // refs lists the branches and the tags of a repository with the commit each | |||
| 16 | + | // designates: branches by name with the default first, tags newest first. | |||
| 17 | + | func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 18 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 19 | + | if err != nil { | |||
| 20 | + | return nil, 0, err | |||
| 21 | + | } | |||
| 22 | + | all := append(append([]*pb.Ref(nil), d.branches...), d.tags...) | |||
| 23 | + | commits := s.commitsOf(ctx, d.name(), all) | |||
| 24 | + | ||||
| 25 | + | def := d.repo.GetDefaultBranch() | |||
| 26 | + | p := RefsPage{Default: def} | |||
| 27 | + | branches := append([]*pb.Ref(nil), d.branches...) | |||
| 28 | + | sort.SliceStable(branches, func(i, j int) bool { | |||
| 29 | + | a, b := branches[i].GetShortName() == def, branches[j].GetShortName() == def | |||
| 30 | + | return a && !b | |||
| 31 | + | }) | |||
| 32 | + | for i, b := range branches { | |||
| 33 | + | p.Branches = append(p.Branches, s.refRow(d, b, commits, b.GetShortName() == def, i == 0)) | |||
| 34 | + | } | |||
| 35 | + | tags := append([]*pb.Ref(nil), d.tags...) | |||
| 36 | + | sort.SliceStable(tags, func(i, j int) bool { | |||
| 37 | + | return commits[tags[i].GetCommitId()].at > commits[tags[j].GetCommitId()].at | |||
| 38 | + | }) | |||
| 39 | + | for i, t := range tags { | |||
| 40 | + | p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0)) | |||
| 41 | + | } | |||
| 42 | + | if len(tags) > 0 { | |||
| 43 | + | p.Latest = tags[0].GetShortName() | |||
| 44 | + | } | |||
| 45 | + | if d.cut { | |||
| 46 | + | p.Note = "this repository has more refs than are listed here." | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | l := s.repoLayout(ctx, d, "refs") | |||
| 50 | + | l.Title = "refs · " + d.name() + " · " + s.instance | |||
| 51 | + | l.Crumbs[0].Current = false | |||
| 52 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true}) | |||
| 53 | + | l.Status = Status{ | |||
| 54 | + | Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"), | |||
| 55 | + | Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags), | |||
| 56 | + | } | |||
| 57 | + | l.HasRail, l.RailSum = true, "refs · legend" | |||
| 58 | + | return Refs(l, p), http.StatusOK, nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | |||
| 62 | + | c := commits[r.GetCommitId()] | |||
| 63 | + | row := RefRow{ | |||
| 64 | + | Name: r.GetShortName(), Href: repoHref(d.name(), "tree", r.GetShortName()), | |||
| 65 | + | Short: short(r.GetCommitId()), Subject: c.subject, When: c.when, WhenAt: c.whenAt, | |||
| 66 | + | Default: isDefault, Cursor: cursor, | |||
| 67 | + | } | |||
| 68 | + | if r.GetCommitId() != "" { | |||
| 69 | + | row.CHref = repoHref(d.name(), "commit", r.GetCommitId()) | |||
| 70 | + | } | |||
| 71 | + | return row | |||
| 72 | + | } | |||
A internal/adapters/web/repo.go +218 -0
| @@ -0,0 +1,218 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "slices" | |
| 8 | + | "sort" | |
| 9 | + | "sync" | |
| 10 | + | ||
| 11 | + | "connectrpc.com/connect" | |
| 12 | + | ||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // maxRefs bounds the refs one page reads. A repository with more has its | |
| 17 | + | // lists cut, and the pages say so. | |
| 18 | + | const maxRefs = 1000 | |
| 19 | + | ||
| 20 | + | // repoData is what the pages of one repository start from: its metadata and | |
| 21 | + | // its refs, read once per request. | |
| 22 | + | type repoData struct { | |
| 23 | + | repo *pb.Repo | |
| 24 | + | branches []*pb.Ref | |
| 25 | + | tags []*pb.Ref | |
| 26 | + | // cut is true when the repository has more refs than maxRefs. | |
| 27 | + | cut bool | |
| 28 | + | // head is the branch the repository opens on: its default branch, or the | |
| 29 | + | // first one when the default does not exist. nil for an empty repository. | |
| 30 | + | head *pb.Ref | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // loadRepo reads a repository and its refs. A missing, private or invalidly | |
| 34 | + | // named repository is a not_found / invalid_argument error of the API, which | |
| 35 | + | // the caller turns into the 404 page. | |
| 36 | + | func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) { | |
| 37 | + | res, err := s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name})) | |
| 38 | + | if err != nil { | |
| 39 | + | return nil, err | |
| 40 | + | } | |
| 41 | + | d := &repoData{repo: res.Msg.GetRepo()} | |
| 42 | + | token, n := "", 0 | |
| 43 | + | for { | |
| 44 | + | page, err := s.cfg.Git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{ | |
| 45 | + | Repo: name, PageSize: pageSize, PageToken: token, | |
| 46 | + | Kinds: []pb.RefKind{pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG}, | |
| 47 | + | })) | |
| 48 | + | if err != nil { | |
| 49 | + | return nil, err | |
| 50 | + | } | |
| 51 | + | for _, r := range page.Msg.GetRefs() { | |
| 52 | + | switch r.GetKind() { | |
| 53 | + | case pb.RefKind_REF_KIND_BRANCH: | |
| 54 | + | d.branches = append(d.branches, r) | |
| 55 | + | case pb.RefKind_REF_KIND_TAG: | |
| 56 | + | d.tags = append(d.tags, r) | |
| 57 | + | } | |
| 58 | + | n++ | |
| 59 | + | } | |
| 60 | + | if token = page.Msg.GetNextPageToken(); token == "" { | |
| 61 | + | break | |
| 62 | + | } | |
| 63 | + | if n >= maxRefs { | |
| 64 | + | d.cut = true | |
| 65 | + | break | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | for _, b := range d.branches { | |
| 69 | + | if b.GetShortName() == d.repo.GetDefaultBranch() { | |
| 70 | + | d.head = b | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | if d.head == nil && len(d.branches) > 0 { | |
| 74 | + | d.head = d.branches[0] | |
| 75 | + | } | |
| 76 | + | return d, nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // name is the repository name. | |
| 80 | + | func (d *repoData) name() string { return d.repo.GetName() } | |
| 81 | + | ||
| 82 | + | // layout is the Layout of the pages of a repository: the sections, the ref | |
| 83 | + | // switcher on the head ref and the ":" targets. The caller sets the title, | |
| 84 | + | // the crumbs, the status and the rail. | |
| 85 | + | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | |
| 86 | + | l := s.base() | |
| 87 | + | l.Repo = d.name() | |
| 88 | + | def := d.repo.GetDefaultBranch() | |
| 89 | + | if d.head != nil { | |
| 90 | + | def = d.head.GetShortName() | |
| 91 | + | l.Ref, l.RefPath = def, def | |
| 92 | + | l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId()) | |
| 93 | + | } | |
| 94 | + | mk := func(key, post, href, id string) Section { | |
| 95 | + | return Section{Key: key, Post: post, Href: href, Current: section == id} | |
| 96 | + | } | |
| 97 | + | l.Sections = []Section{ | |
| 98 | + | mk("s", "ummary", repoHref(d.name()), "summary"), | |
| 99 | + | mk("t", "ree", repoHref(d.name(), "tree", def), "tree"), | |
| 100 | + | mk("c", "ommits", repoHref(d.name(), "commits", def), "commits"), | |
| 101 | + | mk("r", "efs", repoHref(d.name(), "refs"), "refs"), | |
| 102 | + | } | |
| 103 | + | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | |
| 104 | + | ||
| 105 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"}) | |
| 106 | + | for _, sec := range l.Sections { | |
| 107 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: sec.Key + sec.Post, Href: sec.Href}) | |
| 108 | + | } | |
| 109 | + | if l.Ref != "" { | |
| 110 | + | for _, b := range d.branches { | |
| 111 | + | it := RefItem{Name: b.GetShortName(), Href: repoHref(d.name(), "tree", b.GetShortName()), Current: b == d.head} | |
| 112 | + | l.Heads = append(l.Heads, it) | |
| 113 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href}) | |
| 114 | + | } | |
| 115 | + | for _, t := range d.tags { | |
| 116 | + | it := RefItem{Name: t.GetShortName(), Href: repoHref(d.name(), "tree", t.GetShortName())} | |
| 117 | + | l.Tags = append(l.Tags, it) | |
| 118 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href}) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | // Other repositories, when the list is reachable; the page does not | |
| 122 | + | // depend on it. | |
| 123 | + | if repos, err := s.listRepos(ctx); err == nil { | |
| 124 | + | l.Cmd = append(l.Cmd, repoCmds(repos, d.name())...) | |
| 125 | + | } | |
| 126 | + | return l | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // cloneURL is the HTTPS clone URL of a repository. | |
| 130 | + | func (s *Server) cloneURL(name string) string { | |
| 131 | + | return s.cfg.PublicURL + "/" + name + ".git" | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // refCommit is what a ref shows: the commit it designates. | |
| 135 | + | type refCommit struct { | |
| 136 | + | short, subject, when, whenAt string | |
| 137 | + | at int64 // unix time, to sort tags | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // commitsOf reads the head commit of each ref, a few at a time. A ref whose | |
| 141 | + | // commit cannot be read (a tag on a tree, say) has no entry. | |
| 142 | + | func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map[string]refCommit { | |
| 143 | + | const workers = 8 | |
| 144 | + | var ( | |
| 145 | + | mu sync.Mutex | |
| 146 | + | out = make(map[string]refCommit, len(refs)) | |
| 147 | + | sem = make(chan struct{}, workers) | |
| 148 | + | wg sync.WaitGroup | |
| 149 | + | ) | |
| 150 | + | now := s.cfg.Now() | |
| 151 | + | for _, r := range refs { | |
| 152 | + | id := r.GetCommitId() | |
| 153 | + | if id == "" { | |
| 154 | + | continue | |
| 155 | + | } | |
| 156 | + | mu.Lock() | |
| 157 | + | _, done := out[id] | |
| 158 | + | if !done { | |
| 159 | + | out[id] = refCommit{} // reserved: one lookup per commit | |
| 160 | + | } | |
| 161 | + | mu.Unlock() | |
| 162 | + | if done { | |
| 163 | + | continue | |
| 164 | + | } | |
| 165 | + | sem <- struct{}{} | |
| 166 | + | wg.Add(1) | |
| 167 | + | go func() { | |
| 168 | + | defer func() { <-sem; wg.Done() }() | |
| 169 | + | // By id, not by name: a branch and a tag may share a name. | |
| 170 | + | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1})) | |
| 171 | + | var rc refCommit | |
| 172 | + | if err == nil && len(res.Msg.GetCommits()) == 1 { | |
| 173 | + | c := res.Msg.GetCommits()[0] | |
| 174 | + | rc.short, rc.subject = short(c.GetId()), c.GetSummary() | |
| 175 | + | if t := c.GetCommitter().GetTime(); t.IsValid() { | |
| 176 | + | rc.at = t.GetSeconds() | |
| 177 | + | rc.when, rc.whenAt = ago(now, t.AsTime()), rfc3339(t.AsTime()) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | mu.Lock() | |
| 181 | + | out[id] = rc | |
| 182 | + | mu.Unlock() | |
| 183 | + | }() | |
| 184 | + | } | |
| 185 | + | wg.Wait() | |
| 186 | + | return out | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // namesAt maps commit ids to the names of the refs that designate them. | |
| 190 | + | func namesAt(refs []*pb.Ref) map[string][]string { | |
| 191 | + | m := map[string][]string{} | |
| 192 | + | for _, r := range refs { | |
| 193 | + | m[r.GetCommitId()] = append(m[r.GetCommitId()], r.GetShortName()) | |
| 194 | + | } | |
| 195 | + | for _, v := range m { | |
| 196 | + | sort.Strings(v) | |
| 197 | + | } | |
| 198 | + | return m | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // emptyState is the page of a repository without a branch. | |
| 202 | + | func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage) { | |
| 203 | + | l := s.repoLayout(ctx, d, "summary") | |
| 204 | + | l.Title = d.name() + " · " + s.instance | |
| 205 | + | l.Sections = nil | |
| 206 | + | l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" }) | |
| 207 | + | l.Status = Status{Mode: "empty", Context: "empty repository · 0 commits", Hints: "g h home · ? help"} | |
| 208 | + | branch := d.repo.GetDefaultBranch() | |
| 209 | + | return l, StatePage{ | |
| 210 | + | Code: "empty", Title: d.name() + " is empty", | |
| 211 | + | Lines: []string{"no commits yet. push a branch to create one."}, | |
| 212 | + | Clone: &Clone{ID: "clone-" + d.name(), Lines: []string{ | |
| 213 | + | "git remote add origin " + s.cloneURL(d.name()), | |
| 214 | + | "git push -u origin " + branch, | |
| 215 | + | }}, | |
| 216 | + | Back: []Link{{Label: "home", Href: "/"}}, | |
| 217 | + | } | |
| 218 | + | } | |
| @@ -0,0 +1,218 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "slices" | |||
| 8 | + | "sort" | |||
| 9 | + | "sync" | |||
| 10 | + | ||||
| 11 | + | "connectrpc.com/connect" | |||
| 12 | + | ||||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // maxRefs bounds the refs one page reads. A repository with more has its | |||
| 17 | + | // lists cut, and the pages say so. | |||
| 18 | + | const maxRefs = 1000 | |||
| 19 | + | ||||
| 20 | + | // repoData is what the pages of one repository start from: its metadata and | |||
| 21 | + | // its refs, read once per request. | |||
| 22 | + | type repoData struct { | |||
| 23 | + | repo *pb.Repo | |||
| 24 | + | branches []*pb.Ref | |||
| 25 | + | tags []*pb.Ref | |||
| 26 | + | // cut is true when the repository has more refs than maxRefs. | |||
| 27 | + | cut bool | |||
| 28 | + | // head is the branch the repository opens on: its default branch, or the | |||
| 29 | + | // first one when the default does not exist. nil for an empty repository. | |||
| 30 | + | head *pb.Ref | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // loadRepo reads a repository and its refs. A missing, private or invalidly | |||
| 34 | + | // named repository is a not_found / invalid_argument error of the API, which | |||
| 35 | + | // the caller turns into the 404 page. | |||
| 36 | + | func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) { | |||
| 37 | + | res, err := s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name})) | |||
| 38 | + | if err != nil { | |||
| 39 | + | return nil, err | |||
| 40 | + | } | |||
| 41 | + | d := &repoData{repo: res.Msg.GetRepo()} | |||
| 42 | + | token, n := "", 0 | |||
| 43 | + | for { | |||
| 44 | + | page, err := s.cfg.Git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{ | |||
| 45 | + | Repo: name, PageSize: pageSize, PageToken: token, | |||
| 46 | + | Kinds: []pb.RefKind{pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG}, | |||
| 47 | + | })) | |||
| 48 | + | if err != nil { | |||
| 49 | + | return nil, err | |||
| 50 | + | } | |||
| 51 | + | for _, r := range page.Msg.GetRefs() { | |||
| 52 | + | switch r.GetKind() { | |||
| 53 | + | case pb.RefKind_REF_KIND_BRANCH: | |||
| 54 | + | d.branches = append(d.branches, r) | |||
| 55 | + | case pb.RefKind_REF_KIND_TAG: | |||
| 56 | + | d.tags = append(d.tags, r) | |||
| 57 | + | } | |||
| 58 | + | n++ | |||
| 59 | + | } | |||
| 60 | + | if token = page.Msg.GetNextPageToken(); token == "" { | |||
| 61 | + | break | |||
| 62 | + | } | |||
| 63 | + | if n >= maxRefs { | |||
| 64 | + | d.cut = true | |||
| 65 | + | break | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | for _, b := range d.branches { | |||
| 69 | + | if b.GetShortName() == d.repo.GetDefaultBranch() { | |||
| 70 | + | d.head = b | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | if d.head == nil && len(d.branches) > 0 { | |||
| 74 | + | d.head = d.branches[0] | |||
| 75 | + | } | |||
| 76 | + | return d, nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // name is the repository name. | |||
| 80 | + | func (d *repoData) name() string { return d.repo.GetName() } | |||
| 81 | + | ||||
| 82 | + | // layout is the Layout of the pages of a repository: the sections, the ref | |||
| 83 | + | // switcher on the head ref and the ":" targets. The caller sets the title, | |||
| 84 | + | // the crumbs, the status and the rail. | |||
| 85 | + | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | |||
| 86 | + | l := s.base() | |||
| 87 | + | l.Repo = d.name() | |||
| 88 | + | def := d.repo.GetDefaultBranch() | |||
| 89 | + | if d.head != nil { | |||
| 90 | + | def = d.head.GetShortName() | |||
| 91 | + | l.Ref, l.RefPath = def, def | |||
| 92 | + | l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId()) | |||
| 93 | + | } | |||
| 94 | + | mk := func(key, post, href, id string) Section { | |||
| 95 | + | return Section{Key: key, Post: post, Href: href, Current: section == id} | |||
| 96 | + | } | |||
| 97 | + | l.Sections = []Section{ | |||
| 98 | + | mk("s", "ummary", repoHref(d.name()), "summary"), | |||
| 99 | + | mk("t", "ree", repoHref(d.name(), "tree", def), "tree"), | |||
| 100 | + | mk("c", "ommits", repoHref(d.name(), "commits", def), "commits"), | |||
| 101 | + | mk("r", "efs", repoHref(d.name(), "refs"), "refs"), | |||
| 102 | + | } | |||
| 103 | + | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | |||
| 104 | + | ||||
| 105 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"}) | |||
| 106 | + | for _, sec := range l.Sections { | |||
| 107 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: sec.Key + sec.Post, Href: sec.Href}) | |||
| 108 | + | } | |||
| 109 | + | if l.Ref != "" { | |||
| 110 | + | for _, b := range d.branches { | |||
| 111 | + | it := RefItem{Name: b.GetShortName(), Href: repoHref(d.name(), "tree", b.GetShortName()), Current: b == d.head} | |||
| 112 | + | l.Heads = append(l.Heads, it) | |||
| 113 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href}) | |||
| 114 | + | } | |||
| 115 | + | for _, t := range d.tags { | |||
| 116 | + | it := RefItem{Name: t.GetShortName(), Href: repoHref(d.name(), "tree", t.GetShortName())} | |||
| 117 | + | l.Tags = append(l.Tags, it) | |||
| 118 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href}) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | // Other repositories, when the list is reachable; the page does not | |||
| 122 | + | // depend on it. | |||
| 123 | + | if repos, err := s.listRepos(ctx); err == nil { | |||
| 124 | + | l.Cmd = append(l.Cmd, repoCmds(repos, d.name())...) | |||
| 125 | + | } | |||
| 126 | + | return l | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // cloneURL is the HTTPS clone URL of a repository. | |||
| 130 | + | func (s *Server) cloneURL(name string) string { | |||
| 131 | + | return s.cfg.PublicURL + "/" + name + ".git" | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // refCommit is what a ref shows: the commit it designates. | |||
| 135 | + | type refCommit struct { | |||
| 136 | + | short, subject, when, whenAt string | |||
| 137 | + | at int64 // unix time, to sort tags | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // commitsOf reads the head commit of each ref, a few at a time. A ref whose | |||
| 141 | + | // commit cannot be read (a tag on a tree, say) has no entry. | |||
| 142 | + | func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map[string]refCommit { | |||
| 143 | + | const workers = 8 | |||
| 144 | + | var ( | |||
| 145 | + | mu sync.Mutex | |||
| 146 | + | out = make(map[string]refCommit, len(refs)) | |||
| 147 | + | sem = make(chan struct{}, workers) | |||
| 148 | + | wg sync.WaitGroup | |||
| 149 | + | ) | |||
| 150 | + | now := s.cfg.Now() | |||
| 151 | + | for _, r := range refs { | |||
| 152 | + | id := r.GetCommitId() | |||
| 153 | + | if id == "" { | |||
| 154 | + | continue | |||
| 155 | + | } | |||
| 156 | + | mu.Lock() | |||
| 157 | + | _, done := out[id] | |||
| 158 | + | if !done { | |||
| 159 | + | out[id] = refCommit{} // reserved: one lookup per commit | |||
| 160 | + | } | |||
| 161 | + | mu.Unlock() | |||
| 162 | + | if done { | |||
| 163 | + | continue | |||
| 164 | + | } | |||
| 165 | + | sem <- struct{}{} | |||
| 166 | + | wg.Add(1) | |||
| 167 | + | go func() { | |||
| 168 | + | defer func() { <-sem; wg.Done() }() | |||
| 169 | + | // By id, not by name: a branch and a tag may share a name. | |||
| 170 | + | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1})) | |||
| 171 | + | var rc refCommit | |||
| 172 | + | if err == nil && len(res.Msg.GetCommits()) == 1 { | |||
| 173 | + | c := res.Msg.GetCommits()[0] | |||
| 174 | + | rc.short, rc.subject = short(c.GetId()), c.GetSummary() | |||
| 175 | + | if t := c.GetCommitter().GetTime(); t.IsValid() { | |||
| 176 | + | rc.at = t.GetSeconds() | |||
| 177 | + | rc.when, rc.whenAt = ago(now, t.AsTime()), rfc3339(t.AsTime()) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | mu.Lock() | |||
| 181 | + | out[id] = rc | |||
| 182 | + | mu.Unlock() | |||
| 183 | + | }() | |||
| 184 | + | } | |||
| 185 | + | wg.Wait() | |||
| 186 | + | return out | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // namesAt maps commit ids to the names of the refs that designate them. | |||
| 190 | + | func namesAt(refs []*pb.Ref) map[string][]string { | |||
| 191 | + | m := map[string][]string{} | |||
| 192 | + | for _, r := range refs { | |||
| 193 | + | m[r.GetCommitId()] = append(m[r.GetCommitId()], r.GetShortName()) | |||
| 194 | + | } | |||
| 195 | + | for _, v := range m { | |||
| 196 | + | sort.Strings(v) | |||
| 197 | + | } | |||
| 198 | + | return m | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // emptyState is the page of a repository without a branch. | |||
| 202 | + | func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage) { | |||
| 203 | + | l := s.repoLayout(ctx, d, "summary") | |||
| 204 | + | l.Title = d.name() + " · " + s.instance | |||
| 205 | + | l.Sections = nil | |||
| 206 | + | l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" }) | |||
| 207 | + | l.Status = Status{Mode: "empty", Context: "empty repository · 0 commits", Hints: "g h home · ? help"} | |||
| 208 | + | branch := d.repo.GetDefaultBranch() | |||
| 209 | + | return l, StatePage{ | |||
| 210 | + | Code: "empty", Title: d.name() + " is empty", | |||
| 211 | + | Lines: []string{"no commits yet. push a branch to create one."}, | |||
| 212 | + | Clone: &Clone{ID: "clone-" + d.name(), Lines: []string{ | |||
| 213 | + | "git remote add origin " + s.cloneURL(d.name()), | |||
| 214 | + | "git push -u origin " + branch, | |||
| 215 | + | }}, | |||
| 216 | + | Back: []Link{{Label: "home", Href: "/"}}, | |||
| 217 | + | } | |||
| 218 | + | } | |||
A internal/adapters/web/shell.templ +158 -0
| @@ -0,0 +1,158 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // Shell is the page: head, the two bars, the work area (content and rail), | |
| 6 | + | // the overlays and the key map. The content is the children. | |
| 7 | + | templ Shell(l Layout, rail templ.Component) { | |
| 8 | + | <!DOCTYPE html> | |
| 9 | + | <html lang="en"> | |
| 10 | + | <head> | |
| 11 | + | <meta charset="utf-8"/> | |
| 12 | + | <meta name="viewport" content="width=device-width, initial-scale=1"/> | |
| 13 | + | <meta name="color-scheme" content="light dark"/> | |
| 14 | + | <title>{ l.Title }</title> | |
| 15 | + | <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/> | |
| 16 | + | <script src={ l.Assets.HTMX } defer></script> | |
| 17 | + | <script src={ l.Assets.Live } defer></script> | |
| 18 | + | <script src={ l.Assets.SSE } defer></script> | |
| 19 | + | <script src={ l.Assets.App } defer></script> | |
| 20 | + | </head> | |
| 21 | + | <body data-ref={ l.RefPath } data-sha={ l.SHA } data-sel=""> | |
| 22 | + | <a class="skip" href="#main">skip to content</a> | |
| 23 | + | @Topbar(l) | |
| 24 | + | <div class={ "work", templ.KV("has-rail", l.HasRail), templ.KV("read", l.Read) }> | |
| 25 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 26 | + | { children... } | |
| 27 | + | </main> | |
| 28 | + | if l.HasRail && rail != nil { | |
| 29 | + | <aside class="rail" aria-label="Context"> | |
| 30 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 31 | + | <summary>{ l.RailSum }</summary> | |
| 32 | + | @rail | |
| 33 | + | </details> | |
| 34 | + | </aside> | |
| 35 | + | } | |
| 36 | + | </div> | |
| 37 | + | @OverlayHelp() | |
| 38 | + | @OverlayRefs(l) | |
| 39 | + | @OverlayCmd(l.Cmd) | |
| 40 | + | @Statusbar(l) | |
| 41 | + | @Keys() | |
| 42 | + | </body> | |
| 43 | + | </html> | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Topbar is the top bar, neferbar style: the one accent block (instance) | |
| 47 | + | // fading into the black bar, the path, the repo sections, the ref switcher. | |
| 48 | + | templ Topbar(l Layout) { | |
| 49 | + | <header class="bar top"> | |
| 50 | + | <a class="blk" href="/" data-g="h" title="home (g h)">{ l.Instance }</a><span class="fade" aria-hidden="true"></span> | |
| 51 | + | <nav class="crumbs" aria-label="Path"> | |
| 52 | + | <ol> | |
| 53 | + | for _, c := range l.Crumbs { | |
| 54 | + | <li> | |
| 55 | + | if c.Href != "" { | |
| 56 | + | <a | |
| 57 | + | href={ templ.SafeURL(c.Href) } | |
| 58 | + | if c.Current { | |
| 59 | + | aria-current="page" | |
| 60 | + | } | |
| 61 | + | >{ c.Label }</a> | |
| 62 | + | } else { | |
| 63 | + | <span aria-current="page">{ c.Label }</span> | |
| 64 | + | } | |
| 65 | + | </li> | |
| 66 | + | } | |
| 67 | + | </ol> | |
| 68 | + | </nav> | |
| 69 | + | if len(l.Sections) > 0 { | |
| 70 | + | <nav class="secs" aria-label="Sections"> | |
| 71 | + | <ul> | |
| 72 | + | for _, s := range l.Sections { | |
| 73 | + | <li> | |
| 74 | + | <a | |
| 75 | + | href={ templ.SafeURL(s.Href) } | |
| 76 | + | data-key={ s.Key } | |
| 77 | + | aria-keyshortcuts={ "g " + s.Key } | |
| 78 | + | if s.Current { | |
| 79 | + | aria-current="page" | |
| 80 | + | } | |
| 81 | + | >{ s.Key }{ s.Post }</a> | |
| 82 | + | </li> | |
| 83 | + | } | |
| 84 | + | </ul> | |
| 85 | + | </nav> | |
| 86 | + | } | |
| 87 | + | if l.Ref != "" { | |
| 88 | + | <button | |
| 89 | + | type="button" | |
| 90 | + | class="ref" | |
| 91 | + | aria-haspopup="dialog" | |
| 92 | + | aria-controls="panel-refs" | |
| 93 | + | aria-keyshortcuts="b" | |
| 94 | + | title="switch ref (b)" | |
| 95 | + | hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))" | |
| 96 | + | >{ l.Ref }<span class="sha">{ "@" + l.Short }</span></button> | |
| 97 | + | } | |
| 98 | + | </header> | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Statusbar is the bottom bar: mode, context, the ":" command prompt, the | |
| 102 | + | // source link (AGPL §13), hints, live state and the cursor position. Plain | |
| 103 | + | // text only: the top bar carries the accent. | |
| 104 | + | templ Statusbar(l Layout) { | |
| 105 | + | <footer class="bar status" aria-label="Status"> | |
| 106 | + | <span class="mode">{ l.Status.Mode }</span> | |
| 107 | + | <span class="st-ctx">{ l.Status.Context }</span> | |
| 108 | + | <button | |
| 109 | + | type="button" | |
| 110 | + | class="st-cmd" | |
| 111 | + | aria-haspopup="dialog" | |
| 112 | + | aria-controls="panel-cmd" | |
| 113 | + | aria-keyshortcuts=":" | |
| 114 | + | hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))" | |
| 115 | + | ><span class="hi">:</span> go to…</button> | |
| 116 | + | if l.Status.Blob { | |
| 117 | + | <span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span> | |
| 118 | + | } | |
| 119 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span> | |
| 120 | + | <span class="st-hints">{ l.Status.Hints }</span> | |
| 121 | + | @Footer(l) | |
| 122 | + | @Live(l.Live) | |
| 123 | + | if l.Status.Cursor { | |
| 124 | + | <span | |
| 125 | + | class="st-pos num" | |
| 126 | + | title="cursor position" | |
| 127 | + | :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()" | |
| 128 | + | >{ cursorText(l.Status.Count) }</span> | |
| 129 | + | } | |
| 130 | + | </footer> | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Footer is the version and the link to the source of the running version, | |
| 134 | + | // which the AGPL §13 asks every user of the service to be offered. | |
| 135 | + | templ Footer(l Layout) { | |
| 136 | + | <span class="st-src"><span class="st-ver">ermite { l.Version } · </span><a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a></span> | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Live is the connection state of the event stream. Without the stream it | |
| 140 | + | // reads "offline" and does nothing. | |
| 141 | + | templ Live(on bool) { | |
| 142 | + | if on { | |
| 143 | + | <span | |
| 144 | + | class="st-live" | |
| 145 | + | data-state="offline" | |
| 146 | + | role="status" | |
| 147 | + | hx-sse:connect="/-/events" | |
| 148 | + | hx-swap="none" | |
| 149 | + | :text="data.state" | |
| 150 | + | :title="data.state === 'live' ? 'receiving pushes over SSE' : 'not connected to the event stream'" | |
| 151 | + | hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 152 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 153 | + | push -> for (let el of new DOMParser().parseFromString('<template>' + event.detail.data + '</template>', 'text/html').querySelector('template').content.children) q('#' + el.id).insert('replace', el.outerHTML)" | |
| 154 | + | >offline</span> | |
| 155 | + | } else { | |
| 156 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 157 | + | } | |
| 158 | + | } | |
| @@ -0,0 +1,158 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | // Shell is the page: head, the two bars, the work area (content and rail), | |||
| 6 | + | // the overlays and the key map. The content is the children. | |||
| 7 | + | templ Shell(l Layout, rail templ.Component) { | |||
| 8 | + | <!DOCTYPE html> | |||
| 9 | + | <html lang="en"> | |||
| 10 | + | <head> | |||
| 11 | + | <meta charset="utf-8"/> | |||
| 12 | + | <meta name="viewport" content="width=device-width, initial-scale=1"/> | |||
| 13 | + | <meta name="color-scheme" content="light dark"/> | |||
| 14 | + | <title>{ l.Title }</title> | |||
| 15 | + | <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/> | |||
| 16 | + | <script src={ l.Assets.HTMX } defer></script> | |||
| 17 | + | <script src={ l.Assets.Live } defer></script> | |||
| 18 | + | <script src={ l.Assets.SSE } defer></script> | |||
| 19 | + | <script src={ l.Assets.App } defer></script> | |||
| 20 | + | </head> | |||
| 21 | + | <body data-ref={ l.RefPath } data-sha={ l.SHA } data-sel=""> | |||
| 22 | + | <a class="skip" href="#main">skip to content</a> | |||
| 23 | + | @Topbar(l) | |||
| 24 | + | <div class={ "work", templ.KV("has-rail", l.HasRail), templ.KV("read", l.Read) }> | |||
| 25 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 26 | + | { children... } | |||
| 27 | + | </main> | |||
| 28 | + | if l.HasRail && rail != nil { | |||
| 29 | + | <aside class="rail" aria-label="Context"> | |||
| 30 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |||
| 31 | + | <summary>{ l.RailSum }</summary> | |||
| 32 | + | @rail | |||
| 33 | + | </details> | |||
| 34 | + | </aside> | |||
| 35 | + | } | |||
| 36 | + | </div> | |||
| 37 | + | @OverlayHelp() | |||
| 38 | + | @OverlayRefs(l) | |||
| 39 | + | @OverlayCmd(l.Cmd) | |||
| 40 | + | @Statusbar(l) | |||
| 41 | + | @Keys() | |||
| 42 | + | </body> | |||
| 43 | + | </html> | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Topbar is the top bar, neferbar style: the one accent block (instance) | |||
| 47 | + | // fading into the black bar, the path, the repo sections, the ref switcher. | |||
| 48 | + | templ Topbar(l Layout) { | |||
| 49 | + | <header class="bar top"> | |||
| 50 | + | <a class="blk" href="/" data-g="h" title="home (g h)">{ l.Instance }</a><span class="fade" aria-hidden="true"></span> | |||
| 51 | + | <nav class="crumbs" aria-label="Path"> | |||
| 52 | + | <ol> | |||
| 53 | + | for _, c := range l.Crumbs { | |||
| 54 | + | <li> | |||
| 55 | + | if c.Href != "" { | |||
| 56 | + | <a | |||
| 57 | + | href={ templ.SafeURL(c.Href) } | |||
| 58 | + | if c.Current { | |||
| 59 | + | aria-current="page" | |||
| 60 | + | } | |||
| 61 | + | >{ c.Label }</a> | |||
| 62 | + | } else { | |||
| 63 | + | <span aria-current="page">{ c.Label }</span> | |||
| 64 | + | } | |||
| 65 | + | </li> | |||
| 66 | + | } | |||
| 67 | + | </ol> | |||
| 68 | + | </nav> | |||
| 69 | + | if len(l.Sections) > 0 { | |||
| 70 | + | <nav class="secs" aria-label="Sections"> | |||
| 71 | + | <ul> | |||
| 72 | + | for _, s := range l.Sections { | |||
| 73 | + | <li> | |||
| 74 | + | <a | |||
| 75 | + | href={ templ.SafeURL(s.Href) } | |||
| 76 | + | data-key={ s.Key } | |||
| 77 | + | aria-keyshortcuts={ "g " + s.Key } | |||
| 78 | + | if s.Current { | |||
| 79 | + | aria-current="page" | |||
| 80 | + | } | |||
| 81 | + | >{ s.Key }{ s.Post }</a> | |||
| 82 | + | </li> | |||
| 83 | + | } | |||
| 84 | + | </ul> | |||
| 85 | + | </nav> | |||
| 86 | + | } | |||
| 87 | + | if l.Ref != "" { | |||
| 88 | + | <button | |||
| 89 | + | type="button" | |||
| 90 | + | class="ref" | |||
| 91 | + | aria-haspopup="dialog" | |||
| 92 | + | aria-controls="panel-refs" | |||
| 93 | + | aria-keyshortcuts="b" | |||
| 94 | + | title="switch ref (b)" | |||
| 95 | + | hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))" | |||
| 96 | + | >{ l.Ref }<span class="sha">{ "@" + l.Short }</span></button> | |||
| 97 | + | } | |||
| 98 | + | </header> | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // Statusbar is the bottom bar: mode, context, the ":" command prompt, the | |||
| 102 | + | // source link (AGPL §13), hints, live state and the cursor position. Plain | |||
| 103 | + | // text only: the top bar carries the accent. | |||
| 104 | + | templ Statusbar(l Layout) { | |||
| 105 | + | <footer class="bar status" aria-label="Status"> | |||
| 106 | + | <span class="mode">{ l.Status.Mode }</span> | |||
| 107 | + | <span class="st-ctx">{ l.Status.Context }</span> | |||
| 108 | + | <button | |||
| 109 | + | type="button" | |||
| 110 | + | class="st-cmd" | |||
| 111 | + | aria-haspopup="dialog" | |||
| 112 | + | aria-controls="panel-cmd" | |||
| 113 | + | aria-keyshortcuts=":" | |||
| 114 | + | hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))" | |||
| 115 | + | ><span class="hi">:</span> go to…</button> | |||
| 116 | + | if l.Status.Blob { | |||
| 117 | + | <span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span> | |||
| 118 | + | } | |||
| 119 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span> | |||
| 120 | + | <span class="st-hints">{ l.Status.Hints }</span> | |||
| 121 | + | @Footer(l) | |||
| 122 | + | @Live(l.Live) | |||
| 123 | + | if l.Status.Cursor { | |||
| 124 | + | <span | |||
| 125 | + | class="st-pos num" | |||
| 126 | + | title="cursor position" | |||
| 127 | + | :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()" | |||
| 128 | + | >{ cursorText(l.Status.Count) }</span> | |||
| 129 | + | } | |||
| 130 | + | </footer> | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Footer is the version and the link to the source of the running version, | |||
| 134 | + | // which the AGPL §13 asks every user of the service to be offered. | |||
| 135 | + | templ Footer(l Layout) { | |||
| 136 | + | <span class="st-src"><span class="st-ver">ermite { l.Version } · </span><a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a></span> | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Live is the connection state of the event stream. Without the stream it | |||
| 140 | + | // reads "offline" and does nothing. | |||
| 141 | + | templ Live(on bool) { | |||
| 142 | + | if on { | |||
| 143 | + | <span | |||
| 144 | + | class="st-live" | |||
| 145 | + | data-state="offline" | |||
| 146 | + | role="status" | |||
| 147 | + | hx-sse:connect="/-/events" | |||
| 148 | + | hx-swap="none" | |||
| 149 | + | :text="data.state" | |||
| 150 | + | :title="data.state === 'live' ? 'receiving pushes over SSE' : 'not connected to the event stream'" | |||
| 151 | + | hx-on="htmx:sse:after:connection -> data.state = 'live'; | |||
| 152 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |||
| 153 | + | push -> for (let el of new DOMParser().parseFromString('<template>' + event.detail.data + '</template>', 'text/html').querySelector('template').content.children) q('#' + el.id).insert('replace', el.outerHTML)" | |||
| 154 | + | >offline</span> | |||
| 155 | + | } else { | |||
| 156 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 157 | + | } | |||
| 158 | + | } | |||
A internal/adapters/web/shell_templ.go +685 -0 generated file: collapsed
A internal/adapters/web/static/css/00-tokens.css +68 -0
| @@ -0,0 +1,68 @@ | |||
| 1 | + | @layer reset, tokens, base, components, utilities; | |
| 2 | + | ||
| 3 | + | /* Palette: the neferbar "mono" theme, black and grays only. | |
| 4 | + | Page surfaces follow the OS theme; the two bars stay black in both themes, | |
| 5 | + | like neferbar under a light or a dark terminal. */ | |
| 6 | + | @layer tokens { | |
| 7 | + | :root { | |
| 8 | + | color-scheme: light dark; | |
| 9 | + | ||
| 10 | + | /* page */ | |
| 11 | + | --paper: light-dark(#f2f2f2, #000000); | |
| 12 | + | --paper-alt: light-dark(#e8e8e8, #0c0c0c); | |
| 13 | + | --row: light-dark(#dddddd, #1a1a1a); | |
| 14 | + | --sel: light-dark(#cbcbcb, #303030); | |
| 15 | + | --ink: light-dark(#161616, #d0d0d0); | |
| 16 | + | --ink-hi: light-dark(#000000, #ffffff); | |
| 17 | + | --ink-dim: light-dark(#4a4a4a, #9a9a9a); | |
| 18 | + | --ink-mute: light-dark(#5c5c5c, #8a8a8a); | |
| 19 | + | --rule: light-dark(#bcbcbc, #303030); | |
| 20 | + | --focus: light-dark(#000000, #ffffff); | |
| 21 | + | ||
| 22 | + | /* bars (neferbar mono.conf: background, foreground, color4 accent, color0 ink) */ | |
| 23 | + | --bar: #000000; | |
| 24 | + | --bar-ink: #d0d0d0; | |
| 25 | + | --bar-dim: #8a8a8a; | |
| 26 | + | --accent: #c8c8c8; | |
| 27 | + | --on-accent: #000000; | |
| 28 | + | /* lib.sh fade(): four cells from the accent to the bar, each pulled halfway to the bar */ | |
| 29 | + | --fade-1: #505050; | |
| 30 | + | --fade-2: #3c3c3c; | |
| 31 | + | --fade-3: #282828; | |
| 32 | + | --fade-4: #141414; | |
| 33 | + | ||
| 34 | + | /* Colour, sparingly, from the owner's Despair theme: only code and diffs carry hue. | |
| 35 | + | Dark values are Despair's own; light values are the same hues darkened to AA on --paper. */ | |
| 36 | + | --teal: light-dark(#2f6b6b, #7db5b5); /* Despair bright teal: keywords, additions */ | |
| 37 | + | --teal-2: light-dark(#355f5f, #5a8585); /* slate teal: types, numbers */ | |
| 38 | + | --red: light-dark(#a8404b, #d96c75); /* soft red: deletions */ | |
| 39 | + | --rose: light-dark(#6b6570, #a7a2a9); /* rose quartz: strings */ | |
| 40 | + | ||
| 41 | + | --syn-plain: var(--ink); | |
| 42 | + | --syn-keyword: var(--teal); | |
| 43 | + | --syn-string: var(--rose); | |
| 44 | + | --syn-comment: light-dark(#5c5f63, #6a6e73); | |
| 45 | + | --syn-number: var(--teal-2); | |
| 46 | + | --syn-func: var(--ink-hi); | |
| 47 | + | --syn-type: var(--teal-2); | |
| 48 | + | --syn-punct: var(--ink-dim); | |
| 49 | + | ||
| 50 | + | /* diff rows: the line keeps the normal ink; the hue is in a faint row tint and the marker */ | |
| 51 | + | --add: light-dark(#d8e5e5, #121919); | |
| 52 | + | --del: light-dark(#eedadc, #231113); | |
| 53 | + | ||
| 54 | + | /* one family (the neferbar font when installed), three sizes, two weights */ | |
| 55 | + | --font: "Adwaita Mono", "AdwaitaMono Nerd Font Mono", ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace; | |
| 56 | + | --fs: 14px; | |
| 57 | + | --fs-code: 13px; | |
| 58 | + | --fs-s: 12px; | |
| 59 | + | --lh: 20px; | |
| 60 | + | --lh-code: 18px; | |
| 61 | + | ||
| 62 | + | --pad: 2ch; | |
| 63 | + | --bar-h: 20px; /* one text line, like neferbar: no padding above or below */ | |
| 64 | + | --stick: var(--bar-h); /* top offset of sticky headers inside the content (0 in the app shell) */ | |
| 65 | + | --measure: 80ch; | |
| 66 | + | --rail: 40ch; | |
| 67 | + | } | |
| 68 | + | } | |
| @@ -0,0 +1,68 @@ | |||||
| 1 | + | @layer reset, tokens, base, components, utilities; | |||
| 2 | + | ||||
| 3 | + | /* Palette: the neferbar "mono" theme, black and grays only. | |||
| 4 | + | Page surfaces follow the OS theme; the two bars stay black in both themes, | |||
| 5 | + | like neferbar under a light or a dark terminal. */ | |||
| 6 | + | @layer tokens { | |||
| 7 | + | :root { | |||
| 8 | + | color-scheme: light dark; | |||
| 9 | + | ||||
| 10 | + | /* page */ | |||
| 11 | + | --paper: light-dark(#f2f2f2, #000000); | |||
| 12 | + | --paper-alt: light-dark(#e8e8e8, #0c0c0c); | |||
| 13 | + | --row: light-dark(#dddddd, #1a1a1a); | |||
| 14 | + | --sel: light-dark(#cbcbcb, #303030); | |||
| 15 | + | --ink: light-dark(#161616, #d0d0d0); | |||
| 16 | + | --ink-hi: light-dark(#000000, #ffffff); | |||
| 17 | + | --ink-dim: light-dark(#4a4a4a, #9a9a9a); | |||
| 18 | + | --ink-mute: light-dark(#5c5c5c, #8a8a8a); | |||
| 19 | + | --rule: light-dark(#bcbcbc, #303030); | |||
| 20 | + | --focus: light-dark(#000000, #ffffff); | |||
| 21 | + | ||||
| 22 | + | /* bars (neferbar mono.conf: background, foreground, color4 accent, color0 ink) */ | |||
| 23 | + | --bar: #000000; | |||
| 24 | + | --bar-ink: #d0d0d0; | |||
| 25 | + | --bar-dim: #8a8a8a; | |||
| 26 | + | --accent: #c8c8c8; | |||
| 27 | + | --on-accent: #000000; | |||
| 28 | + | /* lib.sh fade(): four cells from the accent to the bar, each pulled halfway to the bar */ | |||
| 29 | + | --fade-1: #505050; | |||
| 30 | + | --fade-2: #3c3c3c; | |||
| 31 | + | --fade-3: #282828; | |||
| 32 | + | --fade-4: #141414; | |||
| 33 | + | ||||
| 34 | + | /* Colour, sparingly, from the owner's Despair theme: only code and diffs carry hue. | |||
| 35 | + | Dark values are Despair's own; light values are the same hues darkened to AA on --paper. */ | |||
| 36 | + | --teal: light-dark(#2f6b6b, #7db5b5); /* Despair bright teal: keywords, additions */ | |||
| 37 | + | --teal-2: light-dark(#355f5f, #5a8585); /* slate teal: types, numbers */ | |||
| 38 | + | --red: light-dark(#a8404b, #d96c75); /* soft red: deletions */ | |||
| 39 | + | --rose: light-dark(#6b6570, #a7a2a9); /* rose quartz: strings */ | |||
| 40 | + | ||||
| 41 | + | --syn-plain: var(--ink); | |||
| 42 | + | --syn-keyword: var(--teal); | |||
| 43 | + | --syn-string: var(--rose); | |||
| 44 | + | --syn-comment: light-dark(#5c5f63, #6a6e73); | |||
| 45 | + | --syn-number: var(--teal-2); | |||
| 46 | + | --syn-func: var(--ink-hi); | |||
| 47 | + | --syn-type: var(--teal-2); | |||
| 48 | + | --syn-punct: var(--ink-dim); | |||
| 49 | + | ||||
| 50 | + | /* diff rows: the line keeps the normal ink; the hue is in a faint row tint and the marker */ | |||
| 51 | + | --add: light-dark(#d8e5e5, #121919); | |||
| 52 | + | --del: light-dark(#eedadc, #231113); | |||
| 53 | + | ||||
| 54 | + | /* one family (the neferbar font when installed), three sizes, two weights */ | |||
| 55 | + | --font: "Adwaita Mono", "AdwaitaMono Nerd Font Mono", ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace; | |||
| 56 | + | --fs: 14px; | |||
| 57 | + | --fs-code: 13px; | |||
| 58 | + | --fs-s: 12px; | |||
| 59 | + | --lh: 20px; | |||
| 60 | + | --lh-code: 18px; | |||
| 61 | + | ||||
| 62 | + | --pad: 2ch; | |||
| 63 | + | --bar-h: 20px; /* one text line, like neferbar: no padding above or below */ | |||
| 64 | + | --stick: var(--bar-h); /* top offset of sticky headers inside the content (0 in the app shell) */ | |||
| 65 | + | --measure: 80ch; | |||
| 66 | + | --rail: 40ch; | |||
| 67 | + | } | |||
| 68 | + | } | |||
A internal/adapters/web/static/css/10-base.css +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | @layer reset { | |
| 2 | + | *, *::before, *::after { box-sizing: border-box; border-radius: 0; } | |
| 3 | + | html { -webkit-text-size-adjust: 100%; } | |
| 4 | + | body, h1, h2, h3, h4, p, ul, ol, dl, dd, figure, pre { margin: 0; } | |
| 5 | + | ul, ol { padding: 0; list-style: none; } | |
| 6 | + | table { border-collapse: collapse; border-spacing: 0; } | |
| 7 | + | th, td { padding: 0; text-align: left; font-weight: inherit; } | |
| 8 | + | button, input, select { font: inherit; color: inherit; letter-spacing: inherit; } | |
| 9 | + | button { background: none; border: 0; padding: 0; cursor: pointer; text-align: inherit; } | |
| 10 | + | input { background: transparent; border: 0; border-radius: 0; } | |
| 11 | + | summary { cursor: pointer; } | |
| 12 | + | dialog { color: inherit; border: 0; padding: 0; max-width: none; max-height: none; } | |
| 13 | + | img { max-width: 100%; } | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | @layer base { | |
| 17 | + | html { background: var(--paper); scrollbar-width: thin; scrollbar-color: var(--rule) transparent; } | |
| 18 | + | body { | |
| 19 | + | min-height: 100dvh; | |
| 20 | + | display: grid; | |
| 21 | + | grid-template-rows: auto auto 1fr auto; | |
| 22 | + | font: var(--fs) / var(--lh) var(--font); | |
| 23 | + | color: var(--ink); | |
| 24 | + | background: var(--paper); | |
| 25 | + | font-variant-ligatures: none; | |
| 26 | + | tab-size: 4; | |
| 27 | + | } | |
| 28 | + | h1, h2, h3, h4 { font-size: inherit; font-weight: bold; } | |
| 29 | + | b, strong { font-weight: bold; } | |
| 30 | + | em, i { font-style: normal; text-decoration: underline; text-underline-offset: 3px; } | |
| 31 | + | small { font-size: var(--fs-s); } | |
| 32 | + | code, pre, kbd, samp { font: inherit; } | |
| 33 | + | a { color: inherit; text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; } | |
| 34 | + | a:hover { text-decoration-color: currentColor; } | |
| 35 | + | hr { border: 0; border-top: 1px solid var(--rule); margin: var(--lh) 0; } | |
| 36 | + | ::selection { background: var(--ink); color: var(--paper); } | |
| 37 | + | ||
| 38 | + | :focus { outline: none; } | |
| 39 | + | :focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } | |
| 40 | + | ||
| 41 | + | [hidden] { display: none !important; } | |
| 42 | + | ||
| 43 | + | .skip { | |
| 44 | + | position: absolute; left: 0; top: 0; z-index: 30; padding: 0 1ch; | |
| 45 | + | background: var(--ink); color: var(--paper); | |
| 46 | + | transform: translateY(-100%); | |
| 47 | + | } | |
| 48 | + | .skip:focus { transform: none; } | |
| 49 | + | ||
| 50 | + | main { padding: 0 var(--pad) var(--lh); min-width: 0; } | |
| 51 | + | ||
| 52 | + | @media (prefers-reduced-motion: no-preference) { | |
| 53 | + | a, button, tr, li, summary { transition: background-color 90ms linear, opacity 90ms linear; } | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | @layer utilities { | |
| 58 | + | .dim { color: var(--ink-dim); } | |
| 59 | + | .mute { color: var(--ink-mute); } | |
| 60 | + | .nowrap { white-space: nowrap; } | |
| 61 | + | .num { text-align: right; font-variant-numeric: tabular-nums; } | |
| 62 | + | .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } | |
| 63 | + | .rule { border-top: 1px solid var(--rule); } | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | @layer reset { | |||
| 2 | + | *, *::before, *::after { box-sizing: border-box; border-radius: 0; } | |||
| 3 | + | html { -webkit-text-size-adjust: 100%; } | |||
| 4 | + | body, h1, h2, h3, h4, p, ul, ol, dl, dd, figure, pre { margin: 0; } | |||
| 5 | + | ul, ol { padding: 0; list-style: none; } | |||
| 6 | + | table { border-collapse: collapse; border-spacing: 0; } | |||
| 7 | + | th, td { padding: 0; text-align: left; font-weight: inherit; } | |||
| 8 | + | button, input, select { font: inherit; color: inherit; letter-spacing: inherit; } | |||
| 9 | + | button { background: none; border: 0; padding: 0; cursor: pointer; text-align: inherit; } | |||
| 10 | + | input { background: transparent; border: 0; border-radius: 0; } | |||
| 11 | + | summary { cursor: pointer; } | |||
| 12 | + | dialog { color: inherit; border: 0; padding: 0; max-width: none; max-height: none; } | |||
| 13 | + | img { max-width: 100%; } | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | @layer base { | |||
| 17 | + | html { background: var(--paper); scrollbar-width: thin; scrollbar-color: var(--rule) transparent; } | |||
| 18 | + | body { | |||
| 19 | + | min-height: 100dvh; | |||
| 20 | + | display: grid; | |||
| 21 | + | grid-template-rows: auto auto 1fr auto; | |||
| 22 | + | font: var(--fs) / var(--lh) var(--font); | |||
| 23 | + | color: var(--ink); | |||
| 24 | + | background: var(--paper); | |||
| 25 | + | font-variant-ligatures: none; | |||
| 26 | + | tab-size: 4; | |||
| 27 | + | } | |||
| 28 | + | h1, h2, h3, h4 { font-size: inherit; font-weight: bold; } | |||
| 29 | + | b, strong { font-weight: bold; } | |||
| 30 | + | em, i { font-style: normal; text-decoration: underline; text-underline-offset: 3px; } | |||
| 31 | + | small { font-size: var(--fs-s); } | |||
| 32 | + | code, pre, kbd, samp { font: inherit; } | |||
| 33 | + | a { color: inherit; text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; } | |||
| 34 | + | a:hover { text-decoration-color: currentColor; } | |||
| 35 | + | hr { border: 0; border-top: 1px solid var(--rule); margin: var(--lh) 0; } | |||
| 36 | + | ::selection { background: var(--ink); color: var(--paper); } | |||
| 37 | + | ||||
| 38 | + | :focus { outline: none; } | |||
| 39 | + | :focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } | |||
| 40 | + | ||||
| 41 | + | [hidden] { display: none !important; } | |||
| 42 | + | ||||
| 43 | + | .skip { | |||
| 44 | + | position: absolute; left: 0; top: 0; z-index: 30; padding: 0 1ch; | |||
| 45 | + | background: var(--ink); color: var(--paper); | |||
| 46 | + | transform: translateY(-100%); | |||
| 47 | + | } | |||
| 48 | + | .skip:focus { transform: none; } | |||
| 49 | + | ||||
| 50 | + | main { padding: 0 var(--pad) var(--lh); min-width: 0; } | |||
| 51 | + | ||||
| 52 | + | @media (prefers-reduced-motion: no-preference) { | |||
| 53 | + | a, button, tr, li, summary { transition: background-color 90ms linear, opacity 90ms linear; } | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | @layer utilities { | |||
| 58 | + | .dim { color: var(--ink-dim); } | |||
| 59 | + | .mute { color: var(--ink-mute); } | |||
| 60 | + | .nowrap { white-space: nowrap; } | |||
| 61 | + | .num { text-align: right; font-variant-numeric: tabular-nums; } | |||
| 62 | + | .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } | |||
| 63 | + | .rule { border-top: 1px solid var(--rule); } | |||
| 64 | + | } | |||
A internal/adapters/web/static/css/20-shell.css +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | @layer base { | |
| 2 | + | body { grid-template-rows: auto 1fr auto; } | |
| 3 | + | } | |
| 4 | + | ||
| 5 | + | @layer components { | |
| 6 | + | /* neferbar: black bars; one accent block (top left) with a 4-cell stepped fade. | |
| 7 | + | The fade is four hard-edged 1ch cells (not a blend): it is the bar's own glyph-grid look. */ | |
| 8 | + | .bar { | |
| 9 | + | display: flex; align-items: stretch; column-gap: 1ch; | |
| 10 | + | height: var(--bar-h); line-height: var(--bar-h); | |
| 11 | + | background: var(--bar); color: var(--bar-ink); | |
| 12 | + | white-space: nowrap; overflow: hidden; | |
| 13 | + | position: sticky; z-index: 20; | |
| 14 | + | & a { text-decoration: none; } | |
| 15 | + | & a:hover { text-decoration: underline; } | |
| 16 | + | & :focus-visible { outline-color: var(--accent); outline-offset: -2px; } | |
| 17 | + | & .blk { background: var(--accent); color: var(--on-accent); font-weight: bold; padding: 0 1ch; } | |
| 18 | + | & .fade { | |
| 19 | + | flex: none; width: 4ch; margin-left: -1ch; | |
| 20 | + | background: linear-gradient(to right, | |
| 21 | + | var(--fade-1) 0 25%, var(--fade-2) 25% 50%, var(--fade-3) 50% 75%, var(--fade-4) 75%); | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | .top { | |
| 26 | + | top: 0; | |
| 27 | + | & .crumbs ol { display: flex; } | |
| 28 | + | & .crumbs li + li::before { content: "/"; color: var(--bar-dim); padding: 0 .5ch; } | |
| 29 | + | & .crumbs [aria-current] { color: #fff; font-weight: bold; } | |
| 30 | + | & .secs { margin-left: 2ch; overflow: hidden; } | |
| 31 | + | & .secs ul { display: flex; } | |
| 32 | + | & .secs li + li::before { content: "/"; color: var(--bar-dim); } | |
| 33 | + | & .secs a { display: inline-block; padding: 0 1ch; color: var(--bar-dim); } | |
| 34 | + | & .secs a:hover { color: var(--bar-ink); } | |
| 35 | + | & .secs a::first-letter { text-decoration: underline; } | |
| 36 | + | & .secs a[aria-current] { color: #fff; font-weight: bold; } | |
| 37 | + | & .secs a[aria-current]::before { content: "▸ "; } | |
| 38 | + | & .ref { flex: none; margin-left: auto; padding-right: 1ch; color: #fff; font-weight: bold; } | |
| 39 | + | & .ref .sha { font-weight: normal; color: var(--bar-dim); } | |
| 40 | + | & .ref:hover { text-decoration: underline; } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | .status { | |
| 44 | + | bottom: 0; font-size: var(--fs-s); | |
| 45 | + | padding: 0 1ch; | |
| 46 | + | & > span, & .st-cmd { color: var(--bar-dim); } | |
| 47 | + | & .mode { color: #fff; font-weight: bold; } | |
| 48 | + | & .st-ctx { color: var(--bar-ink); } | |
| 49 | + | & .st-cmd { flex: none; } | |
| 50 | + | & .st-cmd .hi, & .st-cmd:hover { color: #fff; font-weight: bold; } | |
| 51 | + | & .st-sel { color: var(--bar-ink); } | |
| 52 | + | & .st-flash:empty { display: none; } | |
| 53 | + | & .st-flash { color: #fff; font-weight: bold; } | |
| 54 | + | & .st-hints { margin-left: auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; } | |
| 55 | + | & .st-live::before { content: "○ "; } | |
| 56 | + | & .st-live[data-state="live"] { color: var(--bar-ink); } | |
| 57 | + | & .st-live[data-state="live"]::before { content: "● "; } | |
| 58 | + | & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /* work area: content + context rail. | |
| 62 | + | Tables and code take the free width and the rail is fixed; on prose pages the text | |
| 63 | + | is capped at the reading measure and the rail takes the rest, so no column is idle. */ | |
| 64 | + | .work { min-width: 0; } | |
| 65 | + | .rail { padding: 0 var(--pad) var(--lh); min-width: 0; } | |
| 66 | + | @media (min-width: 1100px) { | |
| 67 | + | /* app shell: the bars stay put, the content and the rail scroll on their own, so each | |
| 68 | + | column has its scrollbar at its own edge (the README's sits next to the README) */ | |
| 69 | + | body { height: 100dvh; overflow: hidden; } | |
| 70 | + | .work { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 0; } | |
| 71 | + | .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); } | |
| 72 | + | .work.has-rail.read { grid-template-columns: minmax(0, calc(var(--measure) + 2 * var(--pad))) minmax(var(--rail), 1fr); } | |
| 73 | + | main, .rail { min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--rule) transparent; } | |
| 74 | + | main:focus { outline: none; } | |
| 75 | + | .rail { border-left: 1px solid var(--rule); } | |
| 76 | + | :root { --stick: 0px; } | |
| 77 | + | } | |
| 78 | + | .rail-d > summary { display: none; } | |
| 79 | + | @media (max-width: 1099.98px) { | |
| 80 | + | /* the rail folds into one line above the content */ | |
| 81 | + | .work.has-rail { display: flex; flex-direction: column; } | |
| 82 | + | .rail { order: -1; border-bottom: 1px solid var(--rule); padding-bottom: 0; } | |
| 83 | + | .rail-d > summary { | |
| 84 | + | display: block; list-style: none; color: var(--ink-dim); | |
| 85 | + | overflow: hidden; white-space: nowrap; text-overflow: ellipsis; | |
| 86 | + | } | |
| 87 | + | .rail-d > summary::-webkit-details-marker { display: none; } | |
| 88 | + | .rail-d > summary::before { content: "▸ "; color: var(--ink); } | |
| 89 | + | .rail-d[open] > summary::before { content: "▾ "; } | |
| 90 | + | .rail-d[open] { padding-bottom: var(--lh); } | |
| 91 | + | } | |
| 92 | + | @media (max-width: 700px) { | |
| 93 | + | /* the sections drop to a second bar row instead of being cut off */ | |
| 94 | + | .top { flex-wrap: wrap; height: auto; } | |
| 95 | + | .top .secs { order: 10; flex-basis: 100%; margin: 0; border-top: 1px solid #202020; } | |
| 96 | + | .top .crumbs { flex: 1; min-width: 0; overflow: hidden; } | |
| 97 | + | .status .st-hints, .status .st-ctx { display: none; } | |
| 98 | + | .list .c-who, .list .c-desc, .list .c-sub, .list .c-ref, .list .c-size, .list .c-lang { display: none; } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /* rail blocks: a dim title rule, then key/value rows on the character grid */ | |
| 102 | + | .rail section { margin-top: var(--lh); } | |
| 103 | + | .rail h2 { | |
| 104 | + | display: flex; gap: 1ch; color: var(--ink-dim); font-weight: normal; | |
| 105 | + | &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); } | |
| 106 | + | } | |
| 107 | + | .kv { | |
| 108 | + | display: grid; grid-template-columns: 11ch minmax(0, 1fr); column-gap: 1ch; | |
| 109 | + | & dt { color: var(--ink-mute); } | |
| 110 | + | & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 111 | + | } | |
| 112 | + | .outline { | |
| 113 | + | & a { display: block; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 114 | + | & a:hover { text-decoration: underline; } | |
| 115 | + | & .l1 { font-weight: bold; } | |
| 116 | + | & .l3 { padding-left: 2ch; } | |
| 117 | + | & .l4, & .l5, & .l6 { padding-left: 4ch; } | |
| 118 | + | & a[aria-current]::before { content: "▸"; margin-left: -1ch; } | |
| 119 | + | } | |
| 120 | + | .rail .clone { border: 0; padding: 0; margin-top: 0; } | |
| 121 | + | .rail .clone-line { flex-wrap: wrap; } | |
| 122 | + | } | |
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | @layer base { | |||
| 2 | + | body { grid-template-rows: auto 1fr auto; } | |||
| 3 | + | } | |||
| 4 | + | ||||
| 5 | + | @layer components { | |||
| 6 | + | /* neferbar: black bars; one accent block (top left) with a 4-cell stepped fade. | |||
| 7 | + | The fade is four hard-edged 1ch cells (not a blend): it is the bar's own glyph-grid look. */ | |||
| 8 | + | .bar { | |||
| 9 | + | display: flex; align-items: stretch; column-gap: 1ch; | |||
| 10 | + | height: var(--bar-h); line-height: var(--bar-h); | |||
| 11 | + | background: var(--bar); color: var(--bar-ink); | |||
| 12 | + | white-space: nowrap; overflow: hidden; | |||
| 13 | + | position: sticky; z-index: 20; | |||
| 14 | + | & a { text-decoration: none; } | |||
| 15 | + | & a:hover { text-decoration: underline; } | |||
| 16 | + | & :focus-visible { outline-color: var(--accent); outline-offset: -2px; } | |||
| 17 | + | & .blk { background: var(--accent); color: var(--on-accent); font-weight: bold; padding: 0 1ch; } | |||
| 18 | + | & .fade { | |||
| 19 | + | flex: none; width: 4ch; margin-left: -1ch; | |||
| 20 | + | background: linear-gradient(to right, | |||
| 21 | + | var(--fade-1) 0 25%, var(--fade-2) 25% 50%, var(--fade-3) 50% 75%, var(--fade-4) 75%); | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | .top { | |||
| 26 | + | top: 0; | |||
| 27 | + | & .crumbs ol { display: flex; } | |||
| 28 | + | & .crumbs li + li::before { content: "/"; color: var(--bar-dim); padding: 0 .5ch; } | |||
| 29 | + | & .crumbs [aria-current] { color: #fff; font-weight: bold; } | |||
| 30 | + | & .secs { margin-left: 2ch; overflow: hidden; } | |||
| 31 | + | & .secs ul { display: flex; } | |||
| 32 | + | & .secs li + li::before { content: "/"; color: var(--bar-dim); } | |||
| 33 | + | & .secs a { display: inline-block; padding: 0 1ch; color: var(--bar-dim); } | |||
| 34 | + | & .secs a:hover { color: var(--bar-ink); } | |||
| 35 | + | & .secs a::first-letter { text-decoration: underline; } | |||
| 36 | + | & .secs a[aria-current] { color: #fff; font-weight: bold; } | |||
| 37 | + | & .secs a[aria-current]::before { content: "▸ "; } | |||
| 38 | + | & .ref { flex: none; margin-left: auto; padding-right: 1ch; color: #fff; font-weight: bold; } | |||
| 39 | + | & .ref .sha { font-weight: normal; color: var(--bar-dim); } | |||
| 40 | + | & .ref:hover { text-decoration: underline; } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | .status { | |||
| 44 | + | bottom: 0; font-size: var(--fs-s); | |||
| 45 | + | padding: 0 1ch; | |||
| 46 | + | & > span, & .st-cmd { color: var(--bar-dim); } | |||
| 47 | + | & .mode { color: #fff; font-weight: bold; } | |||
| 48 | + | & .st-ctx { color: var(--bar-ink); } | |||
| 49 | + | & .st-cmd { flex: none; } | |||
| 50 | + | & .st-cmd .hi, & .st-cmd:hover { color: #fff; font-weight: bold; } | |||
| 51 | + | & .st-sel { color: var(--bar-ink); } | |||
| 52 | + | & .st-flash:empty { display: none; } | |||
| 53 | + | & .st-flash { color: #fff; font-weight: bold; } | |||
| 54 | + | & .st-hints { margin-left: auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; } | |||
| 55 | + | & .st-live::before { content: "○ "; } | |||
| 56 | + | & .st-live[data-state="live"] { color: var(--bar-ink); } | |||
| 57 | + | & .st-live[data-state="live"]::before { content: "● "; } | |||
| 58 | + | & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | /* work area: content + context rail. | |||
| 62 | + | Tables and code take the free width and the rail is fixed; on prose pages the text | |||
| 63 | + | is capped at the reading measure and the rail takes the rest, so no column is idle. */ | |||
| 64 | + | .work { min-width: 0; } | |||
| 65 | + | .rail { padding: 0 var(--pad) var(--lh); min-width: 0; } | |||
| 66 | + | @media (min-width: 1100px) { | |||
| 67 | + | /* app shell: the bars stay put, the content and the rail scroll on their own, so each | |||
| 68 | + | column has its scrollbar at its own edge (the README's sits next to the README) */ | |||
| 69 | + | body { height: 100dvh; overflow: hidden; } | |||
| 70 | + | .work { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 0; } | |||
| 71 | + | .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); } | |||
| 72 | + | .work.has-rail.read { grid-template-columns: minmax(0, calc(var(--measure) + 2 * var(--pad))) minmax(var(--rail), 1fr); } | |||
| 73 | + | main, .rail { min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--rule) transparent; } | |||
| 74 | + | main:focus { outline: none; } | |||
| 75 | + | .rail { border-left: 1px solid var(--rule); } | |||
| 76 | + | :root { --stick: 0px; } | |||
| 77 | + | } | |||
| 78 | + | .rail-d > summary { display: none; } | |||
| 79 | + | @media (max-width: 1099.98px) { | |||
| 80 | + | /* the rail folds into one line above the content */ | |||
| 81 | + | .work.has-rail { display: flex; flex-direction: column; } | |||
| 82 | + | .rail { order: -1; border-bottom: 1px solid var(--rule); padding-bottom: 0; } | |||
| 83 | + | .rail-d > summary { | |||
| 84 | + | display: block; list-style: none; color: var(--ink-dim); | |||
| 85 | + | overflow: hidden; white-space: nowrap; text-overflow: ellipsis; | |||
| 86 | + | } | |||
| 87 | + | .rail-d > summary::-webkit-details-marker { display: none; } | |||
| 88 | + | .rail-d > summary::before { content: "▸ "; color: var(--ink); } | |||
| 89 | + | .rail-d[open] > summary::before { content: "▾ "; } | |||
| 90 | + | .rail-d[open] { padding-bottom: var(--lh); } | |||
| 91 | + | } | |||
| 92 | + | @media (max-width: 700px) { | |||
| 93 | + | /* the sections drop to a second bar row instead of being cut off */ | |||
| 94 | + | .top { flex-wrap: wrap; height: auto; } | |||
| 95 | + | .top .secs { order: 10; flex-basis: 100%; margin: 0; border-top: 1px solid #202020; } | |||
| 96 | + | .top .crumbs { flex: 1; min-width: 0; overflow: hidden; } | |||
| 97 | + | .status .st-hints, .status .st-ctx { display: none; } | |||
| 98 | + | .list .c-who, .list .c-desc, .list .c-sub, .list .c-ref, .list .c-size, .list .c-lang { display: none; } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | /* rail blocks: a dim title rule, then key/value rows on the character grid */ | |||
| 102 | + | .rail section { margin-top: var(--lh); } | |||
| 103 | + | .rail h2 { | |||
| 104 | + | display: flex; gap: 1ch; color: var(--ink-dim); font-weight: normal; | |||
| 105 | + | &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); } | |||
| 106 | + | } | |||
| 107 | + | .kv { | |||
| 108 | + | display: grid; grid-template-columns: 11ch minmax(0, 1fr); column-gap: 1ch; | |||
| 109 | + | & dt { color: var(--ink-mute); } | |||
| 110 | + | & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |||
| 111 | + | } | |||
| 112 | + | .outline { | |||
| 113 | + | & a { display: block; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |||
| 114 | + | & a:hover { text-decoration: underline; } | |||
| 115 | + | & .l1 { font-weight: bold; } | |||
| 116 | + | & .l3 { padding-left: 2ch; } | |||
| 117 | + | & .l4, & .l5, & .l6 { padding-left: 4ch; } | |||
| 118 | + | & a[aria-current]::before { content: "▸"; margin-left: -1ch; } | |||
| 119 | + | } | |||
| 120 | + | .rail .clone { border: 0; padding: 0; margin-top: 0; } | |||
| 121 | + | .rail .clone-line { flex-wrap: wrap; } | |||
| 122 | + | } | |||
A internal/adapters/web/static/css/25-panel.css +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | @layer components { | |
| 2 | + | /* bottom panels above the status bar (help, ref switcher, ":" command line) */ | |
| 3 | + | .panel { | |
| 4 | + | position: fixed; inset: auto 0 var(--bar-h) 0; margin: 0; width: 100%; z-index: 15; | |
| 5 | + | background: var(--paper); color: var(--ink); | |
| 6 | + | border-top: 2px solid var(--ink); | |
| 7 | + | max-height: min(46dvh, 28rem); overflow: auto; overscroll-behavior: contain; | |
| 8 | + | padding: 0 var(--pad) var(--lh); | |
| 9 | + | &:not([open]) { display: none; } | |
| 10 | + | & .panel-head { | |
| 11 | + | position: sticky; top: 0; background: var(--paper); display: flex; gap: 1ch; | |
| 12 | + | align-items: baseline; height: calc(var(--lh) + 6px); padding-top: 4px; | |
| 13 | + | border-bottom: 1px solid var(--rule); margin-bottom: 4px; | |
| 14 | + | & input { flex: 1; min-width: 0; outline-offset: 2px; } | |
| 15 | + | & button { margin-left: auto; color: var(--ink-dim); white-space: nowrap; } | |
| 16 | + | & button:hover { color: var(--ink); text-decoration: underline; } | |
| 17 | + | & .hi { font-weight: bold; } | |
| 18 | + | } | |
| 19 | + | & .panel-cols { display: grid; grid-template-columns: repeat(auto-fill, minmax(34ch, 1fr)); column-gap: 3ch; } | |
| 20 | + | & .panel-cols h2 { color: var(--ink-dim); font-weight: normal; } | |
| 21 | + | & li a { display: block; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 22 | + | & li a:hover, & li a:focus-visible { background: var(--sel); } | |
| 23 | + | & li a[aria-current] { font-weight: bold; } | |
| 24 | + | & .mark { display: inline-block; width: 2ch; } | |
| 25 | + | } | |
| 26 | + | .cmd-list { | |
| 27 | + | columns: 34ch; column-gap: 3ch; | |
| 28 | + | & li { break-inside: avoid; } | |
| 29 | + | & li[data-cursor] a { background: var(--row); } | |
| 30 | + | & li[data-cursor] a::before { content: "▸"; position: absolute; margin-left: -1.5ch; } | |
| 31 | + | & a { padding-left: 1.5ch; } | |
| 32 | + | & .k { display: inline-block; width: 8ch; color: var(--ink-mute); } | |
| 33 | + | } | |
| 34 | + | .keys { | |
| 35 | + | display: grid; grid-template-columns: repeat(auto-fill, minmax(46ch, 1fr)); column-gap: 3ch; | |
| 36 | + | & > div { display: grid; grid-template-columns: 14ch 1fr; } | |
| 37 | + | & dt { white-space: nowrap; } | |
| 38 | + | & dd { color: var(--ink-dim); } | |
| 39 | + | & kbd { border: 1px solid var(--rule); padding: 0 .6ch; } | |
| 40 | + | } | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | @layer components { | |||
| 2 | + | /* bottom panels above the status bar (help, ref switcher, ":" command line) */ | |||
| 3 | + | .panel { | |||
| 4 | + | position: fixed; inset: auto 0 var(--bar-h) 0; margin: 0; width: 100%; z-index: 15; | |||
| 5 | + | background: var(--paper); color: var(--ink); | |||
| 6 | + | border-top: 2px solid var(--ink); | |||
| 7 | + | max-height: min(46dvh, 28rem); overflow: auto; overscroll-behavior: contain; | |||
| 8 | + | padding: 0 var(--pad) var(--lh); | |||
| 9 | + | &:not([open]) { display: none; } | |||
| 10 | + | & .panel-head { | |||
| 11 | + | position: sticky; top: 0; background: var(--paper); display: flex; gap: 1ch; | |||
| 12 | + | align-items: baseline; height: calc(var(--lh) + 6px); padding-top: 4px; | |||
| 13 | + | border-bottom: 1px solid var(--rule); margin-bottom: 4px; | |||
| 14 | + | & input { flex: 1; min-width: 0; outline-offset: 2px; } | |||
| 15 | + | & button { margin-left: auto; color: var(--ink-dim); white-space: nowrap; } | |||
| 16 | + | & button:hover { color: var(--ink); text-decoration: underline; } | |||
| 17 | + | & .hi { font-weight: bold; } | |||
| 18 | + | } | |||
| 19 | + | & .panel-cols { display: grid; grid-template-columns: repeat(auto-fill, minmax(34ch, 1fr)); column-gap: 3ch; } | |||
| 20 | + | & .panel-cols h2 { color: var(--ink-dim); font-weight: normal; } | |||
| 21 | + | & li a { display: block; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |||
| 22 | + | & li a:hover, & li a:focus-visible { background: var(--sel); } | |||
| 23 | + | & li a[aria-current] { font-weight: bold; } | |||
| 24 | + | & .mark { display: inline-block; width: 2ch; } | |||
| 25 | + | } | |||
| 26 | + | .cmd-list { | |||
| 27 | + | columns: 34ch; column-gap: 3ch; | |||
| 28 | + | & li { break-inside: avoid; } | |||
| 29 | + | & li[data-cursor] a { background: var(--row); } | |||
| 30 | + | & li[data-cursor] a::before { content: "▸"; position: absolute; margin-left: -1.5ch; } | |||
| 31 | + | & a { padding-left: 1.5ch; } | |||
| 32 | + | & .k { display: inline-block; width: 8ch; color: var(--ink-mute); } | |||
| 33 | + | } | |||
| 34 | + | .keys { | |||
| 35 | + | display: grid; grid-template-columns: repeat(auto-fill, minmax(46ch, 1fr)); column-gap: 3ch; | |||
| 36 | + | & > div { display: grid; grid-template-columns: 14ch 1fr; } | |||
| 37 | + | & dt { white-space: nowrap; } | |||
| 38 | + | & dd { color: var(--ink-dim); } | |||
| 39 | + | & kbd { border: 1px solid var(--rule); padding: 0 .6ch; } | |||
| 40 | + | } | |||
| 41 | + | } | |||
A internal/adapters/web/static/css/30-list.css +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | @layer components { | |
| 2 | + | /* plain-text tables with a ▸ row cursor */ | |
| 3 | + | .list { | |
| 4 | + | width: 100%; table-layout: fixed; | |
| 5 | + | & th { color: var(--ink-mute); font-weight: normal; white-space: nowrap; border-bottom: 1px solid var(--rule); } | |
| 6 | + | & th a { text-decoration: none; } | |
| 7 | + | & th a:hover { text-decoration: underline; color: var(--ink); } | |
| 8 | + | & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink); font-weight: bold; } | |
| 9 | + | & td, & th { padding-right: 2ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 10 | + | & .cur { width: 2ch; padding: 0; } | |
| 11 | + | & tbody tr[data-cursor] { background: var(--row); } | |
| 12 | + | & tbody tr[data-cursor] .cur::before { content: "▸"; font-weight: bold; } | |
| 13 | + | & tbody tr:hover { background: var(--row); } | |
| 14 | + | & tbody tr[hidden] { display: none; } | |
| 15 | + | & td > a { text-decoration: none; } | |
| 16 | + | & tr:hover td > a:first-child, & tr[data-cursor] td > a:first-child { text-decoration: underline; } | |
| 17 | + | & .c-sub { color: var(--ink-dim); } | |
| 18 | + | & .c-sub .refs { color: var(--ink); } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | .home { | |
| 22 | + | & .c-name { width: 24ch; font-weight: bold; } | |
| 23 | + | & th.c-name { font-weight: normal; } | |
| 24 | + | & th.c-name[aria-sort] { font-weight: bold; } | |
| 25 | + | & .c-desc { width: auto; } | |
| 26 | + | & .c-when { width: 13ch; } | |
| 27 | + | & .c-ref { width: 10ch; } | |
| 28 | + | & .c-lang { width: 12ch; padding-right: 0; } | |
| 29 | + | } | |
| 30 | + | .list.tree { | |
| 31 | + | & .c-kind { width: 2ch; color: var(--ink-mute); padding-right: 0; } | |
| 32 | + | & .c-name { width: 34ch; } | |
| 33 | + | & .c-size { width: 9ch; text-align: right; padding-right: 3ch; } | |
| 34 | + | & .c-when { width: 11ch; } | |
| 35 | + | & .c-sub { width: auto; } | |
| 36 | + | & .c-who { width: 12ch; } | |
| 37 | + | } | |
| 38 | + | .list.commits, .list.refs { | |
| 39 | + | & .c-sha { width: 8ch; } | |
| 40 | + | & .c-who { width: 12ch; } | |
| 41 | + | & .c-when { width: 11ch; } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /* filter line (home, tree, commits) */ | |
| 45 | + | .filter { | |
| 46 | + | display: flex; gap: 1ch; height: var(--lh); margin: var(--lh) 0 0; | |
| 47 | + | & label { color: var(--ink-mute); } | |
| 48 | + | & input { flex: 1; min-width: 0; } | |
| 49 | + | & input::placeholder { color: var(--ink-mute); opacity: 1; } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /* headings inside pages read as pager section rules */ | |
| 53 | + | .rule-h { | |
| 54 | + | display: flex; gap: 1ch; margin: var(--lh) 0 0; color: var(--ink-dim); | |
| 55 | + | &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); } | |
| 56 | + | & > :first-child { font-weight: bold; color: var(--ink); } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /* empty / 404 / error */ | |
| 60 | + | .state { | |
| 61 | + | margin-top: var(--lh); max-width: var(--measure); | |
| 62 | + | & h1 { margin-bottom: var(--lh); } | |
| 63 | + | & .code { display: inline-block; min-width: 6ch; } | |
| 64 | + | & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; } | |
| 65 | + | } | |
| 66 | + | .clone { | |
| 67 | + | margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch; | |
| 68 | + | & .clone-line { display: flex; gap: 1ch; align-items: baseline; } | |
| 69 | + | & code { flex: 1; min-width: 0; overflow-x: auto; white-space: pre; } | |
| 70 | + | & .copy { color: var(--ink-dim); white-space: nowrap; min-width: 9ch; text-align: right; } | |
| 71 | + | & .copy:hover { color: var(--ink); text-decoration: underline; } | |
| 72 | + | } | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | @layer components { | |||
| 2 | + | /* plain-text tables with a ▸ row cursor */ | |||
| 3 | + | .list { | |||
| 4 | + | width: 100%; table-layout: fixed; | |||
| 5 | + | & th { color: var(--ink-mute); font-weight: normal; white-space: nowrap; border-bottom: 1px solid var(--rule); } | |||
| 6 | + | & th a { text-decoration: none; } | |||
| 7 | + | & th a:hover { text-decoration: underline; color: var(--ink); } | |||
| 8 | + | & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink); font-weight: bold; } | |||
| 9 | + | & td, & th { padding-right: 2ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |||
| 10 | + | & .cur { width: 2ch; padding: 0; } | |||
| 11 | + | & tbody tr[data-cursor] { background: var(--row); } | |||
| 12 | + | & tbody tr[data-cursor] .cur::before { content: "▸"; font-weight: bold; } | |||
| 13 | + | & tbody tr:hover { background: var(--row); } | |||
| 14 | + | & tbody tr[hidden] { display: none; } | |||
| 15 | + | & td > a { text-decoration: none; } | |||
| 16 | + | & tr:hover td > a:first-child, & tr[data-cursor] td > a:first-child { text-decoration: underline; } | |||
| 17 | + | & .c-sub { color: var(--ink-dim); } | |||
| 18 | + | & .c-sub .refs { color: var(--ink); } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | .home { | |||
| 22 | + | & .c-name { width: 24ch; font-weight: bold; } | |||
| 23 | + | & th.c-name { font-weight: normal; } | |||
| 24 | + | & th.c-name[aria-sort] { font-weight: bold; } | |||
| 25 | + | & .c-desc { width: auto; } | |||
| 26 | + | & .c-when { width: 13ch; } | |||
| 27 | + | & .c-ref { width: 10ch; } | |||
| 28 | + | & .c-lang { width: 12ch; padding-right: 0; } | |||
| 29 | + | } | |||
| 30 | + | .list.tree { | |||
| 31 | + | & .c-kind { width: 2ch; color: var(--ink-mute); padding-right: 0; } | |||
| 32 | + | & .c-name { width: 34ch; } | |||
| 33 | + | & .c-size { width: 9ch; text-align: right; padding-right: 3ch; } | |||
| 34 | + | & .c-when { width: 11ch; } | |||
| 35 | + | & .c-sub { width: auto; } | |||
| 36 | + | & .c-who { width: 12ch; } | |||
| 37 | + | } | |||
| 38 | + | .list.commits, .list.refs { | |||
| 39 | + | & .c-sha { width: 8ch; } | |||
| 40 | + | & .c-who { width: 12ch; } | |||
| 41 | + | & .c-when { width: 11ch; } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | /* filter line (home, tree, commits) */ | |||
| 45 | + | .filter { | |||
| 46 | + | display: flex; gap: 1ch; height: var(--lh); margin: var(--lh) 0 0; | |||
| 47 | + | & label { color: var(--ink-mute); } | |||
| 48 | + | & input { flex: 1; min-width: 0; } | |||
| 49 | + | & input::placeholder { color: var(--ink-mute); opacity: 1; } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | /* headings inside pages read as pager section rules */ | |||
| 53 | + | .rule-h { | |||
| 54 | + | display: flex; gap: 1ch; margin: var(--lh) 0 0; color: var(--ink-dim); | |||
| 55 | + | &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); } | |||
| 56 | + | & > :first-child { font-weight: bold; color: var(--ink); } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | /* empty / 404 / error */ | |||
| 60 | + | .state { | |||
| 61 | + | margin-top: var(--lh); max-width: var(--measure); | |||
| 62 | + | & h1 { margin-bottom: var(--lh); } | |||
| 63 | + | & .code { display: inline-block; min-width: 6ch; } | |||
| 64 | + | & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; } | |||
| 65 | + | } | |||
| 66 | + | .clone { | |||
| 67 | + | margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch; | |||
| 68 | + | & .clone-line { display: flex; gap: 1ch; align-items: baseline; } | |||
| 69 | + | & code { flex: 1; min-width: 0; overflow-x: auto; white-space: pre; } | |||
| 70 | + | & .copy { color: var(--ink-dim); white-space: nowrap; min-width: 9ch; text-align: right; } | |||
| 71 | + | & .copy:hover { color: var(--ink); text-decoration: underline; } | |||
| 72 | + | } | |||
| 73 | + | } | |||
A internal/adapters/web/static/css/40-summary.css +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | @layer components { | |
| 2 | + | .lede { margin-top: var(--lh); max-width: var(--measure); } | |
| 3 | + | ||
| 4 | + | /* key/value facts, aligned on a 12ch key column */ | |
| 5 | + | .facts { | |
| 6 | + | margin-top: var(--lh); display: grid; grid-template-columns: repeat(auto-fill, minmax(38ch, 1fr)); column-gap: 3ch; | |
| 7 | + | & > div { display: grid; grid-template-columns: 12ch 1fr; min-width: 0; } | |
| 8 | + | & dt { color: var(--ink-mute); } | |
| 9 | + | & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | .tagged { color: var(--ink); font-weight: bold; white-space: nowrap; } | |
| 13 | + | .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; } | |
| 14 | + | ||
| 15 | + | /* markdown, rendered from the fixture */ | |
| 16 | + | .prose { | |
| 17 | + | max-width: var(--measure); margin-top: var(--lh); | |
| 18 | + | & h1, & h2, & h3, & h4 { margin: var(--lh) 0 0; } | |
| 19 | + | & h1::before { content: "# "; color: var(--ink-mute); } | |
| 20 | + | & h2::before { content: "## "; color: var(--ink-mute); } | |
| 21 | + | & h3::before { content: "### "; color: var(--ink-mute); } | |
| 22 | + | & h4::before { content: "#### "; color: var(--ink-mute); } | |
| 23 | + | & > :first-child { margin-top: 0; } | |
| 24 | + | & p, & ul, & ol, & pre, & table, & blockquote { margin-top: var(--lh); } | |
| 25 | + | & ul { list-style: none; & > li::before { content: "- "; color: var(--ink-mute); margin-left: -2ch; } } | |
| 26 | + | & ul, & ol { padding-left: 2ch; } | |
| 27 | + | & ol { list-style: decimal; } | |
| 28 | + | & li > ul, & li > ol, & li > p { margin-top: 0; } | |
| 29 | + | & pre { padding: 0 1ch; border-left: 1px solid var(--ink-mute); overflow-x: auto; font-size: var(--fs-code); line-height: var(--lh-code); } | |
| 30 | + | & :not(pre) > code { border-bottom: 1px dotted var(--ink-mute); } | |
| 31 | + | & blockquote { padding-left: 2ch; border-left: 1px solid var(--rule); color: var(--ink-dim); } | |
| 32 | + | & table { border-collapse: collapse; display: block; overflow-x: auto; } | |
| 33 | + | & th, & td { padding: 0 2ch 0 0; white-space: nowrap; vertical-align: top; } | |
| 34 | + | & th { border-bottom: 1px solid var(--rule); } | |
| 35 | + | & hr { margin: var(--lh) 0; } | |
| 36 | + | & .alt { color: var(--ink-mute); } | |
| 37 | + | & img { display: none; } | |
| 38 | + | } | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | @layer components { | |||
| 2 | + | .lede { margin-top: var(--lh); max-width: var(--measure); } | |||
| 3 | + | ||||
| 4 | + | /* key/value facts, aligned on a 12ch key column */ | |||
| 5 | + | .facts { | |||
| 6 | + | margin-top: var(--lh); display: grid; grid-template-columns: repeat(auto-fill, minmax(38ch, 1fr)); column-gap: 3ch; | |||
| 7 | + | & > div { display: grid; grid-template-columns: 12ch 1fr; min-width: 0; } | |||
| 8 | + | & dt { color: var(--ink-mute); } | |||
| 9 | + | & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | .tagged { color: var(--ink); font-weight: bold; white-space: nowrap; } | |||
| 13 | + | .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; } | |||
| 14 | + | ||||
| 15 | + | /* markdown, rendered from the fixture */ | |||
| 16 | + | .prose { | |||
| 17 | + | max-width: var(--measure); margin-top: var(--lh); | |||
| 18 | + | & h1, & h2, & h3, & h4 { margin: var(--lh) 0 0; } | |||
| 19 | + | & h1::before { content: "# "; color: var(--ink-mute); } | |||
| 20 | + | & h2::before { content: "## "; color: var(--ink-mute); } | |||
| 21 | + | & h3::before { content: "### "; color: var(--ink-mute); } | |||
| 22 | + | & h4::before { content: "#### "; color: var(--ink-mute); } | |||
| 23 | + | & > :first-child { margin-top: 0; } | |||
| 24 | + | & p, & ul, & ol, & pre, & table, & blockquote { margin-top: var(--lh); } | |||
| 25 | + | & ul { list-style: none; & > li::before { content: "- "; color: var(--ink-mute); margin-left: -2ch; } } | |||
| 26 | + | & ul, & ol { padding-left: 2ch; } | |||
| 27 | + | & ol { list-style: decimal; } | |||
| 28 | + | & li > ul, & li > ol, & li > p { margin-top: 0; } | |||
| 29 | + | & pre { padding: 0 1ch; border-left: 1px solid var(--ink-mute); overflow-x: auto; font-size: var(--fs-code); line-height: var(--lh-code); } | |||
| 30 | + | & :not(pre) > code { border-bottom: 1px dotted var(--ink-mute); } | |||
| 31 | + | & blockquote { padding-left: 2ch; border-left: 1px solid var(--rule); color: var(--ink-dim); } | |||
| 32 | + | & table { border-collapse: collapse; display: block; overflow-x: auto; } | |||
| 33 | + | & th, & td { padding: 0 2ch 0 0; white-space: nowrap; vertical-align: top; } | |||
| 34 | + | & th { border-bottom: 1px solid var(--rule); } | |||
| 35 | + | & hr { margin: var(--lh) 0; } | |||
| 36 | + | & .alt { color: var(--ink-mute); } | |||
| 37 | + | & img { display: none; } | |||
| 38 | + | } | |||
| 39 | + | } | |||
A internal/adapters/web/static/css/50-code.css +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | @layer components { | |
| 2 | + | /* page title for a file: name, then its actions as plain words */ | |
| 3 | + | .file-h { | |
| 4 | + | display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh); | |
| 5 | + | padding-bottom: 2px; border-bottom: 1px solid var(--rule); | |
| 6 | + | & .acts { margin-left: auto; font-weight: normal; display: flex; gap: 2ch; } | |
| 7 | + | } | |
| 8 | + | .notice { margin-top: var(--lh); border-left: 2px solid var(--ink); padding-left: 1ch; max-width: var(--measure); } | |
| 9 | + | .note { color: var(--ink-dim); } | |
| 10 | + | .pad { padding: 0 1ch; } | |
| 11 | + | ||
| 12 | + | /* source view: gutter numbers are links (selectable lines), lines span the full width */ | |
| 13 | + | .code { | |
| 14 | + | margin: var(--lh) calc(var(--pad) * -1) 0; overflow-x: auto; | |
| 15 | + | font-size: var(--fs-code); line-height: var(--lh-code); | |
| 16 | + | & code { display: grid; min-width: max-content; } | |
| 17 | + | & .line { padding-right: var(--pad); border-left: 2px solid transparent; } | |
| 18 | + | & .line:hover { background: var(--row); } | |
| 19 | + | & .line.sel { background: var(--sel); border-left-color: var(--focus); } | |
| 20 | + | & .ln { | |
| 21 | + | display: inline-block; width: 8ch; padding-right: 2ch; text-align: right; /* 8ch: a multiple of tab-size, so tabs after the gutter keep their width */ | |
| 22 | + | color: var(--ink-mute); text-decoration: none; user-select: none; | |
| 23 | + | } | |
| 24 | + | & .ln:hover, & .sel .ln { color: var(--ink-hi); } | |
| 25 | + | } | |
| 26 | + | .source { margin-top: var(--lh); & > summary { color: var(--ink-dim); } } | |
| 27 | + | ||
| 28 | + | /* commit header, like `git show` */ | |
| 29 | + | .show { | |
| 30 | + | margin-top: var(--lh); | |
| 31 | + | & h1 { max-width: var(--measure); } | |
| 32 | + | & .msg { margin-top: var(--lh); white-space: pre-wrap; max-width: var(--measure); color: var(--ink-dim); } | |
| 33 | + | & .show-kv { margin-top: var(--lh); } | |
| 34 | + | & .show-kv dd { white-space: normal; } | |
| 35 | + | } | |
| 36 | + | .diff-tools { | |
| 37 | + | display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh); | |
| 38 | + | position: sticky; top: var(--stick); z-index: 5; background: var(--paper); | |
| 39 | + | border-bottom: 1px solid var(--rule); | |
| 40 | + | & .grow { flex: 1; } | |
| 41 | + | & button { color: var(--ink-dim); } | |
| 42 | + | & button:hover { color: var(--ink); text-decoration: underline; } | |
| 43 | + | & button[aria-pressed="true"] { color: var(--ink-hi); font-weight: bold; } | |
| 44 | + | & button[aria-pressed="true"]::before { content: "▸"; } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /* one file of a diff */ | |
| 48 | + | .dfile { | |
| 49 | + | margin-top: var(--lh); scroll-margin-top: calc(var(--stick) + var(--lh) + 4px); | |
| 50 | + | & > summary { | |
| 51 | + | position: sticky; top: calc(var(--stick) + var(--lh) + 1px); z-index: 4; | |
| 52 | + | background: var(--paper-alt); border: 1px solid var(--rule); padding: 0 1ch; | |
| 53 | + | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none; | |
| 54 | + | } | |
| 55 | + | & > summary::-webkit-details-marker { display: none; } | |
| 56 | + | & > summary::before { content: "▸ "; } | |
| 57 | + | &[open] > summary::before { content: "▾ "; } | |
| 58 | + | & .st { font-weight: bold; } | |
| 59 | + | & .p { font-weight: bold; } | |
| 60 | + | & > .note { border: 1px solid var(--rule); border-top: 0; } | |
| 61 | + | } | |
| 62 | + | .dt { | |
| 63 | + | width: max-content; min-width: 100%; | |
| 64 | + | font-size: var(--fs-code); line-height: var(--lh-code); | |
| 65 | + | & td { padding: 0; vertical-align: top; } | |
| 66 | + | & .ln { width: 6ch; min-width: 6ch; padding-right: 1ch; text-align: right; color: var(--ink-mute); user-select: none; } | |
| 67 | + | & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); } | |
| 68 | + | & .tx { white-space: pre; } | |
| 69 | + | & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; } | |
| 70 | + | & .add, & tr.add td { background: var(--add); } | |
| 71 | + | & .del, & tr.del td { background: var(--del); } | |
| 72 | + | & .add.m, & tr.add .m { color: var(--teal); font-weight: bold; } | |
| 73 | + | & .del.m, & tr.del .m { color: var(--red); font-weight: bold; } | |
| 74 | + | & tr.add .ln, & .ln.add { color: var(--teal); } | |
| 75 | + | & tr.del .ln, & .ln.del { color: var(--red); } | |
| 76 | + | & .none { background: var(--paper-alt); } | |
| 77 | + | } | |
| 78 | + | .dscroll { overflow-x: auto; border: 1px solid var(--rule); border-top: 0; } | |
| 79 | + | .spl { width: 100%; table-layout: fixed; & .tx { overflow: hidden; text-overflow: ellipsis; } & .ln { width: 6ch; } & .m { width: 2ch; } } | |
| 80 | + | #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; } | |
| 81 | + | ||
| 82 | + | /* diff table of contents in the rail */ | |
| 83 | + | .files { | |
| 84 | + | & a { display: grid; grid-template-columns: 2ch minmax(0, 1fr) auto; column-gap: 1ch; text-decoration: none; } | |
| 85 | + | & a:hover .p { text-decoration: underline; } | |
| 86 | + | & a[aria-current="true"] { background: var(--row); font-weight: bold; } | |
| 87 | + | & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; } | |
| 88 | + | & .n { color: var(--ink-dim); font-size: var(--fs-s); } | |
| 89 | + | & .b { grid-column: 2 / -1; color: var(--ink-mute); font-size: var(--fs-s); letter-spacing: .1ch; line-height: 1; padding-bottom: 4px; } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | @media (max-width: 700px) { | |
| 93 | + | .diff-tools { flex-wrap: wrap; column-gap: 1ch; } | |
| 94 | + | .diff-tools .grow { flex-basis: 100%; height: 0; } | |
| 95 | + | .show-kv dd { overflow-wrap: anywhere; } | |
| 96 | + | .file-h .acts { gap: 1ch; } | |
| 97 | + | } | |
| 98 | + | .pager { display: flex; gap: 3ch; justify-content: center; margin-top: var(--lh); } | |
| 99 | + | .days section[hidden] { display: none; } | |
| 100 | + | .list.refs { | |
| 101 | + | & .c-kind { width: 8ch; } | |
| 102 | + | & .c-name { width: 34ch; } | |
| 103 | + | & .c-info { width: 11ch; } | |
| 104 | + | & .c-sha { width: 9ch; } | |
| 105 | + | & .c-sub { width: auto; } | |
| 106 | + | } | |
| 107 | + | .rail .list.commits { | |
| 108 | + | & .c-who { display: none; } | |
| 109 | + | & .c-sha { width: 8ch; } | |
| 110 | + | & .c-when { width: 9ch; padding-right: 0; text-align: right; } | |
| 111 | + | } | |
| 112 | + | .rail p + p { margin-top: 0; } | |
| 113 | + | .rail section > p { overflow-wrap: anywhere; } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | @layer components { | |
| 117 | + | .gallery section { margin-top: calc(var(--lh) * 2); } | |
| 118 | + | .gallery .rule-h > :last-child { font-weight: normal; color: var(--ink-mute); order: 2; } | |
| 119 | + | .gallery .rule-h::after { order: 1; } | |
| 120 | + | .gallery .files { max-width: var(--rail); } | |
| 121 | + | .linkish { text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; } | |
| 122 | + | .linkish:hover { text-decoration-color: currentColor; } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | @layer components { | |
| 126 | + | /* counts and bars: + in teal, - in red, everywhere a change is summarised */ | |
| 127 | + | .plus { color: var(--teal); } | |
| 128 | + | .minus { color: var(--red); } | |
| 129 | + | } | |
| @@ -0,0 +1,129 @@ | |||||
| 1 | + | @layer components { | |||
| 2 | + | /* page title for a file: name, then its actions as plain words */ | |||
| 3 | + | .file-h { | |||
| 4 | + | display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh); | |||
| 5 | + | padding-bottom: 2px; border-bottom: 1px solid var(--rule); | |||
| 6 | + | & .acts { margin-left: auto; font-weight: normal; display: flex; gap: 2ch; } | |||
| 7 | + | } | |||
| 8 | + | .notice { margin-top: var(--lh); border-left: 2px solid var(--ink); padding-left: 1ch; max-width: var(--measure); } | |||
| 9 | + | .note { color: var(--ink-dim); } | |||
| 10 | + | .pad { padding: 0 1ch; } | |||
| 11 | + | ||||
| 12 | + | /* source view: gutter numbers are links (selectable lines), lines span the full width */ | |||
| 13 | + | .code { | |||
| 14 | + | margin: var(--lh) calc(var(--pad) * -1) 0; overflow-x: auto; | |||
| 15 | + | font-size: var(--fs-code); line-height: var(--lh-code); | |||
| 16 | + | & code { display: grid; min-width: max-content; } | |||
| 17 | + | & .line { padding-right: var(--pad); border-left: 2px solid transparent; } | |||
| 18 | + | & .line:hover { background: var(--row); } | |||
| 19 | + | & .line.sel { background: var(--sel); border-left-color: var(--focus); } | |||
| 20 | + | & .ln { | |||
| 21 | + | display: inline-block; width: 8ch; padding-right: 2ch; text-align: right; /* 8ch: a multiple of tab-size, so tabs after the gutter keep their width */ | |||
| 22 | + | color: var(--ink-mute); text-decoration: none; user-select: none; | |||
| 23 | + | } | |||
| 24 | + | & .ln:hover, & .sel .ln { color: var(--ink-hi); } | |||
| 25 | + | } | |||
| 26 | + | .source { margin-top: var(--lh); & > summary { color: var(--ink-dim); } } | |||
| 27 | + | ||||
| 28 | + | /* commit header, like `git show` */ | |||
| 29 | + | .show { | |||
| 30 | + | margin-top: var(--lh); | |||
| 31 | + | & h1 { max-width: var(--measure); } | |||
| 32 | + | & .msg { margin-top: var(--lh); white-space: pre-wrap; max-width: var(--measure); color: var(--ink-dim); } | |||
| 33 | + | & .show-kv { margin-top: var(--lh); } | |||
| 34 | + | & .show-kv dd { white-space: normal; } | |||
| 35 | + | } | |||
| 36 | + | .diff-tools { | |||
| 37 | + | display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh); | |||
| 38 | + | position: sticky; top: var(--stick); z-index: 5; background: var(--paper); | |||
| 39 | + | border-bottom: 1px solid var(--rule); | |||
| 40 | + | & .grow { flex: 1; } | |||
| 41 | + | & button { color: var(--ink-dim); } | |||
| 42 | + | & button:hover { color: var(--ink); text-decoration: underline; } | |||
| 43 | + | & button[aria-pressed="true"] { color: var(--ink-hi); font-weight: bold; } | |||
| 44 | + | & button[aria-pressed="true"]::before { content: "▸"; } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | /* one file of a diff */ | |||
| 48 | + | .dfile { | |||
| 49 | + | margin-top: var(--lh); scroll-margin-top: calc(var(--stick) + var(--lh) + 4px); | |||
| 50 | + | & > summary { | |||
| 51 | + | position: sticky; top: calc(var(--stick) + var(--lh) + 1px); z-index: 4; | |||
| 52 | + | background: var(--paper-alt); border: 1px solid var(--rule); padding: 0 1ch; | |||
| 53 | + | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none; | |||
| 54 | + | } | |||
| 55 | + | & > summary::-webkit-details-marker { display: none; } | |||
| 56 | + | & > summary::before { content: "▸ "; } | |||
| 57 | + | &[open] > summary::before { content: "▾ "; } | |||
| 58 | + | & .st { font-weight: bold; } | |||
| 59 | + | & .p { font-weight: bold; } | |||
| 60 | + | & > .note { border: 1px solid var(--rule); border-top: 0; } | |||
| 61 | + | } | |||
| 62 | + | .dt { | |||
| 63 | + | width: max-content; min-width: 100%; | |||
| 64 | + | font-size: var(--fs-code); line-height: var(--lh-code); | |||
| 65 | + | & td { padding: 0; vertical-align: top; } | |||
| 66 | + | & .ln { width: 6ch; min-width: 6ch; padding-right: 1ch; text-align: right; color: var(--ink-mute); user-select: none; } | |||
| 67 | + | & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); } | |||
| 68 | + | & .tx { white-space: pre; } | |||
| 69 | + | & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; } | |||
| 70 | + | & .add, & tr.add td { background: var(--add); } | |||
| 71 | + | & .del, & tr.del td { background: var(--del); } | |||
| 72 | + | & .add.m, & tr.add .m { color: var(--teal); font-weight: bold; } | |||
| 73 | + | & .del.m, & tr.del .m { color: var(--red); font-weight: bold; } | |||
| 74 | + | & tr.add .ln, & .ln.add { color: var(--teal); } | |||
| 75 | + | & tr.del .ln, & .ln.del { color: var(--red); } | |||
| 76 | + | & .none { background: var(--paper-alt); } | |||
| 77 | + | } | |||
| 78 | + | .dscroll { overflow-x: auto; border: 1px solid var(--rule); border-top: 0; } | |||
| 79 | + | .spl { width: 100%; table-layout: fixed; & .tx { overflow: hidden; text-overflow: ellipsis; } & .ln { width: 6ch; } & .m { width: 2ch; } } | |||
| 80 | + | #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; } | |||
| 81 | + | ||||
| 82 | + | /* diff table of contents in the rail */ | |||
| 83 | + | .files { | |||
| 84 | + | & a { display: grid; grid-template-columns: 2ch minmax(0, 1fr) auto; column-gap: 1ch; text-decoration: none; } | |||
| 85 | + | & a:hover .p { text-decoration: underline; } | |||
| 86 | + | & a[aria-current="true"] { background: var(--row); font-weight: bold; } | |||
| 87 | + | & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; } | |||
| 88 | + | & .n { color: var(--ink-dim); font-size: var(--fs-s); } | |||
| 89 | + | & .b { grid-column: 2 / -1; color: var(--ink-mute); font-size: var(--fs-s); letter-spacing: .1ch; line-height: 1; padding-bottom: 4px; } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | @media (max-width: 700px) { | |||
| 93 | + | .diff-tools { flex-wrap: wrap; column-gap: 1ch; } | |||
| 94 | + | .diff-tools .grow { flex-basis: 100%; height: 0; } | |||
| 95 | + | .show-kv dd { overflow-wrap: anywhere; } | |||
| 96 | + | .file-h .acts { gap: 1ch; } | |||
| 97 | + | } | |||
| 98 | + | .pager { display: flex; gap: 3ch; justify-content: center; margin-top: var(--lh); } | |||
| 99 | + | .days section[hidden] { display: none; } | |||
| 100 | + | .list.refs { | |||
| 101 | + | & .c-kind { width: 8ch; } | |||
| 102 | + | & .c-name { width: 34ch; } | |||
| 103 | + | & .c-info { width: 11ch; } | |||
| 104 | + | & .c-sha { width: 9ch; } | |||
| 105 | + | & .c-sub { width: auto; } | |||
| 106 | + | } | |||
| 107 | + | .rail .list.commits { | |||
| 108 | + | & .c-who { display: none; } | |||
| 109 | + | & .c-sha { width: 8ch; } | |||
| 110 | + | & .c-when { width: 9ch; padding-right: 0; text-align: right; } | |||
| 111 | + | } | |||
| 112 | + | .rail p + p { margin-top: 0; } | |||
| 113 | + | .rail section > p { overflow-wrap: anywhere; } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | @layer components { | |||
| 117 | + | .gallery section { margin-top: calc(var(--lh) * 2); } | |||
| 118 | + | .gallery .rule-h > :last-child { font-weight: normal; color: var(--ink-mute); order: 2; } | |||
| 119 | + | .gallery .rule-h::after { order: 1; } | |||
| 120 | + | .gallery .files { max-width: var(--rail); } | |||
| 121 | + | .linkish { text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; } | |||
| 122 | + | .linkish:hover { text-decoration-color: currentColor; } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | @layer components { | |||
| 126 | + | /* counts and bars: + in teal, - in red, everywhere a change is summarised */ | |||
| 127 | + | .plus { color: var(--teal); } | |||
| 128 | + | .minus { color: var(--red); } | |||
| 129 | + | } | |||
A internal/adapters/web/static/css/60-proposal.css +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | @layer components { | |
| 2 | + | /* proposals: the decision gate. Hue only where it carries a verdict: | |
| 3 | + | check state (pass teal, fail red) and open high/medium findings (red). */ | |
| 4 | + | .criteria { | |
| 5 | + | margin-top: calc(var(--lh) / 2); max-width: var(--measure); | |
| 6 | + | & li { padding-left: 4ch; text-indent: -4ch; } | |
| 7 | + | & .ck { color: var(--teal); font-weight: bold; } | |
| 8 | + | & li[data-met="false"] .ck { color: var(--red); } | |
| 9 | + | & .mute { font-size: var(--fs-s); overflow-wrap: anywhere; } | |
| 10 | + | } | |
| 11 | + | .prop-h .show-kv { margin-top: 0; } | |
| 12 | + | .prop-h + .rule-h { margin-top: var(--lh); } | |
| 13 | + | ||
| 14 | + | .since { | |
| 15 | + | display: flex; flex-wrap: wrap; column-gap: 3ch; margin-top: var(--lh); | |
| 16 | + | & a { text-decoration: none; color: var(--ink-dim); } | |
| 17 | + | & a:hover { color: var(--ink); text-decoration: underline; } | |
| 18 | + | & a[aria-current] { color: var(--ink-hi); font-weight: bold; } | |
| 19 | + | & a[aria-current]::before { content: "▸ "; } | |
| 20 | + | & .mute { font-weight: normal; } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /* decision block: first thing in the rail */ | |
| 24 | + | .decide { | |
| 25 | + | & .d-state { font-weight: bold; color: var(--ink-hi); text-transform: uppercase; letter-spacing: .1ch; } | |
| 26 | + | &[data-state="ready"] .d-state::before { content: "● "; color: var(--teal); } | |
| 27 | + | &[data-state="accepted"] .d-state::before { content: "✓ "; color: var(--teal); } | |
| 28 | + | &[data-state="refused"] .d-state::before { content: "✗ "; color: var(--red); } | |
| 29 | + | & p + p { margin-top: 0; } | |
| 30 | + | & .kv { grid-template-columns: 9ch minmax(0, 1fr); } | |
| 31 | + | & .kv dd { white-space: normal; } | |
| 32 | + | & .d-acts { margin-top: calc(var(--lh) / 2); } | |
| 33 | + | & textarea { | |
| 34 | + | display: block; width: 100%; resize: vertical; font: inherit; color: inherit; | |
| 35 | + | background: var(--paper-alt); border: 1px solid var(--rule); padding: 0 1ch; | |
| 36 | + | } | |
| 37 | + | & textarea::placeholder { color: var(--ink-mute); opacity: 1; } | |
| 38 | + | & .d-btns { display: flex; gap: 2ch; margin-top: 4px; } | |
| 39 | + | & .d-btns button { color: var(--ink-dim); } | |
| 40 | + | & .d-btns button:hover { color: var(--ink); text-decoration: underline; } | |
| 41 | + | & .d-btns .d-main { background: var(--ink); color: var(--paper); font-weight: bold; padding: 0 1ch; } | |
| 42 | + | & .d-btns .d-main:hover { background: var(--ink-hi); text-decoration: none; } | |
| 43 | + | & .d-or { margin-top: calc(var(--lh) / 2); } | |
| 44 | + | & .clone { margin-top: 0; } | |
| 45 | + | & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 46 | + | } | |
| 47 | + | .closed { margin-top: calc(var(--lh) / 2); & > summary { color: var(--ink-dim); } } | |
| 48 | + | ||
| 49 | + | .checks li { display: grid; grid-template-columns: 5ch minmax(0, 1fr) auto; column-gap: 1ch; } | |
| 50 | + | .checks .c-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 51 | + | .checks .c-st { font-weight: bold; color: var(--teal); } | |
| 52 | + | .checks [data-state="fail"] .c-st { color: var(--red); } | |
| 53 | + | .checks [data-state="run"] .c-st { color: var(--ink-dim); } | |
| 54 | + | ||
| 55 | + | .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 56 | + | .pcommits li[data-new] { background: var(--row); } | |
| 57 | + | .pcommits .s { color: var(--ink); } | |
| 58 | + | ||
| 59 | + | /* a finding: a ruled note, in the rail and under its line in the diff */ | |
| 60 | + | .finding { | |
| 61 | + | border-left: 2px solid var(--ink); padding-left: 1ch; margin-top: calc(var(--lh) / 2); | |
| 62 | + | & p { white-space: normal; } | |
| 63 | + | & .f-sev { font-weight: bold; text-transform: uppercase; } | |
| 64 | + | & .f-state { color: var(--ink-dim); } | |
| 65 | + | & .f-where { font-size: var(--fs-s); overflow-wrap: anywhere; } | |
| 66 | + | & .f-reason { color: var(--ink-dim); } | |
| 67 | + | &[data-state="open"][data-sev="high"], &[data-state="open"][data-sev="medium"] { border-left-color: var(--red); } | |
| 68 | + | &[data-state="open"][data-sev="high"] .f-sev, &[data-state="open"][data-sev="medium"] .f-sev { color: var(--red); } | |
| 69 | + | &[data-state="fixed"], &[data-state="dismissed"] { border-left-color: var(--rule); } | |
| 70 | + | &[data-state="fixed"] .f-text, &[data-state="dismissed"] .f-text { color: var(--ink-dim); } | |
| 71 | + | &[data-state="fixed"] .f-sev, &[data-state="dismissed"] .f-sev { font-weight: normal; color: var(--ink-mute); } | |
| 72 | + | } | |
| 73 | + | .dt .fnote td { background: var(--paper-alt); padding: 2px 0 4px; white-space: normal; } | |
| 74 | + | .dt .fnote .finding { margin: 0; max-width: var(--measure); font-size: var(--fs); line-height: var(--lh); } | |
| 75 | + | .dfile .fcount { color: var(--red); font-weight: bold; } | |
| 76 | + | ||
| 77 | + | .list.props { | |
| 78 | + | & .c-n { width: 5ch; } | |
| 79 | + | & .c-state { width: 18ch; } | |
| 80 | + | & .c-subject { overflow: hidden; text-overflow: ellipsis; } | |
| 81 | + | & .c-chk { width: 6ch; } | |
| 82 | + | & .c-fnd { width: 9ch; } | |
| 83 | + | & .c-who { width: 12ch; } | |
| 84 | + | & .c-when { width: 9ch; } | |
| 85 | + | & tr[data-state="ready"] .c-state { font-weight: bold; } | |
| 86 | + | & tr[data-state="ready"] .c-state::before { content: "● "; color: var(--teal); } | |
| 87 | + | } | |
| 88 | + | .rail code { border-bottom: 1px dotted var(--ink-mute); } | |
| 89 | + | ||
| 90 | + | @media (max-width: 700px) { | |
| 91 | + | .list.props .c-chk, .list.props .c-fnd { display: none; } | |
| 92 | + | } | |
| 93 | + | } | |
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | @layer components { | |||
| 2 | + | /* proposals: the decision gate. Hue only where it carries a verdict: | |||
| 3 | + | check state (pass teal, fail red) and open high/medium findings (red). */ | |||
| 4 | + | .criteria { | |||
| 5 | + | margin-top: calc(var(--lh) / 2); max-width: var(--measure); | |||
| 6 | + | & li { padding-left: 4ch; text-indent: -4ch; } | |||
| 7 | + | & .ck { color: var(--teal); font-weight: bold; } | |||
| 8 | + | & li[data-met="false"] .ck { color: var(--red); } | |||
| 9 | + | & .mute { font-size: var(--fs-s); overflow-wrap: anywhere; } | |||
| 10 | + | } | |||
| 11 | + | .prop-h .show-kv { margin-top: 0; } | |||
| 12 | + | .prop-h + .rule-h { margin-top: var(--lh); } | |||
| 13 | + | ||||
| 14 | + | .since { | |||
| 15 | + | display: flex; flex-wrap: wrap; column-gap: 3ch; margin-top: var(--lh); | |||
| 16 | + | & a { text-decoration: none; color: var(--ink-dim); } | |||
| 17 | + | & a:hover { color: var(--ink); text-decoration: underline; } | |||
| 18 | + | & a[aria-current] { color: var(--ink-hi); font-weight: bold; } | |||
| 19 | + | & a[aria-current]::before { content: "▸ "; } | |||
| 20 | + | & .mute { font-weight: normal; } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | /* decision block: first thing in the rail */ | |||
| 24 | + | .decide { | |||
| 25 | + | & .d-state { font-weight: bold; color: var(--ink-hi); text-transform: uppercase; letter-spacing: .1ch; } | |||
| 26 | + | &[data-state="ready"] .d-state::before { content: "● "; color: var(--teal); } | |||
| 27 | + | &[data-state="accepted"] .d-state::before { content: "✓ "; color: var(--teal); } | |||
| 28 | + | &[data-state="refused"] .d-state::before { content: "✗ "; color: var(--red); } | |||
| 29 | + | & p + p { margin-top: 0; } | |||
| 30 | + | & .kv { grid-template-columns: 9ch minmax(0, 1fr); } | |||
| 31 | + | & .kv dd { white-space: normal; } | |||
| 32 | + | & .d-acts { margin-top: calc(var(--lh) / 2); } | |||
| 33 | + | & textarea { | |||
| 34 | + | display: block; width: 100%; resize: vertical; font: inherit; color: inherit; | |||
| 35 | + | background: var(--paper-alt); border: 1px solid var(--rule); padding: 0 1ch; | |||
| 36 | + | } | |||
| 37 | + | & textarea::placeholder { color: var(--ink-mute); opacity: 1; } | |||
| 38 | + | & .d-btns { display: flex; gap: 2ch; margin-top: 4px; } | |||
| 39 | + | & .d-btns button { color: var(--ink-dim); } | |||
| 40 | + | & .d-btns button:hover { color: var(--ink); text-decoration: underline; } | |||
| 41 | + | & .d-btns .d-main { background: var(--ink); color: var(--paper); font-weight: bold; padding: 0 1ch; } | |||
| 42 | + | & .d-btns .d-main:hover { background: var(--ink-hi); text-decoration: none; } | |||
| 43 | + | & .d-or { margin-top: calc(var(--lh) / 2); } | |||
| 44 | + | & .clone { margin-top: 0; } | |||
| 45 | + | & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; } | |||
| 46 | + | } | |||
| 47 | + | .closed { margin-top: calc(var(--lh) / 2); & > summary { color: var(--ink-dim); } } | |||
| 48 | + | ||||
| 49 | + | .checks li { display: grid; grid-template-columns: 5ch minmax(0, 1fr) auto; column-gap: 1ch; } | |||
| 50 | + | .checks .c-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |||
| 51 | + | .checks .c-st { font-weight: bold; color: var(--teal); } | |||
| 52 | + | .checks [data-state="fail"] .c-st { color: var(--red); } | |||
| 53 | + | .checks [data-state="run"] .c-st { color: var(--ink-dim); } | |||
| 54 | + | ||||
| 55 | + | .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |||
| 56 | + | .pcommits li[data-new] { background: var(--row); } | |||
| 57 | + | .pcommits .s { color: var(--ink); } | |||
| 58 | + | ||||
| 59 | + | /* a finding: a ruled note, in the rail and under its line in the diff */ | |||
| 60 | + | .finding { | |||
| 61 | + | border-left: 2px solid var(--ink); padding-left: 1ch; margin-top: calc(var(--lh) / 2); | |||
| 62 | + | & p { white-space: normal; } | |||
| 63 | + | & .f-sev { font-weight: bold; text-transform: uppercase; } | |||
| 64 | + | & .f-state { color: var(--ink-dim); } | |||
| 65 | + | & .f-where { font-size: var(--fs-s); overflow-wrap: anywhere; } | |||
| 66 | + | & .f-reason { color: var(--ink-dim); } | |||
| 67 | + | &[data-state="open"][data-sev="high"], &[data-state="open"][data-sev="medium"] { border-left-color: var(--red); } | |||
| 68 | + | &[data-state="open"][data-sev="high"] .f-sev, &[data-state="open"][data-sev="medium"] .f-sev { color: var(--red); } | |||
| 69 | + | &[data-state="fixed"], &[data-state="dismissed"] { border-left-color: var(--rule); } | |||
| 70 | + | &[data-state="fixed"] .f-text, &[data-state="dismissed"] .f-text { color: var(--ink-dim); } | |||
| 71 | + | &[data-state="fixed"] .f-sev, &[data-state="dismissed"] .f-sev { font-weight: normal; color: var(--ink-mute); } | |||
| 72 | + | } | |||
| 73 | + | .dt .fnote td { background: var(--paper-alt); padding: 2px 0 4px; white-space: normal; } | |||
| 74 | + | .dt .fnote .finding { margin: 0; max-width: var(--measure); font-size: var(--fs); line-height: var(--lh); } | |||
| 75 | + | .dfile .fcount { color: var(--red); font-weight: bold; } | |||
| 76 | + | ||||
| 77 | + | .list.props { | |||
| 78 | + | & .c-n { width: 5ch; } | |||
| 79 | + | & .c-state { width: 18ch; } | |||
| 80 | + | & .c-subject { overflow: hidden; text-overflow: ellipsis; } | |||
| 81 | + | & .c-chk { width: 6ch; } | |||
| 82 | + | & .c-fnd { width: 9ch; } | |||
| 83 | + | & .c-who { width: 12ch; } | |||
| 84 | + | & .c-when { width: 9ch; } | |||
| 85 | + | & tr[data-state="ready"] .c-state { font-weight: bold; } | |||
| 86 | + | & tr[data-state="ready"] .c-state::before { content: "● "; color: var(--teal); } | |||
| 87 | + | } | |||
| 88 | + | .rail code { border-bottom: 1px dotted var(--ink-mute); } | |||
| 89 | + | ||||
| 90 | + | @media (max-width: 700px) { | |||
| 91 | + | .list.props .c-chk, .list.props .c-fnd { display: none; } | |||
| 92 | + | } | |||
| 93 | + | } | |||
A internal/adapters/web/static/css/70-footer.css +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | @layer components { | |
| 2 | + | /* AGPL §13: the version and the link to the source sit in the status bar, always visible */ | |
| 3 | + | .status .st-src { | |
| 4 | + | flex: none; | |
| 5 | + | & a { color: var(--bar-ink); text-decoration: underline; text-decoration-color: var(--bar-dim); text-underline-offset: 3px; } | |
| 6 | + | & a:hover { color: #fff; text-decoration-color: currentColor; } | |
| 7 | + | } | |
| 8 | + | @media (max-width: 700px) { | |
| 9 | + | .status .st-ver { display: none; } | |
| 10 | + | .status .st-hints { display: none; } | |
| 11 | + | } | |
| 12 | + | } | |
| @@ -0,0 +1,12 @@ | |||||
| 1 | + | @layer components { | |||
| 2 | + | /* AGPL §13: the version and the link to the source sit in the status bar, always visible */ | |||
| 3 | + | .status .st-src { | |||
| 4 | + | flex: none; | |||
| 5 | + | & a { color: var(--bar-ink); text-decoration: underline; text-decoration-color: var(--bar-dim); text-underline-offset: 3px; } | |||
| 6 | + | & a:hover { color: #fff; text-decoration-color: currentColor; } | |||
| 7 | + | } | |||
| 8 | + | @media (max-width: 700px) { | |||
| 9 | + | .status .st-ver { display: none; } | |||
| 10 | + | .status .st-hints { display: none; } | |||
| 11 | + | } | |||
| 12 | + | } | |||
A internal/adapters/web/static/js/ermite.js +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | // ermite.js: the one thing hx-live cannot do is a global key *sequence* state machine | |
| 2 | + | // ("g" then "h"), nor ignore keystrokes aimed at inputs before any element is focused. | |
| 3 | + | // It only translates keys into DOM events named "k-<name>" on <body>; every reaction | |
| 4 | + | // (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live. | |
| 5 | + | (() => { | |
| 6 | + | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | |
| 7 | + | s: 's', t: 't', c: 'c', r: 'r', p: 'p', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' }; | |
| 8 | + | const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', p: 'gp', g: 'top' }; | |
| 9 | + | let pending = 0; | |
| 10 | + | const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); | |
| 11 | + | addEventListener('keydown', (e) => { | |
| 12 | + | if (e.ctrlKey || e.metaKey || e.altKey) return; | |
| 13 | + | const typing = e.target.closest('input, textarea, select, [contenteditable]'); | |
| 14 | + | if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); } | |
| 15 | + | if (typing) return; | |
| 16 | + | if (e.key === 'Enter' && e.target.closest('a, button, summary')) return; | |
| 17 | + | if (pending) { | |
| 18 | + | const name = goto[e.key]; | |
| 19 | + | pending = 0; | |
| 20 | + | if (name) { e.preventDefault(); fire(name); } | |
| 21 | + | return; | |
| 22 | + | } | |
| 23 | + | if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; } | |
| 24 | + | const name = single[e.key]; | |
| 25 | + | if (!name) return; | |
| 26 | + | if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault(); | |
| 27 | + | fire(name); | |
| 28 | + | }); | |
| 29 | + | // In the app shell the page does not scroll, <main> does: give it focus so arrows, | |
| 30 | + | // PageUp/PageDown and space scroll the content without a click first. | |
| 31 | + | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | |
| 32 | + | })(); | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | // ermite.js: the one thing hx-live cannot do is a global key *sequence* state machine | |||
| 2 | + | // ("g" then "h"), nor ignore keystrokes aimed at inputs before any element is focused. | |||
| 3 | + | // It only translates keys into DOM events named "k-<name>" on <body>; every reaction | |||
| 4 | + | // (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live. | |||
| 5 | + | (() => { | |||
| 6 | + | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | |||
| 7 | + | s: 's', t: 't', c: 'c', r: 'r', p: 'p', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' }; | |||
| 8 | + | const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', p: 'gp', g: 'top' }; | |||
| 9 | + | let pending = 0; | |||
| 10 | + | const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); | |||
| 11 | + | addEventListener('keydown', (e) => { | |||
| 12 | + | if (e.ctrlKey || e.metaKey || e.altKey) return; | |||
| 13 | + | const typing = e.target.closest('input, textarea, select, [contenteditable]'); | |||
| 14 | + | if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); } | |||
| 15 | + | if (typing) return; | |||
| 16 | + | if (e.key === 'Enter' && e.target.closest('a, button, summary')) return; | |||
| 17 | + | if (pending) { | |||
| 18 | + | const name = goto[e.key]; | |||
| 19 | + | pending = 0; | |||
| 20 | + | if (name) { e.preventDefault(); fire(name); } | |||
| 21 | + | return; | |||
| 22 | + | } | |||
| 23 | + | if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; } | |||
| 24 | + | const name = single[e.key]; | |||
| 25 | + | if (!name) return; | |||
| 26 | + | if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault(); | |||
| 27 | + | fire(name); | |||
| 28 | + | }); | |||
| 29 | + | // In the app shell the page does not scroll, <main> does: give it focus so arrows, | |||
| 30 | + | // PageUp/PageDown and space scroll the content without a click first. | |||
| 31 | + | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | |||
| 32 | + | })(); | |||
A internal/adapters/web/static/js/htmx-LICENSE +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | Zero-Clause BSD | |
| 2 | + | ============= | |
| 3 | + | ||
| 4 | + | Permission to use, copy, modify, and/or distribute this software for | |
| 5 | + | any purpose with or without fee is hereby granted. | |
| 6 | + | ||
| 7 | + | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL | |
| 8 | + | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES | |
| 9 | + | OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE | |
| 10 | + | FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY | |
| 11 | + | DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN | |
| 12 | + | AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT | |
| 13 | + | OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | |
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | Zero-Clause BSD | |||
| 2 | + | ============= | |||
| 3 | + | ||||
| 4 | + | Permission to use, copy, modify, and/or distribute this software for | |||
| 5 | + | any purpose with or without fee is hereby granted. | |||
| 6 | + | ||||
| 7 | + | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL | |||
| 8 | + | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES | |||
| 9 | + | OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE | |||
| 10 | + | FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY | |||
| 11 | + | DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN | |||
| 12 | + | AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT | |||
| 13 | + | OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | |||
A internal/adapters/web/static/js/htmx.min.js +1 -0 generated file: collapsed
A internal/adapters/web/static/js/hx-live.min.js +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | (()=>{let e,t=new Set,r=!1,n=Symbol(),i=null,l=null,o=null,s=0,a=!1;const u={childList:!0,subtree:!0,attributes:!0,characterData:!0};let f=null;function c(){r||s>0||(r=!0,queueMicrotask(()=>{i?.disconnect();let e=performance.now();t.forEach(e=>e());let n=performance.now()-e;!a&&n>16&&(console.warn(`htmx: hx-live expressions took ${n.toFixed(1)}ms.`),a=!0),0===t.size?i&&(clearTimeout(f),f=null,document.removeEventListener("input",o,!0),o=null,document.removeEventListener("change",l,!0),i.disconnect(),i=null,l=null,a=!1):i.observe(document.documentElement,u),r=!1}))}let d=new Set("disabled required readonly open inert multiple autofocus novalidate default reversed loop muted controls autoplay playsinline formnovalidate async defer ismap typemustmatch allowfullscreen itemscope nomodule alpha headingreset".split(" ")),p=new Set("checked value selected hidden".split(" ")),h=new Set("contenteditable draggable spellcheck writingsuggestions".split(" ")),m=new Set("tabindex colspan rowspan maxlength minlength size span start rows cols width height min max step low high optimum".split(" "));function g(e,t){return e instanceof HTMLElement?t.toLowerCase():t}function v(e,t){if((t=g(e,t)).startsWith("aria-"))return function(e,t){let r=e?.getAttribute("aria-"+t);return null==r?void 0:O.has(t)?r:q.has(t)?r.trim()?r.trim().split(/\s+/):[]:P(r)}(e,t.slice(5));if(t.startsWith("data-"))return function(e,t){let r=e.getAttribute(t);return null===r?void 0:P(r)}(e,t);if("value"===t&&("number"===e.type||"range"===e.type))return""===e.value?null:e.valueAsNumber;if(p.has(t))return e[t];if(d.has(t)||t.startsWith("shadowroot")&&"shadowrootmode"!==t&&"shadowrootslotassignment"!==t)return e.hasAttribute(t);let r=e.getAttribute(t);if(null!=r&&h.has(t))try{return JSON.parse(r.toLowerCase())}catch{}return m.has(t)&&r?.trim()&&isFinite(r)?+r:r}function y(e,t,r){if(t=g(e,t),"function"==typeof r&&(r=r(v(e,t)),"function"==typeof r?.then))throw new TypeError("hx-live: assignment returned a promise");t.startsWith("aria-")?function(e,t,r){let n="aria-"+t;null==r?e.removeAttribute(n):e.setAttribute(n,q.has(t)&&Array.isArray(r)?r.join(" "):String(r))}(e,t.slice(5),r):t.startsWith("data-")?function(e,t,r){void 0===r?e.removeAttribute(t):e.setAttribute(t,"object"==typeof r||P(r)!==r?JSON.stringify(r):r)}(e,t,r):p.has(t)?function(e,t,r){if("checked"===t||"selected"===t){let n=!!r;e[t]=n,e.toggleAttribute(t,n)}else!1===r||null==r?(e[t]="boolean"!=typeof e[t]&&"",e.removeAttribute(t)):!0===r?(e[t]=!0,e.setAttribute(t,"")):(e[t]=r,e.setAttribute(t,String(r)))}(e,t,r):d.has(t)||t.startsWith("shadowroot")&&"shadowrootmode"!==t&&"shadowrootslotassignment"!==t?e.toggleAttribute(t,!!r):null==r?e.removeAttribute(t):e.setAttribute(t,String(r))}function b(e,t){return t?new Set(e.map(t)):e}function x(e,t){let r=e.prefix;return r+("data-"===r?L(t):r?t.toLowerCase():t)}function w(e,t,r){if(!e.cascades)return e.prefix?t.hasAttribute(r)&&t:t;for(;t&&!t.hasAttribute(r);)t=t.parentElement;return t}function A(e,t,r,n){if("string"!=typeof t)return!1;let i=x(e,t);return b(e.elts,e.cascades&&(t=>w(e,t,i)||!n&&t)).forEach(e=>e&&y(e,i,r)),!0}let S={get:(e,t)=>{if(!e.prefix&&"class"===t)return e.scope.class;if("string"!=typeof t)return;let r=x(e,t),n=e.elts[0]&&w(e,e.elts[0],r);return n?v(n,r):void 0},set:(e,t,r)=>A(e,t,r),deleteProperty:(e,t)=>A(e,t,void 0,!0),has:(e,t)=>"data-"===e.prefix&&"string"==typeof t&&!!e.elts[0]&&!!w(e,e.elts[0],x(e,t)),ownKeys:e=>{if("data-"!==e.prefix)return[];let t=[],r=new Set;for(let n=e.elts[0];n;n=e.cascades?n.parentElement:null)for(let e of Object.keys(n.dataset))"htmxPowered"===e||r.has(e)||(r.add(e),t.push(e));return t},getOwnPropertyDescriptor:(e,t)=>{if("data-"===e.prefix&&"string"==typeof t&&"htmxPowered"!==t)return e.elts[0]&&w(e,e.elts[0],x(e,t))?{enumerable:!0,configurable:!0}:void 0}};function E(e,t,r,n=""){return new Proxy({elts:e,cascades:t,scope:r,prefix:n},S)}function L(e){return e.replace(/[A-Z]/g,e=>"-"+e.toLowerCase())}function P(e){try{return JSON.parse(e)}catch{return e}}let j=/(['"`\/])(?:\\.|(?!\1).)*\1|(?<![.\w$#])class(?=\s*[.?[])/gs;function T(e){return e.replace(j,(e,t)=>t?"`"===t?e.replace(/\$\{((?:[^{}]|\{[^{}]*\})*)\}/g,(e,t)=>"${"+T(t)+"}"):e:"attr.class")}let O=new Set("activedescendant details errormessage keyshortcuts label placeholder roledescription valuetext".split(" ")),q=new Set("controls describedby dropeffect flowto labelledby owns relevant".split(" "));function W(e,t){return!!e?.classList.contains(t)}function _(e,t,r){if("function"==typeof r&&(r=r(W(e,t)),"function"==typeof r?.then))throw new TypeError("hx-live: assignment returned a promise");e.classList.toggle(t,!!r),e.classList.length||e.removeAttribute("class")}function k(e,t){let r,n,i,l,o={get data(){return r||=E(e,t,null,"data-")},get aria(){return n||=E(e,t,null,"aria-")},get class(){return i||=function(e,t=!1){let r=e[0],n=(e,r)=>t?e.closest("."+CSS.escape(r)):e,i=e=>W(r&&n(r,e),e),l=(t,r)=>b(e,e=>n(e,t)||e).forEach(e=>_(e,t,r)),o={assign(t){t&&"object"==typeof t&&!Array.isArray(t)?C(l,t):console.warn("hx-live: class.assign expects an object.",{elts:e})},add:(...e)=>e.forEach(e=>l(e,!0)),remove:(...e)=>e.forEach(e=>l(e,!1)),contains:i,toggle(e,t){let r=t??!i(e);return l(e,e=>t??!e),r},replace(r,n){if(t)return!!i(r)&&(l(r,!1),l(n,!0),!0);let o;for(let t=0;t<e.length;t++){let i=e[t].classList.replace(r,n);0===t&&(o=i)}return o}};return new Proxy({},{get:(e,n)=>{if("string"==typeof n&&o[n])return o[n];let l=!t&&r?.classList;if(l&&n in l){let e=l[n];return"function"==typeof e?e.bind(l):e}return r?"string"==typeof n?i(n):void 0:n===Symbol.iterator?()=>[][Symbol.iterator]():void 0},set:(r,n,i)=>{if("string"!=typeof n)return!1;if(t||"value"!==n)l(n,i);else for(let t of e)t.classList.value=i;return!0},deleteProperty:(e,t)=>"string"==typeof t&&(l(t,!1),!0),has:(e,t)=>"string"==typeof t&&i(t),ownKeys:()=>!t&&r?[...r.classList]:[],getOwnPropertyDescriptor:(e,r)=>!t&&i(r)?{enumerable:!0,configurable:!0}:void 0})}(e,t)},get attr(){return l||=E(e,t,o)}};return o}function M(e){let t;return new Proxy(function(){},{apply:(t,r,[n])=>{let i=new Set;for(let t of e){let e=t.closest?.(n);e&&i.add(e)}return Q([...i])},get:(r,n)=>(t||=k(e,!0))[n]})}function $(t,r,n){"class"===r?function(t,r){let n=e.htmxProp(t),i=n.liveClasses||new Set,l=new Set(C((e,r)=>_(t,e,r),r));for(let e of i)l.has(e)||_(t,e,!1);n.liveClasses=l}(t,n):_(t,r.slice(1),n)}function C(e,t){let r=[];if("string"==typeof t&&(t={[t]:!0}),t&&"object"==typeof t)for(let[n,i]of Object.entries(t))for(let t of n.trim().split(/\s+/).filter(Boolean))r.push(t),e(t,!!i);return r}function R(e,t,r){let n=t.startsWith("."),i=n?t.slice(1):t,l=t.startsWith("aria-"),o=n?"."+i:"["+t+"]",s=null==r?e[0]?.parentElement:r.nodeType?r:null,a=s?[s,...s.querySelectorAll(o)]:e.length?document.querySelectorAll("string"==typeof r?r:r?.from||o):[],u=new Set(e);for(let e of a)u.has(e)||(n?(e.classList?.remove(i),0===e.classList?.length&&e.removeAttribute("class")):l?e.setAttribute(t,"false"):e.removeAttribute(t));for(let r of e)n?r.classList?.add(i):l?r.setAttribute(t,"true"):r.setAttribute(t,"")}function D(e,...t){let r=e||document;for(let e of t)e?.addEventListener&&(r=e);return new Promise(e=>{let n=[],i=!1,l=t=>{if(!i){i=!0;for(let e of n)e();e(t)}};for(let e of t){if(null==e||e?.addEventListener)continue;let t="number"==typeof e?e:"string"==typeof e?htmx.parseInterval(e):void 0;if(t>0){let r=setTimeout(()=>l(e),t);n.push(()=>clearTimeout(r))}else"string"==typeof e&&(r.addEventListener(e,l,{once:!0}),n.push(()=>r.removeEventListener(e,l)))}})}function H(e,t,...r){let n=t.startsWith("."),i=n?t.slice(1):t,l=t.startsWith("aria-"),o=r.length>1?r:r[0];if("string"==typeof o&&(o=o.split("|").map(e=>e.trim())),o)if(n){let t=o.findIndex(t=>t&&e.classList.contains(t));t>=0&&e.classList.remove(o[t]);let r=o[(t+1)%o.length];r&&e.classList.add(r)}else{let r=o.indexOf(v(e,t)??""),n=o[(r+1)%o.length];y(e,t,""===n?void 0:n)}else if(n)e.classList.toggle(i);else if(l){let r=e.getAttribute(t);e.setAttribute(t,"true"===r?"false":"true")}else e.toggleAttribute(t)}function F(){let e=new Map;return(t,r)=>{let i=(l=r?r.toString():null,e.get(l)||(e.set(l,{last:0,reject:null}),e.get(l)));var l;i.reject?.(n),i.reject=null;let o=++i.last;if(!r)return new Promise((e,r)=>{i.reject=r,setTimeout(()=>{o===i.last&&(i.reject=null,e())},t)});setTimeout(()=>o===i.last&&r(),t)}}function I(t){let r=e.htmxProp(t);return r.debounce||(r.debounce=F())}function N(e,t=document){return r=>{if("string"!=typeof r)return Q(r?.nodeType?[r]:[...r||[]]);let n=r,i=n.match(/^(.+)\s+in\s+(.+)$/),l=[t];if(i&&(n=i[1],l="this"===i[2]||"me"===i[2]?[e]:[...document.querySelectorAll(i[2])]),!l.length)return Q([]);let o,s=e=>{if(1===l.length)return[...l[0].querySelectorAll(e)];let t=[],r=new Set;for(let n of l)for(let i of n.querySelectorAll(e))r.has(i)||(r.add(i),t.push(i));return t.sort((e,t)=>4&e.compareDocumentPosition(t)?-1:1)},a=n.match(/^(next|previous|closest|first|last)\s+(.+)$/);if(a){let[,t,r]=a,n=t=>e.compareDocumentPosition(t);if("closest"===t){let t=e.closest?.(r);o=t?[t]:[]}else{let e=s(r);if("first"===t)o=e.slice(0,1);else if("last"===t)o=e.slice(-1);else if("next"===t){let t=e.find(e=>4&n(e));o=t?[t]:[]}else{let t=e.reverse().find(e=>2&n(e));o=t?[t]:[]}}}else o=s(n);return Q(o)}}let J="map filter reduce reduceRight forEach some every find findIndex findLast findLastIndex flatMap flat slice indexOf lastIndexOf includes join at".split(" "),z={before:"beforebegin",after:"afterend",start:"afterbegin",end:"beforeend"};function K(e,t,r){let n=e.parentElement;"into"===t?e.innerHTML=r:"replace"===t?e.outerHTML=r:e.insertAdjacentHTML(z[t],r),htmx.process(n)}let B,X,Z,G=()=>{};function Q(e){let t,r,n=new Proxy({},{get:(i,l)=>{if("count"===l)return e.length;if("arr"===l)return()=>e.slice();if(l===Symbol.iterator)return()=>e.values();if("q"===l)return t=>{let r=new Set;for(let n of e)for(let e of N(n,n)(t).arr())r.add(e);return Q([...r])};if("trigger"===l)return(t,r,i)=>(e.forEach(e=>htmx.trigger(e,t,r,i)),n);if("insert"===l)return(t,r)=>(e.forEach(e=>K(e,t,r)),n);if("take"===l)return(t,r)=>(R(e,t,r),n);if("toggle"===l)return(t,...r)=>(e.forEach(e=>H(e,t,...r)),n);if("attr"===l||"class"===l||"aria"===l)return(t||=k(e,!1))[l];if("data"===l)return(r||=M(e)).data;if("local"===l)return t||=k(e,!1);if("closest"===l)return r||=M(e);if(J.includes(l))return e[l].bind(e);if(!e.length)return"function"==typeof function(e){for(let t=HTMLElement.prototype;t;t=Object.getPrototypeOf(t)){let r=Object.getOwnPropertyDescriptor(t,e);if(r)return r}}(l)?.value?G:void 0;let o=e[0]?.[l];return"function"==typeof o?(...t)=>e.map(e=>e[l](...t))[0]:o&&"object"==typeof o?Q(e.map(e=>e[l])):o},set:(t,r,n)=>(e.forEach(e=>{let t=e[r];if(null==t||"function"==typeof t)e[r]=n;else if("function"==typeof n){let i=n(t);if("function"==typeof i?.then)throw new TypeError("hx-live: assignment returned a promise");e[r]=i}else e[r]=n}),c(),!0)});return n}function U(){let e=htmx.config.metaCharacter||":",t=htmx.config.prefix;X=["hx-live"+e],t&&X.push(t+"live"+e);let r=htmx.config.live?.bindPrefix;void 0===r&&(window.Alpine?(r="",console.warn("hx-live: Alpine detected; set config.live.bindPrefix.")):r=":"),r&&X.push(r),Z=["hx-live"],t&&Z.push(t+"live");let n=X.map(e=>`starts-with(name(), "${e}")`).join(" or "),i=Z.map(e=>`@${e}`).join(" or ");B=(new XPathEvaluator).createExpression(`.//*[@*[${n}] or ${i}]`)}function V(e){for(let t of X)if(e.startsWith(t)&&e.length>t.length)return e.slice(t.length)}function Y(e){let r=e._htmx;if(r?.liveRuns){for(let e of r.liveRuns)t.delete(e);delete r.liveRuns,delete r.effectRegistered,delete r.bindings}}function ee(t){if(t.closest("[hx-ignore]"))return;let r=e.htmxProp(t);if(!r.effectRegistered){let e=Z.find(e=>t.hasAttribute(e));e&&(r.effectRegistered=!0,te(t,t.getAttribute(e)))}r.bindings||=new Set;for(let e of t.attributes){let n=V(e.name);n&&!r.bindings.has(n)&&(r.bindings.add(n),te(t,e.value,n))}}function te(r,s,a){!function(){if(i)return;l=()=>c();let e=htmx.parseInterval(htmx.config.live?.inputDebounce??100)??100;o=()=>{clearTimeout(f),f=setTimeout(c,e)},document.addEventListener("input",o,!0),document.addEventListener("change",l,!0),i=new MutationObserver(l),i.observe(document.documentElement,u)}();let d,p=void 0!==a,h=I(r),m=/\bawait\b/.test(s),g=!p&&m,b=!p||m,x=!1,w=async()=>{if(r.isConnected){if(!g||!x){x=g;try{d||=e.executeJavaScript(r,{debounce:h},s,p,b,!0);let t=b?await d():d();p&&(!function(t,r,n){if("function"==typeof n)throw new TypeError("hx-live: binding returned a function");if("text"===r){let e=null==n?"":String(n);return void(t.textContent!==e&&(t.textContent=e))}if("html"===r){let e=null==n?"":String(n);return void(t.innerHTML!==e&&(t.innerHTML=e))}if("style"===r)return void function(t,r){let n=e.htmxProp(t),i=n.liveStyles||new Set,l=[];if("string"==typeof r)for(let e of r.split(";")){let t=e.indexOf(":");if(t<0)continue;let r=e.slice(0,t).trim(),n=e.slice(t+1).trim();r&&l.push([r,n])}else if(r&&"object"==typeof r)for(let[e,t]of Object.entries(r))l.push([L(e),null==t||""===t?null:String(t)]);let o=new Set(l.map(([e])=>e));for(let e of i)o.has(e)||t.style.removeProperty(e);for(let[e,r]of l)null==r?t.style.removeProperty(e):t.style.setProperty(e,r);0===t.style.length&&t.removeAttribute("style"),n.liveStyles=o}(t,n);if("class"===r||r.startsWith("."))return void $(t,r,n);if(v(t,r)===n)return;y(t,r,n)}(r,a,t),b&&i?.takeRecords())}catch(e){e!==n&&console.error("hx-live expression failed",e,p?{elt:r,attr:a}:{elt:r})}finally{g&&queueMicrotask(()=>x=!1)}}}else t.delete(w)};t.add(w);let A=e.htmxProp(r);A.liveRuns=A.liveRuns||new Set,A.liveRuns.add(w),w()}htmx.live={q:e=>N(document.documentElement)(e),debounce:F(),refresh:()=>c(),take:(e,t,r)=>R(htmx.live.q(e).arr(),t,r),toggle:(e,t,...r)=>htmx.live.q(e).forEach(e=>H(e,t,...r)),forEvent:(...e)=>D(null,...e),nextFrame:()=>new Promise(e=>requestAnimationFrame(e))},htmx.live.$=htmx.live.q,htmx.registerExtension("hx-live",{init:t=>{e=t},htmx_before_cleanup:e=>{Y(e)},htmx_before_morph_attr:(e,t)=>{B||U(),X.some(e=>t.attrName.startsWith(e))&&Y(e)},htmx_after_process:e=>{!function(e){B||U(),1===e.nodeType&&ee(e);let t,r=B.evaluate(e),n=[];for(;t=r.iterateNext();)n.push(t);for(t of n)ee(t)}(e)},htmx_before_swap:()=>{s++},htmx_finally_swap:()=>{0===--s&&t.size>0&&c()},htmx_scope:(t,r)=>{let n=e.htmxProp(t);Object.assign(r.scope,n.liveScope||=function(e){let t=k([e],!1),r=M([e]);return{q:N(e),forEvent:(...t)=>D(e,...t),nextFrame:()=>new Promise(e=>requestAnimationFrame(e)),trigger:(t,r,n)=>htmx.trigger(e,t,r,n),debounce:I(e),take:(t,r)=>R([e],t,r),toggle:(t,...r)=>H(e,t,...r),attr:t.attr,insert:(t,r)=>K(e,t,r),matches:t=>e.matches(t),style:e.style,data:r.data,aria:t.aria,local:t,closest:r}}(t)),r.code=T(r.code),htmx.config.live?.useDollar&&(r.scope.$=r.scope.q)}})})();⏎̸ | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | (()=>{let e,t=new Set,r=!1,n=Symbol(),i=null,l=null,o=null,s=0,a=!1;const u={childList:!0,subtree:!0,attributes:!0,characterData:!0};let f=null;function c(){r||s>0||(r=!0,queueMicrotask(()=>{i?.disconnect();let e=performance.now();t.forEach(e=>e());let n=performance.now()-e;!a&&n>16&&(console.warn(`htmx: hx-live expressions took ${n.toFixed(1)}ms.`),a=!0),0===t.size?i&&(clearTimeout(f),f=null,document.removeEventListener("input",o,!0),o=null,document.removeEventListener("change",l,!0),i.disconnect(),i=null,l=null,a=!1):i.observe(document.documentElement,u),r=!1}))}let d=new Set("disabled required readonly open inert multiple autofocus novalidate default reversed loop muted controls autoplay playsinline formnovalidate async defer ismap typemustmatch allowfullscreen itemscope nomodule alpha headingreset".split(" ")),p=new Set("checked value selected hidden".split(" ")),h=new Set("contenteditable draggable spellcheck writingsuggestions".split(" ")),m=new Set("tabindex colspan rowspan maxlength minlength size span start rows cols width height min max step low high optimum".split(" "));function g(e,t){return e instanceof HTMLElement?t.toLowerCase():t}function v(e,t){if((t=g(e,t)).startsWith("aria-"))return function(e,t){let r=e?.getAttribute("aria-"+t);return null==r?void 0:O.has(t)?r:q.has(t)?r.trim()?r.trim().split(/\s+/):[]:P(r)}(e,t.slice(5));if(t.startsWith("data-"))return function(e,t){let r=e.getAttribute(t);return null===r?void 0:P(r)}(e,t);if("value"===t&&("number"===e.type||"range"===e.type))return""===e.value?null:e.valueAsNumber;if(p.has(t))return e[t];if(d.has(t)||t.startsWith("shadowroot")&&"shadowrootmode"!==t&&"shadowrootslotassignment"!==t)return e.hasAttribute(t);let r=e.getAttribute(t);if(null!=r&&h.has(t))try{return JSON.parse(r.toLowerCase())}catch{}return m.has(t)&&r?.trim()&&isFinite(r)?+r:r}function y(e,t,r){if(t=g(e,t),"function"==typeof r&&(r=r(v(e,t)),"function"==typeof r?.then))throw new TypeError("hx-live: assignment returned a promise");t.startsWith("aria-")?function(e,t,r){let n="aria-"+t;null==r?e.removeAttribute(n):e.setAttribute(n,q.has(t)&&Array.isArray(r)?r.join(" "):String(r))}(e,t.slice(5),r):t.startsWith("data-")?function(e,t,r){void 0===r?e.removeAttribute(t):e.setAttribute(t,"object"==typeof r||P(r)!==r?JSON.stringify(r):r)}(e,t,r):p.has(t)?function(e,t,r){if("checked"===t||"selected"===t){let n=!!r;e[t]=n,e.toggleAttribute(t,n)}else!1===r||null==r?(e[t]="boolean"!=typeof e[t]&&"",e.removeAttribute(t)):!0===r?(e[t]=!0,e.setAttribute(t,"")):(e[t]=r,e.setAttribute(t,String(r)))}(e,t,r):d.has(t)||t.startsWith("shadowroot")&&"shadowrootmode"!==t&&"shadowrootslotassignment"!==t?e.toggleAttribute(t,!!r):null==r?e.removeAttribute(t):e.setAttribute(t,String(r))}function b(e,t){return t?new Set(e.map(t)):e}function x(e,t){let r=e.prefix;return r+("data-"===r?L(t):r?t.toLowerCase():t)}function w(e,t,r){if(!e.cascades)return e.prefix?t.hasAttribute(r)&&t:t;for(;t&&!t.hasAttribute(r);)t=t.parentElement;return t}function A(e,t,r,n){if("string"!=typeof t)return!1;let i=x(e,t);return b(e.elts,e.cascades&&(t=>w(e,t,i)||!n&&t)).forEach(e=>e&&y(e,i,r)),!0}let S={get:(e,t)=>{if(!e.prefix&&"class"===t)return e.scope.class;if("string"!=typeof t)return;let r=x(e,t),n=e.elts[0]&&w(e,e.elts[0],r);return n?v(n,r):void 0},set:(e,t,r)=>A(e,t,r),deleteProperty:(e,t)=>A(e,t,void 0,!0),has:(e,t)=>"data-"===e.prefix&&"string"==typeof t&&!!e.elts[0]&&!!w(e,e.elts[0],x(e,t)),ownKeys:e=>{if("data-"!==e.prefix)return[];let t=[],r=new Set;for(let n=e.elts[0];n;n=e.cascades?n.parentElement:null)for(let e of Object.keys(n.dataset))"htmxPowered"===e||r.has(e)||(r.add(e),t.push(e));return t},getOwnPropertyDescriptor:(e,t)=>{if("data-"===e.prefix&&"string"==typeof t&&"htmxPowered"!==t)return e.elts[0]&&w(e,e.elts[0],x(e,t))?{enumerable:!0,configurable:!0}:void 0}};function E(e,t,r,n=""){return new Proxy({elts:e,cascades:t,scope:r,prefix:n},S)}function L(e){return e.replace(/[A-Z]/g,e=>"-"+e.toLowerCase())}function P(e){try{return JSON.parse(e)}catch{return e}}let j=/(['"`\/])(?:\\.|(?!\1).)*\1|(?<![.\w$#])class(?=\s*[.?[])/gs;function T(e){return e.replace(j,(e,t)=>t?"`"===t?e.replace(/\$\{((?:[^{}]|\{[^{}]*\})*)\}/g,(e,t)=>"${"+T(t)+"}"):e:"attr.class")}let O=new Set("activedescendant details errormessage keyshortcuts label placeholder roledescription valuetext".split(" ")),q=new Set("controls describedby dropeffect flowto labelledby owns relevant".split(" "));function W(e,t){return!!e?.classList.contains(t)}function _(e,t,r){if("function"==typeof r&&(r=r(W(e,t)),"function"==typeof r?.then))throw new TypeError("hx-live: assignment returned a promise");e.classList.toggle(t,!!r),e.classList.length||e.removeAttribute("class")}function k(e,t){let r,n,i,l,o={get data(){return r||=E(e,t,null,"data-")},get aria(){return n||=E(e,t,null,"aria-")},get class(){return i||=function(e,t=!1){let r=e[0],n=(e,r)=>t?e.closest("."+CSS.escape(r)):e,i=e=>W(r&&n(r,e),e),l=(t,r)=>b(e,e=>n(e,t)||e).forEach(e=>_(e,t,r)),o={assign(t){t&&"object"==typeof t&&!Array.isArray(t)?C(l,t):console.warn("hx-live: class.assign expects an object.",{elts:e})},add:(...e)=>e.forEach(e=>l(e,!0)),remove:(...e)=>e.forEach(e=>l(e,!1)),contains:i,toggle(e,t){let r=t??!i(e);return l(e,e=>t??!e),r},replace(r,n){if(t)return!!i(r)&&(l(r,!1),l(n,!0),!0);let o;for(let t=0;t<e.length;t++){let i=e[t].classList.replace(r,n);0===t&&(o=i)}return o}};return new Proxy({},{get:(e,n)=>{if("string"==typeof n&&o[n])return o[n];let l=!t&&r?.classList;if(l&&n in l){let e=l[n];return"function"==typeof e?e.bind(l):e}return r?"string"==typeof n?i(n):void 0:n===Symbol.iterator?()=>[][Symbol.iterator]():void 0},set:(r,n,i)=>{if("string"!=typeof n)return!1;if(t||"value"!==n)l(n,i);else for(let t of e)t.classList.value=i;return!0},deleteProperty:(e,t)=>"string"==typeof t&&(l(t,!1),!0),has:(e,t)=>"string"==typeof t&&i(t),ownKeys:()=>!t&&r?[...r.classList]:[],getOwnPropertyDescriptor:(e,r)=>!t&&i(r)?{enumerable:!0,configurable:!0}:void 0})}(e,t)},get attr(){return l||=E(e,t,o)}};return o}function M(e){let t;return new Proxy(function(){},{apply:(t,r,[n])=>{let i=new Set;for(let t of e){let e=t.closest?.(n);e&&i.add(e)}return Q([...i])},get:(r,n)=>(t||=k(e,!0))[n]})}function $(t,r,n){"class"===r?function(t,r){let n=e.htmxProp(t),i=n.liveClasses||new Set,l=new Set(C((e,r)=>_(t,e,r),r));for(let e of i)l.has(e)||_(t,e,!1);n.liveClasses=l}(t,n):_(t,r.slice(1),n)}function C(e,t){let r=[];if("string"==typeof t&&(t={[t]:!0}),t&&"object"==typeof t)for(let[n,i]of Object.entries(t))for(let t of n.trim().split(/\s+/).filter(Boolean))r.push(t),e(t,!!i);return r}function R(e,t,r){let n=t.startsWith("."),i=n?t.slice(1):t,l=t.startsWith("aria-"),o=n?"."+i:"["+t+"]",s=null==r?e[0]?.parentElement:r.nodeType?r:null,a=s?[s,...s.querySelectorAll(o)]:e.length?document.querySelectorAll("string"==typeof r?r:r?.from||o):[],u=new Set(e);for(let e of a)u.has(e)||(n?(e.classList?.remove(i),0===e.classList?.length&&e.removeAttribute("class")):l?e.setAttribute(t,"false"):e.removeAttribute(t));for(let r of e)n?r.classList?.add(i):l?r.setAttribute(t,"true"):r.setAttribute(t,"")}function D(e,...t){let r=e||document;for(let e of t)e?.addEventListener&&(r=e);return new Promise(e=>{let n=[],i=!1,l=t=>{if(!i){i=!0;for(let e of n)e();e(t)}};for(let e of t){if(null==e||e?.addEventListener)continue;let t="number"==typeof e?e:"string"==typeof e?htmx.parseInterval(e):void 0;if(t>0){let r=setTimeout(()=>l(e),t);n.push(()=>clearTimeout(r))}else"string"==typeof e&&(r.addEventListener(e,l,{once:!0}),n.push(()=>r.removeEventListener(e,l)))}})}function H(e,t,...r){let n=t.startsWith("."),i=n?t.slice(1):t,l=t.startsWith("aria-"),o=r.length>1?r:r[0];if("string"==typeof o&&(o=o.split("|").map(e=>e.trim())),o)if(n){let t=o.findIndex(t=>t&&e.classList.contains(t));t>=0&&e.classList.remove(o[t]);let r=o[(t+1)%o.length];r&&e.classList.add(r)}else{let r=o.indexOf(v(e,t)??""),n=o[(r+1)%o.length];y(e,t,""===n?void 0:n)}else if(n)e.classList.toggle(i);else if(l){let r=e.getAttribute(t);e.setAttribute(t,"true"===r?"false":"true")}else e.toggleAttribute(t)}function F(){let e=new Map;return(t,r)=>{let i=(l=r?r.toString():null,e.get(l)||(e.set(l,{last:0,reject:null}),e.get(l)));var l;i.reject?.(n),i.reject=null;let o=++i.last;if(!r)return new Promise((e,r)=>{i.reject=r,setTimeout(()=>{o===i.last&&(i.reject=null,e())},t)});setTimeout(()=>o===i.last&&r(),t)}}function I(t){let r=e.htmxProp(t);return r.debounce||(r.debounce=F())}function N(e,t=document){return r=>{if("string"!=typeof r)return Q(r?.nodeType?[r]:[...r||[]]);let n=r,i=n.match(/^(.+)\s+in\s+(.+)$/),l=[t];if(i&&(n=i[1],l="this"===i[2]||"me"===i[2]?[e]:[...document.querySelectorAll(i[2])]),!l.length)return Q([]);let o,s=e=>{if(1===l.length)return[...l[0].querySelectorAll(e)];let t=[],r=new Set;for(let n of l)for(let i of n.querySelectorAll(e))r.has(i)||(r.add(i),t.push(i));return t.sort((e,t)=>4&e.compareDocumentPosition(t)?-1:1)},a=n.match(/^(next|previous|closest|first|last)\s+(.+)$/);if(a){let[,t,r]=a,n=t=>e.compareDocumentPosition(t);if("closest"===t){let t=e.closest?.(r);o=t?[t]:[]}else{let e=s(r);if("first"===t)o=e.slice(0,1);else if("last"===t)o=e.slice(-1);else if("next"===t){let t=e.find(e=>4&n(e));o=t?[t]:[]}else{let t=e.reverse().find(e=>2&n(e));o=t?[t]:[]}}}else o=s(n);return Q(o)}}let J="map filter reduce reduceRight forEach some every find findIndex findLast findLastIndex flatMap flat slice indexOf lastIndexOf includes join at".split(" "),z={before:"beforebegin",after:"afterend",start:"afterbegin",end:"beforeend"};function K(e,t,r){let n=e.parentElement;"into"===t?e.innerHTML=r:"replace"===t?e.outerHTML=r:e.insertAdjacentHTML(z[t],r),htmx.process(n)}let B,X,Z,G=()=>{};function Q(e){let t,r,n=new Proxy({},{get:(i,l)=>{if("count"===l)return e.length;if("arr"===l)return()=>e.slice();if(l===Symbol.iterator)return()=>e.values();if("q"===l)return t=>{let r=new Set;for(let n of e)for(let e of N(n,n)(t).arr())r.add(e);return Q([...r])};if("trigger"===l)return(t,r,i)=>(e.forEach(e=>htmx.trigger(e,t,r,i)),n);if("insert"===l)return(t,r)=>(e.forEach(e=>K(e,t,r)),n);if("take"===l)return(t,r)=>(R(e,t,r),n);if("toggle"===l)return(t,...r)=>(e.forEach(e=>H(e,t,...r)),n);if("attr"===l||"class"===l||"aria"===l)return(t||=k(e,!1))[l];if("data"===l)return(r||=M(e)).data;if("local"===l)return t||=k(e,!1);if("closest"===l)return r||=M(e);if(J.includes(l))return e[l].bind(e);if(!e.length)return"function"==typeof function(e){for(let t=HTMLElement.prototype;t;t=Object.getPrototypeOf(t)){let r=Object.getOwnPropertyDescriptor(t,e);if(r)return r}}(l)?.value?G:void 0;let o=e[0]?.[l];return"function"==typeof o?(...t)=>e.map(e=>e[l](...t))[0]:o&&"object"==typeof o?Q(e.map(e=>e[l])):o},set:(t,r,n)=>(e.forEach(e=>{let t=e[r];if(null==t||"function"==typeof t)e[r]=n;else if("function"==typeof n){let i=n(t);if("function"==typeof i?.then)throw new TypeError("hx-live: assignment returned a promise");e[r]=i}else e[r]=n}),c(),!0)});return n}function U(){let e=htmx.config.metaCharacter||":",t=htmx.config.prefix;X=["hx-live"+e],t&&X.push(t+"live"+e);let r=htmx.config.live?.bindPrefix;void 0===r&&(window.Alpine?(r="",console.warn("hx-live: Alpine detected; set config.live.bindPrefix.")):r=":"),r&&X.push(r),Z=["hx-live"],t&&Z.push(t+"live");let n=X.map(e=>`starts-with(name(), "${e}")`).join(" or "),i=Z.map(e=>`@${e}`).join(" or ");B=(new XPathEvaluator).createExpression(`.//*[@*[${n}] or ${i}]`)}function V(e){for(let t of X)if(e.startsWith(t)&&e.length>t.length)return e.slice(t.length)}function Y(e){let r=e._htmx;if(r?.liveRuns){for(let e of r.liveRuns)t.delete(e);delete r.liveRuns,delete r.effectRegistered,delete r.bindings}}function ee(t){if(t.closest("[hx-ignore]"))return;let r=e.htmxProp(t);if(!r.effectRegistered){let e=Z.find(e=>t.hasAttribute(e));e&&(r.effectRegistered=!0,te(t,t.getAttribute(e)))}r.bindings||=new Set;for(let e of t.attributes){let n=V(e.name);n&&!r.bindings.has(n)&&(r.bindings.add(n),te(t,e.value,n))}}function te(r,s,a){!function(){if(i)return;l=()=>c();let e=htmx.parseInterval(htmx.config.live?.inputDebounce??100)??100;o=()=>{clearTimeout(f),f=setTimeout(c,e)},document.addEventListener("input",o,!0),document.addEventListener("change",l,!0),i=new MutationObserver(l),i.observe(document.documentElement,u)}();let d,p=void 0!==a,h=I(r),m=/\bawait\b/.test(s),g=!p&&m,b=!p||m,x=!1,w=async()=>{if(r.isConnected){if(!g||!x){x=g;try{d||=e.executeJavaScript(r,{debounce:h},s,p,b,!0);let t=b?await d():d();p&&(!function(t,r,n){if("function"==typeof n)throw new TypeError("hx-live: binding returned a function");if("text"===r){let e=null==n?"":String(n);return void(t.textContent!==e&&(t.textContent=e))}if("html"===r){let e=null==n?"":String(n);return void(t.innerHTML!==e&&(t.innerHTML=e))}if("style"===r)return void function(t,r){let n=e.htmxProp(t),i=n.liveStyles||new Set,l=[];if("string"==typeof r)for(let e of r.split(";")){let t=e.indexOf(":");if(t<0)continue;let r=e.slice(0,t).trim(),n=e.slice(t+1).trim();r&&l.push([r,n])}else if(r&&"object"==typeof r)for(let[e,t]of Object.entries(r))l.push([L(e),null==t||""===t?null:String(t)]);let o=new Set(l.map(([e])=>e));for(let e of i)o.has(e)||t.style.removeProperty(e);for(let[e,r]of l)null==r?t.style.removeProperty(e):t.style.setProperty(e,r);0===t.style.length&&t.removeAttribute("style"),n.liveStyles=o}(t,n);if("class"===r||r.startsWith("."))return void $(t,r,n);if(v(t,r)===n)return;y(t,r,n)}(r,a,t),b&&i?.takeRecords())}catch(e){e!==n&&console.error("hx-live expression failed",e,p?{elt:r,attr:a}:{elt:r})}finally{g&&queueMicrotask(()=>x=!1)}}}else t.delete(w)};t.add(w);let A=e.htmxProp(r);A.liveRuns=A.liveRuns||new Set,A.liveRuns.add(w),w()}htmx.live={q:e=>N(document.documentElement)(e),debounce:F(),refresh:()=>c(),take:(e,t,r)=>R(htmx.live.q(e).arr(),t,r),toggle:(e,t,...r)=>htmx.live.q(e).forEach(e=>H(e,t,...r)),forEvent:(...e)=>D(null,...e),nextFrame:()=>new Promise(e=>requestAnimationFrame(e))},htmx.live.$=htmx.live.q,htmx.registerExtension("hx-live",{init:t=>{e=t},htmx_before_cleanup:e=>{Y(e)},htmx_before_morph_attr:(e,t)=>{B||U(),X.some(e=>t.attrName.startsWith(e))&&Y(e)},htmx_after_process:e=>{!function(e){B||U(),1===e.nodeType&&ee(e);let t,r=B.evaluate(e),n=[];for(;t=r.iterateNext();)n.push(t);for(t of n)ee(t)}(e)},htmx_before_swap:()=>{s++},htmx_finally_swap:()=>{0===--s&&t.size>0&&c()},htmx_scope:(t,r)=>{let n=e.htmxProp(t);Object.assign(r.scope,n.liveScope||=function(e){let t=k([e],!1),r=M([e]);return{q:N(e),forEvent:(...t)=>D(e,...t),nextFrame:()=>new Promise(e=>requestAnimationFrame(e)),trigger:(t,r,n)=>htmx.trigger(e,t,r,n),debounce:I(e),take:(t,r)=>R([e],t,r),toggle:(t,...r)=>H(e,t,...r),attr:t.attr,insert:(t,r)=>K(e,t,r),matches:t=>e.matches(t),style:e.style,data:r.data,aria:t.aria,local:t,closest:r}}(t)),r.code=T(r.code),htmx.config.live?.useDollar&&(r.scope.$=r.scope.q)}})})();⏎̸ | |||
A internal/adapters/web/static/js/hx-sse.min.js +1 -0 generated file: collapsed
A internal/adapters/web/summary.go +221 -0
| @@ -0,0 +1,221 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "html" | |
| 9 | + | "html/template" | |
| 10 | + | "net/http" | |
| 11 | + | "strings" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "connectrpc.com/connect" | |
| 15 | + | "github.com/a-h/templ" | |
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | ||
| 18 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // maxReadmeBytes is the largest README that is rendered in the page; a | |
| 22 | + | // larger one is replaced by a note. | |
| 23 | + | const maxReadmeBytes = 512 << 10 | |
| 24 | + | ||
| 25 | + | // recentCommits is how many commits the rail of the summary lists. | |
| 26 | + | const recentCommits = 5 | |
| 27 | + | ||
| 28 | + | // readmeNames are the README file names in order of preference (compared | |
| 29 | + | // lowercase): Markdown first. | |
| 30 | + | var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"} | |
| 31 | + | ||
| 32 | + | // summary is the page of a repository: description, README, and in the rail | |
| 33 | + | // the clone URL, the facts, the recent commits and the README outline. | |
| 34 | + | func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 35 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 36 | + | if err != nil { | |
| 37 | + | return nil, 0, err | |
| 38 | + | } | |
| 39 | + | if d.head == nil { | |
| 40 | + | l, st := s.emptyState(ctx, d) | |
| 41 | + | return State(l, st), http.StatusOK, nil | |
| 42 | + | } | |
| 43 | + | ref := d.head.GetShortName() | |
| 44 | + | now := s.cfg.Now() | |
| 45 | + | ||
| 46 | + | p := SummaryPage{ | |
| 47 | + | Desc: d.repo.GetDescription(), | |
| 48 | + | AllHref: repoHref(d.name(), "commits", ref), | |
| 49 | + | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |
| 53 | + | Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits, | |
| 54 | + | })) | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, 0, err | |
| 57 | + | } | |
| 58 | + | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |
| 59 | + | for i, c := range commits.Msg.GetCommits() { | |
| 60 | + | row := CommitRow{ | |
| 61 | + | Short: short(c.GetId()), Subject: c.GetSummary(), CHref: repoHref(d.name(), "commit", c.GetId()), | |
| 62 | + | Author: c.GetAuthor().GetName(), Merge: len(c.GetParentIds()) > 1, | |
| 63 | + | Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0, | |
| 64 | + | } | |
| 65 | + | if t := c.GetCommitter().GetTime(); t.IsValid() { | |
| 66 | + | row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime()) | |
| 67 | + | } | |
| 68 | + | p.Recent = append(p.Recent, row) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | p.Facts = s.facts(ctx, d, now) | |
| 72 | + | ||
| 73 | + | if rd, err := s.readme(ctx, d, ref); err != nil { | |
| 74 | + | return nil, 0, err | |
| 75 | + | } else if rd != nil { | |
| 76 | + | p.Readme, p.Outline = rd.readme, rd.outline | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | l := s.repoLayout(ctx, d, "summary") | |
| 80 | + | l.Title = d.name() + " · " + s.instance | |
| 81 | + | l.Status = Status{ | |
| 82 | + | Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"), | |
| 83 | + | Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent), | |
| 84 | + | } | |
| 85 | + | l.HasRail, l.Read, l.RailSum = true, true, "clone · facts · recent commits · outline" | |
| 86 | + | return Summary(l, p), http.StatusOK, nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // maxTagDates is the most tags whose commit dates are read to name the | |
| 90 | + | // latest one; with more, the fact shows the count only. | |
| 91 | + | const maxTagDates = 20 | |
| 92 | + | ||
| 93 | + | // facts are the key/value rows of the rail. | |
| 94 | + | func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact { | |
| 95 | + | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + d.name()} | |
| 96 | + | if t := d.repo.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 { | |
| 97 | + | last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime()) | |
| 98 | + | last.Time = rfc3339(t.AsTime()) | |
| 99 | + | } | |
| 100 | + | fs := []Fact{ | |
| 101 | + | last, | |
| 102 | + | {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")}, | |
| 103 | + | } | |
| 104 | + | if n := len(d.tags); n > 0 { | |
| 105 | + | val := plural(n, "tag", "tags") | |
| 106 | + | if n <= maxTagDates { | |
| 107 | + | if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" { | |
| 108 | + | val = name + " · " + c.when + " · " + val | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | fs = append(fs, Fact{Key: "tag", Val: val}) | |
| 112 | + | } | |
| 113 | + | if c := d.repo.GetCreatedAt(); c.IsValid() { | |
| 114 | + | fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())}) | |
| 115 | + | } | |
| 116 | + | return fs | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // latestTag is the tag whose commit is the newest. | |
| 120 | + | func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) { | |
| 121 | + | var name string | |
| 122 | + | var best refCommit | |
| 123 | + | for _, t := range tags { | |
| 124 | + | c, ok := commits[t.GetCommitId()] | |
| 125 | + | if ok && c.short != "" && (name == "" || c.at > best.at) { | |
| 126 | + | name, best = t.GetShortName(), c | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | return name, best | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // renderedReadme is the README of a repository, ready for the page. | |
| 133 | + | type renderedReadme struct { | |
| 134 | + | readme *Readme | |
| 135 | + | outline []Heading | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // readme finds the README at the root of the tree of the head commit, reads | |
| 139 | + | // it and renders it. It is nil when the repository has none. Rendering | |
| 140 | + | // problems degrade to plain text; only API failures are errors. | |
| 141 | + | func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) { | |
| 142 | + | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()})) | |
| 143 | + | if err != nil { | |
| 144 | + | return nil, err | |
| 145 | + | } | |
| 146 | + | var best *pb.TreeEntry | |
| 147 | + | rank := len(readmeNames) | |
| 148 | + | for _, e := range tree.Msg.GetEntries() { | |
| 149 | + | if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE { | |
| 150 | + | continue | |
| 151 | + | } | |
| 152 | + | for i, n := range readmeNames { | |
| 153 | + | if i < rank && strings.EqualFold(e.GetName(), n) { | |
| 154 | + | best, rank = e, i | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | if best == nil { | |
| 159 | + | return nil, nil | |
| 160 | + | } | |
| 161 | + | rd := &Readme{Name: best.GetName()} | |
| 162 | + | if best.GetSize() > maxReadmeBytes { | |
| 163 | + | rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")." | |
| 164 | + | return &renderedReadme{readme: rd}, nil | |
| 165 | + | } | |
| 166 | + | body, binary, err := s.readBlob(ctx, d.name(), d.head.GetCommitId(), best.GetPath(), maxReadmeBytes) | |
| 167 | + | if err != nil { | |
| 168 | + | return nil, err | |
| 169 | + | } | |
| 170 | + | if binary { | |
| 171 | + | rd.Note = best.GetName() + " is not text." | |
| 172 | + | return &renderedReadme{readme: rd}, nil | |
| 173 | + | } | |
| 174 | + | name := strings.ToLower(best.GetName()) | |
| 175 | + | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | |
| 176 | + | h, outline, err := s.md.render(body, d.name(), ref, "") | |
| 177 | + | if err == nil { | |
| 178 | + | rd.HTML = h | |
| 179 | + | return &renderedReadme{readme: rd, outline: outline}, nil | |
| 180 | + | } | |
| 181 | + | log := zerowrap.FromCtx(ctx) | |
| 182 | + | log.Warn().Err(err).Str("repo", d.name()).Msg("render README, showing plain text") | |
| 183 | + | } | |
| 184 | + | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | |
| 185 | + | return &renderedReadme{readme: rd}, nil | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // readBlob reads a file through the GetBlob stream, at most limit bytes. It | |
| 189 | + | // reports a binary file without its content. | |
| 190 | + | func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) { | |
| 191 | + | stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path})) | |
| 192 | + | if err != nil { | |
| 193 | + | return nil, false, err | |
| 194 | + | } | |
| 195 | + | defer func() { _ = stream.Close() }() | |
| 196 | + | for stream.Receive() { | |
| 197 | + | m := stream.Msg() | |
| 198 | + | if m.GetInfo().GetBinary() { | |
| 199 | + | return nil, true, nil | |
| 200 | + | } | |
| 201 | + | body = append(body, m.GetChunk()...) | |
| 202 | + | if int64(len(body)) > limit { | |
| 203 | + | return body[:limit], false, nil | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | if err := stream.Err(); err != nil { | |
| 207 | + | return nil, false, err | |
| 208 | + | } | |
| 209 | + | return body, false, nil | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // humanSize formats a size in bytes. | |
| 213 | + | func humanSize(n int64) string { | |
| 214 | + | switch { | |
| 215 | + | case n < 1024: | |
| 216 | + | return itoa(int(n)) + " B" | |
| 217 | + | case n < 1<<20: | |
| 218 | + | return fmt.Sprintf("%.1f KiB", float64(n)/1024) | |
| 219 | + | } | |
| 220 | + | return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) | |
| 221 | + | } | |
| @@ -0,0 +1,221 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "html" | |||
| 9 | + | "html/template" | |||
| 10 | + | "net/http" | |||
| 11 | + | "strings" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "connectrpc.com/connect" | |||
| 15 | + | "github.com/a-h/templ" | |||
| 16 | + | "github.com/bnema/zerowrap" | |||
| 17 | + | ||||
| 18 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // maxReadmeBytes is the largest README that is rendered in the page; a | |||
| 22 | + | // larger one is replaced by a note. | |||
| 23 | + | const maxReadmeBytes = 512 << 10 | |||
| 24 | + | ||||
| 25 | + | // recentCommits is how many commits the rail of the summary lists. | |||
| 26 | + | const recentCommits = 5 | |||
| 27 | + | ||||
| 28 | + | // readmeNames are the README file names in order of preference (compared | |||
| 29 | + | // lowercase): Markdown first. | |||
| 30 | + | var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"} | |||
| 31 | + | ||||
| 32 | + | // summary is the page of a repository: description, README, and in the rail | |||
| 33 | + | // the clone URL, the facts, the recent commits and the README outline. | |||
| 34 | + | func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 35 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return nil, 0, err | |||
| 38 | + | } | |||
| 39 | + | if d.head == nil { | |||
| 40 | + | l, st := s.emptyState(ctx, d) | |||
| 41 | + | return State(l, st), http.StatusOK, nil | |||
| 42 | + | } | |||
| 43 | + | ref := d.head.GetShortName() | |||
| 44 | + | now := s.cfg.Now() | |||
| 45 | + | ||||
| 46 | + | p := SummaryPage{ | |||
| 47 | + | Desc: d.repo.GetDescription(), | |||
| 48 | + | AllHref: repoHref(d.name(), "commits", ref), | |||
| 49 | + | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |||
| 53 | + | Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits, | |||
| 54 | + | })) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return nil, 0, err | |||
| 57 | + | } | |||
| 58 | + | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |||
| 59 | + | for i, c := range commits.Msg.GetCommits() { | |||
| 60 | + | row := CommitRow{ | |||
| 61 | + | Short: short(c.GetId()), Subject: c.GetSummary(), CHref: repoHref(d.name(), "commit", c.GetId()), | |||
| 62 | + | Author: c.GetAuthor().GetName(), Merge: len(c.GetParentIds()) > 1, | |||
| 63 | + | Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0, | |||
| 64 | + | } | |||
| 65 | + | if t := c.GetCommitter().GetTime(); t.IsValid() { | |||
| 66 | + | row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime()) | |||
| 67 | + | } | |||
| 68 | + | p.Recent = append(p.Recent, row) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | p.Facts = s.facts(ctx, d, now) | |||
| 72 | + | ||||
| 73 | + | if rd, err := s.readme(ctx, d, ref); err != nil { | |||
| 74 | + | return nil, 0, err | |||
| 75 | + | } else if rd != nil { | |||
| 76 | + | p.Readme, p.Outline = rd.readme, rd.outline | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | l := s.repoLayout(ctx, d, "summary") | |||
| 80 | + | l.Title = d.name() + " · " + s.instance | |||
| 81 | + | l.Status = Status{ | |||
| 82 | + | Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"), | |||
| 83 | + | Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent), | |||
| 84 | + | } | |||
| 85 | + | l.HasRail, l.Read, l.RailSum = true, true, "clone · facts · recent commits · outline" | |||
| 86 | + | return Summary(l, p), http.StatusOK, nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // maxTagDates is the most tags whose commit dates are read to name the | |||
| 90 | + | // latest one; with more, the fact shows the count only. | |||
| 91 | + | const maxTagDates = 20 | |||
| 92 | + | ||||
| 93 | + | // facts are the key/value rows of the rail. | |||
| 94 | + | func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact { | |||
| 95 | + | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + d.name()} | |||
| 96 | + | if t := d.repo.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 { | |||
| 97 | + | last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime()) | |||
| 98 | + | last.Time = rfc3339(t.AsTime()) | |||
| 99 | + | } | |||
| 100 | + | fs := []Fact{ | |||
| 101 | + | last, | |||
| 102 | + | {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")}, | |||
| 103 | + | } | |||
| 104 | + | if n := len(d.tags); n > 0 { | |||
| 105 | + | val := plural(n, "tag", "tags") | |||
| 106 | + | if n <= maxTagDates { | |||
| 107 | + | if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" { | |||
| 108 | + | val = name + " · " + c.when + " · " + val | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | fs = append(fs, Fact{Key: "tag", Val: val}) | |||
| 112 | + | } | |||
| 113 | + | if c := d.repo.GetCreatedAt(); c.IsValid() { | |||
| 114 | + | fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())}) | |||
| 115 | + | } | |||
| 116 | + | return fs | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // latestTag is the tag whose commit is the newest. | |||
| 120 | + | func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) { | |||
| 121 | + | var name string | |||
| 122 | + | var best refCommit | |||
| 123 | + | for _, t := range tags { | |||
| 124 | + | c, ok := commits[t.GetCommitId()] | |||
| 125 | + | if ok && c.short != "" && (name == "" || c.at > best.at) { | |||
| 126 | + | name, best = t.GetShortName(), c | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | return name, best | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // renderedReadme is the README of a repository, ready for the page. | |||
| 133 | + | type renderedReadme struct { | |||
| 134 | + | readme *Readme | |||
| 135 | + | outline []Heading | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // readme finds the README at the root of the tree of the head commit, reads | |||
| 139 | + | // it and renders it. It is nil when the repository has none. Rendering | |||
| 140 | + | // problems degrade to plain text; only API failures are errors. | |||
| 141 | + | func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) { | |||
| 142 | + | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()})) | |||
| 143 | + | if err != nil { | |||
| 144 | + | return nil, err | |||
| 145 | + | } | |||
| 146 | + | var best *pb.TreeEntry | |||
| 147 | + | rank := len(readmeNames) | |||
| 148 | + | for _, e := range tree.Msg.GetEntries() { | |||
| 149 | + | if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE { | |||
| 150 | + | continue | |||
| 151 | + | } | |||
| 152 | + | for i, n := range readmeNames { | |||
| 153 | + | if i < rank && strings.EqualFold(e.GetName(), n) { | |||
| 154 | + | best, rank = e, i | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | if best == nil { | |||
| 159 | + | return nil, nil | |||
| 160 | + | } | |||
| 161 | + | rd := &Readme{Name: best.GetName()} | |||
| 162 | + | if best.GetSize() > maxReadmeBytes { | |||
| 163 | + | rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")." | |||
| 164 | + | return &renderedReadme{readme: rd}, nil | |||
| 165 | + | } | |||
| 166 | + | body, binary, err := s.readBlob(ctx, d.name(), d.head.GetCommitId(), best.GetPath(), maxReadmeBytes) | |||
| 167 | + | if err != nil { | |||
| 168 | + | return nil, err | |||
| 169 | + | } | |||
| 170 | + | if binary { | |||
| 171 | + | rd.Note = best.GetName() + " is not text." | |||
| 172 | + | return &renderedReadme{readme: rd}, nil | |||
| 173 | + | } | |||
| 174 | + | name := strings.ToLower(best.GetName()) | |||
| 175 | + | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | |||
| 176 | + | h, outline, err := s.md.render(body, d.name(), ref, "") | |||
| 177 | + | if err == nil { | |||
| 178 | + | rd.HTML = h | |||
| 179 | + | return &renderedReadme{readme: rd, outline: outline}, nil | |||
| 180 | + | } | |||
| 181 | + | log := zerowrap.FromCtx(ctx) | |||
| 182 | + | log.Warn().Err(err).Str("repo", d.name()).Msg("render README, showing plain text") | |||
| 183 | + | } | |||
| 184 | + | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | |||
| 185 | + | return &renderedReadme{readme: rd}, nil | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // readBlob reads a file through the GetBlob stream, at most limit bytes. It | |||
| 189 | + | // reports a binary file without its content. | |||
| 190 | + | func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) { | |||
| 191 | + | stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path})) | |||
| 192 | + | if err != nil { | |||
| 193 | + | return nil, false, err | |||
| 194 | + | } | |||
| 195 | + | defer func() { _ = stream.Close() }() | |||
| 196 | + | for stream.Receive() { | |||
| 197 | + | m := stream.Msg() | |||
| 198 | + | if m.GetInfo().GetBinary() { | |||
| 199 | + | return nil, true, nil | |||
| 200 | + | } | |||
| 201 | + | body = append(body, m.GetChunk()...) | |||
| 202 | + | if int64(len(body)) > limit { | |||
| 203 | + | return body[:limit], false, nil | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | if err := stream.Err(); err != nil { | |||
| 207 | + | return nil, false, err | |||
| 208 | + | } | |||
| 209 | + | return body, false, nil | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // humanSize formats a size in bytes. | |||
| 213 | + | func humanSize(n int64) string { | |||
| 214 | + | switch { | |||
| 215 | + | case n < 1024: | |||
| 216 | + | return itoa(int(n)) + " B" | |||
| 217 | + | case n < 1<<20: | |||
| 218 | + | return fmt.Sprintf("%.1f KiB", float64(n)/1024) | |||
| 219 | + | } | |||
| 220 | + | return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) | |||
| 221 | + | } | |||
A internal/adapters/web/view.go +177 -0
| @@ -0,0 +1,177 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import "html/template" | |
| 6 | + | ||
| 7 | + | // View models handed to the templ components. Components only loop and | |
| 8 | + | // branch on these; the handlers build them from the API messages. | |
| 9 | + | ||
| 10 | + | // Crumb is one segment of the path in the top bar. | |
| 11 | + | type Crumb struct { | |
| 12 | + | Label string | |
| 13 | + | Href string | |
| 14 | + | Current bool | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // Section is a repository section in the top bar, reached by its key. | |
| 18 | + | type Section struct { | |
| 19 | + | Key string // mnemonic key | |
| 20 | + | Post string // rest of the label after the key | |
| 21 | + | Href string | |
| 22 | + | Current bool | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Status is the content of the bottom bar. | |
| 26 | + | type Status struct { | |
| 27 | + | Mode string // "home", "summary", "refs"… | |
| 28 | + | Context string // "3 repos · sort push ▼" | |
| 29 | + | Hints string // "j/k move · enter open · ? help" | |
| 30 | + | Cursor bool // show the row cursor position n/N | |
| 31 | + | Blob bool // show the selected line range | |
| 32 | + | Count int // number of rows under the cursor | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // RefItem is a branch or tag in the ref switcher. | |
| 36 | + | type RefItem struct { | |
| 37 | + | Name string | |
| 38 | + | Href string | |
| 39 | + | Current bool | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // CmdItem is one target of the ":" command line. | |
| 43 | + | type CmdItem struct{ Kind, Label, Href string } | |
| 44 | + | ||
| 45 | + | // Layout is the input of the page shell. | |
| 46 | + | type Layout struct { | |
| 47 | + | Title string | |
| 48 | + | Instance string | |
| 49 | + | Version string | |
| 50 | + | SourceURL string | |
| 51 | + | Assets Assets | |
| 52 | + | Crumbs []Crumb | |
| 53 | + | Ref string // ref shown in the top bar, "" outside a repo view | |
| 54 | + | Short string // short sha shown after the ref | |
| 55 | + | Sections []Section | |
| 56 | + | Status Status | |
| 57 | + | Repo string | |
| 58 | + | SHA string // full sha of the ref, for the permalink key | |
| 59 | + | RefPath string // the URL segment that `y` rewrites: the ref name | |
| 60 | + | Heads []RefItem | |
| 61 | + | Tags []RefItem | |
| 62 | + | Cmd []CmdItem | |
| 63 | + | HasRail bool | |
| 64 | + | RailSum string // the rail as one line when it folds above the content (< 1100px) | |
| 65 | + | Read bool // prose page: content capped at the reading measure | |
| 66 | + | // Live connects the status bar to the event stream (/-/events). | |
| 67 | + | Live bool | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Link is a plain label + href pair. | |
| 71 | + | type Link struct{ Label, Href string } | |
| 72 | + | ||
| 73 | + | // HomeRow is one repository in the home list. | |
| 74 | + | type HomeRow struct { | |
| 75 | + | ID string | |
| 76 | + | Name string | |
| 77 | + | Href string | |
| 78 | + | Desc string | |
| 79 | + | Pushed string | |
| 80 | + | PushedAt string // RFC 3339, "" when never pushed | |
| 81 | + | Branch string | |
| 82 | + | Tag string | |
| 83 | + | Cursor bool | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // SortCol is a column header of the home list; it links to its sort order. | |
| 87 | + | type SortCol struct { | |
| 88 | + | Label string | |
| 89 | + | Href string | |
| 90 | + | Dir string // "ascending" | "descending" | "none" | |
| 91 | + | Mark string | |
| 92 | + | Class string | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // HomePage is the repository list. | |
| 96 | + | type HomePage struct { | |
| 97 | + | Cols []SortCol | |
| 98 | + | Rows []HomeRow | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Fact is one key/value row of the facts block. | |
| 102 | + | type Fact struct { | |
| 103 | + | Key, Val, ID, Time string | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // Clone is a block of copyable shell lines, one [copy] button per line. | |
| 107 | + | type Clone struct { | |
| 108 | + | ID string | |
| 109 | + | Lines []string | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Heading is one README outline entry. | |
| 113 | + | type Heading struct { | |
| 114 | + | Level int | |
| 115 | + | ID string | |
| 116 | + | Text string | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Readme is a rendered README. HTML comes from the Markdown renderer (raw | |
| 120 | + | // HTML dropped, URLs filtered) or is escaped plain text. | |
| 121 | + | type Readme struct { | |
| 122 | + | Name string | |
| 123 | + | HTML template.HTML | |
| 124 | + | // Note replaces the content when the file is not rendered (too large). | |
| 125 | + | Note string | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // CommitRow is one commit in the commit table. | |
| 129 | + | type CommitRow struct { | |
| 130 | + | ID string | |
| 131 | + | Short string | |
| 132 | + | Subject string | |
| 133 | + | CHref string | |
| 134 | + | Author string | |
| 135 | + | When string | |
| 136 | + | WhenAt string | |
| 137 | + | Merge bool | |
| 138 | + | Branches []string | |
| 139 | + | Tags []string | |
| 140 | + | Cursor bool | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // SummaryPage is the repository summary. | |
| 144 | + | type SummaryPage struct { | |
| 145 | + | Desc string | |
| 146 | + | Facts []Fact | |
| 147 | + | Clone Clone | |
| 148 | + | Recent []CommitRow | |
| 149 | + | Readme *Readme | |
| 150 | + | Outline []Heading | |
| 151 | + | AllHref string | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // RefRow is a branch or a tag in the refs table. | |
| 155 | + | type RefRow struct { | |
| 156 | + | Name, Href, Short, CHref, Subject, When, WhenAt string | |
| 157 | + | Default, Cursor bool | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // RefsPage lists the branches and tags of a repository. | |
| 161 | + | type RefsPage struct { | |
| 162 | + | Branches []RefRow | |
| 163 | + | Tags []RefRow | |
| 164 | + | Latest string | |
| 165 | + | Default string | |
| 166 | + | // Note says the lists were cut. | |
| 167 | + | Note string | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // StatePage is a 404, 500 or empty-repository state. | |
| 171 | + | type StatePage struct { | |
| 172 | + | Code string // "404", "500", "empty" | |
| 173 | + | Title string | |
| 174 | + | Lines []string | |
| 175 | + | Clone *Clone | |
| 176 | + | Back []Link | |
| 177 | + | } | |
| @@ -0,0 +1,177 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import "html/template" | |||
| 6 | + | ||||
| 7 | + | // View models handed to the templ components. Components only loop and | |||
| 8 | + | // branch on these; the handlers build them from the API messages. | |||
| 9 | + | ||||
| 10 | + | // Crumb is one segment of the path in the top bar. | |||
| 11 | + | type Crumb struct { | |||
| 12 | + | Label string | |||
| 13 | + | Href string | |||
| 14 | + | Current bool | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // Section is a repository section in the top bar, reached by its key. | |||
| 18 | + | type Section struct { | |||
| 19 | + | Key string // mnemonic key | |||
| 20 | + | Post string // rest of the label after the key | |||
| 21 | + | Href string | |||
| 22 | + | Current bool | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Status is the content of the bottom bar. | |||
| 26 | + | type Status struct { | |||
| 27 | + | Mode string // "home", "summary", "refs"… | |||
| 28 | + | Context string // "3 repos · sort push ▼" | |||
| 29 | + | Hints string // "j/k move · enter open · ? help" | |||
| 30 | + | Cursor bool // show the row cursor position n/N | |||
| 31 | + | Blob bool // show the selected line range | |||
| 32 | + | Count int // number of rows under the cursor | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // RefItem is a branch or tag in the ref switcher. | |||
| 36 | + | type RefItem struct { | |||
| 37 | + | Name string | |||
| 38 | + | Href string | |||
| 39 | + | Current bool | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // CmdItem is one target of the ":" command line. | |||
| 43 | + | type CmdItem struct{ Kind, Label, Href string } | |||
| 44 | + | ||||
| 45 | + | // Layout is the input of the page shell. | |||
| 46 | + | type Layout struct { | |||
| 47 | + | Title string | |||
| 48 | + | Instance string | |||
| 49 | + | Version string | |||
| 50 | + | SourceURL string | |||
| 51 | + | Assets Assets | |||
| 52 | + | Crumbs []Crumb | |||
| 53 | + | Ref string // ref shown in the top bar, "" outside a repo view | |||
| 54 | + | Short string // short sha shown after the ref | |||
| 55 | + | Sections []Section | |||
| 56 | + | Status Status | |||
| 57 | + | Repo string | |||
| 58 | + | SHA string // full sha of the ref, for the permalink key | |||
| 59 | + | RefPath string // the URL segment that `y` rewrites: the ref name | |||
| 60 | + | Heads []RefItem | |||
| 61 | + | Tags []RefItem | |||
| 62 | + | Cmd []CmdItem | |||
| 63 | + | HasRail bool | |||
| 64 | + | RailSum string // the rail as one line when it folds above the content (< 1100px) | |||
| 65 | + | Read bool // prose page: content capped at the reading measure | |||
| 66 | + | // Live connects the status bar to the event stream (/-/events). | |||
| 67 | + | Live bool | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Link is a plain label + href pair. | |||
| 71 | + | type Link struct{ Label, Href string } | |||
| 72 | + | ||||
| 73 | + | // HomeRow is one repository in the home list. | |||
| 74 | + | type HomeRow struct { | |||
| 75 | + | ID string | |||
| 76 | + | Name string | |||
| 77 | + | Href string | |||
| 78 | + | Desc string | |||
| 79 | + | Pushed string | |||
| 80 | + | PushedAt string // RFC 3339, "" when never pushed | |||
| 81 | + | Branch string | |||
| 82 | + | Tag string | |||
| 83 | + | Cursor bool | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // SortCol is a column header of the home list; it links to its sort order. | |||
| 87 | + | type SortCol struct { | |||
| 88 | + | Label string | |||
| 89 | + | Href string | |||
| 90 | + | Dir string // "ascending" | "descending" | "none" | |||
| 91 | + | Mark string | |||
| 92 | + | Class string | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // HomePage is the repository list. | |||
| 96 | + | type HomePage struct { | |||
| 97 | + | Cols []SortCol | |||
| 98 | + | Rows []HomeRow | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // Fact is one key/value row of the facts block. | |||
| 102 | + | type Fact struct { | |||
| 103 | + | Key, Val, ID, Time string | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // Clone is a block of copyable shell lines, one [copy] button per line. | |||
| 107 | + | type Clone struct { | |||
| 108 | + | ID string | |||
| 109 | + | Lines []string | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Heading is one README outline entry. | |||
| 113 | + | type Heading struct { | |||
| 114 | + | Level int | |||
| 115 | + | ID string | |||
| 116 | + | Text string | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Readme is a rendered README. HTML comes from the Markdown renderer (raw | |||
| 120 | + | // HTML dropped, URLs filtered) or is escaped plain text. | |||
| 121 | + | type Readme struct { | |||
| 122 | + | Name string | |||
| 123 | + | HTML template.HTML | |||
| 124 | + | // Note replaces the content when the file is not rendered (too large). | |||
| 125 | + | Note string | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // CommitRow is one commit in the commit table. | |||
| 129 | + | type CommitRow struct { | |||
| 130 | + | ID string | |||
| 131 | + | Short string | |||
| 132 | + | Subject string | |||
| 133 | + | CHref string | |||
| 134 | + | Author string | |||
| 135 | + | When string | |||
| 136 | + | WhenAt string | |||
| 137 | + | Merge bool | |||
| 138 | + | Branches []string | |||
| 139 | + | Tags []string | |||
| 140 | + | Cursor bool | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // SummaryPage is the repository summary. | |||
| 144 | + | type SummaryPage struct { | |||
| 145 | + | Desc string | |||
| 146 | + | Facts []Fact | |||
| 147 | + | Clone Clone | |||
| 148 | + | Recent []CommitRow | |||
| 149 | + | Readme *Readme | |||
| 150 | + | Outline []Heading | |||
| 151 | + | AllHref string | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // RefRow is a branch or a tag in the refs table. | |||
| 155 | + | type RefRow struct { | |||
| 156 | + | Name, Href, Short, CHref, Subject, When, WhenAt string | |||
| 157 | + | Default, Cursor bool | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // RefsPage lists the branches and tags of a repository. | |||
| 161 | + | type RefsPage struct { | |||
| 162 | + | Branches []RefRow | |||
| 163 | + | Tags []RefRow | |||
| 164 | + | Latest string | |||
| 165 | + | Default string | |||
| 166 | + | // Note says the lists were cut. | |||
| 167 | + | Note string | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // StatePage is a 404, 500 or empty-repository state. | |||
| 171 | + | type StatePage struct { | |||
| 172 | + | Code string // "404", "500", "empty" | |||
| 173 | + | Title string | |||
| 174 | + | Lines []string | |||
| 175 | + | Clone *Clone | |||
| 176 | + | Back []Link | |||
| 177 | + | } | |||
A internal/adapters/web/web.go +225 -0
| @@ -0,0 +1,225 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package web is the HTML front of ermite: the pages of the read-only | |
| 4 | + | // showcase, rendered with templ from the data of the ermite API. | |
| 5 | + | // | |
| 6 | + | // The package is a client of the API like any other. It talks to the | |
| 7 | + | // generated ermite-api Connect clients, which cmd binds in process to the | |
| 8 | + | // handlers of the rpc adapter (ADR 0020), and it knows nothing of the use | |
| 9 | + | // cases or the domain. Who the visitor is comes with the request context, set | |
| 10 | + | // by the middleware cmd puts in front of the handler: anonymous in P5. The | |
| 11 | + | // API then shows public repositories only; a private repository is | |
| 12 | + | // indistinguishable from a missing one, here as everywhere. | |
| 13 | + | // | |
| 14 | + | // Adding a page: a handler on Server, a route in routes, a templ page in | |
| 15 | + | // pages.templ and its view model in view.go. | |
| 16 | + | package web | |
| 17 | + | ||
| 18 | + | import ( | |
| 19 | + | "bytes" | |
| 20 | + | "context" | |
| 21 | + | "crypto/rand" | |
| 22 | + | "encoding/hex" | |
| 23 | + | "errors" | |
| 24 | + | "fmt" | |
| 25 | + | "net/http" | |
| 26 | + | "net/url" | |
| 27 | + | "time" | |
| 28 | + | ||
| 29 | + | "connectrpc.com/connect" | |
| 30 | + | "github.com/a-h/templ" | |
| 31 | + | "github.com/bnema/zerowrap" | |
| 32 | + | ||
| 33 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | // Patterns are the ServeMux patterns the handler serves: everything. The | |
| 37 | + | // router of the package answers the paths it does not know with its 404 | |
| 38 | + | // page, and the more specific patterns of the server (health, API, Git) win | |
| 39 | + | // over this one. | |
| 40 | + | var Patterns = []string{"/"} | |
| 41 | + | ||
| 42 | + | // requestTimeout bounds the API calls that build one page. | |
| 43 | + | const requestTimeout = 30 * time.Second | |
| 44 | + | ||
| 45 | + | // Config configures a Server. | |
| 46 | + | type Config struct { | |
| 47 | + | // Repos and Git are the API clients the pages are built from. | |
| 48 | + | Repos ermiteconnect.RepoServiceClient | |
| 49 | + | Git ermiteconnect.GitServiceClient | |
| 50 | + | // PublicURL is the base URL of the instance, without a trailing slash: | |
| 51 | + | // the clone URLs are built on it, and its host is the instance name. | |
| 52 | + | PublicURL string | |
| 53 | + | // SourceURL is the link to the source of the running version (AGPL §13). | |
| 54 | + | SourceURL string | |
| 55 | + | // Version is the version of the running server. | |
| 56 | + | Version string | |
| 57 | + | // Now is the clock of relative dates; time.Now when nil. | |
| 58 | + | Now func() time.Time | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Server serves the pages. It is an http.Handler. | |
| 62 | + | type Server struct { | |
| 63 | + | cfg Config | |
| 64 | + | instance string | |
| 65 | + | assets *assetSet | |
| 66 | + | md *markdown | |
| 67 | + | mux *http.ServeMux | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // New builds the web handler. | |
| 71 | + | func New(cfg Config) (*Server, error) { | |
| 72 | + | if cfg.Repos == nil || cfg.Git == nil { | |
| 73 | + | return nil, errors.New("web: the repository and Git API clients are required") | |
| 74 | + | } | |
| 75 | + | u, err := url.Parse(cfg.PublicURL) | |
| 76 | + | if err != nil || u.Host == "" { | |
| 77 | + | return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL) | |
| 78 | + | } | |
| 79 | + | if cfg.Now == nil { | |
| 80 | + | cfg.Now = time.Now | |
| 81 | + | } | |
| 82 | + | assets, err := loadAssets() | |
| 83 | + | if err != nil { | |
| 84 | + | return nil, fmt.Errorf("web: load assets: %w", err) | |
| 85 | + | } | |
| 86 | + | s := &Server{cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown()} | |
| 87 | + | s.mux = s.routes() | |
| 88 | + | return s, nil | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // routes is the router of the web. The pages of the next phases (tree, blob, | |
| 92 | + | // raw, commits, commit, events) are added here; until then they, and any | |
| 93 | + | // other path, answer with the 404 page. | |
| 94 | + | func (s *Server) routes() *http.ServeMux { | |
| 95 | + | mux := http.NewServeMux() | |
| 96 | + | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | |
| 97 | + | mux.HandleFunc("GET /{$}", s.page(s.home)) | |
| 98 | + | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| 99 | + | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |
| 100 | + | mux.HandleFunc("GET /{repo}/refs", s.page(s.refs)) | |
| 101 | + | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |
| 102 | + | return mux | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // ServeHTTP implements http.Handler. | |
| 106 | + | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 107 | + | s.mux.ServeHTTP(w, r) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // handler builds a page. It returns the component and its status, or an | |
| 111 | + | // error, which becomes the 404 or 500 page. | |
| 112 | + | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | |
| 113 | + | ||
| 114 | + | // page adapts a handler: it bounds the API calls, renders the component into | |
| 115 | + | // a buffer (so that a failure never leaves half a page), and maps errors to | |
| 116 | + | // the state pages. | |
| 117 | + | func (s *Server) page(h handler) http.HandlerFunc { | |
| 118 | + | return func(w http.ResponseWriter, r *http.Request) { | |
| 119 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 120 | + | defer cancel() | |
| 121 | + | c, status, err := h(ctx, r) | |
| 122 | + | if err != nil { | |
| 123 | + | s.fail(ctx, w, r, err) | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | s.write(ctx, w, r, status, c) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // write renders c with the given status. | |
| 131 | + | func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) { | |
| 132 | + | var buf bytes.Buffer | |
| 133 | + | if err := c.Render(ctx, &buf); err != nil { | |
| 134 | + | // The error page itself failed: a bare answer is all that is left. | |
| 135 | + | log := zerowrap.FromCtx(ctx) | |
| 136 | + | log.Error().Err(err).Str("path", r.URL.Path).Msg("render page") | |
| 137 | + | http.Error(w, "internal error", http.StatusInternalServerError) | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | h := w.Header() | |
| 141 | + | h.Set("Content-Type", "text/html; charset=utf-8") | |
| 142 | + | // Public pages: the browser and any proxy revalidate each time. | |
| 143 | + | h.Set("Cache-Control", "no-cache") | |
| 144 | + | w.WriteHeader(status) | |
| 145 | + | if r.Method != http.MethodHead { | |
| 146 | + | _, _ = w.Write(buf.Bytes()) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // fail answers an error of the API: not found (and a name or revision the | |
| 151 | + | // API refuses, which no page can show either) gives the 404 page, a visitor | |
| 152 | + | // that left gives nothing, anything else gives the 500 page with an id to | |
| 153 | + | // find the cause in the log. | |
| 154 | + | func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |
| 155 | + | switch code := connect.CodeOf(err); { | |
| 156 | + | case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | |
| 157 | + | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | |
| 158 | + | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | |
| 159 | + | // The client is gone; there is nobody to answer. | |
| 160 | + | default: | |
| 161 | + | id := requestID() | |
| 162 | + | log := zerowrap.FromCtx(ctx) | |
| 163 | + | log.Error().Err(err).Str("request_id", id).Str("path", r.URL.Path).Msg("web request failed") | |
| 164 | + | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // errNotFound is returned by handlers for a resource that does not exist | |
| 169 | + | // although the API did not say so (a ref or a path that is not there). | |
| 170 | + | var errNotFound = errors.New("not found") | |
| 171 | + | ||
| 172 | + | // requestID is a short random id, shown on the 500 page and logged. | |
| 173 | + | func requestID() string { | |
| 174 | + | var b [4]byte | |
| 175 | + | _, _ = rand.Read(b[:]) | |
| 176 | + | return hex.EncodeToString(b[:]) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // notFound is the handler of the paths without a page. | |
| 180 | + | func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) { | |
| 181 | + | return nil, 0, errNotFound | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // base is the Layout every page starts from. | |
| 185 | + | func (s *Server) base() Layout { | |
| 186 | + | return Layout{ | |
| 187 | + | Instance: s.instance, | |
| 188 | + | Version: s.cfg.Version, | |
| 189 | + | SourceURL: s.cfg.SourceURL, | |
| 190 | + | Assets: s.assets.urls, | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // stateLayout is the layout of a state page: only the way home. | |
| 195 | + | func (s *Server) stateLayout(title, context string, crumbs []Crumb) Layout { | |
| 196 | + | l := s.base() | |
| 197 | + | l.Title = title + " · " + s.instance | |
| 198 | + | l.Crumbs = crumbs | |
| 199 | + | l.Sections = []Section{{Key: "h", Post: "ome", Href: "/"}} | |
| 200 | + | l.Status = Status{Mode: "state", Context: context, Hints: "g h home · ? help"} | |
| 201 | + | l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}} | |
| 202 | + | return l | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (s *Server) notFoundPage() templ.Component { | |
| 206 | + | l := s.stateLayout("404", "404 · not found", []Crumb{{Label: "-"}, {Label: "404"}}) | |
| 207 | + | return State(l, StatePage{ | |
| 208 | + | Code: "404", Title: "not found", | |
| 209 | + | Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."}, | |
| 210 | + | Back: []Link{{Label: "home", Href: "/"}}, | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func (s *Server) errorPage(id string) templ.Component { | |
| 215 | + | l := s.stateLayout("error", "500 · internal error", []Crumb{{Label: "-"}, {Label: "error"}}) | |
| 216 | + | return State(l, StatePage{ | |
| 217 | + | Code: "500", Title: "internal error", | |
| 218 | + | Lines: []string{ | |
| 219 | + | "the server failed to answer this request.", | |
| 220 | + | "request id: " + id, | |
| 221 | + | "retry in a moment; if it persists, the owner has the log.", | |
| 222 | + | }, | |
| 223 | + | Back: []Link{{Label: "home", Href: "/"}}, | |
| 224 | + | }) | |
| 225 | + | } | |
| @@ -0,0 +1,225 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package web is the HTML front of ermite: the pages of the read-only | |||
| 4 | + | // showcase, rendered with templ from the data of the ermite API. | |||
| 5 | + | // | |||
| 6 | + | // The package is a client of the API like any other. It talks to the | |||
| 7 | + | // generated ermite-api Connect clients, which cmd binds in process to the | |||
| 8 | + | // handlers of the rpc adapter (ADR 0020), and it knows nothing of the use | |||
| 9 | + | // cases or the domain. Who the visitor is comes with the request context, set | |||
| 10 | + | // by the middleware cmd puts in front of the handler: anonymous in P5. The | |||
| 11 | + | // API then shows public repositories only; a private repository is | |||
| 12 | + | // indistinguishable from a missing one, here as everywhere. | |||
| 13 | + | // | |||
| 14 | + | // Adding a page: a handler on Server, a route in routes, a templ page in | |||
| 15 | + | // pages.templ and its view model in view.go. | |||
| 16 | + | package web | |||
| 17 | + | ||||
| 18 | + | import ( | |||
| 19 | + | "bytes" | |||
| 20 | + | "context" | |||
| 21 | + | "crypto/rand" | |||
| 22 | + | "encoding/hex" | |||
| 23 | + | "errors" | |||
| 24 | + | "fmt" | |||
| 25 | + | "net/http" | |||
| 26 | + | "net/url" | |||
| 27 | + | "time" | |||
| 28 | + | ||||
| 29 | + | "connectrpc.com/connect" | |||
| 30 | + | "github.com/a-h/templ" | |||
| 31 | + | "github.com/bnema/zerowrap" | |||
| 32 | + | ||||
| 33 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 34 | + | ) | |||
| 35 | + | ||||
| 36 | + | // Patterns are the ServeMux patterns the handler serves: everything. The | |||
| 37 | + | // router of the package answers the paths it does not know with its 404 | |||
| 38 | + | // page, and the more specific patterns of the server (health, API, Git) win | |||
| 39 | + | // over this one. | |||
| 40 | + | var Patterns = []string{"/"} | |||
| 41 | + | ||||
| 42 | + | // requestTimeout bounds the API calls that build one page. | |||
| 43 | + | const requestTimeout = 30 * time.Second | |||
| 44 | + | ||||
| 45 | + | // Config configures a Server. | |||
| 46 | + | type Config struct { | |||
| 47 | + | // Repos and Git are the API clients the pages are built from. | |||
| 48 | + | Repos ermiteconnect.RepoServiceClient | |||
| 49 | + | Git ermiteconnect.GitServiceClient | |||
| 50 | + | // PublicURL is the base URL of the instance, without a trailing slash: | |||
| 51 | + | // the clone URLs are built on it, and its host is the instance name. | |||
| 52 | + | PublicURL string | |||
| 53 | + | // SourceURL is the link to the source of the running version (AGPL §13). | |||
| 54 | + | SourceURL string | |||
| 55 | + | // Version is the version of the running server. | |||
| 56 | + | Version string | |||
| 57 | + | // Now is the clock of relative dates; time.Now when nil. | |||
| 58 | + | Now func() time.Time | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Server serves the pages. It is an http.Handler. | |||
| 62 | + | type Server struct { | |||
| 63 | + | cfg Config | |||
| 64 | + | instance string | |||
| 65 | + | assets *assetSet | |||
| 66 | + | md *markdown | |||
| 67 | + | mux *http.ServeMux | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // New builds the web handler. | |||
| 71 | + | func New(cfg Config) (*Server, error) { | |||
| 72 | + | if cfg.Repos == nil || cfg.Git == nil { | |||
| 73 | + | return nil, errors.New("web: the repository and Git API clients are required") | |||
| 74 | + | } | |||
| 75 | + | u, err := url.Parse(cfg.PublicURL) | |||
| 76 | + | if err != nil || u.Host == "" { | |||
| 77 | + | return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL) | |||
| 78 | + | } | |||
| 79 | + | if cfg.Now == nil { | |||
| 80 | + | cfg.Now = time.Now | |||
| 81 | + | } | |||
| 82 | + | assets, err := loadAssets() | |||
| 83 | + | if err != nil { | |||
| 84 | + | return nil, fmt.Errorf("web: load assets: %w", err) | |||
| 85 | + | } | |||
| 86 | + | s := &Server{cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown()} | |||
| 87 | + | s.mux = s.routes() | |||
| 88 | + | return s, nil | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // routes is the router of the web. The pages of the next phases (tree, blob, | |||
| 92 | + | // raw, commits, commit, events) are added here; until then they, and any | |||
| 93 | + | // other path, answer with the 404 page. | |||
| 94 | + | func (s *Server) routes() *http.ServeMux { | |||
| 95 | + | mux := http.NewServeMux() | |||
| 96 | + | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | |||
| 97 | + | mux.HandleFunc("GET /{$}", s.page(s.home)) | |||
| 98 | + | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |||
| 99 | + | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |||
| 100 | + | mux.HandleFunc("GET /{repo}/refs", s.page(s.refs)) | |||
| 101 | + | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |||
| 102 | + | return mux | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // ServeHTTP implements http.Handler. | |||
| 106 | + | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 107 | + | s.mux.ServeHTTP(w, r) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // handler builds a page. It returns the component and its status, or an | |||
| 111 | + | // error, which becomes the 404 or 500 page. | |||
| 112 | + | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | |||
| 113 | + | ||||
| 114 | + | // page adapts a handler: it bounds the API calls, renders the component into | |||
| 115 | + | // a buffer (so that a failure never leaves half a page), and maps errors to | |||
| 116 | + | // the state pages. | |||
| 117 | + | func (s *Server) page(h handler) http.HandlerFunc { | |||
| 118 | + | return func(w http.ResponseWriter, r *http.Request) { | |||
| 119 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |||
| 120 | + | defer cancel() | |||
| 121 | + | c, status, err := h(ctx, r) | |||
| 122 | + | if err != nil { | |||
| 123 | + | s.fail(ctx, w, r, err) | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | s.write(ctx, w, r, status, c) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // write renders c with the given status. | |||
| 131 | + | func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) { | |||
| 132 | + | var buf bytes.Buffer | |||
| 133 | + | if err := c.Render(ctx, &buf); err != nil { | |||
| 134 | + | // The error page itself failed: a bare answer is all that is left. | |||
| 135 | + | log := zerowrap.FromCtx(ctx) | |||
| 136 | + | log.Error().Err(err).Str("path", r.URL.Path).Msg("render page") | |||
| 137 | + | http.Error(w, "internal error", http.StatusInternalServerError) | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | h := w.Header() | |||
| 141 | + | h.Set("Content-Type", "text/html; charset=utf-8") | |||
| 142 | + | // Public pages: the browser and any proxy revalidate each time. | |||
| 143 | + | h.Set("Cache-Control", "no-cache") | |||
| 144 | + | w.WriteHeader(status) | |||
| 145 | + | if r.Method != http.MethodHead { | |||
| 146 | + | _, _ = w.Write(buf.Bytes()) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // fail answers an error of the API: not found (and a name or revision the | |||
| 151 | + | // API refuses, which no page can show either) gives the 404 page, a visitor | |||
| 152 | + | // that left gives nothing, anything else gives the 500 page with an id to | |||
| 153 | + | // find the cause in the log. | |||
| 154 | + | func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |||
| 155 | + | switch code := connect.CodeOf(err); { | |||
| 156 | + | case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | |||
| 157 | + | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | |||
| 158 | + | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | |||
| 159 | + | // The client is gone; there is nobody to answer. | |||
| 160 | + | default: | |||
| 161 | + | id := requestID() | |||
| 162 | + | log := zerowrap.FromCtx(ctx) | |||
| 163 | + | log.Error().Err(err).Str("request_id", id).Str("path", r.URL.Path).Msg("web request failed") | |||
| 164 | + | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // errNotFound is returned by handlers for a resource that does not exist | |||
| 169 | + | // although the API did not say so (a ref or a path that is not there). | |||
| 170 | + | var errNotFound = errors.New("not found") | |||
| 171 | + | ||||
| 172 | + | // requestID is a short random id, shown on the 500 page and logged. | |||
| 173 | + | func requestID() string { | |||
| 174 | + | var b [4]byte | |||
| 175 | + | _, _ = rand.Read(b[:]) | |||
| 176 | + | return hex.EncodeToString(b[:]) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // notFound is the handler of the paths without a page. | |||
| 180 | + | func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) { | |||
| 181 | + | return nil, 0, errNotFound | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // base is the Layout every page starts from. | |||
| 185 | + | func (s *Server) base() Layout { | |||
| 186 | + | return Layout{ | |||
| 187 | + | Instance: s.instance, | |||
| 188 | + | Version: s.cfg.Version, | |||
| 189 | + | SourceURL: s.cfg.SourceURL, | |||
| 190 | + | Assets: s.assets.urls, | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // stateLayout is the layout of a state page: only the way home. | |||
| 195 | + | func (s *Server) stateLayout(title, context string, crumbs []Crumb) Layout { | |||
| 196 | + | l := s.base() | |||
| 197 | + | l.Title = title + " · " + s.instance | |||
| 198 | + | l.Crumbs = crumbs | |||
| 199 | + | l.Sections = []Section{{Key: "h", Post: "ome", Href: "/"}} | |||
| 200 | + | l.Status = Status{Mode: "state", Context: context, Hints: "g h home · ? help"} | |||
| 201 | + | l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}} | |||
| 202 | + | return l | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (s *Server) notFoundPage() templ.Component { | |||
| 206 | + | l := s.stateLayout("404", "404 · not found", []Crumb{{Label: "-"}, {Label: "404"}}) | |||
| 207 | + | return State(l, StatePage{ | |||
| 208 | + | Code: "404", Title: "not found", | |||
| 209 | + | Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."}, | |||
| 210 | + | Back: []Link{{Label: "home", Href: "/"}}, | |||
| 211 | + | }) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func (s *Server) errorPage(id string) templ.Component { | |||
| 215 | + | l := s.stateLayout("error", "500 · internal error", []Crumb{{Label: "-"}, {Label: "error"}}) | |||
| 216 | + | return State(l, StatePage{ | |||
| 217 | + | Code: "500", Title: "internal error", | |||
| 218 | + | Lines: []string{ | |||
| 219 | + | "the server failed to answer this request.", | |||
| 220 | + | "request id: " + id, | |||
| 221 | + | "retry in a moment; if it persists, the owner has the log.", | |||
| 222 | + | }, | |||
| 223 | + | Back: []Link{{Label: "home", Href: "/"}}, | |||
| 224 | + | }) | |||
| 225 | + | } | |||