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
| @@ -0,0 +1,1 @@ | |||
| 1 | + | var htmx=(()=>{const e={parse(t){if(!t)return{};if(t.startsWith("{"))return JSON.parse(t);let r=/(?:"([^"]+)"|'([^']+)'|([^\s,:]+))(?:\s*:\s*(?:"([^"]*)"|'([^']*)'|<((?:[^/]|\/(?!>))+)\/>|([^\s,]+)))?(?=\s|,|$)/g,i={};for(let s of t.matchAll(r)){let[,t,r,n,o,a,l,h]=s,c=t??r??n,u=(o??a??l??h??"true").trim();try{u=JSON.parse(u)}catch{}let d=n?.includes("."),f=d?c.split(".").reduceRight((e,t)=>({[t]:e}),u):{[c]:u};e.merge(f,i)}return i},split:e=>e.split(/,(?![^\[]*\])(?![^(]*\))(?![^<]*\/>)(?=(?:[^"']|"[^"]*"|'[^']*')*$)/),merge(t,r){"string"==typeof t&&(t=e.parse(t));for(let[i,s]of Object.entries(t)){if(["__proto__","constructor","prototype"].includes(i))continue;let t=s?.constructor===Object,n=r[i]?.constructor===Object;t&&n?e.merge(s,r[i]):r[i]=s}return r}};class t{#e=null;#t=[];admit(e,t,r){if(!this.#e)return this.#e={strategy:e,abort:r},"run";if("replace"===e||"abort"!==e&&"abort"===this.#e.strategy)return this.#t=[],this.#e.abort?.(),this.#e={strategy:e,abort:r},"run";if("queue all"===e)this.#t.push(t);else if("queue last"===e)this.#t=[t];else{if("abort"===e||"drop"===e||0!==this.#t.length)return"dropped";this.#t.push(t)}return"queued"}continue(){this.#e=null,this.#t.shift()?.()}abort(){this.#e?.abort?.()}}return new class{#r=e;#i=new Map;#s="";#n=new Set;_loc=window.location;#o;#a=Function;#l=Object.getPrototypeOf(async function(){}).constructor;#h={createHTML:e=>e,createScript:e=>e};#c;#u="a,form";#d=["get","post","put","patch","delete","query"];#f;#m;#p;#g;#x;constructor(){this.#b(),this.#y(),this.#c=this.#S("[hx-action],[hx-get],[hx-post],[hx-put],[hx-patch],[hx-delete],[hx-query]"),this.#f=(new XPathEvaluator).createExpression(`.//*[@*[${this.#v("hx-on").map(e=>`starts-with(name(), "${e}")`).join(" or ")}]]`),this.#o={HCON:e,attributeValue:this.#E.bind(this),parseTriggerSpecs:this.#w.bind(this),determineMethodAndAction:this.#A.bind(this),createRequestContext:this.#T.bind(this),collectFormData:this.#C.bind(this),getAttributeObject:this.#q.bind(this),insertContent:this.#N.bind(this),morph:this.#I.bind(this),isSoftMatch:this.#M.bind(this),initSecurity:(e,t,r)=>{e&&(this.#h=e),t&&(this.#a=t),r&&(this.#l=r)},onTrigger:this.#O.bind(this),htmxProp:this.#H.bind(this),triggerHtmxEvent:this.#L.bind(this),executeJavaScript:this.#k.bind(this)};let t=()=>this.initialize();"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):setTimeout(t)}#b(){this.version="4.0.0",this.config={logAll:!1,prefix:"data-hx-",transitions:!1,history:!0,mode:"same-origin",defaultSwap:"innerHTML",defaultFocusScroll:!1,indicatorClass:"htmx-indicator",requestClass:"htmx-request",includeIndicatorCSS:!0,defaultTimeout:6e4,extensions:"",morphIgnore:["data-htmx-powered"],morphSkip:"[hx-morph-skip]",morphSkipChildren:"[hx-morph-skip-children]",morphScanLimit:10,noSwap:[204,304],implicitInheritance:!1,defaultSettleDelay:1,allowEmptySwapAfterOOB:!1};let t=document.querySelector('meta[name="htmx-config"]');t&&e.merge(t.content,this.config),this.#s=this.config.extensions}#y(){if(!1!==this.config.includeIndicatorCSS){let e=this.config.indicatorClass,t=this.config.requestClass,r=new CSSStyleSheet;r.replaceSync(`.${e}{opacity:0;visibility: hidden} .${t} .${e}, .${t}.${e}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`),document.adoptedStyleSheets=[...document.adoptedStyleSheets,r]}}registerExtension(e,t){return!(this.#s&&!this.#s.split(/,\s*/).includes(e))&&(!this.#n.has(e)&&(this.#n.add(e),t.init&&t.init(this.#o),void Object.entries(t).forEach(([e,t])=>{this.#i.get(e)?.push(t)||this.#i.set(e,[t])})))}#V(e){let t=this.config.prefix;return!e.closest||null!=e.closest("[hx-ignore]")||t&&null!=e.closest(`[${t}ignore]`)}#P(e,t){let r=this.config.prefix;return e.getAttribute(t)??(r?e.getAttribute(t.replace("hx-",r)):null)}#R(e,t){let r=this.config.prefix&&t.replace("hx-",this.config.prefix);return e.hasAttribute(t)?t:r&&e.hasAttribute(r)?r:null}#S(e){return this.#v(e).join(",")}#v(e){let t=[e];return this.config.prefix&&t.push(e.replaceAll("hx-",this.config.prefix)),t}#j(e,t){let r=[...e.querySelectorAll?.(t)??[]];return e.matches?.(t)&&r.unshift(e),r}#B(e){return"before"===e?"beforebegin":"after"===e?"afterend":"prepend"===e?"afterbegin":"append"===e?"beforeend":e}#F(e,t){let r=[];return this.#E(e,t,void 0,(e,t)=>{e?.split(/\s*[,:]\s*/).includes("this")&&r.push(t)}),r}#E(e,t,r,i){t=this.#z(t);let s=this.#z(":inherited"),n=this.#z(":append"),o=this.#P(e,t)??this.#P(e,t+s);if(null!=o)return i?i(o,e):o;let a=CSS.escape(this.config.implicitInheritance?t:t+s),l=CSS.escape(t+s+n),h=this.#S(`[${a}],[${l}]`),c=this.#R(e,t+n)??this.#R(e,t+s+n);if(c){let r=e.getAttribute(c),s=e.parentNode?.closest?.(h);if(i&&i(r,e),s){let e=this.#E(s,t,void 0,i);return e?(e+","+r).replace(/[{}]/g,""):r}return r}let u=e.parentNode?.closest?.(h);return u?(o=this.#E(u,t,void 0,i),!i&&o&&this.config.implicitInheritance&&this.#_(e,"htmx:after:implicitInheritance",{elt:e,name:t,parent:u}),o):r}#w(t){return e.split(t).flatMap(t=>{let[,r,i]=t.match(/^\s*(\S+\[[^\]]*\]|\S+)\s*(.*?)\s*$/)??[];if(!r)return[];if(/\[[^\]]*$/.test(r))throw"unterminated:"+r;return[{name:r,...e.parse(i)}]})}#A(e,t){let r=this.#E(e,"hx-method"),i=this.#E(e,"hx-action");if(!i)for(let t of this.#d){let s=this.#E(e,"hx-"+t);if(null!=s){i=s,r=t;break}}return this.#D(e)&&(i||=t.submitter?.getAttribute?.("formAction")||e.getAttribute(e.matches("a")?"href":"action")),r||=t.submitter?.getAttribute?.("formmethod")||e.getAttribute("method")||"GET",{action:i,method:r.toUpperCase()}}#H(e){return e._htmx||(e._htmx={listeners:[],triggerSpecs:[]},e.setAttribute("data-htmx-powered","true")),e._htmx}#$(e){return e._htmx_state||={}}#W(e){if(this.#U(e)&&this.#L(e,"htmx:before:init",{},!0)){let t=this.#H(e);t.initialized=!0,t.eventHandler=this.#Q(e),this.#J(e),this.#L(e,"htmx:after:init",{},!0)}}#Q(e){return async t=>{try{let r=this.#T(e,t);await this.#X(r)}catch(t){this.#L(e,"htmx:error",{error:t})}}}#T(t,r){let{action:i,method:s}=this.#A(t,r),[n,o]=(i||"").split("#"),a=new AbortController,l={sourceElement:t,sourceEvent:r,status:"created",select:this.#E(t,"hx-select"),selectOOB:this.#E(t,"hx-select-oob"),target:this.#E(t,"hx-target"),swap:this.#E(t,"hx-swap")??this.config.defaultSwap,push:this.#E(t,"hx-push-url"),replace:this.#E(t,"hx-replace-url"),transition:this.config.transitions,confirm:this.#E(t,"hx-confirm"),request:{validate:"true"===this.#E(t,"hx-validate",!t.matches("form")||t.noValidate||r.submitter?.formNoValidate?"false":"true"),action:n,anchor:o,method:s,headers:this.#G(t),abort:a.abort.bind(a),credentials:"same-origin",signal:a.signal,mode:this.config.mode}};t._htmx?.boosted&&e.merge(t._htmx.boosted,l),l.target=this.#K(t,l.target),l.target&&(l.request.headers["HX-Target"]=this.#Y(l.target));let h=this.#E(t,"hx-config");return h&&(e.merge(h,l.request),l.request.mode=this.config.mode),l}#Y(e){return`${e.tagName.toLowerCase()}${e.id?"#"+encodeURI(e.id):""}`}#G(e){let t={"HX-Request":"true","HX-Source":this.#Y(e),"HX-Current-URL":location.href,Accept:"text/html"};return this.#D(e)&&(t["HX-Boosted"]="true"),t}#Z(e,t){return this.#q(e,"hx-headers",e=>{for(let r in e)t.request.headers[r]=String(e[r])},{ctx:t})}#K(e,t){return t instanceof Element?t:null!=t?this.#ee(e,t,"hx-target"):this.#D(e)?document.body:e}#D(e){return e?._htmx?.boosted}async#X(e){let t=e.sourceElement,r=e.sourceEvent;if(!t.isConnected)return;if(this.#te(r))return;this.#re(r)&&r.preventDefault();let i=/GET|DELETE/.test(e.request.method),s=i?t.matches("form")?t:null:t.form||t.closest("form"),n=this.#C(t,s,r.submitter,e.request.validate,i);if(!n)return;let o=this.#q(t,"hx-vals",t=>{e.vals=t;for(let e in t)n.set(e,t[e])},{ctx:e});if(o&&await o,e.values)for(let t in e.values)n.delete(t),n.append(t,e.values[t]);let a=this.#Z(t,e);if(a&&await a,Object.assign(e.request,{form:s,submitter:r.submitter,body:n}),!this.#L(t,"htmx:config:request",{ctx:e}))return;if("DIALOG"===e.request.method)return;let l=this.#ie(e.request.action);if(null!=l){let t=Object.fromEntries(e.request.body);return void await this.#k(e.sourceElement,t,l,!1)}if(i){let t=new URL(e.request.action,document.baseURI);for(let r of e.request.body.keys())t.searchParams.delete(r);for(let[r,i]of e.request.body)t.searchParams.append(r,i);t.origin===location.origin?e.request.action=t.pathname+t.search:e.request.action=t.href,e.request.body=null}else"multipart/form-data"!==(this.#E(t,"hx-encoding")??s?.enctype)&&(e.request.body=new URLSearchParams(e.request.body));await this.#se(e)}async#se(e){let t=e.sourceElement,r=this.#ne(t),i=this.#oe(t);if(this.#ae(t),"run"!==i.admit(r,()=>this.#se(e),()=>e.request?.abort?.()))return;e.status="issuing";let s=[],n=[];try{if(e.confirm){if(!await new Promise(r=>{let i={ctx:e,issueRequest:()=>r(!0),dropRequest:()=>r(!1)};if(this.#L(t,"htmx:confirm",i)){let i=this.#ie(e.confirm);r(i?this.#k(t,{ctx:e},i,!0):window.confirm(e.confirm))}}))return}if(this.#le(e),s=this.#he(t),n=this.#ce(t),e.fetch||=window.fetch.bind(window),e.request.headers["HX-Request-Type"]=e.target===document.body||e.select?"full":"partial",!this.#L(t,"htmx:before:request",{ctx:e}))return;let r=await e.fetch(e.request.action,e.request);if(e.response={raw:r,status:r.status,headers:r.headers},this.#ue(e),!this.#L(t,"htmx:before:response",{ctx:e}))return;if(e.text=await r.text(),!this.#L(t,"htmx:after:request",{ctx:e}))return;if(e.response.status>=400&&this.#L(t,"htmx:response:error",{ctx:e}),this.#de(e))return void(e.keepIndicators=!0);"issuing"===e.status&&(e.hx.retarget&&(e.target=e.hx.retarget),e.hx.reswap&&(e.swap=e.hx.reswap),e.hx.reselect&&(e.select=e.hx.reselect),e.status="response received",this.#fe(e),await this.swap(e),e.status="swapped")}catch(r){e.status="error: "+r,this.#L(t,"htmx:error",{ctx:e,error:r})}finally{await(e.extensionPromise?.catch(()=>{})),clearTimeout(e.requestTimeout),e.hx?.trigger&&this.#me(e.hx.trigger,e.sourceElement),this.#L(t,"htmx:finally:request",{ctx:e}),e.keepIndicators||(this.#pe(s),this.#ge(n)),i.continue()}}#ue(e){e.hx={};for(let[t,r]of e.response.raw.headers)t.toLowerCase().startsWith("hx-")&&(e.hx[t.slice(3).toLowerCase().replace(/-/g,"")]=r)}#de(t){if("true"===t.hx.refresh)return this._loc.reload(),!0;if(t.hx.redirect)return this._loc.href=t.hx.redirect,!0;if(t.hx.location){let r=t.hx.location,i={},s=e.parse(r);return"{"!==r[0]&&null==s.path||(i=s,r=i.path,delete i.path),null==i.push&&null==i.replace&&(i.push="true"),this.ajax("GET",r,i),!0}}#le(e){let t=null!=e.request.timeout?this.parseInterval(e.request.timeout):this.config.defaultTimeout;t&&(e.requestTimeout=setTimeout(()=>e.request?.abort?.(),t))}#ne(e){let t=this.#E(e,"hx-sync");if(!t)return"queue first";let r=t.split(":").pop().trim();return/^(drop|abort|replace|queue)/.test(r)?r:"queue first"}#oe(e){let r=this.#E(e,"hx-sync"),i=e;if(r){let t=r.includes(":")?r.slice(0,r.lastIndexOf(":")).trim():/^(drop|abort|replace|queue)/.test(r)?null:r;t&&(i=this.#ee(e,t,"hx-sync")||e)}return this.#$(i).rq||=new t}#te(e){return"click"===e.type&&(e.ctrlKey||e.metaKey||e.shiftKey)&&!!e.currentTarget?.closest?.("a[href]")}#re(e){let t=e.currentTarget;if("submit"===e.type&&"FORM"===t?.tagName)return!0;if(!("click"===e.type&&0===e.button))return!1;let r=t?.closest?.('button, input[type="submit"], input[type="image"]'),i=r?.form||r?.closest("form");if(r&&!r.disabled&&i&&("submit"===r.type||"image"===r.type||!r.type&&"BUTTON"===r.tagName))return!0;let s=t?.closest?.("a");if(!s||!s.href)return!1;let n=s.getAttribute("href");return!(n&&n.startsWith("#")&&n.length>1)}#J(e,t=e._htmx.eventHandler){let r=this.#E(e,"hx-trigger")||(e.matches("form")?"submit":e.matches("input:not([type=button]):not([type=submit]),select,textarea")?"change":"click");this.#O(e,r,t)}#O(e,t,r){let i=this.#w(t);this.#H(e).triggerSpecs.push(...i);for(let t of i){t.listeners=[];let[i,s]=this.#xe(t.name),n=[e];"outside"===t.from?n=[document]:t.from&&"self"!==t.from&&(n=this.#be(e,t.from));let o=e=>{if((t.halt||t.prevent)&&e.preventDefault(),(t.halt||t.stop||t.consume)&&e.stopPropagation(),t.once)for(let e of t.listeners)e.fromElt.removeEventListener(e.eventName,e.handler,e);r(e)},a=o;if(t.delay?a=e=>{clearTimeout(t.timeout),t.timeout=setTimeout(()=>o(e),this.parseInterval(t.delay))}:t.throttle&&(a=e=>{t.throttled?t.throttledEvent=e:(t.throttled=!0,o(e),t.throttleTimeout=setTimeout(()=>{if(t.throttled=!1,t.throttledEvent){let e=t.throttledEvent;t.throttledEvent=null,a(e)}},this.parseInterval(t.throttle)))}),t.handler=r=>{if(("self"!==t.from||r.target===e)&&("outside"!==t.from||!e.contains(r.target))&&(!t.target||r.target?.matches?.(t.target))){if(t.changed){let e=t.values??=new WeakMap,r=!1;for(let t of n)e.get(t)!==t.value&&(r=!0,e.set(t,t.value));if(!r)return}if(s){this.#re(r)&&r.preventDefault();let t={};for(let e in r)t[e]=r[e];if(!this.#k(e,t,s,!0,!1))return}a(r)}},"intersect"===i||"revealed"===i){let r={rootMargin:t.rootMargin};t.root&&(r.root=this.#ee(e,t.root)),t.threshold&&(r.threshold=parseFloat(t.threshold));let s="revealed"===i;t.observer=new IntersectionObserver(r=>{for(let i=0;i<r.length;i++)if(r[i].isIntersecting){this.trigger(e,"intersect",{},!1),s&&t.observer.disconnect();break}},r),i="intersect",t.observer.observe(e)}if("every"===i){let r=Object.keys(t).find(e=>"name"!==e);t.interval=setInterval(()=>{e.isConnected?this.#L(e,"every",{},!1):clearInterval(t.interval)},this.parseInterval(r))}if("load"!==i)for(let r of n){let s={fromElt:r,eventName:i,handler:t.handler,capture:!!t.capture,passive:!!t.passive};e._htmx.listeners.push(s),t.listeners.push(s),r.addEventListener(i,t.handler,s)}else t.handler(new CustomEvent("load"))}}#xe(e){let t=e.match(/^([^\[]*)\[([^\]]*)]/);return t?[t[1],t[2]]:[e,null]}#me(t,r){if("{"===t[0]){let i=e.parse(t);for(let e in i){let t=i[e],s=r;t?.target&&(s=this.find(t.target)),this.trigger(s,e,"object"==typeof t?t:{value:t})}}else t.split(",").forEach(e=>this.trigger(r,e.trim(),{}))}#ye(e){let t={},r=Object.getPrototypeOf(this);for(let i of Object.getOwnPropertyNames(r))"constructor"!==i&&"function"==typeof this[i]&&(["find","findAll"].includes(i)?t[i]=(t,r)=>void 0===r?this[i](e,t):this[i](t,r):t[i]=this[i].bind(this));return t}#k(e,t,r,i=!0,s=!0,n=!1){let o={};Object.assign(o,this.#ye(e));let a={},l={scope:a,code:r};this.#_(e,"htmx:scope",l),r=l.code,Object.assign(o,a),Object.assign(o,t);let h=Object.keys(o),c=Object.values(o),u=new(s?this.#l:this.#a)(...h,i?`return (${r})`:r);return n?()=>u.call(e,...c):u.call(e,...c)}process(e,t){if(!e?.isConnected)return;if(!(e instanceof Element)){for(let r of e.children||[])this.process(r,t);return}if(t&&this.#Se(e,!0),this.#V(e))return;if(!this.#L(e,"htmx:before:process"))return;let r=[e],i=this.#f.evaluate(e),s=null;for(;s=i.iterateNext();)r.push(s);for(let e of r)!this.#V(e)&&this.#L(e,"htmx:before:on:init",{},!0)&&this.#ve(e);for(let t of this.#j(e,this.#c))this.#W(t);for(let t of this.#j(e,this.#u))this.#Ee(t);this.#L(e,"htmx:after:process")}#Ee(e){let t=this.#E(e,"hx-boost");if(t&&"false"!==t&&this.#we(e)&&this.#L(e,"htmx:before:init",{},!0)){let r=this.#H(e);r.initialized=!0,r.eventHandler=this.#Q(e),r.boosted=t;let i=e.matches("a")?"click":"submit";e._htmx.listeners.push({fromElt:e,eventName:i,handler:e._htmx.eventHandler}),e.addEventListener(i,e._htmx.eventHandler),this.#L(e,"htmx:after:init",{},!0)}}#we(e){if(this.#U(e))if("A"===e.tagName){if(""===e.target||"_self"===e.target)return!e.hasAttribute("download")&&!e.getAttribute("href")?.startsWith?.("#")&&this.#Ae(e.href)}else if("FORM"===e.tagName)return"dialog"!==e.method&&this.#Ae(e.action)}#Ae(e){try{return new URL(e,window.location.href).origin===window.location.origin}catch(e){return!1}}#U(e){return!e._htmx?.initialized&&!this.#V(e)}#Se(e,t){let r=[e,...e.querySelectorAll?.("[data-htmx-powered]")??[]];for(let e of r)if(e._htmx){this.#L(e,"htmx:before:cleanup");for(let t of e._htmx.triggerSpecs||[])t.interval&&clearInterval(t.interval),t.timeout&&clearTimeout(t.timeout),t.throttleTimeout&&clearTimeout(t.throttleTimeout),t.observer?.disconnect();for(let t of e._htmx.listeners||[])t.fromElt.removeEventListener(t.eventName,t.handler,t);e.removeAttribute("data-htmx-powered"),this.#L(e,"htmx:after:cleanup"),t&&delete e._htmx}}#Te(e){let t=document.createElement("div");t.hidden=!0,document.body.insertAdjacentElement("afterend",t);let r=e.querySelectorAll?.(this.#S("[hx-preserve]"))||[];for(let e of r){let r=document.getElementById(e.id);r&&this.#Ce(t,r,null)}return t}#qe(e){for(let t of[...e.children]){let e=document.getElementById(t.id);e&&(this.#Ce(e.parentNode,t,e),this.#Se(e),e.remove())}e.remove()}#Ne(e){let t=this.#h.createHTML(e);return Document.parseHTMLUnsafe?.(t)||(new DOMParser).parseFromString(t,"text/html")}#Ie(e){let t=e.replace(/<hx-([a-z]+)(\s+|>)/gi,'<template hx type="$1"$2').replace(/<\/hx-[a-z]+>/gi,"</template>"),r="";t=t.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,e=>(r=this.#Ne(e).title,""));let i,s,n=t.match(/<([a-z][^\/>\x20\t\r\n\f]*)/i)?.[1]?.toLowerCase();if("html"===n||"body"===n?(i=this.#Ne(t),s=document.createDocumentFragment(),s.append(i.body)):(i=this.#Ne(`<template>${t}</template>`),s=i.querySelector("template").content),!r){let e=s.querySelector("title:not(svg title)");e&&(r=e.textContent,e.remove())}return this.#Me(s),{fragment:s,title:r}}#Oe(e,t,r,i){let s=t.id?"#"+CSS.escape(t.id):null;"true"!==r&&r&&!r.includes(" ")&&([r,s=s]=r.split(/:(.*)/)),"true"!==r&&r||(r="outerHTML");let n=this.#He(r);if(s=n.target||s,n.strip??=!n.style.startsWith("outer"),!s)return;let o=[...document.querySelectorAll(s)];for(let r of o){let s=document.createDocumentFragment();s.append(t.cloneNode(!0)),e.push({type:"oob",fragment:s,target:r,swapSpec:n,sourceElement:i})}t.remove()}#Le(e,t,r){let i=[];if(r)for(let s of r.split(",")){let[r,n="true"]=s.split(/:(.*)/);for(let s of e.querySelectorAll(r))this.#Oe(i,s,n,t)}for(let r of e.querySelectorAll(this.#S("[hx-swap-oob]"))){let e=this.#R(r,"hx-swap-oob"),s=r.getAttribute(e);r.removeAttribute(e),this.#Oe(i,r,s,t)}return i}#ke(e,t,r){t?t.before(...r.childNodes):e.append(...r.childNodes)}#He(t){t=t.trim();let r=this.config.defaultSwap;if(t&&!/^\S*:/.test(t)){let e=t.match(/^(\S+)\s*(.*)$/);r=e[1],t=e[2]}return{style:this.#B(r),...e.parse(t)}}#Ve(e,t){let r=[];for(let i of e.querySelectorAll("template[hx]")){let e=i.getAttribute("type");if("partial"===e){let e=this.#P(i,"hx-target")||(i.id?"#"+CSS.escape(i.id):null);if(e){this.#Me(i.content);let s=this.#He(this.#P(i,"hx-swap")||this.config.defaultSwap),n=this.#be(t.sourceElement,e);for(let e of n.length?n:[null])r.push({type:"partial",fragment:i.content.cloneNode(!0),target:e,swapSpec:s,sourceElement:t.sourceElement})}}else this.#_(i,"htmx:process:"+e,{ctx:t,tasks:r});i.remove()}return r}#Pe(e,t,r,i){try{null!=r&&e.setSelectionRange&&e.setSelectionRange(r,i),e.focus(t)}catch(e){}}#Re(e){let t=this.#j(e,"[autofocus]")[0];t&&this.#Pe(t)}#je(e,t){if(e.scroll){let r=e.scrollTarget?this.#Be(e.scrollTarget):t;r&&("top"===e.scroll?r.scrollTop=0:"bottom"===e.scroll&&(r.scrollTop=r.scrollHeight))}if("top"===e.show||"bottom"===e.show){let r=e.showTarget?this.#Be(e.showTarget):t;r?.scrollIntoView?.("top"===e.show)}}#Fe(e){e.request?.anchor&&document.getElementById(e.request.anchor)?.scrollIntoView({block:"start",behavior:"auto"})}#Me(e){let t=this.#j(e,"script");for(let e of t){let t=document.createElement("script");for(let r of e.attributes)t.setAttribute(r.name,r.value);this.config.inlineScriptNonce&&(t.nonce=this.config.inlineScriptNonce),t.textContent=this.#h.createScript(e.textContent),e.replaceWith(t)}}initialize(){this.config.history&&!this.#g&&(this.#g=!0,history.state||history.replaceState({htmx:!0},"",location.href),window.navigation&&!/firefox/i.test(navigator.userAgent)?navigation.addEventListener("navigate",e=>{"traverse"===e.navigationType&&e.canIntercept&&!e.hashChange&&e.intercept({handler:()=>this.#ze()})}):window.addEventListener("popstate",e=>this.#ze(e.state))),this.process(document.body)}async swap(e){try{this.#_e(e);let{fragment:t,title:r}=this.#Ie(e.text);e.title=r;let i=[],s=this.#Le(t,e.sourceElement,e.selectOOB),n=this.#Ve(t,e);i.push(...s,...n);let o=n.length||s.length&&!this.config.allowEmptySwapAfterOOB,a=this.#De(e,t,o);if(a&&i.unshift(a),!this.#L(e.sourceElement,"htmx:before:swap",{ctx:e,tasks:i}))return;let l=[],h=[];for(let t of i)t.swapSpec?.transition??a?.transition??e.transition?h.push(t):l.push(this.#N(t));if(h.length>0){let t=async()=>{for(let e of h)await this.#N(e,!1)};l.push(this.#$e(t,e))}await Promise.all(l),!e.sourceElement?.isConnected&&a?.target?.isConnected&&(e.sourceElement=a.target),this.#L(e.sourceElement,"htmx:after:swap",{ctx:e}),e.title&&!a?.swapSpec?.ignoreTitle&&(document.title=e.title),this.#Fe(e)}finally{this.#L(e.sourceElement,"htmx:finally:swap",{ctx:e})}}#De(e,t,r){let i=this.#He(e.swap||this.config.defaultSwap);if("delete"===i.style||t.childElementCount>0||t.textContent.trim()||(i.swapEmpty??!r)){if(e.select){let r=t.querySelectorAll(e.select);(t=document.createDocumentFragment()).append(...r)}return this.#D(e.sourceElement)&&(i.show||="top"),{type:"main",fragment:t,target:this.#K(e.sourceElement||document.body,i.target||e.target),swapSpec:i,sourceElement:e.sourceElement,transition:e.transition&&!1!==i.transition}}}async#N(e,t=!0){let{target:r,swapSpec:i,fragment:s}=e;if("string"==typeof r&&(r=document.querySelector(r)),!r)return;"string"==typeof i&&(i=this.#He(i));let n,o=i.style;if("none"===o)return;if("BODY"===s.firstElementChild?.tagName){const e=r===document.body&&o.startsWith("outer");e&&"outerHTML"===o&&(o="outerSync"),i.strip??=!e}if(i.strip&&s.firstElementChild&&(s=document.createDocumentFragment(),s.append(...(e.fragment.firstElementChild.content||e.fragment.firstElementChild).childNodes)),this.#We(r,"htmx-swapping"),t&&e.swapSpec?.swap&&await this.timeout(e.swapSpec?.swap),"delete"===o)return void(r.parentNode&&(this.#Se(r),r.parentNode.removeChild(r)));let a=[],l=i.settle??this.config.defaultSettleDelay,h=r.parentNode;if("innerHTML"===o||"outerHTML"===o&&h){let e=document.activeElement;if(e?.id){let t,r;try{t=e.selectionStart,r=e.selectionEnd}catch(e){}n={elt:e,start:t,end:r}}a=t&&l?this.#Ue(s,r):[]}let c=this.#Te(s),u=[...s.childNodes];try{if("innerHTML"===o){for(const e of r.children)this.#Se(e);r.replaceChildren(...s.childNodes)}else if("textContent"===o){for(const e of r.querySelectorAll("[data-htmx-powered]"))this.#Se(e);r.textContent=s.textContent}else if("outerHTML"===o)h&&(this.#ke(h,r,s),this.#Se(r),h.removeChild(r),r=u[0]||h);else if("outerSync"===o){this.#Qe(r,s.firstElementChild);for(const e of r.children)this.#Se(e);r.replaceChildren(...s.firstElementChild.childNodes),u=[r]}else if("innerMorph"===o)this.#I(r,s,!0),u=[...r.childNodes];else if("outerMorph"===o)this.#I(r,s,!1),u.push(r);else if("beforebegin"===o)h&&this.#ke(h,r,s);else if("afterbegin"===o)this.#ke(r,r.firstChild,s);else if("beforeend"===o)this.#ke(r,null,s);else if("afterend"===o)h&&this.#ke(h,r.nextSibling,s);else{let e=this.#i.get("handle_swap")||[],t=!1;for(const n of e){let e=n(o,r,s,i);if(e){t=!0,Array.isArray(e)&&(u=e);break}}if(!t)throw new Error(`Unknown swap style: ${o}`)}}finally{this.#Je(r,"htmx-swapping")}if(e.target=r,this.#qe(c),n&&!n.elt.matches(":focus")){let e=document.getElementById(n.elt.id);if(e){let t={preventScroll:void 0!==i.focusScroll?!i.focusScroll:!this.config.defaultFocusScroll};this.#Pe(e,t,n.start,n.end)}}this.#L(r,"htmx:before:settle",{task:e,newContent:u,settleTasks:a});for(const e of u)this.#We(e,"htmx-added");if(t&&a.length>0){this.#We(r,"htmx-settling"),await this.timeout(l);for(let e of a)e();this.#Je(r,"htmx-settling")}this.#L(r,"htmx:after:settle",{task:e,newContent:u,settleTasks:a});for(const e of u)this.#Je(e,"htmx-added"),this.process(e),this.#Re(e);this.#je(i,r)}#L(e,t,r={},i=!0){if(r.error){let i=`htmx: ${t}: ${r.error.message??r.error}`;r.error instanceof Error?console.error(i,r.error,{elt:e,detail:r}):console.error(i,{elt:e,detail:r})}else r.warn?console.warn(`htmx: ${t}: ${r.warn}`,{elt:e,detail:r}):this.config.logAll&&console.log(`htmx: ${t}`,{elt:e,detail:r});return e=this.#Xe(e),this.#_(e,t,r),this.trigger(e,this.#z(t),r,i)}#_(e,t,r={}){let i=this.#i.get(t.replace(/:/g,"_"));if(i){r.cancelled=!1;for(const t of i)if(!1===t(e,r)||r.cancelled)return r.cancelled=!0,!1}return!0}timeout(e){if((e=this.parseInterval(e))>0)return new Promise(t=>setTimeout(t,e))}onLoad(e){this.on(this.#z("htmx:after:process"),t=>{e(t.target)})}on(e,t,r){let i,s=document;return void 0===r?(i=e,r=t):(s=this.#Xe(e),i=t),s.addEventListener(i,r),r}find(e,t){return this.#Be(e,t)}findAll(e,t){return this.#be(e,t)}parseInterval(e){if("number"==typeof e)return e;let[,t,r]=e?.match(/^([\d.]+)(ms|s|m)?$/)||[],i=parseFloat(t)*({ms:1,s:1e3,m:6e4}[r]||1);return isNaN(i)?void 0:i}trigger(e,t,r={},i=!0){e=this.#Xe(e);let s=new CustomEvent(t,{detail:r,cancelable:!0,bubbles:i,composed:!0}),n=e?.isConnected?e:document;return!r.cancelled&&n.dispatchEvent(s)}ajax(e,t,r){(!r||r instanceof Element||"string"==typeof r)&&(r={target:r});let i="string"==typeof r.source?document.querySelector(r.source):r.source;if("string"==typeof r.source&&!i)return Promise.reject(new Error("Source not found"));if(r.target){let e=this.#K(document.body,r.target);if(!e)return Promise.reject(new Error("Target not found"));i||=e}i||=document.body;let s=this.#T(i,r.event||{});return Object.assign(s,r),r.target&&(s.target=this.#K(document.body,r.target)),Object.assign(s.request,{action:t,method:e.toUpperCase()}),r.headers&&Object.assign(s.request.headers,r.headers),this.#X(s)}#Ge(e){this.config.history&&(history.state||history.replaceState({htmx:!0},"",location.href),history.pushState({htmx:!0},"",e),this.#L(document,"htmx:after:history:push",{path:e}))}#Ke(e){this.config.history&&(history.replaceState({htmx:!0},"",e),this.#L(document,"htmx:after:history:replace",{path:e}))}async#ze(e,t){if(await this.timeout(1),e??=history.state,!e?.htmx)return;this.#p?.abort(),t=t||location.pathname+location.search;let r=document.querySelector(this.#S("[hx-history-elt]"))||document.body;if(this.#L(document,"htmx:before:history:restore",{path:t,cacheMiss:!0})){if("reload"!==this.config.history)return this.#p=new AbortController,this.ajax("GET",t,{target:r,swap:"outerSync",select:r!==document.body?this.#S("[hx-history-elt]"):void 0,request:{headers:{"HX-History-Restore-Request":"true"},signal:this.#p.signal}});this._loc.reload()}}#Ye(e){let{sourceElement:t,push:r,replace:i,hx:s,response:n}=e;if((s?.pushurl||s?.replaceurl)&&(r=s.pushurl,i=s.replaceurl),null==r&&null==i&&this.#D(t)&&(r="true"),"false"!==r&&!1!==r||(r=null),"false"!==i&&!1!==i||(i=null),!r&&!i)return null;let o=r||i;if("true"===o){let t=n?.raw?.url||e.request.action,r=new URL(t,location.href);o=r.pathname+r.search+(e.request.anchor?"#"+e.request.anchor:"")}return{type:r?"push":"replace",path:o}}#_e(e){if(!this.config.history)return;let t=this.#Ye(e);if(!t)return;let r={history:t,sourceElement:e.sourceElement,response:e.response};this.#L(document,"htmx:before:history:update",r)&&("push"===t.type?this.#Ge(t.path):this.#Ke(t.path),this.#L(document,"htmx:after:history:update",r))}#ve(e){if(e._htmx?.onInitialized)return;let t=this.#v("hx-on"),r=this.config.metaCharacter||":",i=t=>async r=>{try{await this.#k(e,{event:r},`with(event?.detail||{}){${t}}`,!1)}catch(t){"symbol"!=typeof t&&this.#L(e,"htmx:error",{error:t})}};for(let s of e.getAttributeNames()){let n=t.find(e=>s.startsWith(e));if(!n)continue;this.#H(e).onInitialized=!0;let o=s.substring(n.length),a=e.getAttribute(s);if(!o){for(let t of a.split(/;(?=[^;]*->)/)){let r=t.indexOf("->");-1!==r&&this.#O(e,t.substring(0,r).trim(),i(t.substring(r+2).trim()))}continue}if(o[0]!==r)continue;let l=o.substring(1);l.startsWith(r)&&(l="htmx"+r+l.substring(1)),this.#O(e,l,i(a))}}#he(e){let t,r=this.#E(e,"hx-indicator");if(r)t=this.#be(e,r,"hx-indicator");else{if(e===document.body)return[];t=[e]}for(const e of t){let t=this.#$(e);t.rc=(t.rc||0)+1,this.#We(e,this.config.requestClass)}return t}#pe(e){for(let t of e){let e=this.#$(t);e.rc&&--e.rc<=0&&(this.#Je(t,this.config.requestClass),delete e.rc)}}#ce(e){let t=this.#E(e,"hx-disable"),r=[];if(t){r=this.#be(e,t,"hx-disable");for(let e of r){let t=this.#$(e);t.dc=(t.dc||0)+1,e.disabled=!0}}return r}#ge(e){for(const t of e){let e=this.#$(t);e.dc&&--e.dc<=0&&(t.disabled=!1,delete e.dc)}}#C(e,t,r,i,s){if(i&&t&&!t.reportValidity())return;let n=t?new FormData(t):new FormData,o=t?new Set(t.elements):new Set;if(!t){if(i&&e.reportValidity&&!e.reportValidity())return;this.#Ze(e,o,n,s)}r&&r.name&&(n.append(r.name,r.value),o.add(r));let a=this.#E(e,"hx-include");if(a)for(let t of this.#be(e,a)){if(i&&t.reportValidity&&!t.reportValidity())return;this.#Ze(t,o,n)}return n}#Ze(e,t,r,i){let s=e.tagName,n=[];"BUTTON"===s||s.includes("-")?n=[e]:!["INPUT","SELECT","TEXTAREA","FIELDSET"].includes(s)&&i||(n=this.#j(e,"[name]:not(button)"));for(let e of n){let i=e.name||e.getAttribute?.("name");if(!i||e.matches(":disabled")||t.has(e))continue;t.add(e);let s=e.type;if("checkbox"===s||"radio"===s||"INPUT"!==e.tagName&&"checked"in e)e.checked&&r.append(i,e.value);else if("file"===s)for(let t of e.files)r.append(i,t);else if("select-multiple"===s)for(let t of e.selectedOptions)r.append(i,t.value);else if(Array.isArray(e.value))for(let t of e.value)r.append(i,t);else r.append(i,e.value)}}#q(t,r,i,s={}){let n=this.#E(t,r);if(!n)return null;let o=this.#ie(n);if(o)return 0!==o.indexOf("{")&&(o="{"+o+"}"),this.#k(t,s,o,!0).then(e=>{i(e)});i(e.parse(n))}#et(e){let t=e.trim();return t.startsWith("<")&&t.endsWith("/>")?t.slice(1,-2):t}#be(t,r,i,s){let n=r??t,o=r?this.#Xe(t)||document.body:document;if(n.startsWith("global "))return this.#be(o,n.slice(7),i,!0);let a=n?e.split(n):[],l=[],h=[];for(const e of a){let t,r=this.#et(e);if(r.startsWith("closest "))t=o.closest(r.slice(8));else if(r.startsWith("find "))t=o.querySelector(r.slice(5));else if(r.startsWith("findAll "))l.push(...o.querySelectorAll(r.slice(8)));else if("next"===r||"nextElementSibling"===r)t=o.nextElementSibling;else if(r.startsWith("next "))t=this.#tt(o,r.slice(5),!!s);else if("previous"===r||"previousElementSibling"===r)t=o.previousElementSibling;else if(r.startsWith("previous "))t=this.#rt(o,r.slice(9),!!s);else if("document"===r)t=document;else if("window"===r)t=window;else if("body"===r)t=document.body;else if("host"===r)t=o.getRootNode().host;else if("this"===r){if(i){l.push(...this.#F(o,i));continue}t=o}else h.push(r);t&&l.push(t)}if(h.length>0){let e=h.join(","),t=this.#it(o,!!s);l.push(...t.querySelectorAll(e))}return[...new Set(l)]}#tt(e,t,r){return this.#st(this.#it(e,r).querySelectorAll(t),e,Node.DOCUMENT_POSITION_PRECEDING)}#rt(e,t,r){let i=[...this.#it(e,r).querySelectorAll(t)].reverse();return this.#st(i,e,Node.DOCUMENT_POSITION_FOLLOWING)}#st(e,t,r){for(const i of e)if(i.compareDocumentPosition(t)===r)return i}#it(e,t){return e.isConnected&&e.getRootNode?e.getRootNode?.({composed:t}):document}#ee(e,t,r){let i=this.#be(e,t,r)[0];return i||console.warn(`htmx: '${t}' on ${r} did not match any element`,{elt:e,selector:t,attr:r}),i}#Be(e,t,r){return this.#be(e,t,r)[0]}#ie(e){if(null!=e){if(e.startsWith("js:"))return e.substring(3);if(e.startsWith("javascript:"))return e.substring(11)}}#ae(e){let t=this.#H(e);if(t.abortInitialized)return;t.abortInitialized=!0;let r=()=>{this.#oe(e).abort()};e.addEventListener("htmx:abort",r),t.listeners.push({fromElt:e,eventName:"htmx:abort",handler:r})}#I(e,t,r){let{persistentIds:i,idMap:s}=this.#nt(e,t),n=document.createElement("div");n.hidden=!0,document.body.after(n);let o={target:e,idMap:s,persistentIds:i,pantry:n,futureMatches:new WeakSet};r?this.#ot(o,e,t):this.#ot(o,e.parentNode,t,e,e.nextSibling),this.#Se(n),n.remove()}#ot(e,t,r,i=null,s=null){t instanceof HTMLTemplateElement&&r instanceof HTMLTemplateElement&&(t=t.content,r=r.content),i||=t.firstChild;let n=r.firstChild;for(;n;){let r;if(i&&i!=s&&(r=this.#at(e,n,i,s),r&&r!==i)){let o=i;for(;o&&o!==r;){let r=o;o=o.nextSibling,r instanceof Element&&(e.idMap.has(r)||this.#lt(e,r,n))?this.#Ce(t,r,s):this.#ht(e,r)}}if(!r&&n instanceof Element&&e.persistentIds.has(n.id)){let s=CSS.escape(n.id);r=e.target.id===n.id&&e.target||e.target.querySelector(`[id="${s}"]`)||e.pantry.querySelector(`[id="${s}"]`);let o=r;for(;o=o.parentNode;){let t=e.idMap.get(o);t&&(t.delete(r.id),t.size||e.idMap.delete(o))}this.#Ce(t,r,i)}if(r){this.#ct(r,n,e),i=r.nextSibling,n=n.nextSibling;continue}let o=n.nextSibling;if(e.idMap.has(n)){let r=document.createElement(n.tagName);t.insertBefore(r,i),this.#ct(r,n,e),this.process(r),i=r.nextSibling}else t.insertBefore(n,i),i=n.nextSibling;n=o}for(;i&&i!=s;){let t=i;i=i.nextSibling,this.#ht(e,t)}}#lt(e,t,r){if(e.futureMatches.has(t))return!0;for(let i=r.nextSibling,s=0;i&&s<this.config.morphScanLimit;i=i.nextSibling,s++)if(i instanceof Element&&t.isEqualNode(i))return e.futureMatches.add(t),!0;return!1}#at(e,t,r,i){if(3===t.nodeType)return 3===r?.nodeType?r:null;if(!(t instanceof Element))return null;let s=null,n=0,o=this.config.morphScanLimit,a=e.idMap.get(t),l=a?.size||0;if(t.id&&!a)return null;let h=r;for(;h&&h!=i;){let r=e.idMap.get(h);if(this.#o.isSoftMatch(h,t)){if(r&&a&&[...r].some(e=>a.has(e)))return h;if(!r){if(o>0&&h.isEqualNode(t))return h;s||(s=h)}}if(n+=r?.size||0,n>l)break;if(null!=document.activeElement?.selectionStart&&h.contains(document.activeElement))break;if(--o<1&&0===l)break;h=h.nextSibling}return s&&this.#lt(e,s,t)?null:s}#M(e,t){return e instanceof Element&&e.tagName===t.tagName&&(!("SCRIPT"===e.tagName&&!e.isEqualNode(t))&&(!e.id||e.id===t.id))}#ht(e,t){e.idMap.has(t)?this.#Ce(e.pantry,t,null):(this.#Se(t),t.remove())}#Ce(e,t,r){if(e.moveBefore)try{return void e.moveBefore(t,r)}catch(e){}e.insertBefore(t,r)}#ct(e,t,r){if(3===e.nodeType)return void(e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue));if(this.config.morphSkip&&e.matches?.(this.config.morphSkip))return;if(!this.#_(e,"htmx:before:morph:node",{oldNode:e,newNode:t}))return;this.#Qe(e,t),e instanceof HTMLTextAreaElement&&document.activeElement!==e&&e.defaultValue!=t.defaultValue&&(e.value=t.value),this.config.morphSkipChildren&&e.matches?.(this.config.morphSkipChildren)||e.isEqualNode(t)&&"TEMPLATE"!==t.tagName&&!t.querySelector?.("template")||this.#ot(r,e,t)}#Qe(e,t){let r=this.config.morphIgnore||[],i=!1,s=e=>this.#v("hx-").some(t=>e.startsWith(t));for(const n of t.attributes)if(!r.some(e=>n.name.startsWith(e))&&e.getAttribute(n.name)!==n.value){if(s(n.name)&&(i=!0),!this.#_(e,"htmx:before:morph:attr",{attrName:n.name,newValue:n.value}))continue;e.setAttribute(n.name,n.value),"value"===n.name&&e instanceof HTMLInputElement&&"file"!==e.type&&document.activeElement!==e&&(e.value=n.value)}for(let n=e.attributes.length-1;n>=0;n--){let o=e.attributes[n];if(o&&!t.hasAttribute(o.name)&&!r.some(e=>o.name.startsWith(e))){if(s(o.name)&&(i=!0),!this.#_(e,"htmx:before:morph:attr",{attrName:o.name,newValue:null}))continue;e.removeAttribute(o.name)}}i&&this.#Se(e,!0)}#ut(e,t,r,i){for(const s of i)if(t.has(s.id)){let t=s;for(;t&&t!==r;){let r=e.get(t);null==r&&(r=new Set,e.set(t,r)),r.add(s.id),t=t.parentElement}}}#nt(e,t){let r=this.#j(e,"[id]"),i=t.querySelectorAll("[id]"),s=this.#dt(r,i),n=new Map;return this.#ut(n,s,e.parentElement,r),this.#ut(n,s,t,i),{persistentIds:s,idMap:n}}#dt(e,t){let r=new Set,i=new Map;for(const{id:t,tagName:s}of e)i.has(t)?r.add(t):t&&i.set(t,s);let s=new Set;for(const{id:e,tagName:n}of t)s.has(e)?r.add(e):i.get(e)===n&&s.add(e);for(const e of r)s.delete(e);return s}#fe(t){let r=t.response.raw.status,i=this.config.noSwap.map(e=>e+""),s=r+"";for(let r of[s,s.slice(0,2)+"x",s[0]+"xx"]){if(i.includes(r))return void(t.swap="none");let s=this.#E(t.sourceElement,"hx-status:"+r);if(s)return void e.merge(s,t)}}#$e(e,t){return new Promise(r=>{this.#m||=[],this.#m.push({task:e,resolve:r,ctx:t}),this.#x||this.#ft()})}async#ft(){if(0===this.#m.length||this.#x)return;this.#x=!0;let{task:e,resolve:t,ctx:r}=this.#m.shift();try{if(document.startViewTransition){let t={task:e,ctx:r};this.#L(r.sourceElement,"htmx:before:viewTransition",t),await document.startViewTransition(t.task).finished,this.#L(r.sourceElement,"htmx:after:viewTransition",t)}else await e()}catch(e){}finally{this.#x=!1,t(),this.#ft()}}#Ue(e,t){let r=t.querySelectorAll("[id]"),i=Object.fromEntries([...r].map(e=>[e.id,e])),s=e.querySelectorAll("[id]"),n=[];for(let e of s){let t=i[e.id];if(t?.tagName===e.tagName){let r=e.cloneNode(!1);this.#Qe(e,t),n.push(()=>{this.#Qe(e,r)})}}return n}#We(e,t){e?.classList?.add?.(t)}#Je(e,t){e?.classList?.remove?.(t),0===e?.classList?.length&&e.removeAttribute("class")}#Xe(e){return"string"==typeof e?this.find(e):e}#z(e){return this.config.metaCharacter?e.replace(/:/g,this.config.metaCharacter):e}}})();⏎̸ | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | var htmx=(()=>{const e={parse(t){if(!t)return{};if(t.startsWith("{"))return JSON.parse(t);let r=/(?:"([^"]+)"|'([^']+)'|([^\s,:]+))(?:\s*:\s*(?:"([^"]*)"|'([^']*)'|<((?:[^/]|\/(?!>))+)\/>|([^\s,]+)))?(?=\s|,|$)/g,i={};for(let s of t.matchAll(r)){let[,t,r,n,o,a,l,h]=s,c=t??r??n,u=(o??a??l??h??"true").trim();try{u=JSON.parse(u)}catch{}let d=n?.includes("."),f=d?c.split(".").reduceRight((e,t)=>({[t]:e}),u):{[c]:u};e.merge(f,i)}return i},split:e=>e.split(/,(?![^\[]*\])(?![^(]*\))(?![^<]*\/>)(?=(?:[^"']|"[^"]*"|'[^']*')*$)/),merge(t,r){"string"==typeof t&&(t=e.parse(t));for(let[i,s]of Object.entries(t)){if(["__proto__","constructor","prototype"].includes(i))continue;let t=s?.constructor===Object,n=r[i]?.constructor===Object;t&&n?e.merge(s,r[i]):r[i]=s}return r}};class t{#e=null;#t=[];admit(e,t,r){if(!this.#e)return this.#e={strategy:e,abort:r},"run";if("replace"===e||"abort"!==e&&"abort"===this.#e.strategy)return this.#t=[],this.#e.abort?.(),this.#e={strategy:e,abort:r},"run";if("queue all"===e)this.#t.push(t);else if("queue last"===e)this.#t=[t];else{if("abort"===e||"drop"===e||0!==this.#t.length)return"dropped";this.#t.push(t)}return"queued"}continue(){this.#e=null,this.#t.shift()?.()}abort(){this.#e?.abort?.()}}return new class{#r=e;#i=new Map;#s="";#n=new Set;_loc=window.location;#o;#a=Function;#l=Object.getPrototypeOf(async function(){}).constructor;#h={createHTML:e=>e,createScript:e=>e};#c;#u="a,form";#d=["get","post","put","patch","delete","query"];#f;#m;#p;#g;#x;constructor(){this.#b(),this.#y(),this.#c=this.#S("[hx-action],[hx-get],[hx-post],[hx-put],[hx-patch],[hx-delete],[hx-query]"),this.#f=(new XPathEvaluator).createExpression(`.//*[@*[${this.#v("hx-on").map(e=>`starts-with(name(), "${e}")`).join(" or ")}]]`),this.#o={HCON:e,attributeValue:this.#E.bind(this),parseTriggerSpecs:this.#w.bind(this),determineMethodAndAction:this.#A.bind(this),createRequestContext:this.#T.bind(this),collectFormData:this.#C.bind(this),getAttributeObject:this.#q.bind(this),insertContent:this.#N.bind(this),morph:this.#I.bind(this),isSoftMatch:this.#M.bind(this),initSecurity:(e,t,r)=>{e&&(this.#h=e),t&&(this.#a=t),r&&(this.#l=r)},onTrigger:this.#O.bind(this),htmxProp:this.#H.bind(this),triggerHtmxEvent:this.#L.bind(this),executeJavaScript:this.#k.bind(this)};let t=()=>this.initialize();"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):setTimeout(t)}#b(){this.version="4.0.0",this.config={logAll:!1,prefix:"data-hx-",transitions:!1,history:!0,mode:"same-origin",defaultSwap:"innerHTML",defaultFocusScroll:!1,indicatorClass:"htmx-indicator",requestClass:"htmx-request",includeIndicatorCSS:!0,defaultTimeout:6e4,extensions:"",morphIgnore:["data-htmx-powered"],morphSkip:"[hx-morph-skip]",morphSkipChildren:"[hx-morph-skip-children]",morphScanLimit:10,noSwap:[204,304],implicitInheritance:!1,defaultSettleDelay:1,allowEmptySwapAfterOOB:!1};let t=document.querySelector('meta[name="htmx-config"]');t&&e.merge(t.content,this.config),this.#s=this.config.extensions}#y(){if(!1!==this.config.includeIndicatorCSS){let e=this.config.indicatorClass,t=this.config.requestClass,r=new CSSStyleSheet;r.replaceSync(`.${e}{opacity:0;visibility: hidden} .${t} .${e}, .${t}.${e}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`),document.adoptedStyleSheets=[...document.adoptedStyleSheets,r]}}registerExtension(e,t){return!(this.#s&&!this.#s.split(/,\s*/).includes(e))&&(!this.#n.has(e)&&(this.#n.add(e),t.init&&t.init(this.#o),void Object.entries(t).forEach(([e,t])=>{this.#i.get(e)?.push(t)||this.#i.set(e,[t])})))}#V(e){let t=this.config.prefix;return!e.closest||null!=e.closest("[hx-ignore]")||t&&null!=e.closest(`[${t}ignore]`)}#P(e,t){let r=this.config.prefix;return e.getAttribute(t)??(r?e.getAttribute(t.replace("hx-",r)):null)}#R(e,t){let r=this.config.prefix&&t.replace("hx-",this.config.prefix);return e.hasAttribute(t)?t:r&&e.hasAttribute(r)?r:null}#S(e){return this.#v(e).join(",")}#v(e){let t=[e];return this.config.prefix&&t.push(e.replaceAll("hx-",this.config.prefix)),t}#j(e,t){let r=[...e.querySelectorAll?.(t)??[]];return e.matches?.(t)&&r.unshift(e),r}#B(e){return"before"===e?"beforebegin":"after"===e?"afterend":"prepend"===e?"afterbegin":"append"===e?"beforeend":e}#F(e,t){let r=[];return this.#E(e,t,void 0,(e,t)=>{e?.split(/\s*[,:]\s*/).includes("this")&&r.push(t)}),r}#E(e,t,r,i){t=this.#z(t);let s=this.#z(":inherited"),n=this.#z(":append"),o=this.#P(e,t)??this.#P(e,t+s);if(null!=o)return i?i(o,e):o;let a=CSS.escape(this.config.implicitInheritance?t:t+s),l=CSS.escape(t+s+n),h=this.#S(`[${a}],[${l}]`),c=this.#R(e,t+n)??this.#R(e,t+s+n);if(c){let r=e.getAttribute(c),s=e.parentNode?.closest?.(h);if(i&&i(r,e),s){let e=this.#E(s,t,void 0,i);return e?(e+","+r).replace(/[{}]/g,""):r}return r}let u=e.parentNode?.closest?.(h);return u?(o=this.#E(u,t,void 0,i),!i&&o&&this.config.implicitInheritance&&this.#_(e,"htmx:after:implicitInheritance",{elt:e,name:t,parent:u}),o):r}#w(t){return e.split(t).flatMap(t=>{let[,r,i]=t.match(/^\s*(\S+\[[^\]]*\]|\S+)\s*(.*?)\s*$/)??[];if(!r)return[];if(/\[[^\]]*$/.test(r))throw"unterminated:"+r;return[{name:r,...e.parse(i)}]})}#A(e,t){let r=this.#E(e,"hx-method"),i=this.#E(e,"hx-action");if(!i)for(let t of this.#d){let s=this.#E(e,"hx-"+t);if(null!=s){i=s,r=t;break}}return this.#D(e)&&(i||=t.submitter?.getAttribute?.("formAction")||e.getAttribute(e.matches("a")?"href":"action")),r||=t.submitter?.getAttribute?.("formmethod")||e.getAttribute("method")||"GET",{action:i,method:r.toUpperCase()}}#H(e){return e._htmx||(e._htmx={listeners:[],triggerSpecs:[]},e.setAttribute("data-htmx-powered","true")),e._htmx}#$(e){return e._htmx_state||={}}#W(e){if(this.#U(e)&&this.#L(e,"htmx:before:init",{},!0)){let t=this.#H(e);t.initialized=!0,t.eventHandler=this.#Q(e),this.#J(e),this.#L(e,"htmx:after:init",{},!0)}}#Q(e){return async t=>{try{let r=this.#T(e,t);await this.#X(r)}catch(t){this.#L(e,"htmx:error",{error:t})}}}#T(t,r){let{action:i,method:s}=this.#A(t,r),[n,o]=(i||"").split("#"),a=new AbortController,l={sourceElement:t,sourceEvent:r,status:"created",select:this.#E(t,"hx-select"),selectOOB:this.#E(t,"hx-select-oob"),target:this.#E(t,"hx-target"),swap:this.#E(t,"hx-swap")??this.config.defaultSwap,push:this.#E(t,"hx-push-url"),replace:this.#E(t,"hx-replace-url"),transition:this.config.transitions,confirm:this.#E(t,"hx-confirm"),request:{validate:"true"===this.#E(t,"hx-validate",!t.matches("form")||t.noValidate||r.submitter?.formNoValidate?"false":"true"),action:n,anchor:o,method:s,headers:this.#G(t),abort:a.abort.bind(a),credentials:"same-origin",signal:a.signal,mode:this.config.mode}};t._htmx?.boosted&&e.merge(t._htmx.boosted,l),l.target=this.#K(t,l.target),l.target&&(l.request.headers["HX-Target"]=this.#Y(l.target));let h=this.#E(t,"hx-config");return h&&(e.merge(h,l.request),l.request.mode=this.config.mode),l}#Y(e){return`${e.tagName.toLowerCase()}${e.id?"#"+encodeURI(e.id):""}`}#G(e){let t={"HX-Request":"true","HX-Source":this.#Y(e),"HX-Current-URL":location.href,Accept:"text/html"};return this.#D(e)&&(t["HX-Boosted"]="true"),t}#Z(e,t){return this.#q(e,"hx-headers",e=>{for(let r in e)t.request.headers[r]=String(e[r])},{ctx:t})}#K(e,t){return t instanceof Element?t:null!=t?this.#ee(e,t,"hx-target"):this.#D(e)?document.body:e}#D(e){return e?._htmx?.boosted}async#X(e){let t=e.sourceElement,r=e.sourceEvent;if(!t.isConnected)return;if(this.#te(r))return;this.#re(r)&&r.preventDefault();let i=/GET|DELETE/.test(e.request.method),s=i?t.matches("form")?t:null:t.form||t.closest("form"),n=this.#C(t,s,r.submitter,e.request.validate,i);if(!n)return;let o=this.#q(t,"hx-vals",t=>{e.vals=t;for(let e in t)n.set(e,t[e])},{ctx:e});if(o&&await o,e.values)for(let t in e.values)n.delete(t),n.append(t,e.values[t]);let a=this.#Z(t,e);if(a&&await a,Object.assign(e.request,{form:s,submitter:r.submitter,body:n}),!this.#L(t,"htmx:config:request",{ctx:e}))return;if("DIALOG"===e.request.method)return;let l=this.#ie(e.request.action);if(null!=l){let t=Object.fromEntries(e.request.body);return void await this.#k(e.sourceElement,t,l,!1)}if(i){let t=new URL(e.request.action,document.baseURI);for(let r of e.request.body.keys())t.searchParams.delete(r);for(let[r,i]of e.request.body)t.searchParams.append(r,i);t.origin===location.origin?e.request.action=t.pathname+t.search:e.request.action=t.href,e.request.body=null}else"multipart/form-data"!==(this.#E(t,"hx-encoding")??s?.enctype)&&(e.request.body=new URLSearchParams(e.request.body));await this.#se(e)}async#se(e){let t=e.sourceElement,r=this.#ne(t),i=this.#oe(t);if(this.#ae(t),"run"!==i.admit(r,()=>this.#se(e),()=>e.request?.abort?.()))return;e.status="issuing";let s=[],n=[];try{if(e.confirm){if(!await new Promise(r=>{let i={ctx:e,issueRequest:()=>r(!0),dropRequest:()=>r(!1)};if(this.#L(t,"htmx:confirm",i)){let i=this.#ie(e.confirm);r(i?this.#k(t,{ctx:e},i,!0):window.confirm(e.confirm))}}))return}if(this.#le(e),s=this.#he(t),n=this.#ce(t),e.fetch||=window.fetch.bind(window),e.request.headers["HX-Request-Type"]=e.target===document.body||e.select?"full":"partial",!this.#L(t,"htmx:before:request",{ctx:e}))return;let r=await e.fetch(e.request.action,e.request);if(e.response={raw:r,status:r.status,headers:r.headers},this.#ue(e),!this.#L(t,"htmx:before:response",{ctx:e}))return;if(e.text=await r.text(),!this.#L(t,"htmx:after:request",{ctx:e}))return;if(e.response.status>=400&&this.#L(t,"htmx:response:error",{ctx:e}),this.#de(e))return void(e.keepIndicators=!0);"issuing"===e.status&&(e.hx.retarget&&(e.target=e.hx.retarget),e.hx.reswap&&(e.swap=e.hx.reswap),e.hx.reselect&&(e.select=e.hx.reselect),e.status="response received",this.#fe(e),await this.swap(e),e.status="swapped")}catch(r){e.status="error: "+r,this.#L(t,"htmx:error",{ctx:e,error:r})}finally{await(e.extensionPromise?.catch(()=>{})),clearTimeout(e.requestTimeout),e.hx?.trigger&&this.#me(e.hx.trigger,e.sourceElement),this.#L(t,"htmx:finally:request",{ctx:e}),e.keepIndicators||(this.#pe(s),this.#ge(n)),i.continue()}}#ue(e){e.hx={};for(let[t,r]of e.response.raw.headers)t.toLowerCase().startsWith("hx-")&&(e.hx[t.slice(3).toLowerCase().replace(/-/g,"")]=r)}#de(t){if("true"===t.hx.refresh)return this._loc.reload(),!0;if(t.hx.redirect)return this._loc.href=t.hx.redirect,!0;if(t.hx.location){let r=t.hx.location,i={},s=e.parse(r);return"{"!==r[0]&&null==s.path||(i=s,r=i.path,delete i.path),null==i.push&&null==i.replace&&(i.push="true"),this.ajax("GET",r,i),!0}}#le(e){let t=null!=e.request.timeout?this.parseInterval(e.request.timeout):this.config.defaultTimeout;t&&(e.requestTimeout=setTimeout(()=>e.request?.abort?.(),t))}#ne(e){let t=this.#E(e,"hx-sync");if(!t)return"queue first";let r=t.split(":").pop().trim();return/^(drop|abort|replace|queue)/.test(r)?r:"queue first"}#oe(e){let r=this.#E(e,"hx-sync"),i=e;if(r){let t=r.includes(":")?r.slice(0,r.lastIndexOf(":")).trim():/^(drop|abort|replace|queue)/.test(r)?null:r;t&&(i=this.#ee(e,t,"hx-sync")||e)}return this.#$(i).rq||=new t}#te(e){return"click"===e.type&&(e.ctrlKey||e.metaKey||e.shiftKey)&&!!e.currentTarget?.closest?.("a[href]")}#re(e){let t=e.currentTarget;if("submit"===e.type&&"FORM"===t?.tagName)return!0;if(!("click"===e.type&&0===e.button))return!1;let r=t?.closest?.('button, input[type="submit"], input[type="image"]'),i=r?.form||r?.closest("form");if(r&&!r.disabled&&i&&("submit"===r.type||"image"===r.type||!r.type&&"BUTTON"===r.tagName))return!0;let s=t?.closest?.("a");if(!s||!s.href)return!1;let n=s.getAttribute("href");return!(n&&n.startsWith("#")&&n.length>1)}#J(e,t=e._htmx.eventHandler){let r=this.#E(e,"hx-trigger")||(e.matches("form")?"submit":e.matches("input:not([type=button]):not([type=submit]),select,textarea")?"change":"click");this.#O(e,r,t)}#O(e,t,r){let i=this.#w(t);this.#H(e).triggerSpecs.push(...i);for(let t of i){t.listeners=[];let[i,s]=this.#xe(t.name),n=[e];"outside"===t.from?n=[document]:t.from&&"self"!==t.from&&(n=this.#be(e,t.from));let o=e=>{if((t.halt||t.prevent)&&e.preventDefault(),(t.halt||t.stop||t.consume)&&e.stopPropagation(),t.once)for(let e of t.listeners)e.fromElt.removeEventListener(e.eventName,e.handler,e);r(e)},a=o;if(t.delay?a=e=>{clearTimeout(t.timeout),t.timeout=setTimeout(()=>o(e),this.parseInterval(t.delay))}:t.throttle&&(a=e=>{t.throttled?t.throttledEvent=e:(t.throttled=!0,o(e),t.throttleTimeout=setTimeout(()=>{if(t.throttled=!1,t.throttledEvent){let e=t.throttledEvent;t.throttledEvent=null,a(e)}},this.parseInterval(t.throttle)))}),t.handler=r=>{if(("self"!==t.from||r.target===e)&&("outside"!==t.from||!e.contains(r.target))&&(!t.target||r.target?.matches?.(t.target))){if(t.changed){let e=t.values??=new WeakMap,r=!1;for(let t of n)e.get(t)!==t.value&&(r=!0,e.set(t,t.value));if(!r)return}if(s){this.#re(r)&&r.preventDefault();let t={};for(let e in r)t[e]=r[e];if(!this.#k(e,t,s,!0,!1))return}a(r)}},"intersect"===i||"revealed"===i){let r={rootMargin:t.rootMargin};t.root&&(r.root=this.#ee(e,t.root)),t.threshold&&(r.threshold=parseFloat(t.threshold));let s="revealed"===i;t.observer=new IntersectionObserver(r=>{for(let i=0;i<r.length;i++)if(r[i].isIntersecting){this.trigger(e,"intersect",{},!1),s&&t.observer.disconnect();break}},r),i="intersect",t.observer.observe(e)}if("every"===i){let r=Object.keys(t).find(e=>"name"!==e);t.interval=setInterval(()=>{e.isConnected?this.#L(e,"every",{},!1):clearInterval(t.interval)},this.parseInterval(r))}if("load"!==i)for(let r of n){let s={fromElt:r,eventName:i,handler:t.handler,capture:!!t.capture,passive:!!t.passive};e._htmx.listeners.push(s),t.listeners.push(s),r.addEventListener(i,t.handler,s)}else t.handler(new CustomEvent("load"))}}#xe(e){let t=e.match(/^([^\[]*)\[([^\]]*)]/);return t?[t[1],t[2]]:[e,null]}#me(t,r){if("{"===t[0]){let i=e.parse(t);for(let e in i){let t=i[e],s=r;t?.target&&(s=this.find(t.target)),this.trigger(s,e,"object"==typeof t?t:{value:t})}}else t.split(",").forEach(e=>this.trigger(r,e.trim(),{}))}#ye(e){let t={},r=Object.getPrototypeOf(this);for(let i of Object.getOwnPropertyNames(r))"constructor"!==i&&"function"==typeof this[i]&&(["find","findAll"].includes(i)?t[i]=(t,r)=>void 0===r?this[i](e,t):this[i](t,r):t[i]=this[i].bind(this));return t}#k(e,t,r,i=!0,s=!0,n=!1){let o={};Object.assign(o,this.#ye(e));let a={},l={scope:a,code:r};this.#_(e,"htmx:scope",l),r=l.code,Object.assign(o,a),Object.assign(o,t);let h=Object.keys(o),c=Object.values(o),u=new(s?this.#l:this.#a)(...h,i?`return (${r})`:r);return n?()=>u.call(e,...c):u.call(e,...c)}process(e,t){if(!e?.isConnected)return;if(!(e instanceof Element)){for(let r of e.children||[])this.process(r,t);return}if(t&&this.#Se(e,!0),this.#V(e))return;if(!this.#L(e,"htmx:before:process"))return;let r=[e],i=this.#f.evaluate(e),s=null;for(;s=i.iterateNext();)r.push(s);for(let e of r)!this.#V(e)&&this.#L(e,"htmx:before:on:init",{},!0)&&this.#ve(e);for(let t of this.#j(e,this.#c))this.#W(t);for(let t of this.#j(e,this.#u))this.#Ee(t);this.#L(e,"htmx:after:process")}#Ee(e){let t=this.#E(e,"hx-boost");if(t&&"false"!==t&&this.#we(e)&&this.#L(e,"htmx:before:init",{},!0)){let r=this.#H(e);r.initialized=!0,r.eventHandler=this.#Q(e),r.boosted=t;let i=e.matches("a")?"click":"submit";e._htmx.listeners.push({fromElt:e,eventName:i,handler:e._htmx.eventHandler}),e.addEventListener(i,e._htmx.eventHandler),this.#L(e,"htmx:after:init",{},!0)}}#we(e){if(this.#U(e))if("A"===e.tagName){if(""===e.target||"_self"===e.target)return!e.hasAttribute("download")&&!e.getAttribute("href")?.startsWith?.("#")&&this.#Ae(e.href)}else if("FORM"===e.tagName)return"dialog"!==e.method&&this.#Ae(e.action)}#Ae(e){try{return new URL(e,window.location.href).origin===window.location.origin}catch(e){return!1}}#U(e){return!e._htmx?.initialized&&!this.#V(e)}#Se(e,t){let r=[e,...e.querySelectorAll?.("[data-htmx-powered]")??[]];for(let e of r)if(e._htmx){this.#L(e,"htmx:before:cleanup");for(let t of e._htmx.triggerSpecs||[])t.interval&&clearInterval(t.interval),t.timeout&&clearTimeout(t.timeout),t.throttleTimeout&&clearTimeout(t.throttleTimeout),t.observer?.disconnect();for(let t of e._htmx.listeners||[])t.fromElt.removeEventListener(t.eventName,t.handler,t);e.removeAttribute("data-htmx-powered"),this.#L(e,"htmx:after:cleanup"),t&&delete e._htmx}}#Te(e){let t=document.createElement("div");t.hidden=!0,document.body.insertAdjacentElement("afterend",t);let r=e.querySelectorAll?.(this.#S("[hx-preserve]"))||[];for(let e of r){let r=document.getElementById(e.id);r&&this.#Ce(t,r,null)}return t}#qe(e){for(let t of[...e.children]){let e=document.getElementById(t.id);e&&(this.#Ce(e.parentNode,t,e),this.#Se(e),e.remove())}e.remove()}#Ne(e){let t=this.#h.createHTML(e);return Document.parseHTMLUnsafe?.(t)||(new DOMParser).parseFromString(t,"text/html")}#Ie(e){let t=e.replace(/<hx-([a-z]+)(\s+|>)/gi,'<template hx type="$1"$2').replace(/<\/hx-[a-z]+>/gi,"</template>"),r="";t=t.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,e=>(r=this.#Ne(e).title,""));let i,s,n=t.match(/<([a-z][^\/>\x20\t\r\n\f]*)/i)?.[1]?.toLowerCase();if("html"===n||"body"===n?(i=this.#Ne(t),s=document.createDocumentFragment(),s.append(i.body)):(i=this.#Ne(`<template>${t}</template>`),s=i.querySelector("template").content),!r){let e=s.querySelector("title:not(svg title)");e&&(r=e.textContent,e.remove())}return this.#Me(s),{fragment:s,title:r}}#Oe(e,t,r,i){let s=t.id?"#"+CSS.escape(t.id):null;"true"!==r&&r&&!r.includes(" ")&&([r,s=s]=r.split(/:(.*)/)),"true"!==r&&r||(r="outerHTML");let n=this.#He(r);if(s=n.target||s,n.strip??=!n.style.startsWith("outer"),!s)return;let o=[...document.querySelectorAll(s)];for(let r of o){let s=document.createDocumentFragment();s.append(t.cloneNode(!0)),e.push({type:"oob",fragment:s,target:r,swapSpec:n,sourceElement:i})}t.remove()}#Le(e,t,r){let i=[];if(r)for(let s of r.split(",")){let[r,n="true"]=s.split(/:(.*)/);for(let s of e.querySelectorAll(r))this.#Oe(i,s,n,t)}for(let r of e.querySelectorAll(this.#S("[hx-swap-oob]"))){let e=this.#R(r,"hx-swap-oob"),s=r.getAttribute(e);r.removeAttribute(e),this.#Oe(i,r,s,t)}return i}#ke(e,t,r){t?t.before(...r.childNodes):e.append(...r.childNodes)}#He(t){t=t.trim();let r=this.config.defaultSwap;if(t&&!/^\S*:/.test(t)){let e=t.match(/^(\S+)\s*(.*)$/);r=e[1],t=e[2]}return{style:this.#B(r),...e.parse(t)}}#Ve(e,t){let r=[];for(let i of e.querySelectorAll("template[hx]")){let e=i.getAttribute("type");if("partial"===e){let e=this.#P(i,"hx-target")||(i.id?"#"+CSS.escape(i.id):null);if(e){this.#Me(i.content);let s=this.#He(this.#P(i,"hx-swap")||this.config.defaultSwap),n=this.#be(t.sourceElement,e);for(let e of n.length?n:[null])r.push({type:"partial",fragment:i.content.cloneNode(!0),target:e,swapSpec:s,sourceElement:t.sourceElement})}}else this.#_(i,"htmx:process:"+e,{ctx:t,tasks:r});i.remove()}return r}#Pe(e,t,r,i){try{null!=r&&e.setSelectionRange&&e.setSelectionRange(r,i),e.focus(t)}catch(e){}}#Re(e){let t=this.#j(e,"[autofocus]")[0];t&&this.#Pe(t)}#je(e,t){if(e.scroll){let r=e.scrollTarget?this.#Be(e.scrollTarget):t;r&&("top"===e.scroll?r.scrollTop=0:"bottom"===e.scroll&&(r.scrollTop=r.scrollHeight))}if("top"===e.show||"bottom"===e.show){let r=e.showTarget?this.#Be(e.showTarget):t;r?.scrollIntoView?.("top"===e.show)}}#Fe(e){e.request?.anchor&&document.getElementById(e.request.anchor)?.scrollIntoView({block:"start",behavior:"auto"})}#Me(e){let t=this.#j(e,"script");for(let e of t){let t=document.createElement("script");for(let r of e.attributes)t.setAttribute(r.name,r.value);this.config.inlineScriptNonce&&(t.nonce=this.config.inlineScriptNonce),t.textContent=this.#h.createScript(e.textContent),e.replaceWith(t)}}initialize(){this.config.history&&!this.#g&&(this.#g=!0,history.state||history.replaceState({htmx:!0},"",location.href),window.navigation&&!/firefox/i.test(navigator.userAgent)?navigation.addEventListener("navigate",e=>{"traverse"===e.navigationType&&e.canIntercept&&!e.hashChange&&e.intercept({handler:()=>this.#ze()})}):window.addEventListener("popstate",e=>this.#ze(e.state))),this.process(document.body)}async swap(e){try{this.#_e(e);let{fragment:t,title:r}=this.#Ie(e.text);e.title=r;let i=[],s=this.#Le(t,e.sourceElement,e.selectOOB),n=this.#Ve(t,e);i.push(...s,...n);let o=n.length||s.length&&!this.config.allowEmptySwapAfterOOB,a=this.#De(e,t,o);if(a&&i.unshift(a),!this.#L(e.sourceElement,"htmx:before:swap",{ctx:e,tasks:i}))return;let l=[],h=[];for(let t of i)t.swapSpec?.transition??a?.transition??e.transition?h.push(t):l.push(this.#N(t));if(h.length>0){let t=async()=>{for(let e of h)await this.#N(e,!1)};l.push(this.#$e(t,e))}await Promise.all(l),!e.sourceElement?.isConnected&&a?.target?.isConnected&&(e.sourceElement=a.target),this.#L(e.sourceElement,"htmx:after:swap",{ctx:e}),e.title&&!a?.swapSpec?.ignoreTitle&&(document.title=e.title),this.#Fe(e)}finally{this.#L(e.sourceElement,"htmx:finally:swap",{ctx:e})}}#De(e,t,r){let i=this.#He(e.swap||this.config.defaultSwap);if("delete"===i.style||t.childElementCount>0||t.textContent.trim()||(i.swapEmpty??!r)){if(e.select){let r=t.querySelectorAll(e.select);(t=document.createDocumentFragment()).append(...r)}return this.#D(e.sourceElement)&&(i.show||="top"),{type:"main",fragment:t,target:this.#K(e.sourceElement||document.body,i.target||e.target),swapSpec:i,sourceElement:e.sourceElement,transition:e.transition&&!1!==i.transition}}}async#N(e,t=!0){let{target:r,swapSpec:i,fragment:s}=e;if("string"==typeof r&&(r=document.querySelector(r)),!r)return;"string"==typeof i&&(i=this.#He(i));let n,o=i.style;if("none"===o)return;if("BODY"===s.firstElementChild?.tagName){const e=r===document.body&&o.startsWith("outer");e&&"outerHTML"===o&&(o="outerSync"),i.strip??=!e}if(i.strip&&s.firstElementChild&&(s=document.createDocumentFragment(),s.append(...(e.fragment.firstElementChild.content||e.fragment.firstElementChild).childNodes)),this.#We(r,"htmx-swapping"),t&&e.swapSpec?.swap&&await this.timeout(e.swapSpec?.swap),"delete"===o)return void(r.parentNode&&(this.#Se(r),r.parentNode.removeChild(r)));let a=[],l=i.settle??this.config.defaultSettleDelay,h=r.parentNode;if("innerHTML"===o||"outerHTML"===o&&h){let e=document.activeElement;if(e?.id){let t,r;try{t=e.selectionStart,r=e.selectionEnd}catch(e){}n={elt:e,start:t,end:r}}a=t&&l?this.#Ue(s,r):[]}let c=this.#Te(s),u=[...s.childNodes];try{if("innerHTML"===o){for(const e of r.children)this.#Se(e);r.replaceChildren(...s.childNodes)}else if("textContent"===o){for(const e of r.querySelectorAll("[data-htmx-powered]"))this.#Se(e);r.textContent=s.textContent}else if("outerHTML"===o)h&&(this.#ke(h,r,s),this.#Se(r),h.removeChild(r),r=u[0]||h);else if("outerSync"===o){this.#Qe(r,s.firstElementChild);for(const e of r.children)this.#Se(e);r.replaceChildren(...s.firstElementChild.childNodes),u=[r]}else if("innerMorph"===o)this.#I(r,s,!0),u=[...r.childNodes];else if("outerMorph"===o)this.#I(r,s,!1),u.push(r);else if("beforebegin"===o)h&&this.#ke(h,r,s);else if("afterbegin"===o)this.#ke(r,r.firstChild,s);else if("beforeend"===o)this.#ke(r,null,s);else if("afterend"===o)h&&this.#ke(h,r.nextSibling,s);else{let e=this.#i.get("handle_swap")||[],t=!1;for(const n of e){let e=n(o,r,s,i);if(e){t=!0,Array.isArray(e)&&(u=e);break}}if(!t)throw new Error(`Unknown swap style: ${o}`)}}finally{this.#Je(r,"htmx-swapping")}if(e.target=r,this.#qe(c),n&&!n.elt.matches(":focus")){let e=document.getElementById(n.elt.id);if(e){let t={preventScroll:void 0!==i.focusScroll?!i.focusScroll:!this.config.defaultFocusScroll};this.#Pe(e,t,n.start,n.end)}}this.#L(r,"htmx:before:settle",{task:e,newContent:u,settleTasks:a});for(const e of u)this.#We(e,"htmx-added");if(t&&a.length>0){this.#We(r,"htmx-settling"),await this.timeout(l);for(let e of a)e();this.#Je(r,"htmx-settling")}this.#L(r,"htmx:after:settle",{task:e,newContent:u,settleTasks:a});for(const e of u)this.#Je(e,"htmx-added"),this.process(e),this.#Re(e);this.#je(i,r)}#L(e,t,r={},i=!0){if(r.error){let i=`htmx: ${t}: ${r.error.message??r.error}`;r.error instanceof Error?console.error(i,r.error,{elt:e,detail:r}):console.error(i,{elt:e,detail:r})}else r.warn?console.warn(`htmx: ${t}: ${r.warn}`,{elt:e,detail:r}):this.config.logAll&&console.log(`htmx: ${t}`,{elt:e,detail:r});return e=this.#Xe(e),this.#_(e,t,r),this.trigger(e,this.#z(t),r,i)}#_(e,t,r={}){let i=this.#i.get(t.replace(/:/g,"_"));if(i){r.cancelled=!1;for(const t of i)if(!1===t(e,r)||r.cancelled)return r.cancelled=!0,!1}return!0}timeout(e){if((e=this.parseInterval(e))>0)return new Promise(t=>setTimeout(t,e))}onLoad(e){this.on(this.#z("htmx:after:process"),t=>{e(t.target)})}on(e,t,r){let i,s=document;return void 0===r?(i=e,r=t):(s=this.#Xe(e),i=t),s.addEventListener(i,r),r}find(e,t){return this.#Be(e,t)}findAll(e,t){return this.#be(e,t)}parseInterval(e){if("number"==typeof e)return e;let[,t,r]=e?.match(/^([\d.]+)(ms|s|m)?$/)||[],i=parseFloat(t)*({ms:1,s:1e3,m:6e4}[r]||1);return isNaN(i)?void 0:i}trigger(e,t,r={},i=!0){e=this.#Xe(e);let s=new CustomEvent(t,{detail:r,cancelable:!0,bubbles:i,composed:!0}),n=e?.isConnected?e:document;return!r.cancelled&&n.dispatchEvent(s)}ajax(e,t,r){(!r||r instanceof Element||"string"==typeof r)&&(r={target:r});let i="string"==typeof r.source?document.querySelector(r.source):r.source;if("string"==typeof r.source&&!i)return Promise.reject(new Error("Source not found"));if(r.target){let e=this.#K(document.body,r.target);if(!e)return Promise.reject(new Error("Target not found"));i||=e}i||=document.body;let s=this.#T(i,r.event||{});return Object.assign(s,r),r.target&&(s.target=this.#K(document.body,r.target)),Object.assign(s.request,{action:t,method:e.toUpperCase()}),r.headers&&Object.assign(s.request.headers,r.headers),this.#X(s)}#Ge(e){this.config.history&&(history.state||history.replaceState({htmx:!0},"",location.href),history.pushState({htmx:!0},"",e),this.#L(document,"htmx:after:history:push",{path:e}))}#Ke(e){this.config.history&&(history.replaceState({htmx:!0},"",e),this.#L(document,"htmx:after:history:replace",{path:e}))}async#ze(e,t){if(await this.timeout(1),e??=history.state,!e?.htmx)return;this.#p?.abort(),t=t||location.pathname+location.search;let r=document.querySelector(this.#S("[hx-history-elt]"))||document.body;if(this.#L(document,"htmx:before:history:restore",{path:t,cacheMiss:!0})){if("reload"!==this.config.history)return this.#p=new AbortController,this.ajax("GET",t,{target:r,swap:"outerSync",select:r!==document.body?this.#S("[hx-history-elt]"):void 0,request:{headers:{"HX-History-Restore-Request":"true"},signal:this.#p.signal}});this._loc.reload()}}#Ye(e){let{sourceElement:t,push:r,replace:i,hx:s,response:n}=e;if((s?.pushurl||s?.replaceurl)&&(r=s.pushurl,i=s.replaceurl),null==r&&null==i&&this.#D(t)&&(r="true"),"false"!==r&&!1!==r||(r=null),"false"!==i&&!1!==i||(i=null),!r&&!i)return null;let o=r||i;if("true"===o){let t=n?.raw?.url||e.request.action,r=new URL(t,location.href);o=r.pathname+r.search+(e.request.anchor?"#"+e.request.anchor:"")}return{type:r?"push":"replace",path:o}}#_e(e){if(!this.config.history)return;let t=this.#Ye(e);if(!t)return;let r={history:t,sourceElement:e.sourceElement,response:e.response};this.#L(document,"htmx:before:history:update",r)&&("push"===t.type?this.#Ge(t.path):this.#Ke(t.path),this.#L(document,"htmx:after:history:update",r))}#ve(e){if(e._htmx?.onInitialized)return;let t=this.#v("hx-on"),r=this.config.metaCharacter||":",i=t=>async r=>{try{await this.#k(e,{event:r},`with(event?.detail||{}){${t}}`,!1)}catch(t){"symbol"!=typeof t&&this.#L(e,"htmx:error",{error:t})}};for(let s of e.getAttributeNames()){let n=t.find(e=>s.startsWith(e));if(!n)continue;this.#H(e).onInitialized=!0;let o=s.substring(n.length),a=e.getAttribute(s);if(!o){for(let t of a.split(/;(?=[^;]*->)/)){let r=t.indexOf("->");-1!==r&&this.#O(e,t.substring(0,r).trim(),i(t.substring(r+2).trim()))}continue}if(o[0]!==r)continue;let l=o.substring(1);l.startsWith(r)&&(l="htmx"+r+l.substring(1)),this.#O(e,l,i(a))}}#he(e){let t,r=this.#E(e,"hx-indicator");if(r)t=this.#be(e,r,"hx-indicator");else{if(e===document.body)return[];t=[e]}for(const e of t){let t=this.#$(e);t.rc=(t.rc||0)+1,this.#We(e,this.config.requestClass)}return t}#pe(e){for(let t of e){let e=this.#$(t);e.rc&&--e.rc<=0&&(this.#Je(t,this.config.requestClass),delete e.rc)}}#ce(e){let t=this.#E(e,"hx-disable"),r=[];if(t){r=this.#be(e,t,"hx-disable");for(let e of r){let t=this.#$(e);t.dc=(t.dc||0)+1,e.disabled=!0}}return r}#ge(e){for(const t of e){let e=this.#$(t);e.dc&&--e.dc<=0&&(t.disabled=!1,delete e.dc)}}#C(e,t,r,i,s){if(i&&t&&!t.reportValidity())return;let n=t?new FormData(t):new FormData,o=t?new Set(t.elements):new Set;if(!t){if(i&&e.reportValidity&&!e.reportValidity())return;this.#Ze(e,o,n,s)}r&&r.name&&(n.append(r.name,r.value),o.add(r));let a=this.#E(e,"hx-include");if(a)for(let t of this.#be(e,a)){if(i&&t.reportValidity&&!t.reportValidity())return;this.#Ze(t,o,n)}return n}#Ze(e,t,r,i){let s=e.tagName,n=[];"BUTTON"===s||s.includes("-")?n=[e]:!["INPUT","SELECT","TEXTAREA","FIELDSET"].includes(s)&&i||(n=this.#j(e,"[name]:not(button)"));for(let e of n){let i=e.name||e.getAttribute?.("name");if(!i||e.matches(":disabled")||t.has(e))continue;t.add(e);let s=e.type;if("checkbox"===s||"radio"===s||"INPUT"!==e.tagName&&"checked"in e)e.checked&&r.append(i,e.value);else if("file"===s)for(let t of e.files)r.append(i,t);else if("select-multiple"===s)for(let t of e.selectedOptions)r.append(i,t.value);else if(Array.isArray(e.value))for(let t of e.value)r.append(i,t);else r.append(i,e.value)}}#q(t,r,i,s={}){let n=this.#E(t,r);if(!n)return null;let o=this.#ie(n);if(o)return 0!==o.indexOf("{")&&(o="{"+o+"}"),this.#k(t,s,o,!0).then(e=>{i(e)});i(e.parse(n))}#et(e){let t=e.trim();return t.startsWith("<")&&t.endsWith("/>")?t.slice(1,-2):t}#be(t,r,i,s){let n=r??t,o=r?this.#Xe(t)||document.body:document;if(n.startsWith("global "))return this.#be(o,n.slice(7),i,!0);let a=n?e.split(n):[],l=[],h=[];for(const e of a){let t,r=this.#et(e);if(r.startsWith("closest "))t=o.closest(r.slice(8));else if(r.startsWith("find "))t=o.querySelector(r.slice(5));else if(r.startsWith("findAll "))l.push(...o.querySelectorAll(r.slice(8)));else if("next"===r||"nextElementSibling"===r)t=o.nextElementSibling;else if(r.startsWith("next "))t=this.#tt(o,r.slice(5),!!s);else if("previous"===r||"previousElementSibling"===r)t=o.previousElementSibling;else if(r.startsWith("previous "))t=this.#rt(o,r.slice(9),!!s);else if("document"===r)t=document;else if("window"===r)t=window;else if("body"===r)t=document.body;else if("host"===r)t=o.getRootNode().host;else if("this"===r){if(i){l.push(...this.#F(o,i));continue}t=o}else h.push(r);t&&l.push(t)}if(h.length>0){let e=h.join(","),t=this.#it(o,!!s);l.push(...t.querySelectorAll(e))}return[...new Set(l)]}#tt(e,t,r){return this.#st(this.#it(e,r).querySelectorAll(t),e,Node.DOCUMENT_POSITION_PRECEDING)}#rt(e,t,r){let i=[...this.#it(e,r).querySelectorAll(t)].reverse();return this.#st(i,e,Node.DOCUMENT_POSITION_FOLLOWING)}#st(e,t,r){for(const i of e)if(i.compareDocumentPosition(t)===r)return i}#it(e,t){return e.isConnected&&e.getRootNode?e.getRootNode?.({composed:t}):document}#ee(e,t,r){let i=this.#be(e,t,r)[0];return i||console.warn(`htmx: '${t}' on ${r} did not match any element`,{elt:e,selector:t,attr:r}),i}#Be(e,t,r){return this.#be(e,t,r)[0]}#ie(e){if(null!=e){if(e.startsWith("js:"))return e.substring(3);if(e.startsWith("javascript:"))return e.substring(11)}}#ae(e){let t=this.#H(e);if(t.abortInitialized)return;t.abortInitialized=!0;let r=()=>{this.#oe(e).abort()};e.addEventListener("htmx:abort",r),t.listeners.push({fromElt:e,eventName:"htmx:abort",handler:r})}#I(e,t,r){let{persistentIds:i,idMap:s}=this.#nt(e,t),n=document.createElement("div");n.hidden=!0,document.body.after(n);let o={target:e,idMap:s,persistentIds:i,pantry:n,futureMatches:new WeakSet};r?this.#ot(o,e,t):this.#ot(o,e.parentNode,t,e,e.nextSibling),this.#Se(n),n.remove()}#ot(e,t,r,i=null,s=null){t instanceof HTMLTemplateElement&&r instanceof HTMLTemplateElement&&(t=t.content,r=r.content),i||=t.firstChild;let n=r.firstChild;for(;n;){let r;if(i&&i!=s&&(r=this.#at(e,n,i,s),r&&r!==i)){let o=i;for(;o&&o!==r;){let r=o;o=o.nextSibling,r instanceof Element&&(e.idMap.has(r)||this.#lt(e,r,n))?this.#Ce(t,r,s):this.#ht(e,r)}}if(!r&&n instanceof Element&&e.persistentIds.has(n.id)){let s=CSS.escape(n.id);r=e.target.id===n.id&&e.target||e.target.querySelector(`[id="${s}"]`)||e.pantry.querySelector(`[id="${s}"]`);let o=r;for(;o=o.parentNode;){let t=e.idMap.get(o);t&&(t.delete(r.id),t.size||e.idMap.delete(o))}this.#Ce(t,r,i)}if(r){this.#ct(r,n,e),i=r.nextSibling,n=n.nextSibling;continue}let o=n.nextSibling;if(e.idMap.has(n)){let r=document.createElement(n.tagName);t.insertBefore(r,i),this.#ct(r,n,e),this.process(r),i=r.nextSibling}else t.insertBefore(n,i),i=n.nextSibling;n=o}for(;i&&i!=s;){let t=i;i=i.nextSibling,this.#ht(e,t)}}#lt(e,t,r){if(e.futureMatches.has(t))return!0;for(let i=r.nextSibling,s=0;i&&s<this.config.morphScanLimit;i=i.nextSibling,s++)if(i instanceof Element&&t.isEqualNode(i))return e.futureMatches.add(t),!0;return!1}#at(e,t,r,i){if(3===t.nodeType)return 3===r?.nodeType?r:null;if(!(t instanceof Element))return null;let s=null,n=0,o=this.config.morphScanLimit,a=e.idMap.get(t),l=a?.size||0;if(t.id&&!a)return null;let h=r;for(;h&&h!=i;){let r=e.idMap.get(h);if(this.#o.isSoftMatch(h,t)){if(r&&a&&[...r].some(e=>a.has(e)))return h;if(!r){if(o>0&&h.isEqualNode(t))return h;s||(s=h)}}if(n+=r?.size||0,n>l)break;if(null!=document.activeElement?.selectionStart&&h.contains(document.activeElement))break;if(--o<1&&0===l)break;h=h.nextSibling}return s&&this.#lt(e,s,t)?null:s}#M(e,t){return e instanceof Element&&e.tagName===t.tagName&&(!("SCRIPT"===e.tagName&&!e.isEqualNode(t))&&(!e.id||e.id===t.id))}#ht(e,t){e.idMap.has(t)?this.#Ce(e.pantry,t,null):(this.#Se(t),t.remove())}#Ce(e,t,r){if(e.moveBefore)try{return void e.moveBefore(t,r)}catch(e){}e.insertBefore(t,r)}#ct(e,t,r){if(3===e.nodeType)return void(e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue));if(this.config.morphSkip&&e.matches?.(this.config.morphSkip))return;if(!this.#_(e,"htmx:before:morph:node",{oldNode:e,newNode:t}))return;this.#Qe(e,t),e instanceof HTMLTextAreaElement&&document.activeElement!==e&&e.defaultValue!=t.defaultValue&&(e.value=t.value),this.config.morphSkipChildren&&e.matches?.(this.config.morphSkipChildren)||e.isEqualNode(t)&&"TEMPLATE"!==t.tagName&&!t.querySelector?.("template")||this.#ot(r,e,t)}#Qe(e,t){let r=this.config.morphIgnore||[],i=!1,s=e=>this.#v("hx-").some(t=>e.startsWith(t));for(const n of t.attributes)if(!r.some(e=>n.name.startsWith(e))&&e.getAttribute(n.name)!==n.value){if(s(n.name)&&(i=!0),!this.#_(e,"htmx:before:morph:attr",{attrName:n.name,newValue:n.value}))continue;e.setAttribute(n.name,n.value),"value"===n.name&&e instanceof HTMLInputElement&&"file"!==e.type&&document.activeElement!==e&&(e.value=n.value)}for(let n=e.attributes.length-1;n>=0;n--){let o=e.attributes[n];if(o&&!t.hasAttribute(o.name)&&!r.some(e=>o.name.startsWith(e))){if(s(o.name)&&(i=!0),!this.#_(e,"htmx:before:morph:attr",{attrName:o.name,newValue:null}))continue;e.removeAttribute(o.name)}}i&&this.#Se(e,!0)}#ut(e,t,r,i){for(const s of i)if(t.has(s.id)){let t=s;for(;t&&t!==r;){let r=e.get(t);null==r&&(r=new Set,e.set(t,r)),r.add(s.id),t=t.parentElement}}}#nt(e,t){let r=this.#j(e,"[id]"),i=t.querySelectorAll("[id]"),s=this.#dt(r,i),n=new Map;return this.#ut(n,s,e.parentElement,r),this.#ut(n,s,t,i),{persistentIds:s,idMap:n}}#dt(e,t){let r=new Set,i=new Map;for(const{id:t,tagName:s}of e)i.has(t)?r.add(t):t&&i.set(t,s);let s=new Set;for(const{id:e,tagName:n}of t)s.has(e)?r.add(e):i.get(e)===n&&s.add(e);for(const e of r)s.delete(e);return s}#fe(t){let r=t.response.raw.status,i=this.config.noSwap.map(e=>e+""),s=r+"";for(let r of[s,s.slice(0,2)+"x",s[0]+"xx"]){if(i.includes(r))return void(t.swap="none");let s=this.#E(t.sourceElement,"hx-status:"+r);if(s)return void e.merge(s,t)}}#$e(e,t){return new Promise(r=>{this.#m||=[],this.#m.push({task:e,resolve:r,ctx:t}),this.#x||this.#ft()})}async#ft(){if(0===this.#m.length||this.#x)return;this.#x=!0;let{task:e,resolve:t,ctx:r}=this.#m.shift();try{if(document.startViewTransition){let t={task:e,ctx:r};this.#L(r.sourceElement,"htmx:before:viewTransition",t),await document.startViewTransition(t.task).finished,this.#L(r.sourceElement,"htmx:after:viewTransition",t)}else await e()}catch(e){}finally{this.#x=!1,t(),this.#ft()}}#Ue(e,t){let r=t.querySelectorAll("[id]"),i=Object.fromEntries([...r].map(e=>[e.id,e])),s=e.querySelectorAll("[id]"),n=[];for(let e of s){let t=i[e.id];if(t?.tagName===e.tagName){let r=e.cloneNode(!1);this.#Qe(e,t),n.push(()=>{this.#Qe(e,r)})}}return n}#We(e,t){e?.classList?.add?.(t)}#Je(e,t){e?.classList?.remove?.(t),0===e?.classList?.length&&e.removeAttribute("class")}#Xe(e){return"string"==typeof e?this.find(e):e}#z(e){return this.config.metaCharacter?e.replace(/:/g,this.config.metaCharacter):e}}})();⏎̸ | |||
A internal/adapters/web/static/js/hx-live.min.js +1 -0 generated file: collapsed
A internal/adapters/web/static/js/hx-sse.min.js +1 -0 generated file: collapsed
A internal/adapters/web/summary.go +221 -0
| @@ -0,0 +1,221 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "html" | |
| 9 | + | "html/template" | |
| 10 | + | "net/http" | |
| 11 | + | "strings" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "connectrpc.com/connect" | |
| 15 | + | "github.com/a-h/templ" | |
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | ||
| 18 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // maxReadmeBytes is the largest README that is rendered in the page; a | |
| 22 | + | // larger one is replaced by a note. | |
| 23 | + | const maxReadmeBytes = 512 << 10 | |
| 24 | + | ||
| 25 | + | // recentCommits is how many commits the rail of the summary lists. | |
| 26 | + | const recentCommits = 5 | |
| 27 | + | ||
| 28 | + | // readmeNames are the README file names in order of preference (compared | |
| 29 | + | // lowercase): Markdown first. | |
| 30 | + | var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"} | |
| 31 | + | ||
| 32 | + | // summary is the page of a repository: description, README, and in the rail | |
| 33 | + | // the clone URL, the facts, the recent commits and the README outline. | |
| 34 | + | func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 35 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 36 | + | if err != nil { | |
| 37 | + | return nil, 0, err | |
| 38 | + | } | |
| 39 | + | if d.head == nil { | |
| 40 | + | l, st := s.emptyState(ctx, d) | |
| 41 | + | return State(l, st), http.StatusOK, nil | |
| 42 | + | } | |
| 43 | + | ref := d.head.GetShortName() | |
| 44 | + | now := s.cfg.Now() | |
| 45 | + | ||
| 46 | + | p := SummaryPage{ | |
| 47 | + | Desc: d.repo.GetDescription(), | |
| 48 | + | AllHref: repoHref(d.name(), "commits", ref), | |
| 49 | + | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |
| 53 | + | Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits, | |
| 54 | + | })) | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, 0, err | |
| 57 | + | } | |
| 58 | + | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |
| 59 | + | for i, c := range commits.Msg.GetCommits() { | |
| 60 | + | row := CommitRow{ | |
| 61 | + | Short: short(c.GetId()), Subject: c.GetSummary(), CHref: repoHref(d.name(), "commit", c.GetId()), | |
| 62 | + | Author: c.GetAuthor().GetName(), Merge: len(c.GetParentIds()) > 1, | |
| 63 | + | Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0, | |
| 64 | + | } | |
| 65 | + | if t := c.GetCommitter().GetTime(); t.IsValid() { | |
| 66 | + | row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime()) | |
| 67 | + | } | |
| 68 | + | p.Recent = append(p.Recent, row) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | p.Facts = s.facts(ctx, d, now) | |
| 72 | + | ||
| 73 | + | if rd, err := s.readme(ctx, d, ref); err != nil { | |
| 74 | + | return nil, 0, err | |
| 75 | + | } else if rd != nil { | |
| 76 | + | p.Readme, p.Outline = rd.readme, rd.outline | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | l := s.repoLayout(ctx, d, "summary") | |
| 80 | + | l.Title = d.name() + " · " + s.instance | |
| 81 | + | l.Status = Status{ | |
| 82 | + | Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"), | |
| 83 | + | Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent), | |
| 84 | + | } | |
| 85 | + | l.HasRail, l.Read, l.RailSum = true, true, "clone · facts · recent commits · outline" | |
| 86 | + | return Summary(l, p), http.StatusOK, nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // maxTagDates is the most tags whose commit dates are read to name the | |
| 90 | + | // latest one; with more, the fact shows the count only. | |
| 91 | + | const maxTagDates = 20 | |
| 92 | + | ||
| 93 | + | // facts are the key/value rows of the rail. | |
| 94 | + | func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact { | |
| 95 | + | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + d.name()} | |
| 96 | + | if t := d.repo.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 { | |
| 97 | + | last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime()) | |
| 98 | + | last.Time = rfc3339(t.AsTime()) | |
| 99 | + | } | |
| 100 | + | fs := []Fact{ | |
| 101 | + | last, | |
| 102 | + | {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")}, | |
| 103 | + | } | |
| 104 | + | if n := len(d.tags); n > 0 { | |
| 105 | + | val := plural(n, "tag", "tags") | |
| 106 | + | if n <= maxTagDates { | |
| 107 | + | if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" { | |
| 108 | + | val = name + " · " + c.when + " · " + val | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | fs = append(fs, Fact{Key: "tag", Val: val}) | |
| 112 | + | } | |
| 113 | + | if c := d.repo.GetCreatedAt(); c.IsValid() { | |
| 114 | + | fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())}) | |
| 115 | + | } | |
| 116 | + | return fs | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // latestTag is the tag whose commit is the newest. | |
| 120 | + | func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) { | |
| 121 | + | var name string | |
| 122 | + | var best refCommit | |
| 123 | + | for _, t := range tags { | |
| 124 | + | c, ok := commits[t.GetCommitId()] | |
| 125 | + | if ok && c.short != "" && (name == "" || c.at > best.at) { | |
| 126 | + | name, best = t.GetShortName(), c | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | return name, best | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // renderedReadme is the README of a repository, ready for the page. | |
| 133 | + | type renderedReadme struct { | |
| 134 | + | readme *Readme | |
| 135 | + | outline []Heading | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // readme finds the README at the root of the tree of the head commit, reads | |
| 139 | + | // it and renders it. It is nil when the repository has none. Rendering | |
| 140 | + | // problems degrade to plain text; only API failures are errors. | |
| 141 | + | func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) { | |
| 142 | + | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()})) | |
| 143 | + | if err != nil { | |
| 144 | + | return nil, err | |
| 145 | + | } | |
| 146 | + | var best *pb.TreeEntry | |
| 147 | + | rank := len(readmeNames) | |
| 148 | + | for _, e := range tree.Msg.GetEntries() { | |
| 149 | + | if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE { | |
| 150 | + | continue | |
| 151 | + | } | |
| 152 | + | for i, n := range readmeNames { | |
| 153 | + | if i < rank && strings.EqualFold(e.GetName(), n) { | |
| 154 | + | best, rank = e, i | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | if best == nil { | |
| 159 | + | return nil, nil | |
| 160 | + | } | |
| 161 | + | rd := &Readme{Name: best.GetName()} | |
| 162 | + | if best.GetSize() > maxReadmeBytes { | |
| 163 | + | rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")." | |
| 164 | + | return &renderedReadme{readme: rd}, nil | |
| 165 | + | } | |
| 166 | + | body, binary, err := s.readBlob(ctx, d.name(), d.head.GetCommitId(), best.GetPath(), maxReadmeBytes) | |
| 167 | + | if err != nil { | |
| 168 | + | return nil, err | |
| 169 | + | } | |
| 170 | + | if binary { | |
| 171 | + | rd.Note = best.GetName() + " is not text." | |
| 172 | + | return &renderedReadme{readme: rd}, nil | |
| 173 | + | } | |
| 174 | + | name := strings.ToLower(best.GetName()) | |
| 175 | + | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | |
| 176 | + | h, outline, err := s.md.render(body, d.name(), ref, "") | |
| 177 | + | if err == nil { | |
| 178 | + | rd.HTML = h | |
| 179 | + | return &renderedReadme{readme: rd, outline: outline}, nil | |
| 180 | + | } | |
| 181 | + | log := zerowrap.FromCtx(ctx) | |
| 182 | + | log.Warn().Err(err).Str("repo", d.name()).Msg("render README, showing plain text") | |
| 183 | + | } | |
| 184 | + | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | |
| 185 | + | return &renderedReadme{readme: rd}, nil | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // readBlob reads a file through the GetBlob stream, at most limit bytes. It | |
| 189 | + | // reports a binary file without its content. | |
| 190 | + | func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) { | |
| 191 | + | stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path})) | |
| 192 | + | if err != nil { | |
| 193 | + | return nil, false, err | |
| 194 | + | } | |
| 195 | + | defer func() { _ = stream.Close() }() | |
| 196 | + | for stream.Receive() { | |
| 197 | + | m := stream.Msg() | |
| 198 | + | if m.GetInfo().GetBinary() { | |
| 199 | + | return nil, true, nil | |
| 200 | + | } | |
| 201 | + | body = append(body, m.GetChunk()...) | |
| 202 | + | if int64(len(body)) > limit { | |
| 203 | + | return body[:limit], false, nil | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | if err := stream.Err(); err != nil { | |
| 207 | + | return nil, false, err | |
| 208 | + | } | |
| 209 | + | return body, false, nil | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // humanSize formats a size in bytes. | |
| 213 | + | func humanSize(n int64) string { | |
| 214 | + | switch { | |
| 215 | + | case n < 1024: | |
| 216 | + | return itoa(int(n)) + " B" | |
| 217 | + | case n < 1<<20: | |
| 218 | + | return fmt.Sprintf("%.1f KiB", float64(n)/1024) | |
| 219 | + | } | |
| 220 | + | return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) | |
| 221 | + | } | |
| @@ -0,0 +1,221 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "html" | |||
| 9 | + | "html/template" | |||
| 10 | + | "net/http" | |||
| 11 | + | "strings" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "connectrpc.com/connect" | |||
| 15 | + | "github.com/a-h/templ" | |||
| 16 | + | "github.com/bnema/zerowrap" | |||
| 17 | + | ||||
| 18 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // maxReadmeBytes is the largest README that is rendered in the page; a | |||
| 22 | + | // larger one is replaced by a note. | |||
| 23 | + | const maxReadmeBytes = 512 << 10 | |||
| 24 | + | ||||
| 25 | + | // recentCommits is how many commits the rail of the summary lists. | |||
| 26 | + | const recentCommits = 5 | |||
| 27 | + | ||||
| 28 | + | // readmeNames are the README file names in order of preference (compared | |||
| 29 | + | // lowercase): Markdown first. | |||
| 30 | + | var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"} | |||
| 31 | + | ||||
| 32 | + | // summary is the page of a repository: description, README, and in the rail | |||
| 33 | + | // the clone URL, the facts, the recent commits and the README outline. | |||
| 34 | + | func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 35 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return nil, 0, err | |||
| 38 | + | } | |||
| 39 | + | if d.head == nil { | |||
| 40 | + | l, st := s.emptyState(ctx, d) | |||
| 41 | + | return State(l, st), http.StatusOK, nil | |||
| 42 | + | } | |||
| 43 | + | ref := d.head.GetShortName() | |||
| 44 | + | now := s.cfg.Now() | |||
| 45 | + | ||||
| 46 | + | p := SummaryPage{ | |||
| 47 | + | Desc: d.repo.GetDescription(), | |||
| 48 | + | AllHref: repoHref(d.name(), "commits", ref), | |||
| 49 | + | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |||
| 53 | + | Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits, | |||
| 54 | + | })) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return nil, 0, err | |||
| 57 | + | } | |||
| 58 | + | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |||
| 59 | + | for i, c := range commits.Msg.GetCommits() { | |||
| 60 | + | row := CommitRow{ | |||
| 61 | + | Short: short(c.GetId()), Subject: c.GetSummary(), CHref: repoHref(d.name(), "commit", c.GetId()), | |||
| 62 | + | Author: c.GetAuthor().GetName(), Merge: len(c.GetParentIds()) > 1, | |||
| 63 | + | Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0, | |||
| 64 | + | } | |||
| 65 | + | if t := c.GetCommitter().GetTime(); t.IsValid() { | |||
| 66 | + | row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime()) | |||
| 67 | + | } | |||
| 68 | + | p.Recent = append(p.Recent, row) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | p.Facts = s.facts(ctx, d, now) | |||
| 72 | + | ||||
| 73 | + | if rd, err := s.readme(ctx, d, ref); err != nil { | |||
| 74 | + | return nil, 0, err | |||
| 75 | + | } else if rd != nil { | |||
| 76 | + | p.Readme, p.Outline = rd.readme, rd.outline | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | l := s.repoLayout(ctx, d, "summary") | |||
| 80 | + | l.Title = d.name() + " · " + s.instance | |||
| 81 | + | l.Status = Status{ | |||
| 82 | + | Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"), | |||
| 83 | + | Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent), | |||
| 84 | + | } | |||
| 85 | + | l.HasRail, l.Read, l.RailSum = true, true, "clone · facts · recent commits · outline" | |||
| 86 | + | return Summary(l, p), http.StatusOK, nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // maxTagDates is the most tags whose commit dates are read to name the | |||
| 90 | + | // latest one; with more, the fact shows the count only. | |||
| 91 | + | const maxTagDates = 20 | |||
| 92 | + | ||||
| 93 | + | // facts are the key/value rows of the rail. | |||
| 94 | + | func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact { | |||
| 95 | + | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + d.name()} | |||
| 96 | + | if t := d.repo.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 { | |||
| 97 | + | last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime()) | |||
| 98 | + | last.Time = rfc3339(t.AsTime()) | |||
| 99 | + | } | |||
| 100 | + | fs := []Fact{ | |||
| 101 | + | last, | |||
| 102 | + | {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")}, | |||
| 103 | + | } | |||
| 104 | + | if n := len(d.tags); n > 0 { | |||
| 105 | + | val := plural(n, "tag", "tags") | |||
| 106 | + | if n <= maxTagDates { | |||
| 107 | + | if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" { | |||
| 108 | + | val = name + " · " + c.when + " · " + val | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | fs = append(fs, Fact{Key: "tag", Val: val}) | |||
| 112 | + | } | |||
| 113 | + | if c := d.repo.GetCreatedAt(); c.IsValid() { | |||
| 114 | + | fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())}) | |||
| 115 | + | } | |||
| 116 | + | return fs | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // latestTag is the tag whose commit is the newest. | |||
| 120 | + | func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) { | |||
| 121 | + | var name string | |||
| 122 | + | var best refCommit | |||
| 123 | + | for _, t := range tags { | |||
| 124 | + | c, ok := commits[t.GetCommitId()] | |||
| 125 | + | if ok && c.short != "" && (name == "" || c.at > best.at) { | |||
| 126 | + | name, best = t.GetShortName(), c | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | return name, best | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // renderedReadme is the README of a repository, ready for the page. | |||
| 133 | + | type renderedReadme struct { | |||
| 134 | + | readme *Readme | |||
| 135 | + | outline []Heading | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // readme finds the README at the root of the tree of the head commit, reads | |||
| 139 | + | // it and renders it. It is nil when the repository has none. Rendering | |||
| 140 | + | // problems degrade to plain text; only API failures are errors. | |||
| 141 | + | func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) { | |||
| 142 | + | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()})) | |||
| 143 | + | if err != nil { | |||
| 144 | + | return nil, err | |||
| 145 | + | } | |||
| 146 | + | var best *pb.TreeEntry | |||
| 147 | + | rank := len(readmeNames) | |||
| 148 | + | for _, e := range tree.Msg.GetEntries() { | |||
| 149 | + | if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE { | |||
| 150 | + | continue | |||
| 151 | + | } | |||
| 152 | + | for i, n := range readmeNames { | |||
| 153 | + | if i < rank && strings.EqualFold(e.GetName(), n) { | |||
| 154 | + | best, rank = e, i | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | if best == nil { | |||
| 159 | + | return nil, nil | |||
| 160 | + | } | |||
| 161 | + | rd := &Readme{Name: best.GetName()} | |||
| 162 | + | if best.GetSize() > maxReadmeBytes { | |||
| 163 | + | rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")." | |||
| 164 | + | return &renderedReadme{readme: rd}, nil | |||
| 165 | + | } | |||
| 166 | + | body, binary, err := s.readBlob(ctx, d.name(), d.head.GetCommitId(), best.GetPath(), maxReadmeBytes) | |||
| 167 | + | if err != nil { | |||
| 168 | + | return nil, err | |||
| 169 | + | } | |||
| 170 | + | if binary { | |||
| 171 | + | rd.Note = best.GetName() + " is not text." | |||
| 172 | + | return &renderedReadme{readme: rd}, nil | |||
| 173 | + | } | |||
| 174 | + | name := strings.ToLower(best.GetName()) | |||
| 175 | + | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | |||
| 176 | + | h, outline, err := s.md.render(body, d.name(), ref, "") | |||
| 177 | + | if err == nil { | |||
| 178 | + | rd.HTML = h | |||
| 179 | + | return &renderedReadme{readme: rd, outline: outline}, nil | |||
| 180 | + | } | |||
| 181 | + | log := zerowrap.FromCtx(ctx) | |||
| 182 | + | log.Warn().Err(err).Str("repo", d.name()).Msg("render README, showing plain text") | |||
| 183 | + | } | |||
| 184 | + | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | |||
| 185 | + | return &renderedReadme{readme: rd}, nil | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // readBlob reads a file through the GetBlob stream, at most limit bytes. It | |||
| 189 | + | // reports a binary file without its content. | |||
| 190 | + | func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) { | |||
| 191 | + | stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path})) | |||
| 192 | + | if err != nil { | |||
| 193 | + | return nil, false, err | |||
| 194 | + | } | |||
| 195 | + | defer func() { _ = stream.Close() }() | |||
| 196 | + | for stream.Receive() { | |||
| 197 | + | m := stream.Msg() | |||
| 198 | + | if m.GetInfo().GetBinary() { | |||
| 199 | + | return nil, true, nil | |||
| 200 | + | } | |||
| 201 | + | body = append(body, m.GetChunk()...) | |||
| 202 | + | if int64(len(body)) > limit { | |||
| 203 | + | return body[:limit], false, nil | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | if err := stream.Err(); err != nil { | |||
| 207 | + | return nil, false, err | |||
| 208 | + | } | |||
| 209 | + | return body, false, nil | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // humanSize formats a size in bytes. | |||
| 213 | + | func humanSize(n int64) string { | |||
| 214 | + | switch { | |||
| 215 | + | case n < 1024: | |||
| 216 | + | return itoa(int(n)) + " B" | |||
| 217 | + | case n < 1<<20: | |||
| 218 | + | return fmt.Sprintf("%.1f KiB", float64(n)/1024) | |||
| 219 | + | } | |||
| 220 | + | return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) | |||
| 221 | + | } | |||
A internal/adapters/web/view.go +177 -0
| @@ -0,0 +1,177 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import "html/template" | |
| 6 | + | ||
| 7 | + | // View models handed to the templ components. Components only loop and | |
| 8 | + | // branch on these; the handlers build them from the API messages. | |
| 9 | + | ||
| 10 | + | // Crumb is one segment of the path in the top bar. | |
| 11 | + | type Crumb struct { | |
| 12 | + | Label string | |
| 13 | + | Href string | |
| 14 | + | Current bool | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // Section is a repository section in the top bar, reached by its key. | |
| 18 | + | type Section struct { | |
| 19 | + | Key string // mnemonic key | |
| 20 | + | Post string // rest of the label after the key | |
| 21 | + | Href string | |
| 22 | + | Current bool | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Status is the content of the bottom bar. | |
| 26 | + | type Status struct { | |
| 27 | + | Mode string // "home", "summary", "refs"… | |
| 28 | + | Context string // "3 repos · sort push ▼" | |
| 29 | + | Hints string // "j/k move · enter open · ? help" | |
| 30 | + | Cursor bool // show the row cursor position n/N | |
| 31 | + | Blob bool // show the selected line range | |
| 32 | + | Count int // number of rows under the cursor | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // RefItem is a branch or tag in the ref switcher. | |
| 36 | + | type RefItem struct { | |
| 37 | + | Name string | |
| 38 | + | Href string | |
| 39 | + | Current bool | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // CmdItem is one target of the ":" command line. | |
| 43 | + | type CmdItem struct{ Kind, Label, Href string } | |
| 44 | + | ||
| 45 | + | // Layout is the input of the page shell. | |
| 46 | + | type Layout struct { | |
| 47 | + | Title string | |
| 48 | + | Instance string | |
| 49 | + | Version string | |
| 50 | + | SourceURL string | |
| 51 | + | Assets Assets | |
| 52 | + | Crumbs []Crumb | |
| 53 | + | Ref string // ref shown in the top bar, "" outside a repo view | |
| 54 | + | Short string // short sha shown after the ref | |
| 55 | + | Sections []Section | |
| 56 | + | Status Status | |
| 57 | + | Repo string | |
| 58 | + | SHA string // full sha of the ref, for the permalink key | |
| 59 | + | RefPath string // the URL segment that `y` rewrites: the ref name | |
| 60 | + | Heads []RefItem | |
| 61 | + | Tags []RefItem | |
| 62 | + | Cmd []CmdItem | |
| 63 | + | HasRail bool | |
| 64 | + | RailSum string // the rail as one line when it folds above the content (< 1100px) | |
| 65 | + | Read bool // prose page: content capped at the reading measure | |
| 66 | + | // Live connects the status bar to the event stream (/-/events). | |
| 67 | + | Live bool | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Link is a plain label + href pair. | |
| 71 | + | type Link struct{ Label, Href string } | |
| 72 | + | ||
| 73 | + | // HomeRow is one repository in the home list. | |
| 74 | + | type HomeRow struct { | |
| 75 | + | ID string | |
| 76 | + | Name string | |
| 77 | + | Href string | |
| 78 | + | Desc string | |
| 79 | + | Pushed string | |
| 80 | + | PushedAt string // RFC 3339, "" when never pushed | |
| 81 | + | Branch string | |
| 82 | + | Tag string | |
| 83 | + | Cursor bool | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // SortCol is a column header of the home list; it links to its sort order. | |
| 87 | + | type SortCol struct { | |
| 88 | + | Label string | |
| 89 | + | Href string | |
| 90 | + | Dir string // "ascending" | "descending" | "none" | |
| 91 | + | Mark string | |
| 92 | + | Class string | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // HomePage is the repository list. | |
| 96 | + | type HomePage struct { | |
| 97 | + | Cols []SortCol | |
| 98 | + | Rows []HomeRow | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Fact is one key/value row of the facts block. | |
| 102 | + | type Fact struct { | |
| 103 | + | Key, Val, ID, Time string | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // Clone is a block of copyable shell lines, one [copy] button per line. | |
| 107 | + | type Clone struct { | |
| 108 | + | ID string | |
| 109 | + | Lines []string | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Heading is one README outline entry. | |
| 113 | + | type Heading struct { | |
| 114 | + | Level int | |
| 115 | + | ID string | |
| 116 | + | Text string | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Readme is a rendered README. HTML comes from the Markdown renderer (raw | |
| 120 | + | // HTML dropped, URLs filtered) or is escaped plain text. | |
| 121 | + | type Readme struct { | |
| 122 | + | Name string | |
| 123 | + | HTML template.HTML | |
| 124 | + | // Note replaces the content when the file is not rendered (too large). | |
| 125 | + | Note string | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // CommitRow is one commit in the commit table. | |
| 129 | + | type CommitRow struct { | |
| 130 | + | ID string | |
| 131 | + | Short string | |
| 132 | + | Subject string | |
| 133 | + | CHref string | |
| 134 | + | Author string | |
| 135 | + | When string | |
| 136 | + | WhenAt string | |
| 137 | + | Merge bool | |
| 138 | + | Branches []string | |
| 139 | + | Tags []string | |
| 140 | + | Cursor bool | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // SummaryPage is the repository summary. | |
| 144 | + | type SummaryPage struct { | |
| 145 | + | Desc string | |
| 146 | + | Facts []Fact | |
| 147 | + | Clone Clone | |
| 148 | + | Recent []CommitRow | |
| 149 | + | Readme *Readme | |
| 150 | + | Outline []Heading | |
| 151 | + | AllHref string | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // RefRow is a branch or a tag in the refs table. | |
| 155 | + | type RefRow struct { | |
| 156 | + | Name, Href, Short, CHref, Subject, When, WhenAt string | |
| 157 | + | Default, Cursor bool | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // RefsPage lists the branches and tags of a repository. | |
| 161 | + | type RefsPage struct { | |
| 162 | + | Branches []RefRow | |
| 163 | + | Tags []RefRow | |
| 164 | + | Latest string | |
| 165 | + | Default string | |
| 166 | + | // Note says the lists were cut. | |
| 167 | + | Note string | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // StatePage is a 404, 500 or empty-repository state. | |
| 171 | + | type StatePage struct { | |
| 172 | + | Code string // "404", "500", "empty" | |
| 173 | + | Title string | |
| 174 | + | Lines []string | |
| 175 | + | Clone *Clone | |
| 176 | + | Back []Link | |
| 177 | + | } | |
| @@ -0,0 +1,177 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import "html/template" | |||
| 6 | + | ||||
| 7 | + | // View models handed to the templ components. Components only loop and | |||
| 8 | + | // branch on these; the handlers build them from the API messages. | |||
| 9 | + | ||||
| 10 | + | // Crumb is one segment of the path in the top bar. | |||
| 11 | + | type Crumb struct { | |||
| 12 | + | Label string | |||
| 13 | + | Href string | |||
| 14 | + | Current bool | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // Section is a repository section in the top bar, reached by its key. | |||
| 18 | + | type Section struct { | |||
| 19 | + | Key string // mnemonic key | |||
| 20 | + | Post string // rest of the label after the key | |||
| 21 | + | Href string | |||
| 22 | + | Current bool | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Status is the content of the bottom bar. | |||
| 26 | + | type Status struct { | |||
| 27 | + | Mode string // "home", "summary", "refs"… | |||
| 28 | + | Context string // "3 repos · sort push ▼" | |||
| 29 | + | Hints string // "j/k move · enter open · ? help" | |||
| 30 | + | Cursor bool // show the row cursor position n/N | |||
| 31 | + | Blob bool // show the selected line range | |||
| 32 | + | Count int // number of rows under the cursor | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // RefItem is a branch or tag in the ref switcher. | |||
| 36 | + | type RefItem struct { | |||
| 37 | + | Name string | |||
| 38 | + | Href string | |||
| 39 | + | Current bool | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // CmdItem is one target of the ":" command line. | |||
| 43 | + | type CmdItem struct{ Kind, Label, Href string } | |||
| 44 | + | ||||
| 45 | + | // Layout is the input of the page shell. | |||
| 46 | + | type Layout struct { | |||
| 47 | + | Title string | |||
| 48 | + | Instance string | |||
| 49 | + | Version string | |||
| 50 | + | SourceURL string | |||
| 51 | + | Assets Assets | |||
| 52 | + | Crumbs []Crumb | |||
| 53 | + | Ref string // ref shown in the top bar, "" outside a repo view | |||
| 54 | + | Short string // short sha shown after the ref | |||
| 55 | + | Sections []Section | |||
| 56 | + | Status Status | |||
| 57 | + | Repo string | |||
| 58 | + | SHA string // full sha of the ref, for the permalink key | |||
| 59 | + | RefPath string // the URL segment that `y` rewrites: the ref name | |||
| 60 | + | Heads []RefItem | |||
| 61 | + | Tags []RefItem | |||
| 62 | + | Cmd []CmdItem | |||
| 63 | + | HasRail bool | |||
| 64 | + | RailSum string // the rail as one line when it folds above the content (< 1100px) | |||
| 65 | + | Read bool // prose page: content capped at the reading measure | |||
| 66 | + | // Live connects the status bar to the event stream (/-/events). | |||
| 67 | + | Live bool | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Link is a plain label + href pair. | |||
| 71 | + | type Link struct{ Label, Href string } | |||
| 72 | + | ||||
| 73 | + | // HomeRow is one repository in the home list. | |||
| 74 | + | type HomeRow struct { | |||
| 75 | + | ID string | |||
| 76 | + | Name string | |||
| 77 | + | Href string | |||
| 78 | + | Desc string | |||
| 79 | + | Pushed string | |||
| 80 | + | PushedAt string // RFC 3339, "" when never pushed | |||
| 81 | + | Branch string | |||
| 82 | + | Tag string | |||
| 83 | + | Cursor bool | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // SortCol is a column header of the home list; it links to its sort order. | |||
| 87 | + | type SortCol struct { | |||
| 88 | + | Label string | |||
| 89 | + | Href string | |||
| 90 | + | Dir string // "ascending" | "descending" | "none" | |||
| 91 | + | Mark string | |||
| 92 | + | Class string | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // HomePage is the repository list. | |||
| 96 | + | type HomePage struct { | |||
| 97 | + | Cols []SortCol | |||
| 98 | + | Rows []HomeRow | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // Fact is one key/value row of the facts block. | |||
| 102 | + | type Fact struct { | |||
| 103 | + | Key, Val, ID, Time string | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // Clone is a block of copyable shell lines, one [copy] button per line. | |||
| 107 | + | type Clone struct { | |||
| 108 | + | ID string | |||
| 109 | + | Lines []string | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Heading is one README outline entry. | |||
| 113 | + | type Heading struct { | |||
| 114 | + | Level int | |||
| 115 | + | ID string | |||
| 116 | + | Text string | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Readme is a rendered README. HTML comes from the Markdown renderer (raw | |||
| 120 | + | // HTML dropped, URLs filtered) or is escaped plain text. | |||
| 121 | + | type Readme struct { | |||
| 122 | + | Name string | |||
| 123 | + | HTML template.HTML | |||
| 124 | + | // Note replaces the content when the file is not rendered (too large). | |||
| 125 | + | Note string | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // CommitRow is one commit in the commit table. | |||
| 129 | + | type CommitRow struct { | |||
| 130 | + | ID string | |||
| 131 | + | Short string | |||
| 132 | + | Subject string | |||
| 133 | + | CHref string | |||
| 134 | + | Author string | |||
| 135 | + | When string | |||
| 136 | + | WhenAt string | |||
| 137 | + | Merge bool | |||
| 138 | + | Branches []string | |||
| 139 | + | Tags []string | |||
| 140 | + | Cursor bool | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // SummaryPage is the repository summary. | |||
| 144 | + | type SummaryPage struct { | |||
| 145 | + | Desc string | |||
| 146 | + | Facts []Fact | |||
| 147 | + | Clone Clone | |||
| 148 | + | Recent []CommitRow | |||
| 149 | + | Readme *Readme | |||
| 150 | + | Outline []Heading | |||
| 151 | + | AllHref string | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // RefRow is a branch or a tag in the refs table. | |||
| 155 | + | type RefRow struct { | |||
| 156 | + | Name, Href, Short, CHref, Subject, When, WhenAt string | |||
| 157 | + | Default, Cursor bool | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // RefsPage lists the branches and tags of a repository. | |||
| 161 | + | type RefsPage struct { | |||
| 162 | + | Branches []RefRow | |||
| 163 | + | Tags []RefRow | |||
| 164 | + | Latest string | |||
| 165 | + | Default string | |||
| 166 | + | // Note says the lists were cut. | |||
| 167 | + | Note string | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // StatePage is a 404, 500 or empty-repository state. | |||
| 171 | + | type StatePage struct { | |||
| 172 | + | Code string // "404", "500", "empty" | |||
| 173 | + | Title string | |||
| 174 | + | Lines []string | |||
| 175 | + | Clone *Clone | |||
| 176 | + | Back []Link | |||
| 177 | + | } | |||
A internal/adapters/web/web.go +225 -0
| @@ -0,0 +1,225 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package web is the HTML front of ermite: the pages of the read-only | |
| 4 | + | // showcase, rendered with templ from the data of the ermite API. | |
| 5 | + | // | |
| 6 | + | // The package is a client of the API like any other. It talks to the | |
| 7 | + | // generated ermite-api Connect clients, which cmd binds in process to the | |
| 8 | + | // handlers of the rpc adapter (ADR 0020), and it knows nothing of the use | |
| 9 | + | // cases or the domain. Who the visitor is comes with the request context, set | |
| 10 | + | // by the middleware cmd puts in front of the handler: anonymous in P5. The | |
| 11 | + | // API then shows public repositories only; a private repository is | |
| 12 | + | // indistinguishable from a missing one, here as everywhere. | |
| 13 | + | // | |
| 14 | + | // Adding a page: a handler on Server, a route in routes, a templ page in | |
| 15 | + | // pages.templ and its view model in view.go. | |
| 16 | + | package web | |
| 17 | + | ||
| 18 | + | import ( | |
| 19 | + | "bytes" | |
| 20 | + | "context" | |
| 21 | + | "crypto/rand" | |
| 22 | + | "encoding/hex" | |
| 23 | + | "errors" | |
| 24 | + | "fmt" | |
| 25 | + | "net/http" | |
| 26 | + | "net/url" | |
| 27 | + | "time" | |
| 28 | + | ||
| 29 | + | "connectrpc.com/connect" | |
| 30 | + | "github.com/a-h/templ" | |
| 31 | + | "github.com/bnema/zerowrap" | |
| 32 | + | ||
| 33 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | // Patterns are the ServeMux patterns the handler serves: everything. The | |
| 37 | + | // router of the package answers the paths it does not know with its 404 | |
| 38 | + | // page, and the more specific patterns of the server (health, API, Git) win | |
| 39 | + | // over this one. | |
| 40 | + | var Patterns = []string{"/"} | |
| 41 | + | ||
| 42 | + | // requestTimeout bounds the API calls that build one page. | |
| 43 | + | const requestTimeout = 30 * time.Second | |
| 44 | + | ||
| 45 | + | // Config configures a Server. | |
| 46 | + | type Config struct { | |
| 47 | + | // Repos and Git are the API clients the pages are built from. | |
| 48 | + | Repos ermiteconnect.RepoServiceClient | |
| 49 | + | Git ermiteconnect.GitServiceClient | |
| 50 | + | // PublicURL is the base URL of the instance, without a trailing slash: | |
| 51 | + | // the clone URLs are built on it, and its host is the instance name. | |
| 52 | + | PublicURL string | |
| 53 | + | // SourceURL is the link to the source of the running version (AGPL §13). | |
| 54 | + | SourceURL string | |
| 55 | + | // Version is the version of the running server. | |
| 56 | + | Version string | |
| 57 | + | // Now is the clock of relative dates; time.Now when nil. | |
| 58 | + | Now func() time.Time | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Server serves the pages. It is an http.Handler. | |
| 62 | + | type Server struct { | |
| 63 | + | cfg Config | |
| 64 | + | instance string | |
| 65 | + | assets *assetSet | |
| 66 | + | md *markdown | |
| 67 | + | mux *http.ServeMux | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // New builds the web handler. | |
| 71 | + | func New(cfg Config) (*Server, error) { | |
| 72 | + | if cfg.Repos == nil || cfg.Git == nil { | |
| 73 | + | return nil, errors.New("web: the repository and Git API clients are required") | |
| 74 | + | } | |
| 75 | + | u, err := url.Parse(cfg.PublicURL) | |
| 76 | + | if err != nil || u.Host == "" { | |
| 77 | + | return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL) | |
| 78 | + | } | |
| 79 | + | if cfg.Now == nil { | |
| 80 | + | cfg.Now = time.Now | |
| 81 | + | } | |
| 82 | + | assets, err := loadAssets() | |
| 83 | + | if err != nil { | |
| 84 | + | return nil, fmt.Errorf("web: load assets: %w", err) | |
| 85 | + | } | |
| 86 | + | s := &Server{cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown()} | |
| 87 | + | s.mux = s.routes() | |
| 88 | + | return s, nil | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // routes is the router of the web. The pages of the next phases (tree, blob, | |
| 92 | + | // raw, commits, commit, events) are added here; until then they, and any | |
| 93 | + | // other path, answer with the 404 page. | |
| 94 | + | func (s *Server) routes() *http.ServeMux { | |
| 95 | + | mux := http.NewServeMux() | |
| 96 | + | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | |
| 97 | + | mux.HandleFunc("GET /{$}", s.page(s.home)) | |
| 98 | + | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| 99 | + | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |
| 100 | + | mux.HandleFunc("GET /{repo}/refs", s.page(s.refs)) | |
| 101 | + | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |
| 102 | + | return mux | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // ServeHTTP implements http.Handler. | |
| 106 | + | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 107 | + | s.mux.ServeHTTP(w, r) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // handler builds a page. It returns the component and its status, or an | |
| 111 | + | // error, which becomes the 404 or 500 page. | |
| 112 | + | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | |
| 113 | + | ||
| 114 | + | // page adapts a handler: it bounds the API calls, renders the component into | |
| 115 | + | // a buffer (so that a failure never leaves half a page), and maps errors to | |
| 116 | + | // the state pages. | |
| 117 | + | func (s *Server) page(h handler) http.HandlerFunc { | |
| 118 | + | return func(w http.ResponseWriter, r *http.Request) { | |
| 119 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 120 | + | defer cancel() | |
| 121 | + | c, status, err := h(ctx, r) | |
| 122 | + | if err != nil { | |
| 123 | + | s.fail(ctx, w, r, err) | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | s.write(ctx, w, r, status, c) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // write renders c with the given status. | |
| 131 | + | func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) { | |
| 132 | + | var buf bytes.Buffer | |
| 133 | + | if err := c.Render(ctx, &buf); err != nil { | |
| 134 | + | // The error page itself failed: a bare answer is all that is left. | |
| 135 | + | log := zerowrap.FromCtx(ctx) | |
| 136 | + | log.Error().Err(err).Str("path", r.URL.Path).Msg("render page") | |
| 137 | + | http.Error(w, "internal error", http.StatusInternalServerError) | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | h := w.Header() | |
| 141 | + | h.Set("Content-Type", "text/html; charset=utf-8") | |
| 142 | + | // Public pages: the browser and any proxy revalidate each time. | |
| 143 | + | h.Set("Cache-Control", "no-cache") | |
| 144 | + | w.WriteHeader(status) | |
| 145 | + | if r.Method != http.MethodHead { | |
| 146 | + | _, _ = w.Write(buf.Bytes()) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // fail answers an error of the API: not found (and a name or revision the | |
| 151 | + | // API refuses, which no page can show either) gives the 404 page, a visitor | |
| 152 | + | // that left gives nothing, anything else gives the 500 page with an id to | |
| 153 | + | // find the cause in the log. | |
| 154 | + | func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |
| 155 | + | switch code := connect.CodeOf(err); { | |
| 156 | + | case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | |
| 157 | + | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | |
| 158 | + | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | |
| 159 | + | // The client is gone; there is nobody to answer. | |
| 160 | + | default: | |
| 161 | + | id := requestID() | |
| 162 | + | log := zerowrap.FromCtx(ctx) | |
| 163 | + | log.Error().Err(err).Str("request_id", id).Str("path", r.URL.Path).Msg("web request failed") | |
| 164 | + | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // errNotFound is returned by handlers for a resource that does not exist | |
| 169 | + | // although the API did not say so (a ref or a path that is not there). | |
| 170 | + | var errNotFound = errors.New("not found") | |
| 171 | + | ||
| 172 | + | // requestID is a short random id, shown on the 500 page and logged. | |
| 173 | + | func requestID() string { | |
| 174 | + | var b [4]byte | |
| 175 | + | _, _ = rand.Read(b[:]) | |
| 176 | + | return hex.EncodeToString(b[:]) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // notFound is the handler of the paths without a page. | |
| 180 | + | func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) { | |
| 181 | + | return nil, 0, errNotFound | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // base is the Layout every page starts from. | |
| 185 | + | func (s *Server) base() Layout { | |
| 186 | + | return Layout{ | |
| 187 | + | Instance: s.instance, | |
| 188 | + | Version: s.cfg.Version, | |
| 189 | + | SourceURL: s.cfg.SourceURL, | |
| 190 | + | Assets: s.assets.urls, | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // stateLayout is the layout of a state page: only the way home. | |
| 195 | + | func (s *Server) stateLayout(title, context string, crumbs []Crumb) Layout { | |
| 196 | + | l := s.base() | |
| 197 | + | l.Title = title + " · " + s.instance | |
| 198 | + | l.Crumbs = crumbs | |
| 199 | + | l.Sections = []Section{{Key: "h", Post: "ome", Href: "/"}} | |
| 200 | + | l.Status = Status{Mode: "state", Context: context, Hints: "g h home · ? help"} | |
| 201 | + | l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}} | |
| 202 | + | return l | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (s *Server) notFoundPage() templ.Component { | |
| 206 | + | l := s.stateLayout("404", "404 · not found", []Crumb{{Label: "-"}, {Label: "404"}}) | |
| 207 | + | return State(l, StatePage{ | |
| 208 | + | Code: "404", Title: "not found", | |
| 209 | + | Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."}, | |
| 210 | + | Back: []Link{{Label: "home", Href: "/"}}, | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func (s *Server) errorPage(id string) templ.Component { | |
| 215 | + | l := s.stateLayout("error", "500 · internal error", []Crumb{{Label: "-"}, {Label: "error"}}) | |
| 216 | + | return State(l, StatePage{ | |
| 217 | + | Code: "500", Title: "internal error", | |
| 218 | + | Lines: []string{ | |
| 219 | + | "the server failed to answer this request.", | |
| 220 | + | "request id: " + id, | |
| 221 | + | "retry in a moment; if it persists, the owner has the log.", | |
| 222 | + | }, | |
| 223 | + | Back: []Link{{Label: "home", Href: "/"}}, | |
| 224 | + | }) | |
| 225 | + | } | |
| @@ -0,0 +1,225 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package web is the HTML front of ermite: the pages of the read-only | |||
| 4 | + | // showcase, rendered with templ from the data of the ermite API. | |||
| 5 | + | // | |||
| 6 | + | // The package is a client of the API like any other. It talks to the | |||
| 7 | + | // generated ermite-api Connect clients, which cmd binds in process to the | |||
| 8 | + | // handlers of the rpc adapter (ADR 0020), and it knows nothing of the use | |||
| 9 | + | // cases or the domain. Who the visitor is comes with the request context, set | |||
| 10 | + | // by the middleware cmd puts in front of the handler: anonymous in P5. The | |||
| 11 | + | // API then shows public repositories only; a private repository is | |||
| 12 | + | // indistinguishable from a missing one, here as everywhere. | |||
| 13 | + | // | |||
| 14 | + | // Adding a page: a handler on Server, a route in routes, a templ page in | |||
| 15 | + | // pages.templ and its view model in view.go. | |||
| 16 | + | package web | |||
| 17 | + | ||||
| 18 | + | import ( | |||
| 19 | + | "bytes" | |||
| 20 | + | "context" | |||
| 21 | + | "crypto/rand" | |||
| 22 | + | "encoding/hex" | |||
| 23 | + | "errors" | |||
| 24 | + | "fmt" | |||
| 25 | + | "net/http" | |||
| 26 | + | "net/url" | |||
| 27 | + | "time" | |||
| 28 | + | ||||
| 29 | + | "connectrpc.com/connect" | |||
| 30 | + | "github.com/a-h/templ" | |||
| 31 | + | "github.com/bnema/zerowrap" | |||
| 32 | + | ||||
| 33 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 34 | + | ) | |||
| 35 | + | ||||
| 36 | + | // Patterns are the ServeMux patterns the handler serves: everything. The | |||
| 37 | + | // router of the package answers the paths it does not know with its 404 | |||
| 38 | + | // page, and the more specific patterns of the server (health, API, Git) win | |||
| 39 | + | // over this one. | |||
| 40 | + | var Patterns = []string{"/"} | |||
| 41 | + | ||||
| 42 | + | // requestTimeout bounds the API calls that build one page. | |||
| 43 | + | const requestTimeout = 30 * time.Second | |||
| 44 | + | ||||
| 45 | + | // Config configures a Server. | |||
| 46 | + | type Config struct { | |||
| 47 | + | // Repos and Git are the API clients the pages are built from. | |||
| 48 | + | Repos ermiteconnect.RepoServiceClient | |||
| 49 | + | Git ermiteconnect.GitServiceClient | |||
| 50 | + | // PublicURL is the base URL of the instance, without a trailing slash: | |||
| 51 | + | // the clone URLs are built on it, and its host is the instance name. | |||
| 52 | + | PublicURL string | |||
| 53 | + | // SourceURL is the link to the source of the running version (AGPL §13). | |||
| 54 | + | SourceURL string | |||
| 55 | + | // Version is the version of the running server. | |||
| 56 | + | Version string | |||
| 57 | + | // Now is the clock of relative dates; time.Now when nil. | |||
| 58 | + | Now func() time.Time | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Server serves the pages. It is an http.Handler. | |||
| 62 | + | type Server struct { | |||
| 63 | + | cfg Config | |||
| 64 | + | instance string | |||
| 65 | + | assets *assetSet | |||
| 66 | + | md *markdown | |||
| 67 | + | mux *http.ServeMux | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // New builds the web handler. | |||
| 71 | + | func New(cfg Config) (*Server, error) { | |||
| 72 | + | if cfg.Repos == nil || cfg.Git == nil { | |||
| 73 | + | return nil, errors.New("web: the repository and Git API clients are required") | |||
| 74 | + | } | |||
| 75 | + | u, err := url.Parse(cfg.PublicURL) | |||
| 76 | + | if err != nil || u.Host == "" { | |||
| 77 | + | return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL) | |||
| 78 | + | } | |||
| 79 | + | if cfg.Now == nil { | |||
| 80 | + | cfg.Now = time.Now | |||
| 81 | + | } | |||
| 82 | + | assets, err := loadAssets() | |||
| 83 | + | if err != nil { | |||
| 84 | + | return nil, fmt.Errorf("web: load assets: %w", err) | |||
| 85 | + | } | |||
| 86 | + | s := &Server{cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown()} | |||
| 87 | + | s.mux = s.routes() | |||
| 88 | + | return s, nil | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // routes is the router of the web. The pages of the next phases (tree, blob, | |||
| 92 | + | // raw, commits, commit, events) are added here; until then they, and any | |||
| 93 | + | // other path, answer with the 404 page. | |||
| 94 | + | func (s *Server) routes() *http.ServeMux { | |||
| 95 | + | mux := http.NewServeMux() | |||
| 96 | + | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | |||
| 97 | + | mux.HandleFunc("GET /{$}", s.page(s.home)) | |||
| 98 | + | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |||
| 99 | + | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |||
| 100 | + | mux.HandleFunc("GET /{repo}/refs", s.page(s.refs)) | |||
| 101 | + | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |||
| 102 | + | return mux | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // ServeHTTP implements http.Handler. | |||
| 106 | + | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 107 | + | s.mux.ServeHTTP(w, r) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // handler builds a page. It returns the component and its status, or an | |||
| 111 | + | // error, which becomes the 404 or 500 page. | |||
| 112 | + | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | |||
| 113 | + | ||||
| 114 | + | // page adapts a handler: it bounds the API calls, renders the component into | |||
| 115 | + | // a buffer (so that a failure never leaves half a page), and maps errors to | |||
| 116 | + | // the state pages. | |||
| 117 | + | func (s *Server) page(h handler) http.HandlerFunc { | |||
| 118 | + | return func(w http.ResponseWriter, r *http.Request) { | |||
| 119 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |||
| 120 | + | defer cancel() | |||
| 121 | + | c, status, err := h(ctx, r) | |||
| 122 | + | if err != nil { | |||
| 123 | + | s.fail(ctx, w, r, err) | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | s.write(ctx, w, r, status, c) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // write renders c with the given status. | |||
| 131 | + | func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) { | |||
| 132 | + | var buf bytes.Buffer | |||
| 133 | + | if err := c.Render(ctx, &buf); err != nil { | |||
| 134 | + | // The error page itself failed: a bare answer is all that is left. | |||
| 135 | + | log := zerowrap.FromCtx(ctx) | |||
| 136 | + | log.Error().Err(err).Str("path", r.URL.Path).Msg("render page") | |||
| 137 | + | http.Error(w, "internal error", http.StatusInternalServerError) | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | h := w.Header() | |||
| 141 | + | h.Set("Content-Type", "text/html; charset=utf-8") | |||
| 142 | + | // Public pages: the browser and any proxy revalidate each time. | |||
| 143 | + | h.Set("Cache-Control", "no-cache") | |||
| 144 | + | w.WriteHeader(status) | |||
| 145 | + | if r.Method != http.MethodHead { | |||
| 146 | + | _, _ = w.Write(buf.Bytes()) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // fail answers an error of the API: not found (and a name or revision the | |||
| 151 | + | // API refuses, which no page can show either) gives the 404 page, a visitor | |||
| 152 | + | // that left gives nothing, anything else gives the 500 page with an id to | |||
| 153 | + | // find the cause in the log. | |||
| 154 | + | func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |||
| 155 | + | switch code := connect.CodeOf(err); { | |||
| 156 | + | case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | |||
| 157 | + | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | |||
| 158 | + | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | |||
| 159 | + | // The client is gone; there is nobody to answer. | |||
| 160 | + | default: | |||
| 161 | + | id := requestID() | |||
| 162 | + | log := zerowrap.FromCtx(ctx) | |||
| 163 | + | log.Error().Err(err).Str("request_id", id).Str("path", r.URL.Path).Msg("web request failed") | |||
| 164 | + | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // errNotFound is returned by handlers for a resource that does not exist | |||
| 169 | + | // although the API did not say so (a ref or a path that is not there). | |||
| 170 | + | var errNotFound = errors.New("not found") | |||
| 171 | + | ||||
| 172 | + | // requestID is a short random id, shown on the 500 page and logged. | |||
| 173 | + | func requestID() string { | |||
| 174 | + | var b [4]byte | |||
| 175 | + | _, _ = rand.Read(b[:]) | |||
| 176 | + | return hex.EncodeToString(b[:]) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // notFound is the handler of the paths without a page. | |||
| 180 | + | func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) { | |||
| 181 | + | return nil, 0, errNotFound | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // base is the Layout every page starts from. | |||
| 185 | + | func (s *Server) base() Layout { | |||
| 186 | + | return Layout{ | |||
| 187 | + | Instance: s.instance, | |||
| 188 | + | Version: s.cfg.Version, | |||
| 189 | + | SourceURL: s.cfg.SourceURL, | |||
| 190 | + | Assets: s.assets.urls, | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // stateLayout is the layout of a state page: only the way home. | |||
| 195 | + | func (s *Server) stateLayout(title, context string, crumbs []Crumb) Layout { | |||
| 196 | + | l := s.base() | |||
| 197 | + | l.Title = title + " · " + s.instance | |||
| 198 | + | l.Crumbs = crumbs | |||
| 199 | + | l.Sections = []Section{{Key: "h", Post: "ome", Href: "/"}} | |||
| 200 | + | l.Status = Status{Mode: "state", Context: context, Hints: "g h home · ? help"} | |||
| 201 | + | l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}} | |||
| 202 | + | return l | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (s *Server) notFoundPage() templ.Component { | |||
| 206 | + | l := s.stateLayout("404", "404 · not found", []Crumb{{Label: "-"}, {Label: "404"}}) | |||
| 207 | + | return State(l, StatePage{ | |||
| 208 | + | Code: "404", Title: "not found", | |||
| 209 | + | Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."}, | |||
| 210 | + | Back: []Link{{Label: "home", Href: "/"}}, | |||
| 211 | + | }) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func (s *Server) errorPage(id string) templ.Component { | |||
| 215 | + | l := s.stateLayout("error", "500 · internal error", []Crumb{{Label: "-"}, {Label: "error"}}) | |||
| 216 | + | return State(l, StatePage{ | |||
| 217 | + | Code: "500", Title: "internal error", | |||
| 218 | + | Lines: []string{ | |||
| 219 | + | "the server failed to answer this request.", | |||
| 220 | + | "request id: " + id, | |||
| 221 | + | "retry in a moment; if it persists, the owner has the log.", | |||
| 222 | + | }, | |||
| 223 | + | Back: []Link{{Label: "home", Href: "/"}}, | |||
| 224 | + | }) | |||
| 225 | + | } | |||